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
12 changes: 12 additions & 0 deletions .changeset/flow-created-or-updated-trigger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@object-ui/app-shell": patch
---

feat(app-shell): Studio flow start node offers a "Record created or updated" trigger (#3427)

The record-change trigger now supports `record-after-write` (create OR update in
one flow), so the flow designer's start-node trigger picker offers a "Record
created or updated" option. Selecting it shows the Object and Entry-condition
fields, and the scope resolver puts both `record` and `previous` in scope for it
(`previous == null` is how an author branches the create leg) — mirroring the
runtime binding that fires the flow on both insert and update.
1 change: 1 addition & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3210,6 +3210,7 @@ const FLOW_FIELD_ZH: Record<string, Record<string, FlowFieldZh>> = {
opts: {
'record-after-create': '记录创建后',
'record-after-update': '记录更新后',
'record-after-write': '记录创建或更新后',
'record-before-update': '记录更新前',
'record-after-delete': '记录删除后',
'record-change': '记录变更(任意)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ describe('start node trigger-field gating (#5)', () => {
expect(isFieldVisible(condition, node, fields)).toBe(true);
});

it('offers a "created or updated" (record-after-write) option and shows the record fields for it (#3427)', () => {
const triggerType = fields.find((f) => f.id === 'triggerType')!;
expect(triggerType.options?.some((o) => o.value === 'record-after-write')).toBe(true);
const node = { id: 'start', type: 'start', config: { triggerType: 'record-after-write' } };
expect(isFieldVisible(objectName, node, fields)).toBe(true);
expect(isFieldVisible(condition, node, fields)).toBe(true);
});

it('shows for a schedule trigger too', () => {
const node = { id: 'start', type: 'start', config: { triggerType: 'schedule' } };
expect(isFieldVisible(objectName, node, fields)).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
options: [
{ value: 'record-after-create', label: 'Record created' },
{ value: 'record-after-update', label: 'Record updated' },
{ value: 'record-after-write', label: 'Record created or updated' },
{ value: 'record-before-update', label: 'Record before update' },
{ value: 'record-after-delete', label: 'Record deleted' },
{ value: 'record-change', label: 'Record changed (any)' },
Expand All @@ -261,12 +262,12 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
ref: { kind: 'object' },
placeholder: 'crm_lead',
help: 'Target object for record / scheduled-scan triggers.',
showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-after-write', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
}),
cfg('condition', 'Entry condition', 'expression', {
placeholder: 'status == "qualifying" && previous.status != "qualifying"',
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'] },
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. On a "created or updated" trigger, `previous == null` selects the create path.',
showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-after-write', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'time_relative', 'webhook', 'event'] },
}),
// Schedule descriptor — author the canonical nested `config.schedule` object
// the runtime actually reads (resolveTriggerBinding → normalizeSchedule). This
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ describe('resolveFlowScope — graph-aware in-scope references', () => {
expect(groupTokens(resolveFlowScope(create, 'decide'), 'trigger')).not.toContain('previous');
});

it('offers `record` and `previous` for a create-or-update (record-after-write) trigger (#3427)', () => {
// A write trigger fires on update too, so `previous` must be offered — it is
// how an author branches create vs update (`previous == null`).
const write = { ...draft, nodes: [{ id: 'start', type: 'start', config: { triggerType: 'record-after-write', objectName: 'crm_lead' } }, ...draft.nodes.slice(1)] };
const scope = resolveFlowScope(write, 'decide');
expect(groupTokens(scope, 'trigger')).toContain('record');
expect(groupTokens(scope, 'trigger')).toContain('previous');
expect(scope.trigger).toEqual({ objectName: 'crm_lead', fieldPrefix: 'record.', includePrevious: true });
});

it('de-dupes a name that is both a declared variable and an upstream output', () => {
// `lead_score` is declared AND assigned upstream — it should appear once.
const scope = resolveFlowScope(draft, 'decide');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,20 @@ interface FlowEdgeLike {
const RECORD_TRIGGER_TYPES = new Set([
'record-after-create',
'record-after-update',
'record-after-write', // create OR update (#3427)
'record-before-write',
'record-before-update',
'record-after-delete',
'record-change',
]);
/** Trigger types that carry a meaningful `previous` snapshot of the record. */
/** Trigger types that carry a meaningful `previous` snapshot of the record.
* `record-*-write` fires on update too, so `previous` is offered (empty on the
* create leg — `previous == null` is how authors branch on which happened). */
const PREVIOUS_TRIGGER_TYPES = new Set([
'record-after-update',
'record-before-update',
'record-after-write',
'record-before-write',
'record-change',
]);

Expand Down