From d0cf8dbc50074efbd7e94c6590738d0ca639873b Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:17:56 +0300 Subject: [PATCH] fix: align webhook FE with updated API changes (#51800) ## What Aligns the webhooks FE with backend changes confirmed by Pawel (FE-4621): 1. `custom_headers` is optional but never null, matches the updated contract. 2. Removed the client-only `name` field (never persisted by the API). Display name is now the endpoint's URL plus its subscribed event types. 3. The deliveries list endpoint now returns the full `event` object, so `eventType`/`eventPayload` are always populated. Added an Event column to the deliveries table. Also regenerates `packages/api-types` from production to pick up these changes. While testing, found and fixed two more related issues: 4. A delivery can briefly exist before the backend finishes attaching its event data, regardless of what triggered it (test, retry, or real traffic). The deliveries list now polls every 5s while open, so new or still-settling deliveries fill in automatically instead of requiring a manual refresh. 5. "Send test event" always defaulted to an unrelated sample event type. The test endpoint actually accepts a specific `event_type`, so this now sends the endpoint's own subscribed type automatically, or shows a picker when there's more than one to choose from. ## Testing All webhooks component/unit tests pass (30/30), typecheck and lint clean. Manually verified on localhost against a live backend. ## Note This may clash with #51614 (`custom_headers: null` in a test fixture, no longer a valid value once this merges). Will follow up there after this lands. --- .../Webhooks/PlatformWebhooks.types.ts | 9 +- .../Webhooks/PlatformWebhooks.utils.test.ts | 35 +-- .../Webhooks/PlatformWebhooks.utils.ts | 60 ++--- .../PlatformWebhooksEndpointDetails.test.tsx | 2 +- .../PlatformWebhooksEndpointDetails.tsx | 8 +- .../Webhooks/PlatformWebhooksEndpointList.tsx | 11 +- .../PlatformWebhooksEndpointSheet.test.tsx | 46 +--- .../PlatformWebhooksEndpointSheet.tsx | 32 +-- .../Webhooks/PlatformWebhooksPage.tsx | 90 +++++-- .../interfaces/Platform/Webhooks/index.ts | 5 +- .../platform-webhook-deliveries-query.ts | 5 +- ...atform-webhook-endpoint-create-mutation.ts | 5 +- ...platform-webhook-endpoint-test-mutation.ts | 5 +- ...atform-webhook-endpoint-update-mutation.ts | 4 +- .../platform-webhooks-fetchers.ts | 21 +- .../platform-webhooks-mappers.ts | 31 +-- packages/api-types/types/api-v2.d.ts | 220 +++++++++--------- packages/api-types/types/platform.d.ts | 143 ++++++++++++ 18 files changed, 395 insertions(+), 337 deletions(-) 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