diff --git a/.changeset/webhook-form-pickers.md b/.changeset/webhook-form-pickers.md new file mode 100644 index 000000000..4c002caa0 --- /dev/null +++ b/.changeset/webhook-form-pickers.md @@ -0,0 +1,21 @@ +--- +"@objectstack/plugin-webhooks": minor +--- + +Webhook form: pick, don't type. The `sys_webhook` create/edit form made admins +hand-type machine data in three fields; they're now proper controls (extends the +`sys_sharing_rule` pass): + +- `method` — free text → **select** (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`). Option + values are lowercased by `Field.select`; the auto-enqueuer now upper-cases the + resolved method before delivery, so legacy `'POST'` rows and the new lowercase + values both normalise to a canonical HTTP method. +- `triggers` — hand-typed comma-separated string → **multi-select** + (`create`/`update`/`delete`/`undelete`/`api`). Stored as an array; the + auto-enqueuer's `parseRow` now accepts array, JSON-encoded-array-string, and + the legacy comma-separated forms, so existing subscriptions keep firing. +- `object_name` — free text → the **`object-ref`** object picker (same widget as + `sys_sharing_rule`; degrades to a text input where the widget isn't loaded). + +Backward compatible: no data migration required. Added tests covering the array +and JSON-string trigger shapes. diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts index e80406ef0..2e4a7afda 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts @@ -205,6 +205,42 @@ describe('AutoEnqueuer', () => { await ae.stop(); }); + it('parses triggers authored as a multi-select array', async () => { + // The `triggers` field is now a multi-select stored as an array; the + // parser must treat it identically to the legacy CSV form. + const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: ['create'] })] }); + const realtime = new FakeRealtime(); + const { enqueue, calls } = makeRecorder(); + const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 }); + await ae.start(); + + await realtime.publish(event('created', 'contact', { id: 'c-1' })); + await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z')); + await flush(); + + expect(calls).toHaveLength(1); + expect(calls[0].label).toBe('data.record.created'); + await ae.stop(); + }); + + it('parses triggers stored as a JSON-encoded array string', async () => { + // Some drivers hand a JSON array column back as a string — accept it. + const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '["create","update"]' })] }); + const realtime = new FakeRealtime(); + const { enqueue, calls } = makeRecorder(); + const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 }); + await ae.start(); + + await realtime.publish(event('created', 'contact', { id: 'c-1' })); + await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z')); + await flush(); + + // create + update are subscribed; delete is not. + expect(calls).toHaveLength(1); + expect(calls[0].label).toBe('data.record.created'); + await ae.stop(); + }); + it('fans out to multiple matching webhooks', async () => { const engine = new FakeEngine({ sys_webhook: [ diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index 868a82782..6cd46fb54 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -205,11 +205,30 @@ export class AutoEnqueuer { private parseRow(row: any): CachedSubscription | null { if (!row?.id || !row?.url) return null; - const triggersField = (row.triggers ?? '') as string; + // `triggers` is now authored as a multi-select (stored as an array), but + // legacy rows stored a comma-separated string (and some drivers hand a + // JSON-encoded array back as a string). Accept all three shapes so a + // schema change never silently drops a subscription's events. + const rawTriggers = row.triggers; + let triggerList: string[]; + if (Array.isArray(rawTriggers)) { + triggerList = rawTriggers.map((t) => String(t)); + } else { + const s = String(rawTriggers ?? '').trim(); + if (s.startsWith('[')) { + try { + const parsed = JSON.parse(s); + triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s]; + } catch { + triggerList = s.split(','); + } + } else { + triggerList = s.split(','); + } + } const triggers = new Set( - triggersField - .split(',') - .map((s: string) => s.trim().toLowerCase()) + triggerList + .map((t) => t.trim().toLowerCase()) .filter(Boolean) as Array<'create' | 'update' | 'delete' | 'undelete'>, ); if (triggers.size === 0) { @@ -235,7 +254,11 @@ export class AutoEnqueuer { objectName: row.object_name ? String(row.object_name) : undefined, triggers, url: String(row.url), - method: row.method ?? defn.method ?? 'POST', + // Method is authored via a select whose option values are lowercased + // (get/post/…); upper-case here so delivery uses a canonical HTTP + // method regardless of whether the row was authored before or after + // the select change (legacy rows stored 'POST'). + method: String(row.method ?? defn.method ?? 'POST').toUpperCase(), headers: defn.headers, secret: defn.secret, timeoutMs: defn.timeoutMs, diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 0a07e1f2f..adbd35f62 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -113,17 +113,26 @@ export const SysWebhook = ObjectSchema.create({ label: 'Object', required: false, maxLength: 100, + // Object picker (same widget as sys_sharing_rule) instead of a free-text + // machine name. Falls back to a text input when the widget is unavailable. + widget: 'object-ref', description: 'Short object name whose events fire this webhook (blank = manual / API-triggered)', group: 'Definition', }), - triggers: Field.text({ - label: 'Triggers', - required: false, - maxLength: 200, - description: 'Comma-separated event list: create,update,delete,undelete,api', - group: 'Definition', - }), + triggers: Field.select( + ['create', 'update', 'delete', 'undelete', 'api'], + { + label: 'Triggers', + required: false, + // Multi-select instead of a hand-typed comma-separated string. Stored as + // an array; the auto-enqueuer parser also tolerates the legacy + // comma-separated / JSON-string forms so existing rows keep working. + multiple: true, + description: 'Record events that fire this webhook (empty = manual / API-triggered)', + group: 'Definition', + }, + ), url: Field.text({ label: 'Target URL', @@ -133,14 +142,20 @@ export const SysWebhook = ObjectSchema.create({ group: 'Definition', }), - method: Field.text({ - label: 'HTTP Method', - required: true, - defaultValue: 'POST', - maxLength: 10, - description: 'GET / POST / PUT / PATCH / DELETE', - group: 'Definition', - }), + method: Field.select( + ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + { + label: 'HTTP Method', + required: true, + // Select instead of free text. Option values are lowercased by the + // Field.select helper (get/post/…); the auto-enqueuer upper-cases the + // resolved method before delivery, so existing 'POST' rows and the + // lowercase option values both normalise correctly. + defaultValue: 'post', + description: 'HTTP method used for the callback request', + group: 'Definition', + }, + ), description: Field.textarea({ label: 'Description', required: false, group: 'Definition' }),