Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 32124ac

Browse files
fix(app-shell): the flow designer's three add-node paths open one palette and add into the path (objectui#11778) (#11832)
Fixes #11778 Clause-②: no ## What was wrong, measured on `main` (`2b30d39`) A probe through the real `FlowCanvas` on the draft Studio creates for a new flow (`start → end`, edge `e1`), before any change: | Gesture | What it wrote | |---|---| | the edge's "+" (Insert node here) | a `create_record` node at once, no palette, pinned at `position {x: 28, y: 89}`: the midpoint of the two cards' corners, on top of the Start card | | Start's "Add connected node" | a `create_record` node at once, no palette, wired `start → node_1` beside `start → end`: a second branch that leads nowhere | | toolbar Add node, nothing selected | the picked `notify` node with no edge at all | | toolbar Add node, Start selected | the picked `notify` node as a second branch `start → node_1` beside `start → end` (the card's report) | | the Node Type select on Start | the trigger read the raw `start`; the list was the hand list `FLOW_NODE_TYPE_OPTIONS`, which has no `notify` and spells `http_request`, `connector_action` and `try_catch` raw | ## What changed - **Every "+" opens the one add-node palette.** The edge's "+" and a card's "Add connected node" now wrap their button in the same `NodePalette` the toolbar uses (grouped, searchable, with Recently used). Nothing is added until a type is picked. The canvas keeps one key for which "+" has the palette open (`toolbar`, `edge:EDGEKEY` or `node:ID`), so at most one is open and a pick closes it wherever it was opened. No second picker was built. - **The picked node goes into the path, by one named rule.** The edge "+" splits its edge, as before: A → B becomes A → N (the original edge, its id and guard kept) plus N → B. The other two entry points share `placeAfter`, "add after this node": - the anchor is the card's node for "Add connected node", and for the toolbar the selected node, else Start; - an anchor with exactly one way on: split that edge, so N takes the next node's place in the path (`start → end` becomes `start → N → end`); - an anchor with no way on: append (anchor → N); - a branching anchor (`decision`, `approval`, `parallel_gateway`), or one that already leads to two or more nodes, has no single path after it: N becomes a new branch from it. That is the existing append, so a decision still carries its next unused condition onto the new edge; - End has no "after": with End selected the toolbar puts N before End, splitting its one way in; - **no single path to insert into** — an End reached by several paths or by none, or a flow with no Start and nothing selected: N is added with no edge, and the Problems panel names it unreachable, as it names any unconnected node. "A way on" is a forward edge between two distinct nodes that both exist: a declared back-edge is not one, and neither is an edge naming a missing node (objectui#11772 leaves those for the Problems panel to name). Because the canvas selects the node it adds, toolbar adds in a row build the path in order: `start → N1 → N2 → N3 → end` (pinned). - **The inserted node is laid out.** The edge insert no longer pins `position` at its endpoints' midpoint. The new node carries no position, like an append always did, so the layered layout gives it its own row and moves the nodes below it down. Nodes the author dragged keep their stored positions. - **The Node Type select is the palette's list.** `FlowNodeInspector` reads `useFlowNodePalette()` (the hardcoded `NODE_PALETTE` merged with the engine's published descriptors, the same list the canvas adds from) through a new module-local helper `paletteTypeOptions`, which keeps the palette's order (its category sections) and its display names (`translateNodeLabel` over the palette's label, exactly as the palette draws them). So `notify` is offered, no raw type name is shown, and an engine-published type joins the list as it joins the palette. A stored type the palette does not offer (`start`, the deprecated alias `http_request`, a plugin type whose engine is not answering) is added for that node only, under its display name, so the trigger still says what the node is. - **`FLOW_NODE_TYPE_OPTIONS` is retired.** The select was its only production reader. Its six test readers used it as the population of a sweep over the inspector's field-group table; they now walk `FLOW_NODE_CONFIG_TYPES`, the table's own keys, a strict superset of the retired list (it adds `notify`, `boundary_event`, `parallel_gateway`, `join_gateway`, `legacy_action`), so no sweep lost a type. `flow-node-config.spec-reconciliation.test.ts` now also asserts its source-parsed table keys equal that export exactly. ## The PM's mechanism hypotheses, measured - **H1 (what each entry point produced): confirmed, with the two open halves now measured** (table above). Add connected node called `addNode('create_record', { from })`, and the toolbar passed `from: selectedId`, so with nothing selected it wrote no edge and with Start selected it wrote the second branch. - **H2 (the rule for "inserted into the path"): decided as above.** "After the selected node" for the toolbar, with Start standing in for no selection, because the canvas selects every node it adds: repeated adds then extend the path in order instead of stacking after Start. "Add connected node" gets the same rule so the two never disagree; a decision or an approval keeps getting a new branch, because their out-edges are their outcomes and the canvas has no other gesture that draws an edge. - **H3 (dropping the pin is the auto-layout): confirmed.** Nothing else was needed; the layered layout already places an unpinned node. The objectui#11772 round trip is untouched by it: inserting and then removing the node gives the draft back byte for byte, and that pin stayed green under every ablation below, including the one that restores the pin. - **H4 (`FLOW_NODE_TYPE_OPTIONS` readers): one production reader (the select) and six test readers**, handled as above. - **H5 (reuse `NodePalette`): confirmed, with one finding.** A palette opened from an edge or a card is portaled out of the canvas in the DOM but still sits inside the canvas viewport in the React tree, so a press on a palette row bubbled to the viewport's pan handler, which clears the selection and takes pointer capture; the browser then fires the click at the viewport and the row's select never runs (objectui#11546's shape). `NodePalette`'s content now stops that press. Ablation B below shows the five edge- and card-palette picks red without it, and the toolbar's (outside the viewport) green. ## objectui#11772's pins `FlowPreview.removeNode-11772.test.tsx` changed in three places, none of them an assertion's strength: - its palette hook stub returned `[]`, which is not the hook's offline answer (`NODE_PALETTE`, as the stub's own comment says) and leaves a palette with nothing to pick; it returns `NODE_PALETTE` now; - its two gesture helpers pick "Create record" in the palette after the "+", the type the "+" used to add on its own; - the control "adding a connected node mints exactly what it did before" keeps its exact ids (`node_1`, `edge_1`) and its exact `toEqual`, but its wiring is this card's: `e1: start → node_1`, `edge_1: node_1 → end`, where it used to pin the second branch `start → node_1` beside `start → end`, which is the defect this card reports. The byte-for-byte round trip assertion is unchanged and green. `FlowNodeInspector.removeEdges-11772.test.tsx` and `flow-problems.repeatedEdge-11772.test.ts` are untouched and green. ## Tests New pins, through the real components: - `previews/FlowCanvas.addNodePaths-11778.test.tsx` (11): each "+" opens the palette and adds nothing before a pick; the edge "+" with Notify gives `start → notify → end`, keeps `e1`, and the node has no position and sits a full row below Start with End moved below it; Add connected node on Start inserts into the path, on a node with no way on appends, on a decision adds a branch carrying its `Else` default; the toolbar with nothing selected, with Start selected and with End selected, and three adds in a row. Palette rows are pressed with `browserClick`, which routes a click the way a browser does after pointer capture. - `inspectors/FlowNodeInspector.nodeTypeOptions-11778.test.tsx` (6): the option list equals the palette's labels in its order, Notify included; no raw type name; picking Notify writes `type: 'notify'`; zh-CN names; Start shown as "Start" on its own node and not offered to another; an engine-published type joins the list. Touched existing tests: `FlowCanvas.test.tsx` (the insert pin now asserts no position, and both gestures pick a type), `flow-node-producers.label.test.tsx` (both gestures pick a type), `FlowNodeInspector.connectorInput.test.tsx` (its text query for the "Connector" field label is scoped to the label, because the Node Type trigger now reads "Connector" too), and the six sweep tests above. Ablations, each through `ablation-replace.mjs` from the committed tree at `84f3800`, each restore proven by blob hash equal to HEAD and an empty `git diff HEAD`, over the two new pin files, `FlowCanvas.test.tsx` and the objectui#11772 pin file (50 tests): | Mutation | Red | |---|---| | A: the edge "+" adds `create_record` at once, no palette (the card's suggested ablation) | 6: the three edge-"+" pins, the 11772 reproduction and round trip (they now need a palette row), the `FlowCanvas.test.tsx` insert pin | | B: the palette no longer stops a press on its rows | 5: every edge- and card-palette pick; the four toolbar pins stay green | | C: the toolbar back to `addNode(type, { from: selectedId })` | 4: all four toolbar pins | | D: Add connected node back to a plain `from` append | 2: the Start pin and the 11772 control | | E: the edge insert pinned at the midpoint again | 4: the layout pin, the Start insert, both `FlowCanvas.test.tsx` position pins; the 11772 round trip stays green | | F: the select back to the hand list with raw names | 6: all six inspector pins | The first attempt at B measured nothing: the tool refused it before running, because the replacement text was a substring of the anchor. It was re-run with a distinct marker; the row above is that run. Browser check: Chromium (`/opt/pw-browsers/chromium`) on the console's preview gallery (`preview-gallery.html?only=flow`, the offline palette), real mouse clicks. The edge "+" opened the palette beside the "+" and added nothing; picking Notify inserted `node_1` between Start and the next node on its own row (card tops after the pick: Start 28, new node 150, the next node 272); a card's "+" then Create record inserted below that card; the toolbar then put a Script node after the selected one; the inspector's Node Type read "Script" and listed the palette's 18 names, Notify among them; no page errors. Local verification at `3947a9a` (HEAD): - `pnpm exec vitest run` over `packages/app-shell/src/views/metadata-admin/previews/`, `.../inspectors/` and the two Studio files that count the canvas's "+" buttons: 230 files, 3043 passed, 1 skipped. - `pnpm --filter @object-ui/app-shell type-check`: exit 0. Both of its projects ran; `tsconfig.test.json --listFiles` includes every touched and new test file. - `pnpm exec eslint` on the 16 touched `.ts`/`.tsx` files: 0 errors, 12 warnings, one more than on `main` (a `react-refresh/only-export-components` warning for the new `paletteTypeOptions` export, the same warning the file's seven other non-component exports carry). - `check:control-bytes`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:new-line-citations`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:unreferenced-sources`, `check-changeset-presence`, `check-changeset-no-major`: all exit 0. - The whole `@object-ui/app-shell` suite ran once, at `acdf998`: 1064 files, 2 failed. One was `FlowNodeInspector.connectorInput.test.tsx`, the label query fixed in `84f3800`. The other, `AppContent.inaccessibleAppStrand.test.tsx`, failed on a network escape to `localhost:3000` under full-suite load; this branch does not touch it, and it passed alone. The suite was not re-run after `84f3800`, which changed a test file and the changeset only; CI runs it. ## Files and surface Every file is on the claim's file surface: `FlowCanvas.tsx`, `flow-canvas-parts.tsx`, `flow-node-config.ts`, `FlowNodeInspector.tsx`, tests in `previews/` and `inspectors/`, and `.changeset/11778-flow-add-node-paths.md`. `i18n.ts` is not touched: every string these entry points show already existed. `StudioDesignSurface.tsx` is not touched. Nothing is added to the package entry. After a build, none of the four changed source modules is reachable from `dist/index.d.ts` by its relative imports (168 declaration files reached; `SchemaForm.d.ts`, which the entry re-exports, is the positive control). Patch changeset on `@object-ui/app-shell`. ## Acceptance notes - `content/docs/guide/flow-designer.md` still describes the card's "+" as "Append a connected child node", and its palette section names only the toolbar's Add node. It is not wrong, but it does not mention that every "+" opens the palette or the edge "+". It is off the claim's surface, so it is not edited here. - The inspector now reads `GET /api/v1/automation/actions` twice per mount (`useActionConfigSchemas` and `useFlowNodePalette` each run `useActionDescriptors`). Sharing one read means changing `useFlowNodePalette.ts`, which is off the surface; nearly every inspector test stubs that module by name, so a combined hook would also need those stubs changed. - Offline, the Node Type list no longer offers `map`, which the hand list had: `NODE_PALETTE` does not list it. The engine publishes a `map` descriptor, so against a running backend the palette and the list both offer it. - When nodes around an insert carry dragged positions, the new node takes its layout slot, not a point between its pinned neighbours. - `main` moved to `d53fd02` after this branch was cut (objectui#11773, objectui#11780). Neither commit touches the flow canvas or inspector files; this branch has not merged it. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8273f4b commit 32124ac

17 files changed

Lines changed: 673 additions & 117 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The flow designer's three ways to add a node agree: each opens the add-node palette, the picked node goes into the flow's path and is laid out, and the node inspector's Node Type list is the palette's list (objectui#11778).
6+
7+
- **The "+" on an edge and a node's "Add connected node" ask for the type.** Both used to add a "Create record" node at once. They now open the same grouped, searchable palette as the toolbar's Add node, with its Recently used group, and add the node you pick.
8+
- **The new node goes into the path.** The "+" on an edge splits that edge, as before. Add connected node and the toolbar's Add node put the node after a node: the node whose "+" you pressed, or for the toolbar the selected node, else Start. When that node has one way on, the new node goes between it and the next node. When it has none, the new node follows it. A decision, an approval, or a node that already leads to two or more nodes gets the new node as a new branch, as before. With End selected, the toolbar puts the new node before End. The toolbar used to start a second branch from the selected node, or add a node with no connection at all when nothing was selected.
9+
- **The new node is laid out.** A node inserted on an edge was pinned halfway between the edge's two ends, on top of the card above it. It now has no stored position, so the canvas lays it out on its own row and moves the nodes below it down. Nodes you have dragged keep their positions.
10+
- **The Node Type list is the palette's.** It offers every node type the palette offers, under the palette's names, so a node can now be changed into a Notify node. It no longer shows raw type names such as `http_request`, `connector_action` or `try_catch`. It no longer offers a type the palette does not add, such as Start, or `http_request`, the older spelling of the palette's HTTP request (`http`). A node whose stored type the palette does not list, such as Start, still shows that type under its name.
11+
12+
Nothing is added to the package entry: no export, prop, type member or language-pack key.

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.connectorInput.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,9 @@ describe('#4305 — pins that must NOT move', () => {
294294
mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]);
295295
renderInspector(makeDraft());
296296

297-
await waitFor(() => expect(screen.getByText('Connector')).toBeTruthy());
297+
// The field's label: since objectui#11778 the Node Type select names this
298+
// node's type "Connector" too (the palette's name), not `connector_action`.
299+
await waitFor(() => expect(screen.getByText('Connector', { selector: 'label' })).toBeTruthy());
298300
expect(screen.getByText('Action')).toBeTruthy();
299301
expect(screen.getByDisplayValue('slack')).toBeTruthy();
300302
expect(screen.getByDisplayValue('chat.postMessage')).toBeTruthy();

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ vi.mock('../previews/useObjectFields', () => ({
7979
}));
8080

8181
import { FlowNodeInspector } from './FlowNodeInspector';
82-
import { fieldsForNodeType, FLOW_NODE_TYPE_OPTIONS } from './flow-node-config';
82+
import { fieldsForNodeType, FLOW_NODE_CONFIG_TYPES } from './flow-node-config';
8383
import type { MetadataSelection } from '../preview-registry';
8484
// The runtime's own answer for an omitted `escalation` key. Imported so the
8585
// #6620 rows below compare the RENDERED control against the installed contract
@@ -989,19 +989,21 @@ describe('the declaration surface this card names', () => {
989989
* here; that is a result, not a method. Whoever lands next re-reads the
990990
* number the same way rather than trusting this sentence.
991991
*
992-
* Swept over the picker's node types plus the four that carry config but are
993-
* not offered in the picker (ADR-0031 import/export-only, and the legacy
994-
* aliases). The sweep is asserted to cover the picker so a new node type
995-
* cannot slip a declaration past it.
992+
* Swept over every group the table holds (`FLOW_NODE_CONFIG_TYPES`, the
993+
* table's own keys), so a new node type cannot slip a declaration past it.
994+
* Until objectui#11778 this walked the inspector's hand-kept type list plus
995+
* the types that carry config but that list never offered (ADR-0031
996+
* import/export-only, the legacy aliases, and `notify`); the table's keys
997+
* hold every one of them, which the first assertion re-reads.
996998
*/
997999
const OFF_PICKER_TYPES = ['boundary_event', 'parallel_gateway', 'join_gateway', 'legacy_action', 'notify'];
9981000

9991001
it('exactly ten fields declare a defaultValue, and these are they', () => {
1000-
const swept = [...FLOW_NODE_TYPE_OPTIONS, ...OFF_PICKER_TYPES];
1002+
const swept = FLOW_NODE_CONFIG_TYPES;
10011003
expect(
1002-
FLOW_NODE_TYPE_OPTIONS.every((t) => swept.includes(t)),
1003-
'the sweep covers every node type the picker offers',
1004-
).toBe(true);
1004+
swept,
1005+
'the sweep walks the whole table, the types the retired hand list never offered included',
1006+
).toEqual(expect.arrayContaining(OFF_PICKER_TYPES));
10051007

10061008
const declaring = new Set<string>();
10071009
for (const type of swept) {
@@ -1040,7 +1042,7 @@ describe('the declaration surface this card names', () => {
10401042
* whose growth should be visible in review.
10411043
*/
10421044
it('every select-kind declaring field renders its declared default', () => {
1043-
const swept = [...FLOW_NODE_TYPE_OPTIONS, ...OFF_PICKER_TYPES];
1045+
const swept = FLOW_NODE_CONFIG_TYPES;
10441046
const cases: Array<{ id: string; type: string; label: string; expected: string }> = [];
10451047
for (const type of swept) {
10461048
for (const field of fieldsForNodeType(type)) {

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.endOutputVariable-9335.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ vi.mock('../previews/useObjectFields', () => ({
6969
}));
7070

7171
import { FlowNodeInspector } from './FlowNodeInspector';
72-
import { fieldsForNodeType, FLOW_NODE_TYPE_OPTIONS } from './flow-node-config';
72+
import { fieldsForNodeType, FLOW_NODE_CONFIG_TYPES } from './flow-node-config';
7373
import type { MetadataSelection } from '../preview-registry';
7474
// ⛔ The `/automation` subpath is load bearing — these read `undefined` off the
7575
// package root, and any `.parse` on them then throws.
@@ -221,7 +221,7 @@ describe('the end group no longer offers the key (objectui#9335)', () => {
221221
// this table, and on the CRUD / script / subflow / http / map / legacy
222222
// groups the key IS declared by the spec and legitimate. Derived by sweep,
223223
// never restated as a count.
224-
const types = [...FLOW_NODE_TYPE_OPTIONS, 'action'];
224+
const types = [...FLOW_NODE_CONFIG_TYPES, 'action'];
225225
const stillOffering = types.filter((t) => fieldsForNodeType(t).some((f) => f.id === 'outputVariable'));
226226

227227
expect(

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.loopItemVariable-9340.test.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ vi.mock('../previews/useObjectFields', () => ({
6363
}));
6464

6565
import { FlowNodeInspector } from './FlowNodeInspector';
66-
import { fieldsForNodeType, localizeFlowFields, FLOW_NODE_TYPE_OPTIONS } from './flow-node-config';
66+
import { fieldsForNodeType, localizeFlowFields, FLOW_NODE_CONFIG_TYPES } from './flow-node-config';
6767
import type { MetadataSelection } from '../preview-registry';
6868
// ⛔ The `/automation` subpath is load bearing — these read `undefined` off the
6969
// package root, and any `.parse` on them then throws.
@@ -236,7 +236,7 @@ describe('the loop group hints the identifier the spec applies (objectui#9340)',
236236
// The lit control: descriptors in this SAME table do carry the slot, so the
237237
// `undefined` above is a deliberate non-declaration, not a shape that can
238238
// never hold one.
239-
const declaring = FLOW_NODE_TYPE_OPTIONS.flatMap((t) => fieldsForNodeType(t)).filter((f) => f.defaultValue !== undefined);
239+
const declaring = FLOW_NODE_CONFIG_TYPES.flatMap((t) => fieldsForNodeType(t)).filter((f) => f.defaultValue !== undefined);
240240
expect(declaring.length, 'other rows in this table DO declare a default').toBeGreaterThan(0);
241241
});
242242

@@ -259,11 +259,11 @@ describe('the loop group hints the identifier the spec applies (objectui#9340)',
259259
});
260260

261261
it('sweeps EVERY node type — so a third group growing the key cannot drift unseen', () => {
262-
// The structural identification, generalised: walk every type the picker
263-
// offers, keep the ones whose group owns this key, and hold each against
262+
// The structural identification, generalised: walk every type the table
263+
// holds a group for, keep the ones whose group owns this key, and hold each against
264264
// the identifier ITS OWN schema applies. `map` is in here as the live lit
265265
// control — if the fenced-off twin ever drifts, this arm reddens.
266-
const offering = FLOW_NODE_TYPE_OPTIONS.filter((t) => iteratorRowOf(t) !== undefined);
266+
const offering = FLOW_NODE_CONFIG_TYPES.filter((t) => iteratorRowOf(t) !== undefined);
267267

268268
expect(
269269
offering.length,
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11778 — the inspector's Node Type select offers the add-node
5+
* palette's list, with the palette's display names.
6+
*
7+
* It was a hand list (`FLOW_NODE_TYPE_OPTIONS`) that left out `notify`, so a
8+
* node could not be turned into the Notify the palette adds, and it showed raw
9+
* type names (`http_request`, `connector_action`, `try_catch`). The palette
10+
* hook runs for real here: the engine overlay answers "absent" (the palette's
11+
* offline answer is the hardcoded `NODE_PALETTE`) except in the one case that
12+
* serves an engine descriptor, which the select must then offer too.
13+
*/
14+
15+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
16+
import { render, screen, cleanup, act } from '@testing-library/react';
17+
import userEvent from '@testing-library/user-event';
18+
19+
vi.mock('../previews/useObjectFields', () => ({
20+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
21+
}));
22+
23+
import { FlowNodeInspector } from './FlowNodeInspector';
24+
import { NODE_PALETTE } from '../previews/flow-canvas-parts';
25+
import { t, type SupportedLocale } from '../i18n';
26+
import type { MetadataSelection } from '../preview-registry';
27+
28+
const ACTIONS_ROUTE = '/api/v1/automation/actions';
29+
let engineActions: unknown[] | null = null;
30+
31+
beforeEach(() => {
32+
engineActions = null;
33+
vi.stubGlobal(
34+
'fetch',
35+
vi.fn(async (input: unknown) => {
36+
const url = String(input).split('?')[0];
37+
if (url === ACTIONS_ROUTE && engineActions) {
38+
return new Response(JSON.stringify({ data: { actions: engineActions } }), {
39+
status: 200,
40+
headers: { 'Content-Type': 'application/json' },
41+
});
42+
}
43+
return new Response('not found', { status: 404 });
44+
}),
45+
);
46+
});
47+
48+
afterEach(() => {
49+
cleanup();
50+
vi.unstubAllGlobals();
51+
});
52+
53+
type Node = { id: string; type: string; label?: string };
54+
55+
function mount(node: Node, locale: SupportedLocale = 'en-US') {
56+
const onPatch = vi.fn();
57+
render(
58+
<FlowNodeInspector
59+
type="flow"
60+
name="f"
61+
draft={{ nodes: [{ id: 'start', type: 'start', label: 'Start' }, node], edges: [] }}
62+
selection={{ kind: 'node', id: node.id } as MetadataSelection}
63+
onPatch={onPatch}
64+
onClearSelection={vi.fn()}
65+
readOnly={false}
66+
locale={locale}
67+
/>,
68+
);
69+
return { onPatch };
70+
}
71+
72+
const typeSelect = (locale: SupportedLocale = 'en-US') =>
73+
screen.getByRole('combobox', { name: t('engine.inspector.flowNode.type', locale) });
74+
75+
async function optionNames(locale: SupportedLocale = 'en-US'): Promise<string[]> {
76+
await userEvent.click(typeSelect(locale));
77+
return (await screen.findAllByRole('option')).map((o) => o.textContent ?? '');
78+
}
79+
80+
describe('the Node Type select is the palette\'s list (objectui#11778)', () => {
81+
it('offers every palette type under its display name, in the palette\'s order — Notify included', async () => {
82+
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
83+
const names = await optionNames();
84+
// Derived from the palette's own registry, never restated.
85+
expect(names).toEqual(NODE_PALETTE.map((p) => p.label));
86+
expect(names).toContain('Notify');
87+
});
88+
89+
it('shows no raw type name — not even for the types the hand list spelled raw', async () => {
90+
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
91+
const names = await optionNames();
92+
for (const raw of ['http_request', 'connector_action', 'try_catch', 'create_record', 'notify']) {
93+
expect(names, raw).not.toContain(raw);
94+
}
95+
});
96+
97+
it('picking Notify turns the node into a notify node', async () => {
98+
const { onPatch } = mount({ id: 'n1', type: 'create_record', label: 'Create record' });
99+
await optionNames();
100+
await userEvent.click(screen.getByRole('option', { name: 'Notify' }));
101+
expect(onPatch).toHaveBeenCalled();
102+
const patch = onPatch.mock.calls.at(-1)![0] as { nodes: Node[] };
103+
expect(patch.nodes.find((n) => n.id === 'n1')!.type).toBe('notify');
104+
});
105+
106+
it('zh-CN reads the palette\'s localized names', async () => {
107+
mount({ id: 'n1', type: 'create_record', label: '创建记录' }, 'zh-CN');
108+
const names = await optionNames('zh-CN');
109+
expect(names).toContain('通知');
110+
expect(names).toContain('创建记录');
111+
expect(names).not.toContain('Notify');
112+
});
113+
114+
it('a stored type the palette does not offer shows under its display name, for that node only', async () => {
115+
mount({ id: 'start2', type: 'start', label: 'Begin' });
116+
expect(typeSelect().textContent, 'the trigger names the type, not the raw `start`').toBe('Start');
117+
const names = await optionNames();
118+
expect(names.filter((n) => n === 'Start')).toHaveLength(1);
119+
cleanup();
120+
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
121+
expect(await optionNames(), 'another node is not offered Start').not.toContain('Start');
122+
});
123+
124+
it('an engine-published node type joins the list, as it joins the palette', async () => {
125+
engineActions = [{ type: 'map', name: 'Map items', description: 'Run a subflow per item', paradigms: ['flow'] }];
126+
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
127+
// The engine answers after the first paint; let its answer land first.
128+
await act(async () => {
129+
await new Promise((resolve) => setTimeout(resolve, 20));
130+
});
131+
expect(await optionNames()).toContain('Map items');
132+
});
133+
});

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,8 @@ import {
4141
switchedBlockOf,
4242
isBareSwitchedOffBlock,
4343
configKeyOf,
44-
FLOW_NODE_TYPE_OPTIONS,
4544
type FlowConfigField,
4645
} from './flow-node-config.js';
47-
import { translateNodeLabel } from '../i18n.js';
4846
import { jsonSchemaToFlowFields } from './json-schema-to-fields.js';
4947
import {
5048
applyConnectorInputForm,
@@ -55,7 +53,8 @@ import {
5553
useConnectorRegistry,
5654
} from './connector-input-fields.js';
5755
import { applyDecisionBranches, syncDecisionEdgesByOrder, withBranchTargets } from './flow-decision-edges.js';
58-
import { useActionConfigSchemas } from '../previews/useFlowNodePalette.js';
56+
import { useActionConfigSchemas, useFlowNodePalette } from '../previews/useFlowNodePalette.js';
57+
import { defaultNodeLabel, paletteTypeOptions } from '../previews/flow-canvas-parts.js';
5958
import { FlowNodeConfigField } from './FlowNodeConfigField.js';
6059
import { specRequiredColumns, specRequiresField } from './flow-required-keys.js';
6160
import { useFlowScope } from './useFlowScope.js';
@@ -178,6 +177,9 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
178177
// with the backend. Falls back to the hardcoded field group when no schema is
179178
// published (offline / plugin absent / older backend).
180179
const configSchemas = useActionConfigSchemas();
180+
// objectui#11778 — the Node Type select offers the add-node palette's types
181+
// with its display names: the list the canvas adds from, engine-merged.
182+
const paletteItems = useFlowNodePalette();
181183
// A nested node anchors its scope on the container (ADR-0031 outer scope). The
182184
// container's own outputs — a loop's iteratorVariable — are excluded from the
183185
// graph walk at its id, so inject the loop group explicitly for a body node.
@@ -443,9 +445,15 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
443445
onClearSelection();
444446
};
445447

446-
const typeOptions = FLOW_NODE_TYPE_OPTIONS.includes(node.type as (typeof FLOW_NODE_TYPE_OPTIONS)[number])
447-
? [...FLOW_NODE_TYPE_OPTIONS]
448-
: [...FLOW_NODE_TYPE_OPTIONS, node.type ?? ''].filter(Boolean);
448+
// objectui#11778 — the palette's list (it once was a hand list here that
449+
// missed `notify` and showed raw type names). A stored type the palette does
450+
// not offer (`start`, an alias like `http_request`, a plugin type whose engine
451+
// is not answering) is added for THIS node only, under its display name, so
452+
// the select still shows what the node is; no other node is offered it.
453+
const typeOptions = paletteTypeOptions(paletteItems, locale);
454+
if (node.type && !typeOptions.some((o) => o.value === node.type)) {
455+
typeOptions.push({ value: node.type, label: defaultNodeLabel(node.type, locale) });
456+
}
449457

450458
// A nested node has no structural editing this phase (no delete, id is
451459
// read-only — those live on the container's Advanced JSON).
@@ -482,7 +490,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
482490
<InspectorSelectField
483491
label={t('engine.inspector.flowNode.type', locale)}
484492
value={node.type}
485-
options={typeOptions.map((v) => ({ value: v, label: translateNodeLabel(v, locale, v) }))}
493+
options={typeOptions}
486494
onCommit={(v) => patchNode({ type: v })}
487495
disabled={readOnly}
488496
/>

‎packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.inactiveRetained.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
fieldsForNodeType,
2424
isFieldVisible,
2525
inactiveRetainedKind,
26-
FLOW_NODE_TYPE_OPTIONS,
26+
FLOW_NODE_CONFIG_TYPES,
2727
type FlowConfigField,
2828
} from './flow-node-config.js';
2929
import { jsonSchemaToFlowFields } from './json-schema-to-fields.js';
@@ -131,7 +131,7 @@ describe('inactiveRetainedKind — mechanical coverage of every showWhen group',
131131
'subflow', 'notify', 'connector_action', 'parallel', 'try_catch', 'parallel_gateway',
132132
'join_gateway', 'boundary_event', 'legacy_action',
133133
];
134-
const TYPES = [...new Set([...CANONICAL, ...FLOW_NODE_TYPE_OPTIONS, ...TYPE_ALIASES])];
134+
const TYPES = [...new Set([...CANONICAL, ...FLOW_NODE_CONFIG_TYPES, ...TYPE_ALIASES])];
135135

136136
/** Write `value` at `field.path` on a fresh node object. */
137137
function nodeWith(type: string, entries: Array<[string[], unknown]>) {

‎packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import * as Automation from '@objectstack/spec/automation';
3838
// The Zod wrapper-key vocabulary — one list, read by the `.mjs` CI gates that
3939
// walk the same internals (objectui#6923, ruled 2026-08-31).
4040
import { ZOD_WRAPPER_KEYS } from '@object-ui/test-support';
41-
import { fieldsForNodeType, FLOW_NODE_TYPE_OPTIONS, type FlowConfigField } from './flow-node-config';
41+
import { fieldsForNodeType, FLOW_NODE_CONFIG_TYPES, type FlowConfigField } from './flow-node-config';
4242

4343
// Feature-detected exports — absent on a spec that predates framework#4278.
4444
// (Truthiness alone never resolves a lazySchema proxy.)
@@ -369,9 +369,10 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object
369369
// sweep of their own: `fieldsForNodeType` resolves every alias to one of
370370
// these canonical tables, so walking the table keys walks every field.
371371
expect(NODE_TYPES.length, 'FLOW_NODE_CONFIG declares node types').toBeGreaterThan(20);
372-
expect(NODE_TYPES, 'and the picker types are among them').toEqual(
373-
expect.arrayContaining([...FLOW_NODE_TYPE_OPTIONS]),
374-
);
372+
expect(
373+
[...NODE_TYPES].sort(),
374+
'and they are exactly the table types the module exports (objectui#11778)',
375+
).toEqual([...FLOW_NODE_CONFIG_TYPES].sort());
375376
expect(NODE_TYPES, 'including the off-picker tables a picker-only sweep would miss').toEqual(
376377
expect.arrayContaining(['boundary_event', 'notify', 'legacy_action', 'join_gateway']),
377378
);

0 commit comments

Comments
 (0)