diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts index 197eadf73fb..e082738255a 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts @@ -12,8 +12,6 @@ export interface WebhookHeader { export interface WebhookEndpoint { id: string - // No `name` field in the real API — client-only until backend adds one. - name: string url: string description: string enabled: boolean @@ -27,10 +25,8 @@ export interface WebhookDelivery { id: string endpointId: string eventId: string - // Only present when fetched via the single-delivery endpoint (drill-down view) — - // the deliveries *list* endpoint doesn't return either, so list rows leave these unset. - eventType?: string - eventPayload?: Record + eventType: string + eventPayload: Record status: WebhookDeliveryStatus responseCode: number responseBody: string | Record | null @@ -50,7 +46,6 @@ export interface PlatformWebhooksState { } export interface UpsertWebhookEndpointInput { - name: string url: string description: string enabled: boolean diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.test.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.test.ts index 32dd5a09644..f8ae06c74f6 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.test.ts +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.test.ts @@ -1,16 +1,14 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' import { filterWebhookDeliveries, filterWebhookEndpoints, - generateWebhookEndpointName, getWebhookEndpointDisplayName, } from './PlatformWebhooks.utils' const createEndpoint = (overrides: Partial = {}): WebhookEndpoint => ({ id: 'endpoint-1', - name: '', url: 'https://hooks.example.com/billing', description: '', enabled: true, @@ -25,6 +23,8 @@ const createDelivery = (overrides: Partial = {}): WebhookDelive id: 'delivery-1', endpointId: 'endpoint-1', eventId: 'event-1', + eventType: 'v1.project.updated', + eventPayload: {}, status: 'success', responseCode: 200, responseBody: null, @@ -34,40 +34,29 @@ const createDelivery = (overrides: Partial = {}): WebhookDelive }) describe('PlatformWebhooks.utils', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('returns the trimmed endpoint name when present', () => { + it('combines the endpoint url and subscribed event types', () => { expect( getWebhookEndpointDisplayName({ - name: ' Billing events ', url: 'https://hooks.example.com/billing', + eventTypes: ['v1.project.paused', 'v1.project.created'], }) - ).toBe('Billing events') + ).toBe('https://hooks.example.com/billing (v1.project.paused, v1.project.created)') }) - it('falls back to the endpoint url when the name is empty', () => { + it('shows "all events" when subscribed to every event type', () => { expect( getWebhookEndpointDisplayName({ - name: ' ', - url: 'https://hooks.example.com/fallback', + url: 'https://hooks.example.com/billing', + eventTypes: ['*'], }) - ).toBe('https://hooks.example.com/fallback') + ).toBe('https://hooks.example.com/billing (all events)') }) - it('generates an adjective-noun endpoint name', () => { - vi.spyOn(Math, 'random').mockReturnValueOnce(0).mockReturnValueOnce(0.1) - - expect(generateWebhookEndpointName()).toBe('swift-courier') - }) - - it('filters endpoints by name, url, and description', () => { + it('filters endpoints by url and description', () => { const endpoints = [ - createEndpoint({ id: '1', name: 'Billing events', url: 'https://hooks.example.com/billing' }), + createEndpoint({ id: '1', url: 'https://hooks.example.com/billing' }), createEndpoint({ id: '2', - name: 'Slack alerts', url: 'https://hooks.slack.com/x', description: 'operational alerts', }), diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.ts index fbc974cfd42..ec420e61815 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.ts +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.utils.ts @@ -1,50 +1,5 @@ import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' -const WEBHOOK_NAME_ADJECTIVES = [ - 'swift', - 'winged', - 'wayfinding', - 'moonlit', - 'fleet', - 'nimble', - 'roving', - 'brisk', - 'gliding', - 'steady', - 'northbound', - 'starlit', - 'quiet', - 'amber', - 'far-flung', - 'secret', - 'flying', -] - -const WEBHOOK_NAME_NOUNS = [ - 'pigeon', - 'courier', - 'postmark', - 'relay', - 'dispatch', - 'lantern', - 'beacon', - 'messenger', - 'waypost', - 'sparrow', - 'satchel', - 'signalfire', - 'envelope', - 'parcel', - 'gull', - 'kite', - 'beagle', -] - -const getRandomItem = (values: string[]) => values[Math.floor(Math.random() * values.length)] - -export const generateWebhookEndpointName = () => - `${getRandomItem(WEBHOOK_NAME_ADJECTIVES)}-${getRandomItem(WEBHOOK_NAME_NOUNS)}` - const secureRandomHex = (length: number) => { if (length <= 0) return '' @@ -64,8 +19,19 @@ const secureRandomHex = (length: number) => { export const generateSigningSecret = () => `whsec_${secureRandomHex(16)}` -export const getWebhookEndpointDisplayName = (endpoint: Pick) => - endpoint.name.trim() || endpoint.url +export const getWebhookEndpointDisplayName = ( + endpoint: Pick +) => { + const eventTypesLabel = endpoint.eventTypes.includes('*') + ? 'all events' + : endpoint.eventTypes.join(', ') + return `${endpoint.url} (${eventTypesLabel})` +} + +export const getTestWebhookEventType = ( + subscribedEventTypes: string[], + fallbackEventTypes: string[] +) => subscribedEventTypes.find((eventType) => eventType !== '*') ?? fallbackEventTypes[0] const normalizeSearch = (value: string) => value.trim().toLowerCase() diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx index 51e7906e68d..a6d8357110a 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx @@ -46,7 +46,6 @@ const SELECTED_ENDPOINT_ID = '7f2c9d4a-6e31-4d9d-9a1f-2c4b5e6f7081' const selectedEndpoint: WebhookEndpoint = { id: SELECTED_ENDPOINT_ID, - name: 'Lovable production', url: 'https://api.lovable.dev/webhooks/supabase', description: 'Primary organization webhook endpoint', enabled: true, @@ -61,6 +60,7 @@ const createDelivery = (overrides: Partial): WebhookDelivery => endpointId: SELECTED_ENDPOINT_ID, eventId: 'event', eventType: 'v1.project.updated', + eventPayload: {}, status: 'success', responseCode: 200, responseBody: null, diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx index 5a135605e04..f520b2f5e4d 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -78,6 +78,11 @@ const DELIVERY_COLUMNS: ColumnDef[] = [ {row.original.status} ), }, + { + accessorKey: 'eventType', + header: ({ column }) => Event, + cell: ({ row }) => {row.original.eventType}, + }, { accessorKey: 'responseCode', header: ({ column }) => Response, @@ -158,7 +163,6 @@ export const PlatformWebhooksEndpointDetails = ({ onRetryDelivery, }: PlatformWebhooksEndpointDetailsProps) => { const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0 - const hasName = selectedEndpoint.name.trim().length > 0 const hasDescription = selectedEndpoint.description.trim().length > 0 const deliverySearchRef = useRef(null) const [sorting, setSorting] = useState(DEFAULT_DELIVERY_SORTING) @@ -204,8 +208,6 @@ export const PlatformWebhooksEndpointDetails = ({
- {hasName && {selectedEndpoint.name}} - {selectedEndpoint.url} {sortedEndpoints.map((endpoint) => { - const hasName = endpoint.name.trim().length > 0 - const displayName = getWebhookEndpointDisplayName(endpoint) - return ( -

{displayName}

- {hasName && ( -

- {endpoint.url} -

- )} +

{endpoint.url}

{formatEventCount(endpoint.eventTypes)} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx index 7cad2e3b852..a7697289415 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx @@ -7,23 +7,10 @@ import type { WebhookEndpoint } from './PlatformWebhooks.types' import { PlatformWebhooksEndpointSheet, toEndpointPayload } from './PlatformWebhooksEndpointSheet' import { customRender } from '@/tests/lib/custom-render' -const { generateWebhookEndpointNameMock } = vi.hoisted(() => ({ - generateWebhookEndpointNameMock: vi.fn(() => 'winged-envelope'), -})) - -vi.mock(import('./PlatformWebhooks.utils'), async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - generateWebhookEndpointName: generateWebhookEndpointNameMock, - } -}) - const PROJECT_EVENT_TYPES = ['project.updated', 'project.paused'] const createEndpoint = (overrides?: Partial): WebhookEndpoint => ({ id: '3c9b7e21-8d54-4f63-b2a1-6e7d8c9f0a12', - name: 'Billing events', url: 'https://hooks.example.com/billing', description: 'Invoices and receipts', enabled: true, @@ -65,44 +52,16 @@ const findEventTypeCheckbox = (eventType: string) => name: new RegExp(eventType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), }) describe('PlatformWebhooksEndpointSheet', () => { - it('prefills the name field when creating an endpoint', async () => { - renderEndpointSheet() - - expect(await screen.findByDisplayValue('winged-envelope')).toBeInTheDocument() - }) - - it('loads the existing name and description in edit mode', async () => { + it('loads the existing url and description in edit mode', async () => { renderEndpointSheet({ mode: 'edit', endpoint: createEndpoint(), }) - expect(await screen.findByDisplayValue('Billing events')).toBeInTheDocument() + expect(await screen.findByDisplayValue('https://hooks.example.com/billing')).toBeInTheDocument() expect(screen.getByDisplayValue('Invoices and receipts')).toBeInTheDocument() }) - it('submits an empty name when an existing name is cleared', async () => { - const user = userEvent.setup() - const { onSubmit } = renderEndpointSheet({ - mode: 'edit', - endpoint: createEndpoint(), - }) - - const nameInput = await screen.findByDisplayValue('Billing events') - await user.clear(nameInput) - submitForm() - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - url: 'https://hooks.example.com/billing', - description: 'Invoices and receipts', - }), - expect.anything() - ) - }) - it('blocks submit when the endpoint URL is empty', async () => { const user = userEvent.setup() const { onSubmit } = renderEndpointSheet() @@ -248,7 +207,6 @@ describe('PlatformWebhooksEndpointSheet', () => { it('strips fully empty custom header rows from the payload', () => { expect( toEndpointPayload({ - name: 'Billing events', url: 'https://hooks.example.com/billing', description: '', enabled: true, diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx index 9f7dcfafc20..8cd520978ec 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -39,7 +39,7 @@ import type { WebhookEndpoint, WebhookScope, } from './PlatformWebhooks.types' -import { generateSigningSecret, generateWebhookEndpointName } from './PlatformWebhooks.utils' +import { generateSigningSecret } from './PlatformWebhooks.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { InlineLink } from '@/components/ui/InlineLink' import { Shortcut } from '@/components/ui/Shortcut' @@ -50,7 +50,6 @@ import { SHORTCUT_IDS } from '@/state/shortcuts/registry' const buildEndpointFormSchema = (mode: 'create' | 'edit') => z .object({ - name: z.string().trim().max(64, 'Name cannot exceed 64 characters'), url: httpEndpointUrlSchema({ requiredMessage: 'Please provide a URL', invalidMessage: 'Please provide a valid URL', @@ -153,7 +152,6 @@ const toControlId = (prefix: string, value: string) => `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}` export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({ - name: values.name, url: values.url, description: values.description, enabled: values.enabled, @@ -192,7 +190,6 @@ export const PlatformWebhooksEndpointSheet = ({ const form = useForm({ resolver: zodResolver(buildEndpointFormSchema(mode)), defaultValues: { - name: generateWebhookEndpointName(), url: '', description: '', enabled: true, @@ -225,7 +222,6 @@ export const PlatformWebhooksEndpointSheet = ({ if (!endpoint) { form.reset({ - name: generateWebhookEndpointName(), url: '', description: '', enabled: true, @@ -238,7 +234,6 @@ export const PlatformWebhooksEndpointSheet = ({ } form.reset({ - name: endpoint.name, url: endpoint.url, description: endpoint.description, enabled: enabledOverride ?? endpoint.enabled, @@ -278,8 +273,8 @@ export const PlatformWebhooksEndpointSheet = ({ {mode === 'create' ? 'Create endpoint' : 'Edit endpoint'} {mode === 'create' - ? 'Create a webhook endpoint by setting a name, URL, and event subscriptions.' - : 'Edit this webhook endpoint name, URL, and event subscriptions.'} + ? 'Create a webhook endpoint by setting a URL and event subscriptions.' + : 'Edit this webhook endpoint URL and event subscriptions.'} @@ -291,27 +286,6 @@ export const PlatformWebhooksEndpointSheet = ({ onSubmit={form.handleSubmit(onSubmit)} >
- ( - - Name - {/* Technically optional but encourage, so no (optional) label */} - - } - layout="vertical" - className="gap-1" - > - - - - - )} - /> - (null) const [deliveryDetailsTab, setDeliveryDetailsTab] = useState<'event' | 'response'>('event') const [pendingCreatedEndpointId, setPendingCreatedEndpointId] = useState(null) @@ -159,33 +167,22 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage ) const isEndpointView = !!selectedEndpoint - const selectedEndpointHasName = selectedEndpoint ? selectedEndpoint.name.trim().length > 0 : false const selectedEndpointDisplayName = selectedEndpoint ? getWebhookEndpointDisplayName(selectedEndpoint) : '' const headerTitle = isEndpointView ? selectedEndpointDisplayName : scopeLabel - const headerDescription = isEndpointView - ? selectedEndpointHasName - ? (selectedEndpoint?.url ?? '') - : '' - : scopeDescription + const headerDescription = isEndpointView ? '' : scopeDescription const endpointPendingDelete = useMemo( () => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null, [endpoints, endpointIdPendingDelete] ) - const endpointPendingDeleteHasName = endpointPendingDelete - ? endpointPendingDelete.name.trim().length > 0 - : false const endpointPendingDeleteDisplayName = endpointPendingDelete ? getWebhookEndpointDisplayName(endpointPendingDelete) : '' - let deleteEndpointDescription = 'This action cannot be undone.' - if (endpointPendingDelete) { - deleteEndpointDescription = endpointPendingDeleteHasName - ? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.` - : 'Deleting this endpoint stops all deliveries to the URL below. This can’t be undone.' - } + const deleteEndpointDescription = endpointPendingDelete + ? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.` + : 'This action cannot be undone.' const shouldRedirect = shouldRedirectFromWebhooks({ areFlagsReady: isFeaturePreviewInitialized, @@ -360,10 +357,14 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage toast.success('Signing secret regenerated') } - const handleSendTestEvent = async () => { + const handleSendTestEvent = async (eventType: string) => { if (!selectedEndpoint) return try { - await testEndpointMutation.mutateAsync({ scope: apiScope, id: selectedEndpoint.id }) + await testEndpointMutation.mutateAsync({ + scope: apiScope, + id: selectedEndpoint.id, + eventType, + }) } catch (error) { console.error('Failed to send webhook test event', error) return @@ -371,6 +372,23 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage toast.success('Test event sent — check deliveries shortly') } + const testEventTypeChoices = selectedEndpoint + ? selectedEndpoint.eventTypes.includes('*') + ? eventTypeOptions + : selectedEndpoint.eventTypes + : [] + + const handleSendTestEventClick = () => { + if (!selectedEndpoint) return + if (testEventTypeChoices.length <= 1) { + const eventType = getTestWebhookEventType(selectedEndpoint.eventTypes, eventTypeOptions) + if (eventType) handleSendTestEvent(eventType) + return + } + setTestEventType(testEventTypeChoices[0]) + setShowTestEventPicker(true) + } + const handleRetryDelivery = async (deliveryId: string) => { if (!selectedEndpoint) return const delivery = deliveries.find((item) => item.id === deliveryId) @@ -462,7 +480,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage