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
16 changes: 16 additions & 0 deletions .changeset/11067-drop-platform-event-trigger.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5898,7 +5898,6 @@ const FLOW_FIELD_ZH: Record<string, Record<string, FlowFieldZh>> = {
time_relative: '相对时间(日期扫描)',
manual: '手动 / 自动启动',
api: 'Webhook / API',
event: '平台事件',
},
},
// objectui#11054 — the inbound hook's per-flow secret (write-only).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// 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<string, unknown>;
type Locale = 'en-US' | 'zh-CN';

function flowWith(config: Record<string, unknown>): 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<Record<string, unknown>>)[0].config ?? {}) as Record<string, unknown>;

/**
* 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<Draft>(initial);
return (
<FlowNodeInspector
type="flow"
name="on_event"
draft={draft}
selection={{ kind: 'node', id: 'start' } as MetadataSelection}
onPatch={(patch) => {
patches.push(patch);
setDraft((d) => {
current = { ...d, ...patch };
return current;
});
}}
onClearSelection={vi.fn()}
readOnly={false}
locale={locale}
/>
);
}
const utils = render(<Host />);
return { ...utils, patches, latest: () => current };
}

/** The Trigger select's accessible name in each locale (the zh overlay's label). */
const TRIGGER_LABEL: Record<Locale, string> = { '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);
});
}
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,11 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
// 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
Expand All @@ -430,7 +434,7 @@ 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-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
Expand All @@ -441,7 +445,7 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
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
Expand Down
Loading