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/11778-flow-add-node-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
---

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).

- **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.
- **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.
- **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.
- **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.

Nothing is added to the package entry: no export, prop, type member or language-pack key.
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,9 @@ describe('#4305 — pins that must NOT move', () => {
mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]);
renderInspector(makeDraft());

await waitFor(() => expect(screen.getByText('Connector')).toBeTruthy());
// The field's label: since objectui#11778 the Node Type select names this
// node's type "Connector" too (the palette's name), not `connector_action`.
await waitFor(() => expect(screen.getByText('Connector', { selector: 'label' })).toBeTruthy());
expect(screen.getByText('Action')).toBeTruthy();
expect(screen.getByDisplayValue('slack')).toBeTruthy();
expect(screen.getByDisplayValue('chat.postMessage')).toBeTruthy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ vi.mock('../previews/useObjectFields', () => ({
}));

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

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

const declaring = new Set<string>();
for (const type of swept) {
Expand Down Expand Up @@ -1040,7 +1042,7 @@ describe('the declaration surface this card names', () => {
* whose growth should be visible in review.
*/
it('every select-kind declaring field renders its declared default', () => {
const swept = [...FLOW_NODE_TYPE_OPTIONS, ...OFF_PICKER_TYPES];
const swept = FLOW_NODE_CONFIG_TYPES;
const cases: Array<{ id: string; type: string; label: string; expected: string }> = [];
for (const type of swept) {
for (const field of fieldsForNodeType(type)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ vi.mock('../previews/useObjectFields', () => ({
}));

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

expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ vi.mock('../previews/useObjectFields', () => ({
}));

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

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

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

expect(
offering.length,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11778 — the inspector's Node Type select offers the add-node
* palette's list, with the palette's display names.
*
* It was a hand list (`FLOW_NODE_TYPE_OPTIONS`) that left out `notify`, so a
* node could not be turned into the Notify the palette adds, and it showed raw
* type names (`http_request`, `connector_action`, `try_catch`). The palette
* hook runs for real here: the engine overlay answers "absent" (the palette's
* offline answer is the hardcoded `NODE_PALETTE`) except in the one case that
* serves an engine descriptor, which the select must then offer too.
*/

import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { FlowNodeInspector } from './FlowNodeInspector';
import { NODE_PALETTE } from '../previews/flow-canvas-parts';
import { t, type SupportedLocale } from '../i18n';
import type { MetadataSelection } from '../preview-registry';

const ACTIONS_ROUTE = '/api/v1/automation/actions';
let engineActions: unknown[] | null = null;

beforeEach(() => {
engineActions = null;
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(input).split('?')[0];
if (url === ACTIONS_ROUTE && engineActions) {
return new Response(JSON.stringify({ data: { actions: engineActions } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('not found', { status: 404 });
}),
);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

type Node = { id: string; type: string; label?: string };

function mount(node: Node, locale: SupportedLocale = 'en-US') {
const onPatch = vi.fn();
render(
<FlowNodeInspector
type="flow"
name="f"
draft={{ nodes: [{ id: 'start', type: 'start', label: 'Start' }, node], edges: [] }}
selection={{ kind: 'node', id: node.id } as MetadataSelection}
onPatch={onPatch}
onClearSelection={vi.fn()}
readOnly={false}
locale={locale}
/>,
);
return { onPatch };
}

const typeSelect = (locale: SupportedLocale = 'en-US') =>
screen.getByRole('combobox', { name: t('engine.inspector.flowNode.type', locale) });

async function optionNames(locale: SupportedLocale = 'en-US'): Promise<string[]> {
await userEvent.click(typeSelect(locale));
return (await screen.findAllByRole('option')).map((o) => o.textContent ?? '');
}

describe('the Node Type select is the palette\'s list (objectui#11778)', () => {
it('offers every palette type under its display name, in the palette\'s order — Notify included', async () => {
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
const names = await optionNames();
// Derived from the palette's own registry, never restated.
expect(names).toEqual(NODE_PALETTE.map((p) => p.label));
expect(names).toContain('Notify');
});

it('shows no raw type name — not even for the types the hand list spelled raw', async () => {
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
const names = await optionNames();
for (const raw of ['http_request', 'connector_action', 'try_catch', 'create_record', 'notify']) {
expect(names, raw).not.toContain(raw);
}
});

it('picking Notify turns the node into a notify node', async () => {
const { onPatch } = mount({ id: 'n1', type: 'create_record', label: 'Create record' });
await optionNames();
await userEvent.click(screen.getByRole('option', { name: 'Notify' }));
expect(onPatch).toHaveBeenCalled();
const patch = onPatch.mock.calls.at(-1)![0] as { nodes: Node[] };
expect(patch.nodes.find((n) => n.id === 'n1')!.type).toBe('notify');
});

it('zh-CN reads the palette\'s localized names', async () => {
mount({ id: 'n1', type: 'create_record', label: '创建记录' }, 'zh-CN');
const names = await optionNames('zh-CN');
expect(names).toContain('通知');
expect(names).toContain('创建记录');
expect(names).not.toContain('Notify');
});

it('a stored type the palette does not offer shows under its display name, for that node only', async () => {
mount({ id: 'start2', type: 'start', label: 'Begin' });
expect(typeSelect().textContent, 'the trigger names the type, not the raw `start`').toBe('Start');
const names = await optionNames();
expect(names.filter((n) => n === 'Start')).toHaveLength(1);
cleanup();
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
expect(await optionNames(), 'another node is not offered Start').not.toContain('Start');
});

it('an engine-published node type joins the list, as it joins the palette', async () => {
engineActions = [{ type: 'map', name: 'Map items', description: 'Run a subflow per item', paradigms: ['flow'] }];
mount({ id: 'n1', type: 'create_record', label: 'Create record' });
// The engine answers after the first paint; let its answer land first.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(await optionNames()).toContain('Map items');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,8 @@ import {
switchedBlockOf,
isBareSwitchedOffBlock,
configKeyOf,
FLOW_NODE_TYPE_OPTIONS,
type FlowConfigField,
} from './flow-node-config.js';
import { translateNodeLabel } from '../i18n.js';
import { jsonSchemaToFlowFields } from './json-schema-to-fields.js';
import {
applyConnectorInputForm,
Expand All @@ -55,7 +53,8 @@ import {
useConnectorRegistry,
} from './connector-input-fields.js';
import { applyDecisionBranches, syncDecisionEdgesByOrder, withBranchTargets } from './flow-decision-edges.js';
import { useActionConfigSchemas } from '../previews/useFlowNodePalette.js';
import { useActionConfigSchemas, useFlowNodePalette } from '../previews/useFlowNodePalette.js';
import { defaultNodeLabel, paletteTypeOptions } from '../previews/flow-canvas-parts.js';
import { FlowNodeConfigField } from './FlowNodeConfigField.js';
import { specRequiredColumns, specRequiresField } from './flow-required-keys.js';
import { useFlowScope } from './useFlowScope.js';
Expand Down Expand Up @@ -178,6 +177,9 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
// with the backend. Falls back to the hardcoded field group when no schema is
// published (offline / plugin absent / older backend).
const configSchemas = useActionConfigSchemas();
// objectui#11778 — the Node Type select offers the add-node palette's types
// with its display names: the list the canvas adds from, engine-merged.
const paletteItems = useFlowNodePalette();
// A nested node anchors its scope on the container (ADR-0031 outer scope). The
// container's own outputs — a loop's iteratorVariable — are excluded from the
// graph walk at its id, so inject the loop group explicitly for a body node.
Expand Down Expand Up @@ -443,9 +445,15 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
onClearSelection();
};

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

// A nested node has no structural editing this phase (no delete, id is
// read-only — those live on the container's Advanced JSON).
Expand Down Expand Up @@ -482,7 +490,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
<InspectorSelectField
label={t('engine.inspector.flowNode.type', locale)}
value={node.type}
options={typeOptions.map((v) => ({ value: v, label: translateNodeLabel(v, locale, v) }))}
options={typeOptions}
onCommit={(v) => patchNode({ type: v })}
disabled={readOnly}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
fieldsForNodeType,
isFieldVisible,
inactiveRetainedKind,
FLOW_NODE_TYPE_OPTIONS,
FLOW_NODE_CONFIG_TYPES,
type FlowConfigField,
} from './flow-node-config.js';
import { jsonSchemaToFlowFields } from './json-schema-to-fields.js';
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('inactiveRetainedKind — mechanical coverage of every showWhen group',
'subflow', 'notify', 'connector_action', 'parallel', 'try_catch', 'parallel_gateway',
'join_gateway', 'boundary_event', 'legacy_action',
];
const TYPES = [...new Set([...CANONICAL, ...FLOW_NODE_TYPE_OPTIONS, ...TYPE_ALIASES])];
const TYPES = [...new Set([...CANONICAL, ...FLOW_NODE_CONFIG_TYPES, ...TYPE_ALIASES])];

/** Write `value` at `field.path` on a fresh node object. */
function nodeWith(type: string, entries: Array<[string[], unknown]>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import * as Automation from '@objectstack/spec/automation';
// The Zod wrapper-key vocabulary — one list, read by the `.mjs` CI gates that
// walk the same internals (objectui#6923, ruled 2026-08-31).
import { ZOD_WRAPPER_KEYS } from '@object-ui/test-support';
import { fieldsForNodeType, FLOW_NODE_TYPE_OPTIONS, type FlowConfigField } from './flow-node-config';
import { fieldsForNodeType, FLOW_NODE_CONFIG_TYPES, type FlowConfigField } from './flow-node-config';

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