Skip to content

Commit f344ee1

Browse files
os-zhuangclaude
andauthored
feat(webhooks): pick-not-type sys_webhook form (method / triggers / object) (#2880)
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. Claude-Session: https://claude.ai/code/session_013wGu7aa1YXhHseojW9CBRf Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6a72eb commit f344ee1

4 files changed

Lines changed: 115 additions & 20 deletions

File tree

.changeset/webhook-form-pickers.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/plugin-webhooks": minor
3+
---
4+
5+
Webhook form: pick, don't type. The `sys_webhook` create/edit form made admins
6+
hand-type machine data in three fields; they're now proper controls (extends the
7+
`sys_sharing_rule` pass):
8+
9+
- `method` — free text → **select** (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`). Option
10+
values are lowercased by `Field.select`; the auto-enqueuer now upper-cases the
11+
resolved method before delivery, so legacy `'POST'` rows and the new lowercase
12+
values both normalise to a canonical HTTP method.
13+
- `triggers` — hand-typed comma-separated string → **multi-select**
14+
(`create`/`update`/`delete`/`undelete`/`api`). Stored as an array; the
15+
auto-enqueuer's `parseRow` now accepts array, JSON-encoded-array-string, and
16+
the legacy comma-separated forms, so existing subscriptions keep firing.
17+
- `object_name` — free text → the **`object-ref`** object picker (same widget as
18+
`sys_sharing_rule`; degrades to a text input where the widget isn't loaded).
19+
20+
Backward compatible: no data migration required. Added tests covering the array
21+
and JSON-string trigger shapes.

packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,42 @@ describe('AutoEnqueuer', () => {
205205
await ae.stop();
206206
});
207207

208+
it('parses triggers authored as a multi-select array', async () => {
209+
// The `triggers` field is now a multi-select stored as an array; the
210+
// parser must treat it identically to the legacy CSV form.
211+
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: ['create'] })] });
212+
const realtime = new FakeRealtime();
213+
const { enqueue, calls } = makeRecorder();
214+
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
215+
await ae.start();
216+
217+
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
218+
await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z'));
219+
await flush();
220+
221+
expect(calls).toHaveLength(1);
222+
expect(calls[0].label).toBe('data.record.created');
223+
await ae.stop();
224+
});
225+
226+
it('parses triggers stored as a JSON-encoded array string', async () => {
227+
// Some drivers hand a JSON array column back as a string — accept it.
228+
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '["create","update"]' })] });
229+
const realtime = new FakeRealtime();
230+
const { enqueue, calls } = makeRecorder();
231+
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
232+
await ae.start();
233+
234+
await realtime.publish(event('created', 'contact', { id: 'c-1' }));
235+
await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
236+
await flush();
237+
238+
// create + update are subscribed; delete is not.
239+
expect(calls).toHaveLength(1);
240+
expect(calls[0].label).toBe('data.record.created');
241+
await ae.stop();
242+
});
243+
208244
it('fans out to multiple matching webhooks', async () => {
209245
const engine = new FakeEngine({
210246
sys_webhook: [

packages/plugins/plugin-webhooks/src/auto-enqueuer.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,30 @@ export class AutoEnqueuer {
205205

206206
private parseRow(row: any): CachedSubscription | null {
207207
if (!row?.id || !row?.url) return null;
208-
const triggersField = (row.triggers ?? '') as string;
208+
// `triggers` is now authored as a multi-select (stored as an array), but
209+
// legacy rows stored a comma-separated string (and some drivers hand a
210+
// JSON-encoded array back as a string). Accept all three shapes so a
211+
// schema change never silently drops a subscription's events.
212+
const rawTriggers = row.triggers;
213+
let triggerList: string[];
214+
if (Array.isArray(rawTriggers)) {
215+
triggerList = rawTriggers.map((t) => String(t));
216+
} else {
217+
const s = String(rawTriggers ?? '').trim();
218+
if (s.startsWith('[')) {
219+
try {
220+
const parsed = JSON.parse(s);
221+
triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];
222+
} catch {
223+
triggerList = s.split(',');
224+
}
225+
} else {
226+
triggerList = s.split(',');
227+
}
228+
}
209229
const triggers = new Set(
210-
triggersField
211-
.split(',')
212-
.map((s: string) => s.trim().toLowerCase())
230+
triggerList
231+
.map((t) => t.trim().toLowerCase())
213232
.filter(Boolean) as Array<'create' | 'update' | 'delete' | 'undelete'>,
214233
);
215234
if (triggers.size === 0) {
@@ -235,7 +254,11 @@ export class AutoEnqueuer {
235254
objectName: row.object_name ? String(row.object_name) : undefined,
236255
triggers,
237256
url: String(row.url),
238-
method: row.method ?? defn.method ?? 'POST',
257+
// Method is authored via a select whose option values are lowercased
258+
// (get/post/…); upper-case here so delivery uses a canonical HTTP
259+
// method regardless of whether the row was authored before or after
260+
// the select change (legacy rows stored 'POST').
261+
method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),
239262
headers: defn.headers,
240263
secret: defn.secret,
241264
timeoutMs: defn.timeoutMs,

packages/plugins/plugin-webhooks/src/sys-webhook.object.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,26 @@ export const SysWebhook = ObjectSchema.create({
113113
label: 'Object',
114114
required: false,
115115
maxLength: 100,
116+
// Object picker (same widget as sys_sharing_rule) instead of a free-text
117+
// machine name. Falls back to a text input when the widget is unavailable.
118+
widget: 'object-ref',
116119
description: 'Short object name whose events fire this webhook (blank = manual / API-triggered)',
117120
group: 'Definition',
118121
}),
119122

120-
triggers: Field.text({
121-
label: 'Triggers',
122-
required: false,
123-
maxLength: 200,
124-
description: 'Comma-separated event list: create,update,delete,undelete,api',
125-
group: 'Definition',
126-
}),
123+
triggers: Field.select(
124+
['create', 'update', 'delete', 'undelete', 'api'],
125+
{
126+
label: 'Triggers',
127+
required: false,
128+
// Multi-select instead of a hand-typed comma-separated string. Stored as
129+
// an array; the auto-enqueuer parser also tolerates the legacy
130+
// comma-separated / JSON-string forms so existing rows keep working.
131+
multiple: true,
132+
description: 'Record events that fire this webhook (empty = manual / API-triggered)',
133+
group: 'Definition',
134+
},
135+
),
127136

128137
url: Field.text({
129138
label: 'Target URL',
@@ -133,14 +142,20 @@ export const SysWebhook = ObjectSchema.create({
133142
group: 'Definition',
134143
}),
135144

136-
method: Field.text({
137-
label: 'HTTP Method',
138-
required: true,
139-
defaultValue: 'POST',
140-
maxLength: 10,
141-
description: 'GET / POST / PUT / PATCH / DELETE',
142-
group: 'Definition',
143-
}),
145+
method: Field.select(
146+
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
147+
{
148+
label: 'HTTP Method',
149+
required: true,
150+
// Select instead of free text. Option values are lowercased by the
151+
// Field.select helper (get/post/…); the auto-enqueuer upper-cases the
152+
// resolved method before delivery, so existing 'POST' rows and the
153+
// lowercase option values both normalise correctly.
154+
defaultValue: 'post',
155+
description: 'HTTP method used for the callback request',
156+
group: 'Definition',
157+
},
158+
),
144159

145160
description: Field.textarea({ label: 'Description', required: false, group: 'Definition' }),
146161

0 commit comments

Comments
 (0)