Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/time-relative-trigger-designer.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
emptyLabel={t('engine.inspector.flowNode.list.empty', locale)}
/>
);
case 'numberList':
return (
<FlowStringListField
label={field.label}
// Stored as number[]; the list editor works in strings, so show each
// number as text and coerce back to number[] on commit (dropping
// blanks / non-numbers). Keeps the backend contract strict (number[])
// rather than persisting string values the schema would reject.
value={Array.isArray(value) ? (value as unknown[]).map((n) => 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 (
<FlowObjectListField
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { fieldsForNodeType, isFieldVisible, getFieldValue } from './flow-node-config';
import { fieldsForNodeType, isFieldVisible, getFieldValue, configKeyOf } from './flow-node-config';

describe('start node trigger-field gating (#5)', () => {
const fields = fieldsForNodeType('start');
Expand Down Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type FlowConfigFieldKind =
| 'textarea'
| 'keyValue'
| 'stringList'
| 'numberList'
| 'objectList'
| 'reference';

Expand Down Expand Up @@ -218,6 +219,7 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
{ 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' },
Expand All @@ -231,14 +233,73 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
}),
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', {
Expand Down
Loading