Skip to content

Commit a52cfaf

Browse files
fix(app-shell): flow list field reads a stored string as a one-entry list (#26038)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9FoS954UD49NiRcXvuTYghQ)_ Fixes #25999 Clause-②: no Metadata-protocol: none ## What changed Studio's flow inspector draws a notify node's `channels` with `FlowStringListField`. The spec types `channels` as `string | string[]` (`packages/spec/src/automation/io-node-config.zod.ts`), and the runtime's `toStringList` in `notify-node.ts` reads `channels: 'email'` as `['email']`. The field read any non-array value as an empty list, so a stored `'email'` showed as "no entries" and adding a channel saved a list without `email`. `FlowStringListField` now reads its value through `toList`: a list as before, a non-blank string as one trimmed entry (the same normalisation the runtime applies), anything else as empty. No spec change. ## Write-back form **An edit writes the list form.** When the author adds, removes or edits an entry, the field commits `['email', 'sms']`, never the bare string. The spec and the runtime accept both forms, and the list form is the one every other list-valued key of the inspector writes. A field the author never touches commits nothing, so a stored string stays a string until it is edited. ## Tests `FlowStringListField.stringValue-25999.test.tsx`, seven cases read from the rendered rows and the committed value: a string shows as one row, adding a second keeps the first (`['email', 'sms']`), removing the only entry commits `undefined`, the string is trimmed, a blank string reads as empty, a list still reads as its rows, and rendering commits nothing. - Run at `0a84c545` under the verify lock, with the two neighbouring inspector suites: `Test Files 3 passed (3)`, `Tests 16 passed (16)`, VERDICT command-exit 0. - Ablation (deleting the string branch of `toList` through `scripts/ablation-replace.mjs`, anchor 1 to 0, restore verified blob equals HEAD): 4 of the 7 cases go red (shows, keeps, removes, trims), the 3 cases about lists, blanks and no-commit stay green, as expected. ## Gates - `dispatch-gates --commands` at `0a84c545`: 34 families, all exit 0; `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED. - `node scripts/gen-sdui-manifest-node.mjs` re-run: `scripts/sdui-manifest.record.json` is byte-identical, because `ui/packages/app-shell` is not among the registry's source packages. `node scripts/check-sdui-manifest.mjs` exits 0. So the record file is not in this diff. ## Acceptance notes - `FlowObjectListField.tsx` (around line 764) reads a nested `stringList` / `numberList` column the same way, with any non-array read as `[]`. No spec key that renders there is known to accept a bare string, so this is an observation only, not changed here. Carrier: none. - No `ui/.github/**` or root CI workflow file is touched. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9TKf9xmHBvHQzFKLqxM6Q)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e0fe7d3 commit a52cfaf

3 files changed

Lines changed: 110 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the flow inspector's list field shows a stored string as one entry instead of an empty list
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A notify node's `channels` may be a single string (`channels: 'email'`) or a list; the spec accepts both and the runtime reads the string as a one-item list. Studio's flow inspector read any non-list value as empty, so the field showed no channels and adding one saved a list without `email`. A stored non-blank string now reads as a one-entry list (trimmed, as the runtime does). An edit saves the list form (`['email', 'sms']`); a field left untouched keeps the stored string.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25999 — a stored bare string reads as a one-entry list.
5+
*
6+
* A notify node's `channels` is `string | string[]` in the spec, and the
7+
* runtime reads `channels: 'email'` as `['email']`. The field used to read any
8+
* non-array value as an empty list: it showed "no entries", and adding a
9+
* channel committed a list without `email`, silently dropping it.
10+
*
11+
* The cases read the rendered rows and the committed value, never the internal
12+
* list, so an implementation that parses the string but fails to show or keep
13+
* it still fails here.
14+
*/
15+
16+
import { describe, it, expect, vi, afterEach } from 'vitest';
17+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
18+
import { FlowStringListField } from './FlowStringListField';
19+
20+
afterEach(cleanup);
21+
22+
const LABELS = {
23+
label: 'Channels',
24+
addLabel: 'Add',
25+
itemLabel: 'Item',
26+
removeLabel: 'Remove',
27+
emptyLabel: 'No entries',
28+
};
29+
30+
function rowValues(): string[] {
31+
return screen.queryAllByPlaceholderText('Item').map((el) => (el as HTMLInputElement).value);
32+
}
33+
34+
describe('FlowStringListField — stored string value (#25999)', () => {
35+
it('shows a stored string as one row', () => {
36+
render(<FlowStringListField {...LABELS} value="email" onCommit={vi.fn()} />);
37+
expect(rowValues()).toEqual(['email']);
38+
expect(screen.queryByText('No entries')).toBeNull();
39+
});
40+
41+
it('keeps the stored entry when another one is added, committing the list form', () => {
42+
const onCommit = vi.fn();
43+
render(<FlowStringListField {...LABELS} value="email" onCommit={onCommit} />);
44+
45+
fireEvent.click(screen.getByText('Add'));
46+
const inputs = screen.getAllByPlaceholderText('Item');
47+
expect(inputs).toHaveLength(2);
48+
fireEvent.change(inputs[1], { target: { value: 'sms' } });
49+
fireEvent.blur(inputs[1]);
50+
51+
expect(onCommit).toHaveBeenLastCalledWith(['email', 'sms']);
52+
});
53+
54+
it('removing the only entry of a stored string commits undefined', () => {
55+
const onCommit = vi.fn();
56+
render(<FlowStringListField {...LABELS} value="email" onCommit={onCommit} />);
57+
58+
fireEvent.click(screen.getByLabelText('Remove'));
59+
60+
expect(onCommit).toHaveBeenLastCalledWith(undefined);
61+
expect(rowValues()).toEqual([]);
62+
});
63+
64+
it('trims the stored string, as the runtime does', () => {
65+
render(<FlowStringListField {...LABELS} value=" email " onCommit={vi.fn()} />);
66+
expect(rowValues()).toEqual(['email']);
67+
});
68+
69+
it('reads a blank string as empty', () => {
70+
render(<FlowStringListField {...LABELS} value=" " onCommit={vi.fn()} />);
71+
expect(rowValues()).toEqual([]);
72+
expect(screen.getByText('No entries')).toBeTruthy();
73+
});
74+
75+
it('still reads a stored list as its rows', () => {
76+
render(<FlowStringListField {...LABELS} value={['inbox', 'email']} onCommit={vi.fn()} />);
77+
expect(rowValues()).toEqual(['inbox', 'email']);
78+
});
79+
80+
it('does not commit on render, so an untouched string stays as stored', () => {
81+
const onCommit = vi.fn();
82+
render(<FlowStringListField {...LABELS} value="email" onCommit={onCommit} />);
83+
expect(onCommit).not.toHaveBeenCalled();
84+
});
85+
});

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ function rowsToList(rows: Row[]): string[] {
3333
return rows.map((r) => r.value.trim()).filter((v) => v !== '');
3434
}
3535

36+
/**
37+
* The stored value as a list. Some keys accept a bare string as well as a
38+
* list — a notify node's `channels` is `string | string[]` in the spec, and the
39+
* runtime reads `'email'` as `['email']` — so a non-blank string reads as a
40+
* one-entry list (#25999). Reading it as empty showed the field blank, and the
41+
* next edit committed a list without the stored entry. A commit writes the
42+
* list form, which every such key accepts.
43+
*/
44+
function toList(value: unknown): string[] {
45+
if (Array.isArray(value)) return value.map((v) => String(v));
46+
if (typeof value === 'string' && value.trim() !== '') return [value.trim()];
47+
return [];
48+
}
49+
3650
export interface FlowStringListFieldProps {
3751
label: string;
3852
value: unknown;
@@ -57,10 +71,7 @@ export function FlowStringListField({
5771
emptyLabel,
5872
required,
5973
}: FlowStringListFieldProps) {
60-
const external = React.useMemo(
61-
() => (Array.isArray(value) ? value.map((v) => String(v)) : []),
62-
[value],
63-
);
74+
const external = React.useMemo(() => toList(value), [value]);
6475
const [rows, setRows] = React.useState<Row[]>(() => toRows(external, []));
6576
const lastCommitted = React.useRef(JSON.stringify(external));
6677

0 commit comments

Comments
 (0)