From 7ead6b21d47bebe6a191b5464b7e09aa9c7846ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:28:08 +0000 Subject: [PATCH 1/3] feat(studio): long flow help opens behind Learn more; column descriptions are help, not placeholders Claude-Session: https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu Co-authored-by: Claude --- .../FlowFieldHelp.learnMore-27356.test.tsx | 100 ++++++++++++++++++ .../inspectors/FlowFieldHelp.tsx | 45 ++++++++ .../inspectors/FlowNodeConfigField.tsx | 5 +- .../inspectors/FlowObjectListField.tsx | 9 +- .../inspectors/flow-node-config.ts | 2 + .../inspectors/json-schema-to-fields.test.ts | 3 +- .../inspectors/json-schema-to-fields.ts | 6 +- ui/packages/i18n/src/namespaces/studio/en.ts | 2 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 9 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx new file mode 100644 index 00000000000..11ee8d9dfdc --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The flow inspector's help lines (#27356 row 15). + * + * 1. A schema description on an objectList column is the column's help, shown + * under the column, and never its placeholder: a paragraph squeezed into + * an input's placeholder reads as a cut-off example value and vanishes on + * the first keystroke. + * 2. Help longer than `FLOW_HELP_CLAMP_CHARS` opens clamped to two lines with a + * "Learn more" toggle; shorter help renders in full with no toggle. The + * full text is in the DOM either way. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import { z } from 'zod'; +import { ApprovalNodeConfigSchema } from '@objectstack/spec/automation'; +import { FlowObjectListField } from './FlowObjectListField'; +import { FlowNodeConfigField } from './FlowNodeConfigField'; +import { FLOW_HELP_CLAMP_CHARS } from './FlowFieldHelp'; +import { jsonSchemaToFlowFields } from './json-schema-to-fields'; +import type { FlowConfigField } from './flow-node-config'; + +afterEach(cleanup); + +// The approval node's fields as the engine publishes them: the installed +// contract's own JSON Schema, not a copy of it. +const served = jsonSchemaToFlowFields(z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' }))!; +const approvers = served.find((f) => f.id === 'approvers')!; +const orgCol = approvers.columns!.find((c) => c.key === 'organization')!; +const orgDescription = ( + z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' }) as { + properties: { approvers: { items: { properties: { organization: { description: string } } } } }; + } +).properties.approvers.items.properties.organization.description; + +describe('objectList column descriptions are help, not placeholders (#27356 row 15)', () => { + it('maps the served approver `organization` description to the column help', () => { + expect(orgDescription.length).toBeGreaterThan(0); + expect(orgCol.help).toBe(orgDescription); + expect(orgCol.placeholder).toBeUndefined(); + }); + + it('renders the description under the column, with no input carrying it as a placeholder', () => { + const { container } = render( + , + ); + const placeholders = Array.from(container.querySelectorAll('[placeholder]')).map((el) => el.getAttribute('placeholder')); + expect(placeholders).not.toContain(orgDescription); + const helps = screen.getAllByTestId('flow-field-help').map((el) => el.querySelector('p')?.textContent); + expect(helps).toContain(orgDescription); + }); +}); + +const field = (help: string): FlowConfigField => ({ + id: 'excludeSubmitter', + path: ['config', 'excludeSubmitter'], + label: 'Exclude submitter', + kind: 'boolean', + help, +}); + +describe('long help opens clamped behind "Learn more" (#27356 row 15)', () => { + it('clamps a long help and toggles it open and closed', () => { + const long = 'Segregation of duties for this step. '.repeat(5).trim(); + expect(long.length).toBeGreaterThan(FLOW_HELP_CLAMP_CHARS); + render( {}} />); + const help = screen.getByTestId('flow-field-help'); + const text = help.querySelector('p')!; + expect(text.textContent).toBe(long); + expect(text.className).toContain('line-clamp-2'); + const toggle = within(help).getByRole('button', { name: 'Learn more' }); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBe(text.id); + + fireEvent.click(toggle); + expect(text.className).not.toContain('line-clamp-2'); + const less = within(help).getByRole('button', { name: 'Show less' }); + expect(less.getAttribute('aria-expanded')).toBe('true'); + + fireEvent.click(less); + expect(text.className).toContain('line-clamp-2'); + }); + + it('renders a short help in full, with no toggle', () => { + render( {}} />); + const help = screen.getByTestId('flow-field-help'); + expect(help.querySelector('p')!.className).not.toContain('line-clamp-2'); + expect(within(help).queryByRole('button')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.tsx new file mode 100644 index 00000000000..c4b6a6f0cf7 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * FlowFieldHelp — the help line under a flow inspector field or list column. + * + * Help text is often the engine-published schema description, which can run to + * a paragraph. A help longer than {@link FLOW_HELP_CLAMP_CHARS} opens clamped to + * two lines with a "Learn more" toggle; the full text stays in the DOM either + * way, so a screen reader and a text search still reach all of it. + */ + +import * as React from 'react'; +import { cn } from '@object-ui/components'; +import { t, useMetadataLocale } from '../i18n.js'; + +/** Help at or under this many characters renders in full, with no toggle. */ +export const FLOW_HELP_CLAMP_CHARS = 120; + +export function FlowFieldHelp({ text, className }: { text: string; className?: string }) { + const locale = useMetadataLocale(); + const [expanded, setExpanded] = React.useState(false); + const id = React.useId(); + const long = text.length > FLOW_HELP_CLAMP_CHARS; + return ( +
+

+ {text} +

+ {long && ( + + )} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx index f0420f0e674..ea4ec5df2c9 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx @@ -31,6 +31,7 @@ import { } from '@objectstack/spec/automation'; import { FlowKeyValueField, type FlowKeyValueFieldProps } from './FlowKeyValueField.js'; import { FlowExprIssue } from './FlowExprIssue.js'; +import { FlowFieldHelp } from './FlowFieldHelp.js'; import { isEditableValueEnvelope, isValueEnvelopeSlot, @@ -983,9 +984,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, {describeUnknownRefs(unknownRefs, locale)}

)} - {field.help && !exprIssue && unknownRefs.length === 0 && ( -

{field.help}

- )} + {field.help && !exprIssue && unknownRefs.length === 0 && } ); } diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx index 07959675f41..1358aa7a7a0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx @@ -45,6 +45,7 @@ import { FlowStringListField } from './FlowStringListField.js'; import { VariableTextInput } from './VariableTextInput.js'; import type { ScopeGroup } from './useFlowScope.js'; import { FlowExprIssue } from './FlowExprIssue.js'; +import { FlowFieldHelp } from './FlowFieldHelp.js'; import { isScreenVisibleWhenColumn } from '../previews/flow-expr-problems.js'; import { screenPredicateRoots, type ScreenPreviewNode } from '../previews/screen-spec.js'; @@ -707,6 +708,7 @@ export function FlowObjectListField({ {cellRequired && }
{cellControl(row, col, cellRequired, col.kind === 'expression')}
+ {col.help && } ); })} @@ -783,6 +785,7 @@ export function FlowObjectListField({ removeLabel={removeLabel} emptyLabel={emptyLabel} /> + {col.help && } ); } @@ -804,18 +807,22 @@ export function FlowObjectListField({ context={context} scopeGroups={scopeGroups} /> + {col.help && } ); } const cellRequired = requiredColumns?.has(col.key) === true; return ( -
+
+
{cellControl(row, col, cellRequired)}
+ {col.help && } +
); })}
diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index f03c082e1df..fa39ec391ed 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -212,6 +212,8 @@ export interface FlowConfigColumn { | 'numberList' | 'objectList'; placeholder?: string; + /** What the column means, shown under it — a schema description lands here, never in `placeholder`. */ + help?: string; options?: Array<{ value: string; label: string }>; /** For `kind: 'reference'` — the picker data source (may be polymorphic). */ ref?: FlowReferenceSpec; diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts index d0842c951a9..ca7c08f5410 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts @@ -134,7 +134,8 @@ describe('jsonSchemaToFlowFields', () => { objectSource: '$trigger', 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' }, }); - expect(valueCol.placeholder).toBe('User id / membership tier / position / team / department / field — per `type`'); + expect(valueCol.help).toBe('User id / membership tier / position / team / department / field — per `type`'); + expect(valueCol.placeholder).toBeUndefined(); }); it('maps a static-kind xRef column into a reference column', () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts index 156e49e94dc..f12818610a3 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts @@ -361,7 +361,7 @@ function columnsFor(item: JsonSchemaNode, depth = 0): FlowConfigColumn[] { label: prop.title || humanizeKey(key), kind: nested.kind, ...(nested.columns ? { columns: nested.columns } : {}), - ...(prop.description ? { placeholder: prop.description } : {}), + ...(prop.description ? { help: prop.description } : {}), }); continue; } @@ -397,8 +397,8 @@ function columnsFor(item: JsonSchemaNode, depth = 0): FlowConfigColumn[] { kind, ...(options ? { options } : {}), ...(ref ? { ref } : {}), - // Columns have no help slot — surface the schema description as a hint. - ...(prop.description ? { placeholder: prop.description } : {}), + // A description is prose, not an example value: it is the column's help. + ...(prop.description ? { help: prop.description } : {}), }); } return cols; diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 7cf22122e59..5a7644032aa 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -659,6 +659,8 @@ const STUDIO_EN: Record = { 'engine.inspector.flowNode.branch.fromEdges': 'This decision routes on its connections’ conditions. Each card is one connection; edit its condition on the connection.', 'engine.inspector.flowNode.branch.openEdge': 'Edit connection', 'engine.inspector.flowNode.branch.noCondition': 'No condition', + 'engine.inspector.flowNode.help.learnMore': 'Learn more', + 'engine.inspector.flowNode.help.showLess': 'Show less', 'engine.inspector.flowNode.remove': 'Remove node', // App nav inspector 'engine.inspector.appNav.kind': 'Nav Item', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 9d71ff9bd23..ebaa858915e 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -565,6 +565,8 @@ const STUDIO_ZH: Record = { 'engine.inspector.flowNode.branch.fromEdges': '此决策按其连线上的条件路由。每张卡片是一条连线,请在连线上编辑它的条件。', 'engine.inspector.flowNode.branch.openEdge': '编辑连线', 'engine.inspector.flowNode.branch.noCondition': '无条件', + 'engine.inspector.flowNode.help.learnMore': '了解更多', + 'engine.inspector.flowNode.help.showLess': '收起', 'engine.inspector.flowNode.remove': '删除节点', // App nav inspector 'engine.inspector.appNav.kind': '导航项', From dc01744a1cc50dbd290b54b09108177a6502c3b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:35:55 +0000 Subject: [PATCH 2/3] test(studio): pin flow help clamp and column help; changeset Claude-Session: https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu Co-authored-by: Claude --- .changeset/27356-studio-flow-help-learn-more.md | 16 ++++++++++++++++ .../FlowFieldHelp.learnMore-27356.test.tsx | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/27356-studio-flow-help-learn-more.md diff --git a/.changeset/27356-studio-flow-help-learn-more.md b/.changeset/27356-studio-flow-help-learn-more.md new file mode 100644 index 00000000000..52ebeed6c07 --- /dev/null +++ b/.changeset/27356-studio-flow-help-learn-more.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +In Studio's flow node inspector: + +- **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. +- **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. + +The words themselves are unchanged: they are the engine-published schema descriptions. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx index 11ee8d9dfdc..a954253e9a5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx @@ -47,7 +47,7 @@ describe('objectList column descriptions are help, not placeholders (#27356 row Date: Fri, 30 Oct 2026 00:41:22 +0000 Subject: [PATCH 3/3] test(studio): cast the served approval schema through unknown Claude-Session: https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu Co-authored-by: Claude --- .../inspectors/FlowFieldHelp.learnMore-27356.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx index a954253e9a5..04b64109c45 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowFieldHelp.learnMore-27356.test.tsx @@ -26,11 +26,12 @@ afterEach(cleanup); // The approval node's fields as the engine publishes them: the installed // contract's own JSON Schema, not a copy of it. -const served = jsonSchemaToFlowFields(z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' }))!; +const schema = z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' }); +const served = jsonSchemaToFlowFields(schema)!; const approvers = served.find((f) => f.id === 'approvers')!; const orgCol = approvers.columns!.find((c) => c.key === 'organization')!; const orgDescription = ( - z.toJSONSchema(ApprovalNodeConfigSchema, { io: 'input' }) as { + schema as unknown as { properties: { approvers: { items: { properties: { organization: { description: string } } } } }; } ).properties.approvers.items.properties.organization.description;