From aa38ea8f447535231adde10a091559673d92d81c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:42:21 +0000 Subject: [PATCH 1/4] fix(app-shell): the flow start node's trigger select stops offering Platform event `triggerType: 'event'` is a token `resolveFlowTriggerKind` answers no kind for and no engine trigger binds, so a flow authored with it never fired. The option leaves the select, the two start-node `showWhen` lists stop naming it, and the zh overlay drops the option's label. A stored `event` is not rewritten: the select shows it as its flagged unknown value. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- packages/app-shell/src/views/metadata-admin/i18n.ts | 1 - .../metadata-admin/inspectors/flow-node-config.ts | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 98a9c7936c..0a20f87367 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -5898,7 +5898,6 @@ const FLOW_FIELD_ZH: Record> = { time_relative: '相对时间(日期扫描)', manual: '手动 / 自动启动', api: 'Webhook / API', - event: '平台事件', }, }, // objectui#11054 — the inbound hook's per-flow secret (write-only). 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 c132098d10..ee31b4e020 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 @@ -409,7 +409,11 @@ const FLOW_NODE_CONFIG: Record = { // This option used to write `webhook`, which resolves to no trigger // kind at all: the flow bound nothing and never received a post. { value: 'api', label: 'Webhook / API' }, - { value: 'event', label: 'Platform event' }, + // objectui#11067 — no 「Platform event」 option. It wrote + // `triggerType: 'event'`, which `resolveFlowTriggerKind` answers no + // kind for and no engine trigger binds, so a flow authored with it + // never fired. A stored `event` is not rewritten: the select shows it + // as its flagged unknown value, and a save keeps it as written. ], }), // objectui#11054 — the inbound hook's per-flow secret. The engine refuses @@ -430,7 +434,7 @@ 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-after-write', 'record-before-update', 'record-after-delete', 'schedule', 'api', 'event'] }, + showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-after-write', 'record-before-update', 'record-after-delete', 'schedule', 'api'] }, }), cfg('condition', 'Entry condition', 'expression', { // objectui#6226 — the one predicate every business admin meets. Row @@ -441,7 +445,7 @@ const FLOW_NODE_CONFIG: Record = { conditionBuilder: true, 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. 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', 'schedule', 'time_relative', 'api', 'event'] }, + showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-after-write', 'record-before-update', 'record-after-delete', 'schedule', 'time_relative', 'api'] }, }), // Schedule descriptor — author the canonical nested `config.schedule` object // the runtime actually reads (resolveTriggerBinding → normalizeSchedule). This From 8984bd8195571e030b8e15db4d831d269487ad3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:48:12 +0000 Subject: [PATCH 2/4] test(app-shell): pin the start node's trigger select without Platform event The table half asks the spec resolver that `event` is unrouted, and pins that no option writes it, no start field is gated on it, and the zh overlay labels only offered options. The inspector half pins that a stored `event` shows as the select's flagged unknown value and is saved back as stored. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- ...NodeInspector.platformEvent-11067.test.tsx | 170 ++++++++++++++++++ ...ow-node-config.platformEvent-11067.test.ts | 105 +++++++++++ 2 files changed, 275 insertions(+) create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.platformEvent-11067.test.ts diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx new file mode 100644 index 0000000000..632ce24fa9 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11067 — what the author SEES once the start node's trigger select + * stops offering 「Platform event」. + * + * 1. The open Trigger list carries no 「Platform event」 row. + * 2. A start node that already stores `triggerType: 'event'` shows it as the + * select's flagged unknown value — the value itself under the flag the + * select branch words for a value it no longer offers — so the author can + * see the trigger is not one the list offers. + * 3. That value is never rewritten: a save of the node after an edit that did + * not touch the select sends `event` back as it was stored. + * + * The flag's wording is not pinned here (`unknownValueFlag.i18n-9652.test.tsx` + * owns it). The expected text is built with the same `flagUnknownValue` and + * catalogue key the select branch uses, and each case checks it differs from + * the bare value, so a select that drew the value unflagged would fail. + * + * The table half (option, gates, resolver answer) is pinned in + * `flow-node-config.platformEvent-11067.test.ts`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../previews/useFlowNodePalette', () => ({ + useActionConfigSchemas: () => ({}), + useFlowNodePalette: () => [], +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { FlowNodeInspector } from './FlowNodeInspector'; +import { flagUnknownValue } from './_shared'; +import { t } from '../i18n'; +import type { MetadataSelection } from '../preview-registry'; + +/* ── The `meta/object` double ───────────────────────────────────────── + * A start node holding an `objectName` shows the Object reference field, which + * lists objects via `GET /api/v1/meta/object` through the GLOBAL `fetch`. It is + * answered as an empty registry by a RECORDING double (the shape + * `FlowNodeInspector.inactiveRetained.test.tsx` documents in full), and any + * URL outside that route fails the test in `afterEach`. + * ──────────────────────────────────────────────────────────── */ + +const META_OBJECT_ROUTE = '/api/v1/meta/object'; +let metaCalls: string[] = []; +const routeOf = (url: string) => url.split('?')[0]; + +beforeEach(() => { + metaCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + metaCalls.push(url); + if (routeOf(url) !== META_OBJECT_ROUTE) { + return { ok: false, status: 404, headers: new Headers(), json: async () => ({}) }; + } + return { ok: true, status: 200, headers: new Headers(), json: async () => ({ type: 'object', items: [] }) }; + }), + ); +}); + +afterEach(() => { + expect(metaCalls.filter((url) => routeOf(url) !== META_OBJECT_ROUTE)).toEqual([]); + cleanup(); + vi.unstubAllGlobals(); +}); + +type Draft = Record; +type Locale = 'en-US' | 'zh-CN'; + +function flowWith(config: Record): Draft { + return { + name: 'on_event', + label: 'On event', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start', config }], + edges: [], + }; +} + +const startConfig = (draft: Draft) => + ((draft.nodes as Array>)[0].config ?? {}) as Record; + +/** + * The editor loop: every patch is applied to the draft the inspector renders + * next, as the host does. `latest()` is the draft a save would send. + */ +function mount(initial: Draft, locale: Locale = 'en-US') { + let current = initial; + const patches: Draft[] = []; + function Host() { + const [draft, setDraft] = React.useState(initial); + return ( + { + patches.push(patch); + setDraft((d) => { + current = { ...d, ...patch }; + return current; + }); + }} + onClearSelection={vi.fn()} + locale={locale} + /> + ); + } + const utils = render(); + return { ...utils, patches, latest: () => current }; +} + +/** The Trigger select's accessible name in each locale (the zh overlay's label). */ +const TRIGGER_LABEL: Record = { 'en-US': 'Trigger', 'zh-CN': '触发方式' }; +const triggerBox = (locale: Locale) => screen.getByRole('combobox', { name: TRIGGER_LABEL[locale] }); + +/** What the select branch draws for a stored value it does not offer. */ +const flagged = (value: string, locale: Locale) => + flagUnknownValue(value, t('engine.form.deprecated', locale), locale); + +describe('the Trigger select does not offer 「Platform event」 (objectui#11067)', () => { + it('the open list has no such row', async () => { + mount(flowWith({ triggerType: 'manual' })); + await userEvent.click(triggerBox('en-US')); + // Control: the list really is open and populated. + expect(await screen.findByRole('option', { name: 'Webhook / API' })).toBeTruthy(); + expect(screen.queryByRole('option', { name: 'Platform event' })).toBeNull(); + expect(screen.queryByRole('option', { name: /event/i })).toBeNull(); + }); +}); + +describe('a stored `triggerType: event` shows as the flagged unknown value (objectui#11067)', () => { + for (const locale of ['en-US', 'zh-CN'] as const) { + it(`${locale}: the trigger draws the value under the flag, not bare and not blank`, () => { + mount(flowWith({ triggerType: 'event' }), locale); + const text = triggerBox(locale).textContent; + expect(text).toBe(flagged('event', locale)); + // The flag is really there: the expected text is not the bare value. + expect(flagged('event', locale)).not.toBe('event'); + }); + } + + it('the flagged value is a selectable row of the open list', async () => { + mount(flowWith({ triggerType: 'event' })); + await userEvent.click(triggerBox('en-US')); + expect(await screen.findByRole('option', { name: flagged('event', 'en-US') })).toBeTruthy(); + }); +}); + +describe('a stored `triggerType: event` is saved back unchanged (objectui#11067)', () => { + for (const config of [{ triggerType: 'event' }, { triggerType: 'event', objectName: 'task' }]) { + it(`an edit that does not touch the select keeps ${JSON.stringify(config)} as stored`, () => { + const { latest, patches } = mount(flowWith(config)); + fireEvent.change(screen.getByDisplayValue('Start'), { target: { value: 'On event' } }); + expect(patches.length).toBeGreaterThan(0); + expect(startConfig(latest())).toEqual(config); + }); + } +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.platformEvent-11067.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.platformEvent-11067.test.ts new file mode 100644 index 0000000000..21de7c3af1 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.platformEvent-11067.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11067 — the start node's trigger select stops offering + * 「Platform event」, table half. + * + * The option wrote `triggerType: 'event'`, a token the spec's + * `resolveFlowTriggerKind` answers NO kind for: no engine trigger binds it, so + * a flow authored with it never fired and nothing said so. Triage's direction + * was to remove the option, not to invent a route for it. These pins ask the + * resolver itself whether the token is routed, rather than restating its + * grammar, the way `flow-node-config.apiTrigger-11054.test.ts` does for `api`. + * + * What the author SEES on a node that already stores `event` — the select's + * flagged unknown value, and a save that keeps the token — is pinned in + * `FlowNodeInspector.platformEvent-11067.test.tsx`. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveFlowTriggerKind } from '@objectstack/spec/automation'; +import { flowFieldZh } from '../i18n'; +import { + fieldsForNodeType, + isFieldVisible, + inactiveRetainedKind, + type FlowConfigField, +} from './flow-node-config'; + +const fields = fieldsForNodeType('start'); +const byId = (id: string): FlowConfigField => { + const f = fields.find((x) => x.id === id); + expect(f, `start field ${id} exists`).toBeDefined(); + return f as FlowConfigField; +}; +const trigger = byId('triggerType'); +const offered = (trigger.options ?? []).map((o) => o.value); + +/** A one-node flow whose start node carries `config`. */ +function flowWith(config: Record) { + return { name: 'f', type: 'autolaunched', nodes: [{ id: 'start', type: 'start', config }], edges: [] }; +} + +describe('the retired `event` token is one no engine routes (objectui#11067)', () => { + it('the spec resolver answers no kind for it, with or without an object', () => { + expect(resolveFlowTriggerKind(flowWith({ triggerType: 'event' }))).toBeUndefined(); + expect(resolveFlowTriggerKind(flowWith({ triggerType: 'event', objectName: 'task' }))).toBeUndefined(); + // The control: the same probe answers a kind for a routed token, so the + // two lines above are not a resolver that answers nothing for anything. + expect(resolveFlowTriggerKind(flowWith({ triggerType: 'api' }))).toBe('api'); + }); +}); + +describe('the start node trigger select stops offering it (objectui#11067)', () => { + it('no option writes `event`, and none is labelled 「Platform event」', () => { + expect(offered).not.toContain('event'); + expect((trigger.options ?? []).map((o) => o.label)).not.toContain('Platform event'); + // Control: the list is the real roster, not an empty one. + expect(offered).toContain('api'); + }); + + it('no start field is gated on `event` any more', () => { + for (const id of ['objectName', 'condition']) { + const f = byId(id); + expect(f.showWhen?.field, id).toBe('triggerType'); + expect(f.showWhen?.equals, id).not.toContain('event'); + } + }); + + it('every value a start field is gated on is one the select offers', () => { + // A gate naming a token the select does not offer shows fields for a + // trigger the author can no longer choose. That is how `event` stayed in + // both lists, so this states the rule rather than the one token. + const gated = fields.filter((f) => f.showWhen?.field === 'triggerType'); + expect(gated.length).toBeGreaterThan(0); + for (const f of gated) { + for (const v of f.showWhen!.equals) expect(offered, `${f.id} gated on ${v}`).toContain(v); + } + }); + + it('the zh overlay labels only options the select offers', () => { + const opts = flowFieldZh('start', 'triggerType')?.opts ?? {}; + // Control: the overlay is really read here (it labels `api`). + expect(Object.keys(opts)).toContain('api'); + for (const key of Object.keys(opts)) expect(offered, `zh option ${key}`).toContain(key); + }); +}); + +describe('a start node that already stores `event` (objectui#11067)', () => { + it('no longer shows the object and condition fields for it on the token alone', () => { + const node = flowWith({ triggerType: 'event' }).nodes[0]; + for (const id of ['objectName', 'condition']) { + expect(isFieldVisible(byId(id), node, fields), id).toBe(false); + } + }); + + it('keeps a value it already stores on screen, flagged as retained', () => { + const node = flowWith({ triggerType: 'event', objectName: 'task' }).nodes[0]; + const objectName = byId('objectName'); + expect(isFieldVisible(objectName, node, fields)).toBe(true); + expect(inactiveRetainedKind(objectName, node, fields)).toBe('controller-off'); + // Control: on a routed record trigger the same value is live, not retained. + const live = flowWith({ triggerType: 'record-after-create', objectName: 'task' }).nodes[0]; + expect(inactiveRetainedKind(objectName, live, fields)).toBeNull(); + }); +}); From c5ebc3d93f711129b4559dc8f67c760f6c05fa3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:07:41 +0000 Subject: [PATCH 3/4] chore(changeset): app-shell patch for the retired Platform event trigger Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- .changeset/11067-drop-platform-event-trigger.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/11067-drop-platform-event-trigger.md diff --git a/.changeset/11067-drop-platform-event-trigger.md b/.changeset/11067-drop-platform-event-trigger.md new file mode 100644 index 0000000000..96cb21f423 --- /dev/null +++ b/.changeset/11067-drop-platform-event-trigger.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): Studio's flow start node stops offering 「Platform event」, a trigger no engine routes + +The flow designer's start-node inspector offered a 「Platform event」 trigger +that wrote `triggerType: 'event'`. `resolveFlowTriggerKind` (`@objectstack/spec`) +answers no trigger kind for that token and no engine trigger binds it, so a +flow authored this way never fired, and nothing said so. The option is gone +from the trigger select, and the Object and Entry condition fields no longer +appear for that token alone. + +A start node that already stores `triggerType: 'event'` is not rewritten. The +select shows the value flagged as one it no longer offers, and saving the flow +keeps it as stored. Pick a trigger the list offers to make the flow fire. From 4de4b51ea4a4b3e5f16ceb3855d3719dde8d244f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:35:59 +0000 Subject: [PATCH 4/4] test(app-shell): pass readOnly to the inspector in the Platform event pin `MetadataInspectorProps` requires it; the test project's type-check refused the mount without it. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- .../inspectors/FlowNodeInspector.platformEvent-11067.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx index 632ce24fa9..19a0b0d68c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.platformEvent-11067.test.tsx @@ -113,6 +113,7 @@ function mount(initial: Draft, locale: Locale = 'en-US') { }); }} onClearSelection={vi.fn()} + readOnly={false} locale={locale} /> );