From cfb512ab935d72b6c17d18a2cb41c55af32656c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 16:13:24 +0000 Subject: [PATCH] feat(flow-designer): first-class panel for the time-relative trigger (#1874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The start-node inspector gains a "Time-relative (date sweep)" trigger option. Selecting it reveals typed fields for the backend's `config.timeRelative` descriptor (framework #1874) — Sweep object, Date field, Within days, Offset days, Extra filter, Max records — plus the per-record Entry condition. Before this, the descriptor was only authorable via the Advanced (JSON) block. Adds a `numberList` config-field kind: a string-list editor that coerces to `number[]` on commit, so the "Offset days" field emits numbers (matching the backend's strict `z.array(z.number())`) instead of strings. All fields are rooted at the nested `config.timeRelative` block, which the group fully owns (configKeyOf → 'timeRelative'), so the block never leaks into Advanced JSON — same pattern as the approval `escalation.*` fields. Verified in a real browser via the console preview gallery (offline): the panel renders and gates to the time_relative trigger, and the Offset days list editor accepts input. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AHzW68suiFuu6GdJyea8U4 --- .changeset/time-relative-trigger-designer.md | 18 +++++ .../inspectors/FlowNodeConfigField.tsx | 21 ++++++ .../inspectors/flow-node-config.test.ts | 44 ++++++++++++- .../inspectors/flow-node-config.ts | 65 ++++++++++++++++++- 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 .changeset/time-relative-trigger-designer.md diff --git a/.changeset/time-relative-trigger-designer.md b/.changeset/time-relative-trigger-designer.md new file mode 100644 index 000000000..72a70a203 --- /dev/null +++ b/.changeset/time-relative-trigger-designer.md @@ -0,0 +1,18 @@ +--- +"@object-ui/app-shell": minor +--- + +feat(flow-designer): first-class panel for the time-relative trigger (#1874) + +The flow designer's start-node inspector now offers a **Time-relative (date sweep)** +trigger option alongside record / schedule triggers. Picking it reveals typed +fields for the backend's `config.timeRelative` descriptor — Sweep object, Date +field, Within days (range mode), Offset days (T-minus mode), an Extra filter, and +Max records — instead of hand-writing the block in the Advanced JSON editor. The +per-record Entry condition is available too. + +Adds a `numberList` config-field kind (a string-list editor that commits +`number[]`), so **Offset days** authors emit numbers rather than strings — keeping +the backend schema (`z.array(z.number())`) strict rather than coercing on the +consumer side. All fields live under the nested `config.timeRelative` block, which +the group fully owns, so it never double-renders in Advanced JSON. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx index 799c071e7..3f6b21bbd 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx @@ -79,6 +79,27 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, emptyLabel={t('engine.inspector.flowNode.list.empty', locale)} /> ); + case 'numberList': + return ( + String(n)) : value} + onCommit={(v) => { + if (v == null) return onCommit(undefined); + const nums = v.map((s) => Number(String(s).trim())).filter((n) => Number.isFinite(n)); + onCommit(nums.length ? nums : undefined); + }} + disabled={disabled} + addLabel={t('engine.inspector.flowNode.list.add', locale)} + itemLabel={t('engine.inspector.flowNode.list.item', locale)} + removeLabel={t('engine.inspector.flowNode.list.remove', locale)} + emptyLabel={t('engine.inspector.flowNode.list.empty', locale)} + /> + ); case 'objectList': return ( { const fields = fieldsForNodeType('start'); @@ -37,6 +37,48 @@ describe('start node trigger-field gating (#5)', () => { }); }); +describe('time-relative trigger fields (#1874)', () => { + const fields = fieldsForNodeType('start'); + const triggerType = fields.find((f) => f.id === 'triggerType')!; + const trFields = [ + 'timeRelative.object', + 'timeRelative.dateField', + 'timeRelative.withinDays', + 'timeRelative.offsetDays', + 'timeRelative.filter', + 'timeRelative.maxRecords', + ].map((id) => fields.find((f) => f.id === id)!); + + it('offers a time_relative option on the trigger select', () => { + expect(triggerType.options?.some((o) => o.value === 'time_relative')).toBe(true); + }); + + it('maps each descriptor field to the right kind under the nested config.timeRelative block', () => { + const byId = Object.fromEntries(trFields.map((f) => [f.id, f])); + expect(byId['timeRelative.object'].kind).toBe('reference'); + expect(byId['timeRelative.dateField'].kind).toBe('text'); + expect(byId['timeRelative.withinDays'].kind).toBe('number'); + // Offset days is a number[] — a numberList so the designer emits numbers, not + // strings (the backend schema is strict `z.array(z.number())`). + expect(byId['timeRelative.offsetDays'].kind).toBe('numberList'); + expect(byId['timeRelative.filter'].kind).toBe('keyValue'); + expect(byId['timeRelative.maxRecords'].kind).toBe('number'); + }); + + it('shows the descriptor fields only for a time_relative trigger', () => { + const trNode = { id: 'start', type: 'start', config: { triggerType: 'time_relative' } }; + const schedNode = { id: 'start', type: 'start', config: { triggerType: 'schedule' } }; + for (const f of trFields) { + expect(isFieldVisible(f, trNode, fields)).toBe(true); + expect(isFieldVisible(f, schedNode, fields)).toBe(false); + } + }); + + it('claims the whole config.timeRelative block so it never leaks to Advanced JSON', () => { + for (const f of trFields) expect(configKeyOf(f)).toBe('timeRelative'); + }); +}); + describe('approval node config (ADR-0044)', () => { const fields = fieldsForNodeType('approval'); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index 653693d57..036263115 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -33,6 +33,7 @@ export type FlowConfigFieldKind = | 'textarea' | 'keyValue' | 'stringList' + | 'numberList' | 'objectList' | 'reference'; @@ -218,6 +219,7 @@ const FLOW_NODE_CONFIG: Record = { { value: 'record-after-delete', label: 'Record deleted' }, { value: 'record-change', label: 'Record changed (any)' }, { value: 'schedule', label: 'Schedule (cron)' }, + { value: 'time_relative', label: 'Time-relative (date sweep)' }, { value: 'manual', label: 'Manual / autolaunched' }, { value: 'webhook', label: 'Webhook / API' }, { value: 'event', label: 'Platform event' }, @@ -231,14 +233,73 @@ const FLOW_NODE_CONFIG: Record = { }), cfg('condition', 'Entry condition', 'expression', { placeholder: 'status == "qualifying" && previous.status != "qualifying"', - help: 'CEL predicate — the flow runs only when this is true. Leave empty to run on every event.', - showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] }, + help: 'CEL predicate — the flow runs only when this is true (for time-relative sweeps it gates each matched record). Leave empty to run on every event.', + showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'time_relative', 'webhook', 'event'] }, }), cfg('cron', 'Cron schedule', 'text', { placeholder: '0 7 * * *', help: 'Cron expression for scheduled triggers.', showWhen: { field: 'triggerType', equals: ['schedule'] }, }), + // Time-relative trigger (#1874) — a `config.timeRelative` descriptor sweeps an + // object on a schedule (daily by default) and launches the flow once per record + // whose date field falls in the window. All fields live under the nested + // `config.timeRelative` block (which the whole group "owns", so it never leaks + // to Advanced JSON — same pattern as the approval `escalation.*` block). + { + id: 'timeRelative.object', + path: ['config', 'timeRelative', 'object'], + label: 'Sweep object', + kind: 'reference', + ref: { kind: 'object' }, + placeholder: 'contracts', + help: 'Object whose records are swept each run.', + showWhen: { field: 'triggerType', equals: ['time_relative'] }, + }, + { + id: 'timeRelative.dateField', + path: ['config', 'timeRelative', 'dateField'], + label: 'Date field', + kind: 'text', + placeholder: 'end_date', + help: 'The date / datetime field compared against today.', + showWhen: { field: 'triggerType', equals: ['time_relative'] }, + }, + { + id: 'timeRelative.withinDays', + path: ['config', 'timeRelative', 'withinDays'], + label: 'Within days', + kind: 'number', + placeholder: '30', + help: 'Range mode: fire while the date is within N days of today (negative = overdue lookback). Leave empty if using Offset days.', + showWhen: { field: 'triggerType', equals: ['time_relative'] }, + }, + { + id: 'timeRelative.offsetDays', + path: ['config', 'timeRelative', 'offsetDays'], + label: 'Offset days', + kind: 'numberList', + placeholder: '60', + help: 'Offset mode: fire when the date is exactly today + each offset (e.g. 60, 30, 7). Leave empty if using Within days.', + showWhen: { field: 'triggerType', equals: ['time_relative'] }, + }, + { + id: 'timeRelative.filter', + path: ['config', 'timeRelative', 'filter'], + label: 'Extra filter', + kind: 'keyValue', + help: 'Optional filter ANDed with the date window (e.g. status = active).', + showWhen: { field: 'triggerType', equals: ['time_relative'] }, + }, + { + id: 'timeRelative.maxRecords', + path: ['config', 'timeRelative', 'maxRecords'], + label: 'Max records / run', + kind: 'number', + placeholder: '1000', + help: 'Cap on records launched per sweep (default 1000).', + showWhen: { field: 'triggerType', equals: ['time_relative'] }, + }, // Legacy keys — rendered only when present so older metadata never falls // back to raw JSON. Prefer `condition` / `cron` above for new flows. cfg('criteria', 'Entry condition (legacy)', 'expression', {