From 7a2c1ca279d976e73396fff735d23b987b7d6b3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 14:49:15 +0000 Subject: [PATCH] feat(app-shell): Studio flow start node offers a "Record created or updated" trigger (#3427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018939yJ413zG3irLzcTtqaa --- .changeset/flow-created-or-updated-trigger.md | 12 ++++++++++++ packages/app-shell/src/views/metadata-admin/i18n.ts | 1 + .../inspectors/flow-node-config.test.ts | 8 ++++++++ .../metadata-admin/inspectors/flow-node-config.ts | 7 ++++--- .../metadata-admin/inspectors/flow-scope.test.ts | 10 ++++++++++ .../views/metadata-admin/inspectors/flow-scope.ts | 8 +++++++- 6 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 .changeset/flow-created-or-updated-trigger.md diff --git a/.changeset/flow-created-or-updated-trigger.md b/.changeset/flow-created-or-updated-trigger.md new file mode 100644 index 000000000..2ac8e520b --- /dev/null +++ b/.changeset/flow-created-or-updated-trigger.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index a3252c4d3..a8889b519 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3210,6 +3210,7 @@ const FLOW_FIELD_ZH: Record> = { opts: { 'record-after-create': '记录创建后', 'record-after-update': '记录更新后', + 'record-after-write': '记录创建或更新后', 'record-before-update': '记录更新前', 'record-after-delete': '记录删除后', 'record-change': '记录变更(任意)', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.test.ts index 5084a8a5c..08cba9e0a 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.test.ts @@ -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); 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 9d8173edd..326126cc2 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 @@ -247,6 +247,7 @@ const FLOW_NODE_CONFIG: Record = { 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)' }, @@ -261,12 +262,12 @@ const FLOW_NODE_CONFIG: Record = { 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 diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.test.ts index f68e92096..ddb83d73d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.test.ts @@ -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'); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts index 655376d27..7b0dc279f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts @@ -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', ]);