From 1149abbf70fa51425917f7812dcbc8b8c379f66a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 09:31:24 +0000 Subject: [PATCH] feat(webhooks): pick-not-type sys_webhook form (method / triggers / object) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the sharing-rule "pick, don't type" pass to the webhook form — the three fields where an admin had to hand-enter machine data become proper controls: - method: free text → select (GET/POST/PUT/PATCH/DELETE). Values are lowercased by Field.select; auto-enqueuer upper-cases the resolved method before delivery so legacy 'POST' rows and the new lowercase values both normalise. - triggers: hand-typed comma string → multi-select (create/update/delete/ undelete/api), stored as an array. parseRow now accepts array / JSON-string / legacy comma-string, so existing subscriptions keep firing (no migration). - object_name: free text → the object-ref picker (same widget as sys_sharing_rule; degrades to a text input when the widget isn't loaded). Tests added for the array and JSON-string trigger shapes. Framework-only — the object-ref widget already shipped in objectui. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013wGu7aa1YXhHseojW9CBRf --- .changeset/webhook-form-pickers.md | 21 +++++++++ .../plugin-webhooks/src/auto-enqueuer.test.ts | 36 +++++++++++++++ .../plugin-webhooks/src/auto-enqueuer.ts | 33 +++++++++++--- .../plugin-webhooks/src/sys-webhook.object.ts | 45 ++++++++++++------- 4 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 .changeset/webhook-form-pickers.md diff --git a/.changeset/webhook-form-pickers.md b/.changeset/webhook-form-pickers.md new file mode 100644 index 0000000000..4c002caa0b --- /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 e80406ef07..2e4a7afda2 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 868a827821..6cd46fb545 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 0a07e1f2fe..adbd35f624 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' }),