diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts index 1a10d55d27..55902f396a 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts @@ -1,4 +1,152 @@ -import type { PlatformWebhooksMockSeed, WebhookScope } from './PlatformWebhooks.types' +import type { + PlatformWebhooksMockSeed, + WebhookDelivery, + WebhookScope, +} from './PlatformWebhooks.types' + +const createDeliveries = ( + endpointId: string, + prefix: string, + deliveries: Array> +): WebhookDelivery[] => + deliveries.map((delivery, index) => ({ + id: `${prefix}-${index + 1}`, + endpointId, + ...delivery, + })) + +const organizationPrimaryDeliveries = createDeliveries('org-endpoint-1', 'org-delivery', [ + { + eventType: 'project.created', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T08:04:00.000Z', + }, + { + eventType: 'project.updated', + status: 'failure', + responseCode: 500, + attemptAt: '2026-02-27T07:56:00.000Z', + }, + { + eventType: 'project.deleted', + status: 'pending', + attemptAt: '2026-02-27T07:45:00.000Z', + }, + { + eventType: 'organization.member_invited', + status: 'success', + responseCode: 202, + attemptAt: '2026-02-27T07:37:00.000Z', + }, + { + eventType: 'project.resumed', + status: 'success', + responseCode: 204, + attemptAt: '2026-02-27T07:18:00.000Z', + }, + { + eventType: 'organization.member_removed', + status: 'failure', + responseCode: 400, + attemptAt: '2026-02-27T06:59:00.000Z', + }, + { + eventType: 'organization.updated', + status: 'skipped', + attemptAt: '2026-02-27T06:40:00.000Z', + }, + { + eventType: 'project.paused', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T06:21:00.000Z', + }, + { + eventType: 'project.created', + status: 'failure', + responseCode: 503, + attemptAt: '2026-02-27T06:03:00.000Z', + }, + { + eventType: 'project.updated', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T05:44:00.000Z', + }, + { + eventType: 'project.deleted', + status: 'skipped', + attemptAt: '2026-02-27T05:25:00.000Z', + }, + { + eventType: 'organization.member_invited', + status: 'success', + responseCode: 201, + attemptAt: '2026-02-27T05:07:00.000Z', + }, +]) + +const organizationSecondaryDeliveries = createDeliveries( + 'org-endpoint-2', + 'org-secondary-delivery', + [ + { + eventType: 'organization.updated', + status: 'skipped', + attemptAt: '2026-02-26T14:12:00.000Z', + }, + ] +) + +const projectDeliveries = createDeliveries('project-endpoint-1', 'project-delivery', [ + { + eventType: 'project.updated', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T09:01:00.000Z', + }, + { + eventType: 'project.resource_exhausted', + status: 'failure', + responseCode: 429, + attemptAt: '2026-02-27T08:47:00.000Z', + }, + { + eventType: 'project.paused', + status: 'success', + responseCode: 202, + attemptAt: '2026-02-26T21:10:00.000Z', + }, + { + eventType: 'project.branch_created', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-26T19:45:00.000Z', + }, + { + eventType: 'project.resumed', + status: 'pending', + attemptAt: '2026-02-26T18:30:00.000Z', + }, + { + eventType: 'project.branch_deleted', + status: 'success', + responseCode: 204, + attemptAt: '2026-02-26T17:04:00.000Z', + }, + { + eventType: 'project.updated', + status: 'failure', + responseCode: 500, + attemptAt: '2026-02-26T15:59:00.000Z', + }, + { + eventType: 'project.resource_exhausted', + status: 'skipped', + attemptAt: '2026-02-26T14:12:00.000Z', + }, +]) export const PLATFORM_WEBHOOKS_MOCK_DATA: Record = { organization: { @@ -39,38 +187,7 @@ export const PLATFORM_WEBHOOKS_MOCK_DATA: Record ({ + DataTableColumnStatusCode: ({ value }: { value: number }) => {value}, +})) + +vi.mock('ui-patterns', async () => { + const actual = await vi.importActual('ui-patterns') + + return { + ...actual, + TimestampInfo: ({ utcTimestamp, className }: { utcTimestamp: string; className?: string }) => ( + {utcTimestamp} + ), + } +}) + +describe('PlatformWebhooksEndpointDetails', () => { + const selectedEndpoint = PLATFORM_WEBHOOKS_MOCK_DATA.organization.endpoints[0] + const allDeliveries = PLATFORM_WEBHOOKS_MOCK_DATA.organization.deliveries.filter( + (delivery) => delivery.endpointId === selectedEndpoint.id + ) + + const renderComponent = ( + props?: Partial> + ) => + render( + + ) + + it('renders paginated deliveries with previous and next controls', async () => { + const user = userEvent.setup() + + renderComponent() + + expect(screen.getByText('Showing 1 to 5 of 12 deliveries')).toBeInTheDocument() + expect(screen.queryByText('organization.member_removed')).not.toBeInTheDocument() + + await user.click(screen.getByLabelText('Next page')) + + expect(screen.getByText('Showing 6 to 10 of 12 deliveries')).toBeInTheDocument() + expect(screen.getByText('organization.member_removed')).toBeInTheDocument() + + await user.click(screen.getByLabelText('Previous page')) + + expect(screen.getByText('Showing 1 to 5 of 12 deliveries')).toBeInTheDocument() + expect(screen.queryByText('organization.member_removed')).not.toBeInTheDocument() + }) + + it('sorts deliveries by event type from the header', async () => { + const user = userEvent.setup() + + renderComponent() + + await user.click(screen.getByRole('button', { name: 'Event type' })) + + expect(screen.getAllByRole('row')[1]).toHaveTextContent('organization.member_invited') + + await user.click(screen.getByRole('button', { name: 'Event type' })) + + expect(screen.getAllByRole('row')[1]).toHaveTextContent('project.updated') + }) + + it('resets to the first page when the delivery search changes', async () => { + const user = userEvent.setup() + const projectDeliveries = allDeliveries.filter((delivery) => + delivery.eventType.includes('project') + ) + const { rerender } = renderComponent() + + await user.click(screen.getByLabelText('Next page')) + expect(screen.getByText('Showing 6 to 10 of 12 deliveries')).toBeInTheDocument() + + rerender( + + ) + + await waitFor(() => { + expect(screen.getByText('Showing 1 to 5 of 8 deliveries')).toBeInTheDocument() + }) + }) +}) diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx index 6d84c04bce..902be3dd68 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -1,11 +1,25 @@ -import { Search } from 'lucide-react' +import { + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type ColumnDef, + type PaginationState, + type SortingState, + useReactTable, +} from '@tanstack/react-table' +import { ChevronLeft, ChevronRight, Search } from 'lucide-react' +import { useEffect, useState, type ReactNode } from 'react' import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DataTableColumnHeader } from 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader' import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' import { Badge, + Button, Card, CardContent, + CardFooter, Table, TableBody, TableCell, @@ -20,7 +34,7 @@ import { statusBadgeVariant } from './PlatformWebhooksView.utils' interface DetailItemProps { label: string - children: React.ReactNode + children: ReactNode ddClassName?: string } @@ -39,6 +53,47 @@ interface PlatformWebhooksEndpointDetailsProps { onOpenDelivery: (deliveryId: string) => void } +const DELIVERIES_PAGE_SIZE = 5 + +const DELIVERY_COLUMNS: ColumnDef[] = [ + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.status} + ), + }, + { + accessorKey: 'eventType', + header: ({ column }) => , + cell: ({ row }) => {row.original.eventType}, + }, + { + accessorKey: 'responseCode', + header: ({ column }) => , + sortingFn: (rowA, rowB, columnId) => { + const responseA = rowA.getValue(columnId) ?? -1 + const responseB = rowB.getValue(columnId) ?? -1 + return responseA - responseB + }, + cell: ({ row }) => + row.original.responseCode ? ( + + ) : ( + + ), + }, + { + accessorKey: 'attemptAt', + header: ({ column }) => , + cell: ({ row }) => , + }, +] + export const PlatformWebhooksEndpointDetails = ({ deliverySearch, filteredDeliveries, @@ -47,6 +102,36 @@ export const PlatformWebhooksEndpointDetails = ({ onOpenDelivery, }: PlatformWebhooksEndpointDetailsProps) => { const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0 + const [sorting, setSorting] = useState([{ id: 'attemptAt', desc: true }]) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: DELIVERIES_PAGE_SIZE, + }) + + const table = useReactTable({ + data: filteredDeliveries, + columns: DELIVERY_COLUMNS, + state: { pagination, sorting }, + getRowId: (row) => row.id, + onPaginationChange: setPagination, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + const paginatedDeliveries = table.getRowModel().rows + const deliveryStartIndex = + table.getState().pagination.pageIndex * table.getState().pagination.pageSize + const deliveryRangeStart = filteredDeliveries.length === 0 ? 0 : deliveryStartIndex + 1 + const deliveryRangeEnd = Math.min( + deliveryStartIndex + table.getState().pagination.pageSize, + filteredDeliveries.length + ) + + useEffect(() => { + setPagination((currentPagination) => ({ ...currentPagination, pageIndex: 0 })) + }, [deliverySearch, selectedEndpoint.id]) return (
@@ -117,57 +202,78 @@ export const PlatformWebhooksEndpointDetails = ({ - - Status - Event type - Response - Attempted - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} - {filteredDeliveries.length > 0 ? ( - filteredDeliveries.map((delivery) => ( + {paginatedDeliveries.length > 0 ? ( + paginatedDeliveries.map((row) => ( onOpenDelivery(delivery.id)} + onClick={() => onOpenDelivery(row.original.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - onOpenDelivery(delivery.id) + onOpenDelivery(row.original.id) } }} tabIndex={0} > - - {delivery.status} - - - {delivery.eventType} - - - {delivery.responseCode ? ( - - ) : ( - - )} - - - - + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} )) ) : ( - - No deliveries found + + +

No deliveries found

+

+ Try adjusting your search to see more webhook attempts. +

+
)}
+ {filteredDeliveries.length > 0 && ( + +

+ Showing {deliveryRangeStart} to {deliveryRangeEnd} of {filteredDeliveries.length}{' '} + deliveries +

+
+
+
+ )}