Skip to content

Commit 20a2a02

Browse files
os-zhuangclaude
andauthored
feat(flow-designer): nested-array columns in the node property form (#2678 P2-5) (#2761)
The server-driven node property form (configSchema → FlowConfigField) now renders nested arrays inside an objectList repeater instead of degrading them to a plain text cell that String()-joined and corrupted the array on save. A repeater column whose item property is itself an array becomes a nested repeater (repeater-in-repeater): - json-schema-to-fields `columnsFor` maps an array-typed item property to a stringList / numberList / objectList column; object-array columns derive their nested columns recursively (bounded by a nesting cap so a pathological / cyclic schema can't build a non-terminating form). Unrepresentable arrays fall through to the prior text behavior — no regression. - FlowConfigColumn gains the three list kinds plus a recursive `columns` for nested objectList. - FlowObjectListField renders those columns via the shared FlowStringListField (string/number lists, with number[] coercion) and a recursive FlowObjectListField (object lists), round-tripping each cell as an array. Closes the last open acceptance item of #2678 (P2-5). Docs + changeset updated; new mapper + nested-render tests. Claude-Session: https://claude.ai/code/session_01Sa7REWrWMsukqPbtoGASPr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29c6040 commit 20a2a02

9 files changed

Lines changed: 395 additions & 32 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(app-shell): nested-array columns in the flow designer property form (#2678 P2-5)
6+
7+
The server-driven node property form (`configSchema``FlowConfigField`) now
8+
renders **nested arrays** inside an `objectList` repeater instead of degrading
9+
them to a plain text cell that `String()`-joined and corrupted the array on
10+
save. A repeater column whose item property is itself an array becomes a
11+
**nested repeater** (repeater-in-repeater):
12+
13+
- `json-schema-to-fields` `columnsFor` maps an array-typed item property to a
14+
`stringList` / `numberList` / `objectList` column; object-array columns derive
15+
their own nested columns recursively (bounded by a nesting cap so a
16+
pathological / cyclic schema can't build a non-terminating form). Arrays that
17+
still aren't representable fall through to the prior text behavior — no
18+
regression.
19+
- `FlowConfigColumn` gains the three list `kind`s plus a recursive `columns` for
20+
nested `objectList`.
21+
- `FlowObjectListField` renders those columns via the shared `FlowStringListField`
22+
(string/number lists, with `number[]` coercion) and a recursive
23+
`FlowObjectListField` (object lists), round-tripping each cell as an array.
24+
25+
Any engine-published node config with a nested array is now editable inline
26+
rather than dropping to the Advanced JSON escape hatch.

content/docs/guide/flow-designer.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ intermediate state.
106106
For node types whose engine executor publishes a `configSchema` (ADR-0018), the
107107
inspector renders a **server-driven property form** from that schema — so a
108108
plugin's node gets a real config UI without the designer hardcoding its fields.
109+
The mapping covers scalars, enums (→ select), typed references (→ picker),
110+
free-form maps (→ key/value), and array/object shapes: an array of objects
111+
becomes a **repeater**, and a repeater column that is itself an array (of
112+
strings, numbers, or objects) becomes a **nested repeater** — so an
113+
engine-published nested-array config is editable inline rather than dropping to
114+
the Advanced JSON block.
109115

110116
A **Decision** node's Branches editor defines each branch's label, CEL
111117
expression, **and target node** in one table: the **Target** column picks the

packages/app-shell/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,11 @@ Config keys come in three editable shapes so authors never hand-write JSON:
364364
single-column **string-list editor** (`stringList` kind).
365365
- **Arrays of objects** — a `screen` node's **Fields** (a list of
366366
`{name,label,type,required,visibleWhen}` definitions) — use a column-driven
367-
**object-list repeater** (`objectList` kind).
367+
**object-list repeater** (`objectList` kind). A repeater column can itself be a
368+
list: an item property that is an array of strings / numbers / objects renders
369+
as a **nested repeater** (`stringList` / `numberList` / `objectList` column) —
370+
a repeater-in-repeater — so an engine-published nested-array config is editable
371+
inline instead of falling to the Advanced JSON block (#2678 P2-5).
368372

369373
A `decision` node's **Branches** repeater additionally shows a per-branch
370374
**Target** column (#1942) — a node picker scoped to this flow — so the whole

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
111111
addLabel={t('engine.inspector.flowNode.list.add', locale)}
112112
removeLabel={t('engine.inspector.flowNode.list.remove', locale)}
113113
emptyLabel={t('engine.inspector.flowNode.list.empty', locale)}
114+
itemLabel={t('engine.inspector.flowNode.list.item', locale)}
114115
context={context}
115116
scopeGroups={scopeGroups}
116117
/>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #2678 P2-5 — an `objectList` column may itself be a list (repeater-in-repeater).
5+
* Before this branch a nested array item property fell through to a plain text
6+
* cell that `String()`-joined the array and corrupted it on save. These tests
7+
* pin that a nested `stringList` / `numberList` / `objectList` column renders its
8+
* own editor and round-trips the nested array through `onCommit` as an array —
9+
* `numberList` coerced back to `number[]`, not the widget's display strings.
10+
*/
11+
12+
import { describe, it, expect, vi, afterEach } from 'vitest';
13+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
14+
import { FlowObjectListField } from './FlowObjectListField';
15+
import type { FlowConfigColumn } from './flow-node-config';
16+
17+
afterEach(cleanup);
18+
19+
const baseProps = {
20+
onCommit: vi.fn(),
21+
addLabel: 'Add',
22+
removeLabel: 'Remove',
23+
emptyLabel: 'None',
24+
itemLabel: 'Item',
25+
};
26+
27+
describe('FlowObjectListField — nested stringList column', () => {
28+
const columns: FlowConfigColumn[] = [
29+
{ key: 'name', label: 'Name', kind: 'text' },
30+
{ key: 'recipients', label: 'Recipients', kind: 'stringList' },
31+
];
32+
33+
it('renders the nested array as its own list editor (not a joined text cell)', () => {
34+
render(<FlowObjectListField label="Rules" columns={columns} value={[{ name: 'r1', recipients: ['a@x.com', 'b@x.com'] }]} {...baseProps} />);
35+
// Each nested item is its own input — a text cell would show "a@x.com,b@x.com".
36+
expect(screen.getByDisplayValue('a@x.com')).toBeInTheDocument();
37+
expect(screen.getByDisplayValue('b@x.com')).toBeInTheDocument();
38+
expect(screen.queryByDisplayValue('a@x.com,b@x.com')).not.toBeInTheDocument();
39+
});
40+
41+
it('round-trips an edit to a nested item as a string[] on the parent object', () => {
42+
const onCommit = vi.fn();
43+
render(<FlowObjectListField label="Rules" columns={columns} value={[{ name: 'r1', recipients: ['a@x.com'] }]} {...baseProps} onCommit={onCommit} />);
44+
const input = screen.getByDisplayValue('a@x.com');
45+
fireEvent.change(input, { target: { value: 'z@x.com' } });
46+
fireEvent.blur(input);
47+
expect(onCommit).toHaveBeenCalled();
48+
expect(onCommit.mock.calls.at(-1)![0]).toEqual([{ name: 'r1', recipients: ['z@x.com'] }]);
49+
});
50+
});
51+
52+
describe('FlowObjectListField — nested numberList column', () => {
53+
const columns: FlowConfigColumn[] = [{ key: 'offsets', label: 'Offsets', kind: 'numberList' }];
54+
55+
it('shows stored numbers as text and commits number[] (not the display strings)', () => {
56+
const onCommit = vi.fn();
57+
render(<FlowObjectListField label="Timers" columns={columns} value={[{ offsets: [1, 2] }]} {...baseProps} onCommit={onCommit} />);
58+
expect(screen.getByDisplayValue('1')).toBeInTheDocument();
59+
expect(screen.getByDisplayValue('2')).toBeInTheDocument();
60+
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '5' } });
61+
fireEvent.blur(screen.getByDisplayValue('5'));
62+
expect(onCommit.mock.calls.at(-1)![0]).toEqual([{ offsets: [5, 2] }]);
63+
});
64+
});
65+
66+
describe('FlowObjectListField — nested objectList column (repeater-in-repeater)', () => {
67+
const columns: FlowConfigColumn[] = [
68+
{ key: 'label', label: 'Label', kind: 'text' },
69+
{
70+
key: 'members',
71+
label: 'Members',
72+
kind: 'objectList',
73+
columns: [{ key: 'user', label: 'User', kind: 'text' }],
74+
},
75+
];
76+
77+
it('renders a nested repeater row from the nested array', () => {
78+
render(<FlowObjectListField label="Groups" columns={columns} value={[{ label: 'mgr', members: [{ user: 'alice' }] }]} {...baseProps} />);
79+
expect(screen.getByDisplayValue('mgr')).toBeInTheDocument();
80+
// The doubly-nested member value renders through the recursive mount.
81+
expect(screen.getByDisplayValue('alice')).toBeInTheDocument();
82+
});
83+
});

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

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
* state with a STABLE id and flushed on blur / Enter / add / remove so a row
1010
* never remounts mid-keystroke. Empty per-cell values are pruned; a row with no
1111
* populated cells is dropped on flush; an empty list commits `undefined`.
12+
*
13+
* A column may itself be a *list* (`stringList` / `numberList` / `objectList`) —
14+
* a repeater-in-repeater. Those cells hold an array and render the matching
15+
* sibling editor inline (recursively, for `objectList`), so an engine-published
16+
* nested-array config is editable here instead of dropping to Advanced JSON.
1217
*/
1318

1419
import * as React from 'react';
@@ -20,16 +25,23 @@ import {
2025
import { uniqueId } from './_shared';
2126
import type { FlowConfigColumn } from './flow-node-config';
2227
import { ReferenceCombobox, resolveRefKind, type FlowReferenceContext } from './FlowReferenceField';
28+
import { FlowStringListField } from './FlowStringListField';
2329
import { VariableTextInput } from './VariableTextInput';
2430
import type { ScopeGroup } from './useFlowScope';
2531
import { FlowExprIssue } from './FlowExprIssue';
2632

27-
type Cell = string | boolean;
33+
/** A cell is a scalar (string/boolean) or, for a nested-list column, an array. */
34+
type Cell = string | boolean | unknown[];
2835
interface Row {
2936
id: string;
3037
values: Record<string, Cell>;
3138
}
3239

40+
/** Columns whose cell holds an array (a nested repeater) rather than a scalar. */
41+
function isListColumn(kind: FlowConfigColumn['kind']): boolean {
42+
return kind === 'stringList' || kind === 'numberList' || kind === 'objectList';
43+
}
44+
3345
function toRows(list: Array<Record<string, unknown>>, columns: FlowConfigColumn[]): Row[] {
3446
const ids: string[] = [];
3547
return list.map((item) => {
@@ -39,6 +51,7 @@ function toRows(list: Array<Record<string, unknown>>, columns: FlowConfigColumn[
3951
for (const col of columns) {
4052
const v = item[col.key];
4153
if (col.kind === 'boolean') values[col.key] = v === true;
54+
else if (isListColumn(col.kind)) values[col.key] = Array.isArray(v) ? v : [];
4255
else if (v != null) values[col.key] = String(v);
4356
else values[col.key] = '';
4457
}
@@ -58,6 +71,13 @@ function rowsToList(rows: Row[], columns: FlowConfigColumn[]): Array<Record<stri
5871
obj[col.key] = true;
5972
hasValue = true;
6073
}
74+
} else if (isListColumn(col.kind)) {
75+
// A nested list commits its own already-normalized array (string[] /
76+
// number[] / object[]); an empty nested list drops the key entirely.
77+
if (Array.isArray(v) && v.length > 0) {
78+
obj[col.key] = v;
79+
hasValue = true;
80+
}
6181
} else if (typeof v === 'string' && v.trim() !== '') {
6282
obj[col.key] = v.trim();
6383
hasValue = true;
@@ -77,6 +97,8 @@ export interface FlowObjectListFieldProps {
7797
addLabel: string;
7898
removeLabel: string;
7999
emptyLabel: string;
100+
/** Per-item placeholder for nested `stringList` / `numberList` columns. */
101+
itemLabel?: string;
80102
/** Draft + node context so `reference` columns can resolve their options. */
81103
context?: FlowReferenceContext;
82104
/** In-scope variable references for `expression` columns (#1934). */
@@ -92,6 +114,7 @@ export function FlowObjectListField({
92114
addLabel,
93115
removeLabel,
94116
emptyLabel,
117+
itemLabel,
95118
context,
96119
scopeGroups,
97120
}: FlowObjectListFieldProps) {
@@ -123,9 +146,22 @@ export function FlowObjectListField({
123146
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, values: { ...r.values, [key]: v } } : r)));
124147
};
125148

149+
// Set a cell AND flush — used by controls with no blur to flush on (checkbox,
150+
// select) and by the nested-list editors, which each commit a whole array on
151+
// their own blur/add/remove. The flush stays inside the `setRows` updater
152+
// (the accepted idiom for the sibling scalar controls) so the `lastCommitted`
153+
// ref is touched only there, never in the render body.
154+
const commitCell = (id: string, key: string, v: Cell) => {
155+
setRows((rs) => {
156+
const next = rs.map((r) => (r.id === id ? { ...r, values: { ...r.values, [key]: v } } : r));
157+
flush(next);
158+
return next;
159+
});
160+
};
161+
126162
const addRow = () => {
127163
const values: Record<string, Cell> = {};
128-
for (const col of columns) values[col.key] = col.kind === 'boolean' ? false : '';
164+
for (const col of columns) values[col.key] = col.kind === 'boolean' ? false : isListColumn(col.kind) ? [] : '';
129165
setRows((rs) => [...rs, { id: uniqueId('ol', rs.map((r) => r.id)), values }]);
130166
};
131167

@@ -161,25 +197,67 @@ export function FlowObjectListField({
161197
</Button>
162198
</div>
163199
<div className="space-y-1.5">
164-
{columns.map((col) => (
165-
<div key={col.key} className="flex items-center gap-2">
200+
{columns.map((col) => {
201+
// A nested-list column (repeater-in-repeater) renders full-width
202+
// as its own list editor, set off with a left rule. `stringList` /
203+
// `numberList` reuse FlowStringListField; `objectList` recurses.
204+
if (col.kind === 'stringList' || col.kind === 'numberList') {
205+
const raw = row.values[col.key];
206+
const arr = Array.isArray(raw) ? raw : [];
207+
return (
208+
<div key={col.key} className="border-l-2 border-muted/60 pl-2">
209+
<FlowStringListField
210+
label={col.label}
211+
value={col.kind === 'numberList' ? arr.map((n) => String(n)) : arr}
212+
onCommit={(v) => {
213+
if (col.kind === 'numberList') {
214+
const nums = (v ?? [])
215+
.map((s) => Number(String(s).trim()))
216+
.filter((n) => Number.isFinite(n));
217+
commitCell(row.id, col.key, nums);
218+
} else {
219+
commitCell(row.id, col.key, v ?? []);
220+
}
221+
}}
222+
disabled={disabled}
223+
addLabel={addLabel}
224+
itemLabel={itemLabel ?? col.label}
225+
removeLabel={removeLabel}
226+
emptyLabel={emptyLabel}
227+
/>
228+
</div>
229+
);
230+
}
231+
if (col.kind === 'objectList') {
232+
const raw = row.values[col.key];
233+
const arr = (Array.isArray(raw) ? raw : []) as Array<Record<string, unknown>>;
234+
return (
235+
<div key={col.key} className="border-l-2 border-muted/60 pl-2">
236+
<FlowObjectListField
237+
label={col.label}
238+
columns={col.columns ?? []}
239+
value={arr}
240+
onCommit={(v) => commitCell(row.id, col.key, v ?? [])}
241+
disabled={disabled}
242+
addLabel={addLabel}
243+
removeLabel={removeLabel}
244+
emptyLabel={emptyLabel}
245+
itemLabel={itemLabel}
246+
context={context}
247+
scopeGroups={scopeGroups}
248+
/>
249+
</div>
250+
);
251+
}
252+
return (
253+
<div key={col.key} className="flex items-center gap-2">
166254
<Label className="w-24 shrink-0 text-[11px] text-muted-foreground">
167255
{col.label}
168256
</Label>
169257
{col.kind === 'boolean' ? (
170258
<Checkbox
171259
checked={row.values[col.key] === true}
172-
onCheckedChange={(c) => {
173-
setRows((rs) => {
174-
const next = rs.map((r) =>
175-
r.id === row.id
176-
? { ...r, values: { ...r.values, [col.key]: c === true } }
177-
: r,
178-
);
179-
flush(next);
180-
return next;
181-
});
182-
}}
260+
onCheckedChange={(c) => commitCell(row.id, col.key, c === true)}
183261
disabled={disabled}
184262
/>
185263
) : col.kind === 'reference' ? (
@@ -213,17 +291,7 @@ export function FlowObjectListField({
213291
<div className="flex-1">
214292
<Select
215293
value={current || undefined}
216-
onValueChange={(v) =>
217-
setRows((rs) => {
218-
const next = rs.map((r) =>
219-
r.id === row.id
220-
? { ...r, values: { ...r.values, [col.key]: v } }
221-
: r,
222-
);
223-
flush(next);
224-
return next;
225-
})
226-
}
294+
onValueChange={(v) => commitCell(row.id, col.key, v)}
227295
disabled={disabled}
228296
>
229297
<SelectTrigger className="h-8 w-full text-xs">
@@ -274,8 +342,9 @@ export function FlowObjectListField({
274342
className="h-8 flex-1 text-xs"
275343
/>
276344
)}
277-
</div>
278-
))}
345+
</div>
346+
);
347+
})}
279348
</div>
280349
</div>
281350
))}

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,31 @@ export interface FlowReferenceSpec {
115115
export interface FlowConfigColumn {
116116
key: string;
117117
label: string;
118-
kind: 'text' | 'expression' | 'boolean' | 'select' | 'reference';
118+
/**
119+
* Scalar cells (`text`/`expression`/`boolean`/`select`/`reference`) plus the
120+
* three *nested-list* kinds — a cell that is itself a repeater
121+
* (repeater-in-repeater). `stringList`/`numberList` hold a primitive array;
122+
* `objectList` holds an array-of-objects whose own shape is in {@link columns}.
123+
*/
124+
kind:
125+
| 'text'
126+
| 'expression'
127+
| 'boolean'
128+
| 'select'
129+
| 'reference'
130+
| 'stringList'
131+
| 'numberList'
132+
| 'objectList';
119133
placeholder?: string;
120134
options?: Array<{ value: string; label: string }>;
121135
/** For `kind: 'reference'` — the picker data source (may be polymorphic). */
122136
ref?: FlowReferenceSpec;
137+
/**
138+
* For `kind: 'objectList'` — the nested repeater's own column schema. Recursive:
139+
* a nested `objectList` column may itself carry `columns`, so an engine-published
140+
* array-of-objects-of-…-arrays renders inline instead of dropping to Advanced JSON.
141+
*/
142+
columns?: FlowConfigColumn[];
123143
}
124144

125145
export interface FlowConfigField {

0 commit comments

Comments
 (0)