|
| 1 | +# Webhook Deliveries Page |
| 2 | + |
| 3 | +**Date:** 2026-05-20 |
| 4 | +**Branch:** bruno/fix/webhooks-models-list |
| 5 | +**Status:** Approved (rev 2 — post code-review fixes) |
| 6 | + |
| 7 | +## Goal |
| 8 | + |
| 9 | +Add a global webhook deliveries page to the admin UI where users can browse all deliveries across all webhooks, filter by app/entity/event/status, and inspect each delivery's full detail inline via an accordion. Delivery detail components must be reusable across accordion, drawer, and dialog contexts. |
| 10 | + |
| 11 | +## Architecture Overview |
| 12 | + |
| 13 | +Three layers of change: |
| 14 | + |
| 15 | +1. **API** — remove the required `webhookId` top-level arg from `listWebhookDeliveries`; expose a `where` GraphQL input that maps directly to the existing `IListWebhookDeliveriesInputWhere` use-case type; add `responseHeaders` to the GraphQL delivery type |
| 16 | +2. **SDK** — update method signature and query to match; add `responseHeaders` to the TypeScript type and selection set |
| 17 | +3. **Admin UI** — extract reusable `DeliveryDetailContent`, build new deliveries page with presenter and filter bar |
| 18 | + |
| 19 | +### Reusable delivery detail component tree |
| 20 | + |
| 21 | +``` |
| 22 | +DeliveryDetailContent ← pure: takes WebhookDelivery, renders all fields |
| 23 | + ↑ |
| 24 | + used by: |
| 25 | + ├── DeliveryAccordionRow ← inline expand/collapse wrapper (new page) |
| 26 | + ├── DeliveryDetail ← existing drawer wrapper (refactored to delegate here) |
| 27 | + └── DeliveryDialog ← future modal wrapper (not built now) |
| 28 | +``` |
| 29 | + |
| 30 | +The resend button and close button stay in each wrapper — not in `DeliveryDetailContent`. |
| 31 | + |
| 32 | +## API Changes |
| 33 | + |
| 34 | +### `listWebhookDeliveries` — GraphQL schema |
| 35 | + |
| 36 | +Replace the current signature: |
| 37 | + |
| 38 | +```graphql |
| 39 | +# before |
| 40 | +listWebhookDeliveries(webhookId: ID!, limit: Int, after: String): WebhookDeliveryListResponse! |
| 41 | +``` |
| 42 | + |
| 43 | +with: |
| 44 | + |
| 45 | +```graphql |
| 46 | +# after |
| 47 | +listWebhookDeliveries( |
| 48 | + where: WebhookDeliveryListWhereInput |
| 49 | + limit: Int |
| 50 | + after: String |
| 51 | +): WebhookDeliveryListResponse! |
| 52 | + |
| 53 | +input WebhookDeliveryListWhereInput { |
| 54 | + webhookId_eq: ID |
| 55 | + eventType_in: [String!] |
| 56 | + status_in: [String!] |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +These three operators map directly to the existing `IListWebhookDeliveriesInputWhere` type (which already extends `IdInterfaceGenerator<"webhookId">`, `TextInterfaceGenerator<"eventType">`, and `TextInterfaceGenerator<WebhookDeliveryStatus>`). The resolver passes `args.where` straight to the use case — no translation layer needed. |
| 61 | + |
| 62 | +The existing drawer calls `listWebhookDeliveries(webhookId: ...)` — update its gateway to pass `where: { webhookId_eq: id }` instead. |
| 63 | + |
| 64 | +### `WebhookDelivery` GraphQL type |
| 65 | + |
| 66 | +Add the missing field: |
| 67 | + |
| 68 | +```graphql |
| 69 | +type WebhookDelivery { |
| 70 | + # ... existing fields ... |
| 71 | + responseHeaders: JSON # was missing; stored compressed, decompressed by transformer |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +### SDK `listWebhookDeliveries` |
| 76 | + |
| 77 | +Three changes: |
| 78 | + |
| 79 | +1. `ListWebhookDeliveriesParams`: replace `webhookId: string` with `where?: { webhookId_eq?: string; eventType_in?: string[]; status_in?: string[] }` |
| 80 | +2. `WebhookDelivery` type: add `responseHeaders: unknown | null` |
| 81 | +3. GraphQL selection set: add `responseHeaders`; same change needed in `getWebhookDelivery` if it shares the type |
| 82 | + |
| 83 | +## Admin UI |
| 84 | + |
| 85 | +### New reusable component: `DeliveryDetailContent` |
| 86 | + |
| 87 | +Location: `packages/webhooks/src/admin/presentation/WebhookDeliveries/components/DeliveryDetailContent.tsx` |
| 88 | + |
| 89 | +Props: `delivery: WebhookDelivery` |
| 90 | + |
| 91 | +Renders a summary row (always visible) plus these independently collapsible sections: |
| 92 | + |
| 93 | +| Section | Content | Default state | |
| 94 | +|---|---|---| |
| 95 | +| Summary | HTTP status code, status badge, response time (ms), created date | always visible | |
| 96 | +| Payload | JSON block | expanded | |
| 97 | +| Request headers | JSON block | collapsed | |
| 98 | +| Response headers | JSON block | collapsed | |
| 99 | +| Response body | text block | collapsed | |
| 100 | + |
| 101 | +Uses `Accordion` from `@webiny/admin-ui` for the collapsible sections internally. |
| 102 | + |
| 103 | +### Refactor: `DeliveryDetail.tsx` |
| 104 | + |
| 105 | +Replace its inline field rendering with `<DeliveryDetailContent delivery={delivery} />`. The close `IconButton` and resend `Button` stay in this wrapper unchanged. |
| 106 | + |
| 107 | +### New component: `DeliveryAccordionRow` |
| 108 | + |
| 109 | +Location: `packages/webhooks/src/admin/presentation/WebhookDeliveries/components/DeliveryAccordionRow.tsx` |
| 110 | + |
| 111 | +A thin wrapper around `Accordion.Item` from `@webiny/admin-ui`: |
| 112 | + |
| 113 | +- `title`: event type string |
| 114 | +- `subtitle`: status `Tag` + HTTP code + response time |
| 115 | +- `actions`: resend `Accordion.Item.Action` (icon button) |
| 116 | +- `children`: `<DeliveryDetailContent delivery={delivery} />` |
| 117 | +- `open` / `onOpenChange`: controlled externally by the presenter |
| 118 | + |
| 119 | +`Accordion` does not have a built-in "single open" mode — the presenter enforces it by tracking `expandedDeliveryId` and passing `open={expandedDeliveryId === delivery.id}` to each row. Clicking an already-open row passes `false` to `onOpenChange`, collapsing it. |
| 120 | + |
| 121 | +The existing `WebhookDeliveriesDrawer` continues to use `DataTable` — `DeliveryAccordionRow` is only used on the new deliveries page. |
| 122 | + |
| 123 | +### New page: `WebhookDeliveriesPage` |
| 124 | + |
| 125 | +Location: `packages/webhooks/src/admin/presentation/WebhookDeliveriesPage/` |
| 126 | + |
| 127 | +Files: |
| 128 | + |
| 129 | +``` |
| 130 | +WebhookDeliveriesPagePresenter.ts ← filter state, available events, accordion, pagination |
| 131 | +feature.ts ← DI wiring |
| 132 | +components/ |
| 133 | + WebhookDeliveriesPage.tsx ← page root |
| 134 | + DeliveryFilters.tsx ← filter bar |
| 135 | +``` |
| 136 | +
|
| 137 | +**Route:** `/webhooks/deliveries` |
| 138 | +
|
| 139 | +Added to `packages/webhooks/src/admin/routes.ts` as `Routes.Deliveries`, and registered in `WebhookRoutes.tsx` **before** `Routes.Form` to prevent the `/webhooks/:id` pattern from capturing it. The existing `/webhooks/settings` route already uses this ordering — follow the same pattern. |
| 140 | +
|
| 141 | +**Filter bar (`DeliveryFilters.tsx`):** |
| 142 | +
|
| 143 | +| Filter | Type | Data source | |
| 144 | +|---|---|---| |
| 145 | +| App | single-select dropdown | distinct `app` values from `listAvailableWebhookEvents` | |
| 146 | +| Entity | single-select dropdown | `entity` values for selected app; cleared when app changes | |
| 147 | +| Event | single-select dropdown | `eventName` values for selected app+entity; cleared when entity changes | |
| 148 | +| Status | multi-select | hardcoded: `pending`, `delivering`, `delivered`, `failed` | |
| 149 | +
|
| 150 | +**Partial filter semantics:** |
| 151 | +
|
| 152 | +The presenter translates filter selections into the `eventType_in` array by matching against the loaded `availableEvents` list: |
| 153 | +
|
| 154 | +- App selected, no entity/event → include all `eventName` values where `event.app === selectedApp` |
| 155 | +- App + entity, no event → include all `eventName` values where `app` and `entity` both match |
| 156 | +- App + entity + event → include the single matching `eventName` |
| 157 | +- No app selected → omit `eventType_in` entirely (backend returns all deliveries) |
| 158 | +- Status selected → pass `status_in: [...]`; omit when empty |
| 159 | +
|
| 160 | +**On filter change:** reset cursor to `null` (fresh first page), collapse any open accordion item (`expandedDeliveryId = null`), replace the delivery list with the new results. |
| 161 | +
|
| 162 | +**Pagination:** "Load more" button below the accordion. Uses `WebhookDeliveriesDataSource` cursor-based pagination. The button is hidden when `hasMoreItems` is `false`. |
| 163 | +
|
| 164 | +**Delivery list:** An `Accordion` (from `@webiny/admin-ui`) where each item is a `DeliveryAccordionRow`. |
| 165 | +
|
| 166 | +**Loading / empty / error states:** |
| 167 | +
|
| 168 | +- Initial load: show a spinner in place of the accordion |
| 169 | +- Empty result (no deliveries or no matches for current filter): show an empty-state message ("No deliveries found") |
| 170 | +- API error: show an inline error message with a retry button |
| 171 | +- "Load more" in-flight: disable the button and show a spinner on it |
| 172 | +- Resend in-flight: disable the resend button on the affected row |
| 173 | +
|
| 174 | +### Presenter: `WebhookDeliveriesPagePresenter` |
| 175 | +
|
| 176 | +State managed (MobX observable): |
| 177 | +
|
| 178 | +- `availableEvents: WebhookEvent[]` — loaded once on `init()` |
| 179 | +- `filters: { app: string | null; entity: string | null; eventName: string | null; status: string[] }` — current selections |
| 180 | +- `expandedDeliveryId: string | null` — which accordion row is open |
| 181 | +- delegates list/pagination to `ListPresenter` (existing abstraction) with the translated `where` input |
| 182 | +
|
| 183 | +Actions: |
| 184 | +
|
| 185 | +- `init()` — loads available events and first page of deliveries in parallel |
| 186 | +- `setAppFilter(app)` — sets app, clears entity, event; triggers fresh fetch |
| 187 | +- `setEntityFilter(entity)` — sets entity, clears event; triggers fresh fetch |
| 188 | +- `setEventFilter(eventName)` — triggers fresh fetch |
| 189 | +- `setStatusFilter(status[])` — triggers fresh fetch |
| 190 | +- `expandDelivery(id)` — toggles accordion row (set to `null` if already open) |
| 191 | +- `loadMore()` — appends next page |
| 192 | +- `resend(id)` — calls resend use case, then refreshes the current page (same `where` + `after: null`) |
| 193 | +
|
| 194 | +### Feature wiring |
| 195 | +
|
| 196 | +`WebhookDeliveriesPageFeature` registers: |
| 197 | +
|
| 198 | +- `ListWebhookDeliveriesFeature` |
| 199 | +- `ListAvailableEventsFeature` ← admin-side name; file: `packages/webhooks/src/admin/features/listAvailableEvents/feature.ts` |
| 200 | +- `ResendWebhookDeliveryFeature` |
| 201 | +- `WebhookDeliveriesPagePresenterFeature` |
| 202 | +
|
| 203 | +## Data Flow |
| 204 | +
|
| 205 | +``` |
| 206 | +mount → init() |
| 207 | + → listAvailableWebhookEvents() → populate filter dropdowns |
| 208 | + → listWebhookDeliveries(where: {}) → populate accordion (all deliveries) |
| 209 | + |
| 210 | +filter change → setXxxFilter() |
| 211 | + → translate filters → eventType_in[], status_in[] |
| 212 | + → reset cursor, collapse expanded row |
| 213 | + → listWebhookDeliveries(where: { eventType_in, status_in }) |
| 214 | + → replace accordion items |
| 215 | + |
| 216 | +row click → expandDelivery(id) |
| 217 | + → set expandedDeliveryId (or null if same row) |
| 218 | + → Accordion.Item open/close via controlled props |
| 219 | + |
| 220 | +load more → loadMore() |
| 221 | + → listWebhookDeliveries(where: ..., after: cursor) |
| 222 | + → append items to accordion |
| 223 | + |
| 224 | +resend(id) → resend use case |
| 225 | + → refresh: listWebhookDeliveries(where: ..., after: null) |
| 226 | + → replace accordion items (preserves expanded row) |
| 227 | + |
| 228 | +drawer (existing, unchanged behaviour) → WebhookDeliveriesDrawer |
| 229 | + → gateway updated: listWebhookDeliveries(where: { webhookId_eq: id }) |
| 230 | +``` |
| 231 | +
|
| 232 | +## What is NOT in scope |
| 233 | +
|
| 234 | +- `DeliveryDialog` wrapper (future — `DeliveryDetailContent` is ready for it) |
| 235 | +- Bulk resend |
| 236 | +- Export / download |
| 237 | +- Search by payload content |
| 238 | +- Delivery analytics / metrics |
| 239 | +- The existing per-webhook drawer (stays as-is; only the gateway call changes) |
0 commit comments