Skip to content

Commit b10fd65

Browse files
fix(studio): a flow node's key / value map stacks each field over its value, so field names are readable (#27405)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ836VkJMbRirYcP4FDCFmjrE)_ Part of #27356 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 流程检查器里,键/值映射(新建记录节点的字段值、HTTP 头等)的每一行改为上下两行:字段名单独一行(旁边是删除按钮),值在下面一行(旁边是表达式切换)。 - 为什么改:HotCRM 18.2.0 走查(hotcrm#2320 Studio S5)发现新建记录节点把字段名截成 4–5 个字符(「subj」「dueD」);300px 的检查器里一行要塞字段、值和两个按钮。 - 风险与回滚:只改这个编辑器的行布局,存储的值不变;回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx`(行渲染的 `flow-kv-key-line` / `flow-kv-value-line`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.stackedRows-27356.test.tsx`,预期 2 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Row 16 of #27356 (hotcrm#2320, Studio report finding S5): "The Create Record node cuts field names to 4–5 characters ("subj", "dueD")." `FlowKeyValueField`, the key / value map editor in the flow node inspector, draws each row on two lines instead of one: - **Key line:** the key box (`min-w-0 flex-1`) and the remove button. - **Value line:** the value box and, where the map has one, the text / expression toggle. Rows are spaced `space-y-3` (was `space-y-1.5`) so one pair reads apart from the next. Nothing about what the map commits changes: the same rows, the same flush on blur, the same refusal and expression-issue lines under the value. `FlowNodeConfigField` is the editor's only caller, so every key / value map in the flow inspector stacks: Create Record and Update Record field values, the Update Record filter, HTTP headers, Assignment assignments, subflow input mapping, script inputs, screen form defaults, action parameters. That is one component's layout, not a sweep; each of those maps had the same cut-off key. ## Why In the 300 px Studio inspector (`STUDIO_ASIDE_WIDTH`, less the inspector shell's `p-4`), the key box got the width left after the value box and two 32 px buttons. <details><summary>Geometry reading (Chromium, Tailwind 4.3.3 compiled from the same class strings, 267 px content width)</summary> Key box holding `customer_satisfaction_score`, computed font 14 px monospace (the `Input` primitive's `md:text-sm` outranks the row's `text-xs` at desktop width; unchanged here): | | key box width | characters visible | |---|---|---| | before (one line: key, value, toggle, remove) | 104 px | 9 | | after (key + remove over value + toggle) | 227 px | 24 | One-off probe, not committed: a static page with both row shapes built from `compile()` of `tailwindcss/dist/lib.mjs`, measured with Playwright. </details> ## Tests `ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.stackedRows-27356.test.tsx` (2 tests, through `FlowNodeConfigField`; jsdom draws no layout, so the pin is the row's structure): - Create Record field values with `customer_satisfaction_score`: the key's line holds no value box, exactly one input and one button (remove); the value's line is a different line, after it, and holds the expression toggle. - HTTP headers (a map with no toggle): the key's line holds no value box. <details><summary>Ablation (one: rejoin the two lines)</summary> `node scripts/ablation-replace.mjs --delete` of the anchor that closes the key line and opens the value line (anchor 1 → 0, blob `9f71951b7644` → `d9d573fada30`): both tests fail, `expected true to be false`. Restored: blob after restore = blob at HEAD = `9f71951b7644`, `git diff HEAD` empty. </details> ## Acceptance notes - The key box still shows the API name (`subject`), as before; the datalist suggestions keep showing `Label (name)`. Showing the label in the box would change what the box edits, which this row does not ask for. - A 27-character name still needs 251 px at desktop width and shows 24 characters in the 300 px aside; the box scrolls as any input does. - The `Input` primitive's `md:text-sm` outranks this editor's `text-xs` at desktop width, so the key box reads at 14 px rather than 12 px. Pre-existing and unchanged; noted only. ## Verification Code on head `d30f8380` (fix + test + changeset); head `5ebc5576` adds a one-line changeset wording fix, after which the changeset families were re-run. Merge base `7e200b4a`. Under the shared verification lock. - New file: 2 / 2 passed. Every app-shell test file that imports `FlowNodeInspector`, `FlowNodeConfigField` or `FlowKeyValueField` (65 files, the new one included): 971 / 971 passed. - `pnpm --filter @object-ui/app-shell run type-check` (both `tsc` programs): exit 0. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: the same 34 families as row 21, all 34 exit 0; `--ran`: 34 run, 0 NOT-MEASURED, 0 UNRUN. Tier: no path-derived mandate; 132 changed lines. - The `ui scripts/` family first exited 1 on `quick-reference-commands-4149` ("no row for apps/site"): an ignored `ui/apps/site/node_modules` left in this worktree from before the site retired (#27329). Deleted it and re-ran: 169 files passed, 2 skipped, exit 0. Not a tree defect. - Re-run on `5ebc5576`: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:pm-changeset-deadline-census`, `check:nul-bytes`, all exit 0. Repo-wide lint, the full ui test battery and the e2e jobs are CI's. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWuU6sVfBYiy4dY4PA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f17135 commit b10fd65

3 files changed

Lines changed: 117 additions & 15 deletions

File tree

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+
'@object-ui/console': patch
4+
---
5+
6+
fix(studio): a flow node's key / value map puts each field name on its own line, so the name is readable
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** In the flow inspector, each row of a key / value map (a Create Record or Update Record node's field values, an HTTP node's headers, an Assignment node's assignments) put the key box, the value box and up to two buttons on one line. In the 300 px inspector the key box showed about nine characters, so a field name read "subj" or "dueD".
12+
- **Now.** A row stacks: the key on its own line beside the remove button, the value on the line under it beside the text / expression toggle where the map has one. The same field name shows about 24 characters. Nothing about what the map stores changes.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27356 (hotcrm#2320 Studio S5) — a Create Record node's field
5+
* names were cut to four or five characters ("subj", "dueD"): each map row put
6+
* the field box, the value box and two buttons on one line, so the narrow
7+
* inspector split its width between them. A row now stacks: the field on its
8+
* own line beside the remove button, the value on the line under it beside the
9+
* text / expression toggle.
10+
*
11+
* jsdom draws no layout, so the pin is the row's structure, read through
12+
* `FlowNodeConfigField` (the editor's only caller) on a `create_record` node:
13+
* the field box never shares a line with the value box. The same editor draws
14+
* every key / value map in the flow inspector; the HTTP `headers` map is the
15+
* second case, a map with no value toggle.
16+
*/
17+
18+
import { describe, it, expect, afterEach, vi } from 'vitest';
19+
import { render, screen, cleanup } from '@testing-library/react';
20+
21+
vi.mock('../previews/useObjectFields', () => ({
22+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
23+
}));
24+
25+
import { FlowNodeConfigField } from './FlowNodeConfigField';
26+
import type { FlowConfigField } from './flow-node-config';
27+
28+
afterEach(cleanup);
29+
30+
const TOGGLE = 'Write as a CEL expression';
31+
const REMOVE = /remove/i;
32+
const LONG_FIELD = 'customer_satisfaction_score';
33+
34+
function renderMap(field: FlowConfigField, nodeType: string, value: Record<string, unknown>) {
35+
render(
36+
<FlowNodeConfigField
37+
field={field}
38+
value={value}
39+
onCommit={vi.fn()}
40+
context={{ draft: {}, node: { id: 'n1', type: nodeType, config: { objectName: 'case' } } }}
41+
/>,
42+
);
43+
}
44+
45+
/** The line a box sits on: the row's key line or value line. */
46+
const lineOf = (el: HTMLElement) => el.closest('[data-testid^="flow-kv-"]') as HTMLElement;
47+
48+
describe('a key / value map row stacks the field over its value (objectstack#27356)', () => {
49+
it('Create Record: the long field name has its own line, beside the remove button only', () => {
50+
renderMap(
51+
{ id: 'fields', path: ['config', 'fields'], label: 'Field values', kind: 'keyValue' },
52+
'create_record',
53+
{ [LONG_FIELD]: '5' },
54+
);
55+
const key = screen.getByDisplayValue(LONG_FIELD);
56+
const value = screen.getByDisplayValue('5');
57+
const keyLine = lineOf(key);
58+
59+
expect(keyLine.contains(value)).toBe(false);
60+
expect(keyLine.querySelectorAll('input')).toHaveLength(1);
61+
const buttons = [...keyLine.querySelectorAll('button')];
62+
expect(buttons).toHaveLength(1);
63+
expect(buttons[0]!.getAttribute('aria-label')).toMatch(REMOVE);
64+
65+
// The value's line carries the text / expression toggle, under the field.
66+
const valueLine = lineOf(value);
67+
expect(valueLine).not.toBe(keyLine);
68+
expect(valueLine.contains(screen.getByRole('button', { name: TOGGLE }))).toBe(true);
69+
expect(keyLine.compareDocumentPosition(valueLine) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
70+
});
71+
72+
it('a map with no value toggle (HTTP headers) stacks the same way', () => {
73+
renderMap(
74+
{ id: 'headers', path: ['config', 'headers'], label: 'Headers', kind: 'keyValue' },
75+
'http',
76+
{ 'X-Request-Correlation-Id': 'abc' },
77+
);
78+
const key = screen.getByDisplayValue('X-Request-Correlation-Id');
79+
expect(lineOf(key).contains(screen.getByDisplayValue('abc'))).toBe(false);
80+
expect(screen.queryByRole('button', { name: TOGGLE })).toBeNull();
81+
});
82+
});

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

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ export function FlowKeyValueField({
359359
{ASSIGNMENT_ARRAY_FORM_PRESCRIPTION}
360360
</p>
361361
)}
362-
<div className="space-y-1.5">
362+
<div className="space-y-3">
363363
{rows.length === 0 && (
364364
<p className="text-[11px] italic text-muted-foreground">{emptyLabel}</p>
365365
)}
@@ -373,7 +373,13 @@ export function FlowKeyValueField({
373373
const refusal = envelopeSlot && writesMap ? valueRefusal(rowValue(row)) : null;
374374
return (
375375
<div key={row.id} className="space-y-1">
376-
<div className="flex items-center gap-1.5">
376+
{/*
377+
* objectstack#27356 — the key sits on its own line, over its
378+
* value. Side by side, the inspector's width split three ways
379+
* (key, value, buttons) and a field name read "subj" or "dueD";
380+
* on its own line it keeps the full width beside one button.
381+
*/}
382+
<div className="flex items-center gap-1.5" data-testid="flow-kv-key-line">
377383
<Input
378384
list={suggestsKeys ? keyListId : undefined}
379385
value={row.key}
@@ -384,8 +390,22 @@ export function FlowKeyValueField({
384390
}}
385391
placeholder={keyLabel}
386392
disabled={disabled}
387-
className="h-8 flex-1 font-mono text-xs"
393+
className="h-8 min-w-0 flex-1 font-mono text-xs"
388394
/>
395+
<Button
396+
type="button"
397+
variant="ghost"
398+
size="sm"
399+
className="h-8 w-8 shrink-0 p-0 text-muted-foreground"
400+
onClick={() => removeRow(row.id)}
401+
disabled={disabled}
402+
aria-label={removeLabel}
403+
title={removeLabel}
404+
>
405+
<X className="h-3.5 w-3.5" />
406+
</Button>
407+
</div>
408+
<div className="flex items-center gap-1.5" data-testid="flow-kv-value-line">
389409
<VariableTextInput
390410
mode={hole ? 'hole' : 'expression'}
391411
mono={expression}
@@ -415,18 +435,6 @@ export function FlowKeyValueField({
415435
<Code2 className="h-3.5 w-3.5" />
416436
</Button>
417437
)}
418-
<Button
419-
type="button"
420-
variant="ghost"
421-
size="sm"
422-
className="h-8 w-8 shrink-0 p-0 text-muted-foreground"
423-
onClick={() => removeRow(row.id)}
424-
disabled={disabled}
425-
aria-label={removeLabel}
426-
title={removeLabel}
427-
>
428-
<X className="h-3.5 w-3.5" />
429-
</Button>
430438
</div>
431439
{refusal ? (
432440
<div className="space-y-0.5" role="alert">

0 commit comments

Comments
 (0)