Skip to content

Commit 7f80732

Browse files
feat(studio): long flow-node help opens behind Learn more; list column descriptions are help, not placeholders (#27832)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:Studio 流程节点检查器里,超过 120 个字符的字段帮助默认折成两行,带「了解更多 / 收起」开关;列表列(如审批节点的审批人)的 schema 说明不再塞进输入框占位符,改成列下方的帮助。 - 为什么改:HotCRM 浏览器走查(#27356 第 15 行)看到节点帮助是一整段引擎说明,占位符里写着「ADR-0105 D9 — organization…」,被截断,一打字就消失。 - 风险与回滚:只改展示,不改存储的值,也不改文字本身;回滚就是 revert 本 PR。 - 先看:`FlowFieldHelp.tsx`(折叠逻辑),`json-schema-to-fields.ts` 里 `columnsFor` 的两处(说明从 placeholder 改成 help),`FlowObjectListField.tsx` 渲染列帮助的四处。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx`,应见 4 passed。 - 需要你做的:无,席位落地。 Part of #27356 (row 15, hotcrm#2320 Studio S4: node inspector help and placeholder) Clause-②: no Metadata-protocol: none ## What changes Row 15 has two halves. This PR is the Studio half: how help is shown. The words half is not Studio's (see Acceptance notes). - **Long help opens clamped.** `FlowFieldHelp` (new, `views/metadata-admin/inspectors/`) renders the help line under a flow inspector field. Help longer than `FLOW_HELP_CLAMP_CHARS` (120) opens clamped to two lines (`line-clamp-2`) with a **Learn more** button (`aria-expanded`, `aria-controls`); **Show less** folds it again. Shorter help renders in full with no button, as before. The full text stays in the DOM in both states. `FlowNodeConfigField` uses it for every field's help (its only help site). - **Column descriptions are help, not placeholders.** `jsonSchemaToFlowFields` used to put an objectList column's schema `description` into the column's `placeholder`. On the approval node's Approvers list this put the served `organization` description ("ADR-0105 D9 — organization whose directory resolves this approver: …") into the input as a cut-off placeholder, and the served `value` description likewise. `FlowConfigColumn` gains `help`; both `columnsFor` sites (scalar columns and nested-list columns) now write the description there, and `FlowObjectListField` shows it under the column with `FlowFieldHelp`, in the default row layout, the decision branch cards and under nested list columns. A hand-written column `placeholder` (the offline table) is unchanged. - **i18n.** Two keys added to the studio namespace, en and zh: `engine.inspector.flowNode.help.learnMore`, `engine.inspector.flowNode.help.showLess`. No key renamed. Boundary: the Add-node menu is #27521 row 39 and is not touched here; this PR changes the node inspector only. ## Tests - New `FlowFieldHelp.learnMore-27356.test.tsx` (4 tests). The column half reads the installed contract: `z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' })` through `jsonSchemaToFlowFields`, then asserts the `organization` column carries the description as `help` and no `placeholder`, and that a rendered Approvers row has no element whose placeholder is that description while a help line shows it. The clamp half renders `FlowNodeConfigField` with a long and a short help. - `json-schema-to-fields.test.ts`: the approver `value` column assertion moved from `placeholder` to `help` (plus `placeholder` undefined). That line pinned the behaviour this PR changes. <details><summary>Readings (head 491ca52 for gates; tests on bc2046a, merge of origin/main touched none of these packages)</summary> - New file: 4/4 passed. - Every ui test file naming `FlowNodeConfigField`, `FlowObjectListField`, `json-schema-to-fields`, `FlowNodeInspector` or `FlowFieldHelp` (72 files): 72 passed, 1065 passed / 1 skipped. - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check`: both Done, exit 0 (lock VERDICT command-exit 0). - Ablation 1, through `scripts/ablation-replace.mjs` (anchor 1 to 0): the scalar column's `help: prop.description` back to `placeholder: prop.description`: 2 failed / 2 passed (both column tests red); restored blob = HEAD blob `f12818610a37`, `git diff HEAD` empty. - Ablation 2: `const long = text.length > FLOW_HELP_CLAMP_CHARS;` to `const long = false;`: 1 failed / 3 passed (the clamp test red); restored blob = HEAD blob `c4b6a6f0cf77`, `git diff HEAD` empty. - vitest resolves `@object-ui/*` to source here, so neither ablation needed a rebuild. </details> ## Acceptance notes - **The words are the engine's.** The help and column descriptions on an approval node come from `@objectstack/spec` `automation/approval.zod.ts` (`.describe()` / `.meta({ description })`), published by the server as the node's `configSchema`; Studio shows the producer's sentence and keeps only the zh rendering (`FLOW_FIELD_ZH`). Rewriting them in business words (for example `excludeSubmitter`'s paragraph naming `submitter_id` and `onEmptyApprovers`, and the `organization` description that opens with an ADR citation) is a spec change, outside this Studio surface. It is filed as #27838 (domain:spec, together with #27521 row 39), not done here. - The 120-character threshold is a length, not a measured overflow: jsdom has no layout, and a length keeps the behaviour deterministic. The threshold was chosen, not measured against a rendered width. - Hand-written help in the offline field table that runs past 120 characters (for example the decision branches help) now also opens clamped. That is the intended reading of row 15 ("the detail behind learn more"). ## Verification On head `491ca521`: `dispatch-gates --commands` derived 34 families for this diff; all 34 were run locally, each exit 0, and `--ran` reconciled 34 run, 0 NOT-MEASURED, 0 UNRUN (`node scripts/check-sdui-manifest.mjs` among them). `check:new-line-citations`: 0 new. CI check-runs were in progress when this was opened. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30f8fdd commit 7f80732

10 files changed

Lines changed: 183 additions & 8 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): a long flow-node help opens clamped behind "Learn more", and a list column's description is shown under the column instead of filling its placeholder
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
In Studio's flow node inspector:
12+
13+
- **Long help.** A field's help longer than 120 characters opens clamped to two lines with a **Learn more** toggle (**Show less** to fold it again). Shorter help renders in full as before. The whole text stays on the page either way, so a screen reader and a text search still reach it.
14+
- **List column descriptions.** In a repeated list such as an approval node's Approvers, a column's schema description used to fill the input's placeholder, where a paragraph read as a cut-off example value and disappeared on the first keystroke. It is now the column's help, shown under the column with the same Learn more toggle, and the input keeps its own placeholder.
15+
16+
The words themselves are unchanged: they are the engine-published schema descriptions.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The flow inspector's help lines (#27356 row 15).
5+
*
6+
* 1. A schema description on an objectList column is the column's help, shown
7+
* under the column, and never its placeholder: a paragraph squeezed into
8+
* an input's placeholder reads as a cut-off example value and vanishes on
9+
* the first keystroke.
10+
* 2. Help longer than `FLOW_HELP_CLAMP_CHARS` opens clamped to two lines with a
11+
* "Learn more" toggle; shorter help renders in full with no toggle. The
12+
* full text is in the DOM either way.
13+
*/
14+
15+
import { describe, it, expect, vi, afterEach } from 'vitest';
16+
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
17+
import { z } from 'zod';
18+
import { ApprovalNodeConfigSchema } from '@objectstack/spec/automation';
19+
import { FlowObjectListField } from './FlowObjectListField';
20+
import { FlowNodeConfigField } from './FlowNodeConfigField';
21+
import { FLOW_HELP_CLAMP_CHARS } from './FlowFieldHelp';
22+
import { jsonSchemaToFlowFields } from './json-schema-to-fields';
23+
import type { FlowConfigField } from './flow-node-config';
24+
25+
afterEach(cleanup);
26+
27+
// The approval node's fields as the engine publishes them: the installed
28+
// contract's own JSON Schema, not a copy of it.
29+
const schema = z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' });
30+
const served = jsonSchemaToFlowFields(schema)!;
31+
const approvers = served.find((f) => f.id === 'approvers')!;
32+
const orgCol = approvers.columns!.find((c) => c.key === 'organization')!;
33+
const orgDescription = (
34+
schema as unknown as {
35+
properties: { approvers: { items: { properties: { organization: { description: string } } } } };
36+
}
37+
).properties.approvers.items.properties.organization.description;
38+
39+
describe('objectList column descriptions are help, not placeholders (#27356 row 15)', () => {
40+
it('maps the served approver `organization` description to the column help', () => {
41+
expect(orgDescription.length).toBeGreaterThan(0);
42+
expect(orgCol.help).toBe(orgDescription);
43+
expect(orgCol.placeholder).toBeUndefined();
44+
});
45+
46+
it('renders the description under the column, with no input carrying it as a placeholder', () => {
47+
const { container } = render(
48+
<FlowObjectListField
49+
label="Approvers"
50+
columns={approvers.columns!}
51+
value={[{ type: 'manager' }]}
52+
onCommit={vi.fn()}
53+
addLabel="Add"
54+
removeLabel="Remove"
55+
emptyLabel="None"
56+
/>,
57+
);
58+
const placeholders = Array.from(container.querySelectorAll('[placeholder]')).map((el) => el.getAttribute('placeholder'));
59+
expect(placeholders).not.toContain(orgDescription);
60+
const helps = screen.getAllByTestId('flow-field-help').map((el) => el.querySelector('p')?.textContent);
61+
expect(helps).toContain(orgDescription);
62+
});
63+
});
64+
65+
const field = (help: string): FlowConfigField => ({
66+
id: 'excludeSubmitter',
67+
path: ['config', 'excludeSubmitter'],
68+
label: 'Exclude submitter',
69+
kind: 'boolean',
70+
help,
71+
});
72+
73+
describe('long help opens clamped behind "Learn more" (#27356 row 15)', () => {
74+
it('clamps a long help and toggles it open and closed', () => {
75+
const long = 'Segregation of duties for this step. '.repeat(5).trim();
76+
expect(long.length).toBeGreaterThan(FLOW_HELP_CLAMP_CHARS);
77+
render(<FlowNodeConfigField field={field(long)} value={undefined} onCommit={() => {}} />);
78+
const help = screen.getByTestId('flow-field-help');
79+
const text = help.querySelector('p')!;
80+
expect(text.textContent).toBe(long);
81+
expect(text.className).toContain('line-clamp-2');
82+
const toggle = within(help).getByRole('button', { name: 'Learn more' });
83+
expect(toggle.getAttribute('aria-expanded')).toBe('false');
84+
expect(toggle.getAttribute('aria-controls')).toBe(text.id);
85+
86+
fireEvent.click(toggle);
87+
expect(text.className).not.toContain('line-clamp-2');
88+
const less = within(help).getByRole('button', { name: 'Show less' });
89+
expect(less.getAttribute('aria-expanded')).toBe('true');
90+
91+
fireEvent.click(less);
92+
expect(text.className).toContain('line-clamp-2');
93+
});
94+
95+
it('renders a short help in full, with no toggle', () => {
96+
render(<FlowNodeConfigField field={field('Lock the record while pending.')} value={undefined} onCommit={() => {}} />);
97+
const help = screen.getByTestId('flow-field-help');
98+
expect(help.querySelector('p')!.className).not.toContain('line-clamp-2');
99+
expect(within(help).queryByRole('button')).toBeNull();
100+
});
101+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* FlowFieldHelp — the help line under a flow inspector field or list column.
5+
*
6+
* Help text is often the engine-published schema description, which can run to
7+
* a paragraph. A help longer than {@link FLOW_HELP_CLAMP_CHARS} opens clamped to
8+
* two lines with a "Learn more" toggle; the full text stays in the DOM either
9+
* way, so a screen reader and a text search still reach all of it.
10+
*/
11+
12+
import * as React from 'react';
13+
import { cn } from '@object-ui/components';
14+
import { t, useMetadataLocale } from '../i18n.js';
15+
16+
/** Help at or under this many characters renders in full, with no toggle. */
17+
export const FLOW_HELP_CLAMP_CHARS = 120;
18+
19+
export function FlowFieldHelp({ text, className }: { text: string; className?: string }) {
20+
const locale = useMetadataLocale();
21+
const [expanded, setExpanded] = React.useState(false);
22+
const id = React.useId();
23+
const long = text.length > FLOW_HELP_CLAMP_CHARS;
24+
return (
25+
<div className={className} data-testid="flow-field-help">
26+
<p
27+
id={id}
28+
className={cn('text-[11px] leading-snug text-muted-foreground', long && !expanded && 'line-clamp-2')}
29+
>
30+
{text}
31+
</p>
32+
{long && (
33+
<button
34+
type="button"
35+
className="text-[11px] font-medium text-primary hover:underline"
36+
aria-expanded={expanded}
37+
aria-controls={id}
38+
onClick={() => setExpanded((v) => !v)}
39+
>
40+
{t(expanded ? 'engine.inspector.flowNode.help.showLess' : 'engine.inspector.flowNode.help.learnMore', locale)}
41+
</button>
42+
)}
43+
</div>
44+
);
45+
}

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from '@objectstack/spec/automation';
3232
import { FlowKeyValueField, type FlowKeyValueFieldProps } from './FlowKeyValueField.js';
3333
import { FlowExprIssue } from './FlowExprIssue.js';
34+
import { FlowFieldHelp } from './FlowFieldHelp.js';
3435
import {
3536
isEditableValueEnvelope,
3637
isValueEnvelopeSlot,
@@ -984,9 +985,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
984985
{describeUnknownRefs(unknownRefs, locale)}
985986
</p>
986987
)}
987-
{field.help && !exprIssue && unknownRefs.length === 0 && (
988-
<p className="text-[11px] leading-snug text-muted-foreground">{field.help}</p>
989-
)}
988+
{field.help && !exprIssue && unknownRefs.length === 0 && <FlowFieldHelp text={field.help} />}
990989
</div>
991990
);
992991
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { FlowStringListField } from './FlowStringListField.js';
4545
import { VariableTextInput } from './VariableTextInput.js';
4646
import type { ScopeGroup } from './useFlowScope.js';
4747
import { FlowExprIssue } from './FlowExprIssue.js';
48+
import { FlowFieldHelp } from './FlowFieldHelp.js';
4849
import { isScreenVisibleWhenColumn } from '../previews/flow-expr-problems.js';
4950
import { screenPredicateRoots, type ScreenPreviewNode } from '../previews/screen-spec.js';
5051

@@ -707,6 +708,7 @@ export function FlowObjectListField({
707708
{cellRequired && <RequiredMarker />}
708709
</Label>
709710
<div className="flex items-center">{cellControl(row, col, cellRequired, col.kind === 'expression')}</div>
711+
{col.help && <FlowFieldHelp text={col.help} />}
710712
</div>
711713
);
712714
})}
@@ -783,6 +785,7 @@ export function FlowObjectListField({
783785
removeLabel={removeLabel}
784786
emptyLabel={emptyLabel}
785787
/>
788+
{col.help && <FlowFieldHelp text={col.help} />}
786789
</div>
787790
);
788791
}
@@ -804,18 +807,22 @@ export function FlowObjectListField({
804807
context={context}
805808
scopeGroups={scopeGroups}
806809
/>
810+
{col.help && <FlowFieldHelp text={col.help} />}
807811
</div>
808812
);
809813
}
810814
const cellRequired = requiredColumns?.has(col.key) === true;
811815
return (
812-
<div key={col.key} className="flex items-center gap-2">
816+
<div key={col.key}>
817+
<div className="flex items-center gap-2">
813818
<Label className="w-24 shrink-0 text-[11px] text-muted-foreground">
814819
{col.label}
815820
{cellRequired && <RequiredMarker />}
816821
</Label>
817822
{cellControl(row, col, cellRequired)}
818823
</div>
824+
{col.help && <FlowFieldHelp text={col.help} className="mt-0.5 pl-[6.5rem]" />}
825+
</div>
819826
);
820827
})}
821828
</div>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ export interface FlowConfigColumn {
212212
| 'numberList'
213213
| 'objectList';
214214
placeholder?: string;
215+
/** What the column means, shown under it — a schema description lands here, never in `placeholder`. */
216+
help?: string;
215217
options?: Array<{ value: string; label: string }>;
216218
/** For `kind: 'reference'` — the picker data source (may be polymorphic). */
217219
ref?: FlowReferenceSpec;

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ describe('jsonSchemaToFlowFields', () => {
134134
objectSource: '$trigger',
135135
map: { user: 'user', org_membership_level: 'org-membership-level', role: 'org-membership-level', position: 'position', team: 'team', department: 'department', manager: 'manager', field: 'object-field', queue: 'queue' },
136136
});
137-
expect(valueCol.placeholder).toBe('User id / membership tier / position / team / department / field — per `type`');
137+
expect(valueCol.help).toBe('User id / membership tier / position / team / department / field — per `type`');
138+
expect(valueCol.placeholder).toBeUndefined();
138139
});
139140

140141
it('maps a static-kind xRef column into a reference column', () => {

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ function columnsFor(item: JsonSchemaNode, depth = 0): FlowConfigColumn[] {
361361
label: prop.title || humanizeKey(key),
362362
kind: nested.kind,
363363
...(nested.columns ? { columns: nested.columns } : {}),
364-
...(prop.description ? { placeholder: prop.description } : {}),
364+
...(prop.description ? { help: prop.description } : {}),
365365
});
366366
continue;
367367
}
@@ -397,8 +397,8 @@ function columnsFor(item: JsonSchemaNode, depth = 0): FlowConfigColumn[] {
397397
kind,
398398
...(options ? { options } : {}),
399399
...(ref ? { ref } : {}),
400-
// Columns have no help slot — surface the schema description as a hint.
401-
...(prop.description ? { placeholder: prop.description } : {}),
400+
// A description is prose, not an example value: it is the column's help.
401+
...(prop.description ? { help: prop.description } : {}),
402402
});
403403
}
404404
return cols;

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,8 @@ const STUDIO_EN: Record<string, string> = {
660660
'engine.inspector.flowNode.branch.fromEdges': 'This decision routes on its connections’ conditions. Each card is one connection; edit its condition on the connection.',
661661
'engine.inspector.flowNode.branch.openEdge': 'Edit connection',
662662
'engine.inspector.flowNode.branch.noCondition': 'No condition',
663+
'engine.inspector.flowNode.help.learnMore': 'Learn more',
664+
'engine.inspector.flowNode.help.showLess': 'Show less',
663665
'engine.inspector.flowNode.remove': 'Remove node',
664666
// App nav inspector
665667
'engine.inspector.appNav.kind': 'Nav Item',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,8 @@ const STUDIO_ZH: Record<string, string> = {
566566
'engine.inspector.flowNode.branch.fromEdges': '此决策按其连线上的条件路由。每张卡片是一条连线,请在连线上编辑它的条件。',
567567
'engine.inspector.flowNode.branch.openEdge': '编辑连线',
568568
'engine.inspector.flowNode.branch.noCondition': '无条件',
569+
'engine.inspector.flowNode.help.learnMore': '了解更多',
570+
'engine.inspector.flowNode.help.showLess': '收起',
569571
'engine.inspector.flowNode.remove': '删除节点',
570572
// App nav inspector
571573
'engine.inspector.appNav.kind': '导航项',

0 commit comments

Comments
 (0)