Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/27356-studio-flow-help-learn-more.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// 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 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 = (
schema as unknown 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(
<FlowObjectListField
label="Approvers"
columns={approvers.columns!}
value={[{ type: 'manager' }]}
onCommit={vi.fn()}
addLabel="Add"
removeLabel="Remove"
emptyLabel="None"
/>,
);
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(<FlowNodeConfigField field={field(long)} value={undefined} onCommit={() => {}} />);
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(<FlowNodeConfigField field={field('Lock the record while pending.')} value={undefined} onCommit={() => {}} />);
const help = screen.getByTestId('flow-field-help');
expect(help.querySelector('p')!.className).not.toContain('line-clamp-2');
expect(within(help).queryByRole('button')).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div className={className} data-testid="flow-field-help">
<p
id={id}
className={cn('text-[11px] leading-snug text-muted-foreground', long && !expanded && 'line-clamp-2')}
>
{text}
</p>
{long && (
<button
type="button"
className="text-[11px] font-medium text-primary hover:underline"
aria-expanded={expanded}
aria-controls={id}
onClick={() => setExpanded((v) => !v)}
>
{t(expanded ? 'engine.inspector.flowNode.help.showLess' : 'engine.inspector.flowNode.help.learnMore', locale)}
</button>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -983,9 +984,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
{describeUnknownRefs(unknownRefs, locale)}
</p>
)}
{field.help && !exprIssue && unknownRefs.length === 0 && (
<p className="text-[11px] leading-snug text-muted-foreground">{field.help}</p>
)}
{field.help && !exprIssue && unknownRefs.length === 0 && <FlowFieldHelp text={field.help} />}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -707,6 +708,7 @@ export function FlowObjectListField({
{cellRequired && <RequiredMarker />}
</Label>
<div className="flex items-center">{cellControl(row, col, cellRequired, col.kind === 'expression')}</div>
{col.help && <FlowFieldHelp text={col.help} />}
</div>
);
})}
Expand Down Expand Up @@ -783,6 +785,7 @@ export function FlowObjectListField({
removeLabel={removeLabel}
emptyLabel={emptyLabel}
/>
{col.help && <FlowFieldHelp text={col.help} />}
</div>
);
}
Expand All @@ -804,18 +807,22 @@ export function FlowObjectListField({
context={context}
scopeGroups={scopeGroups}
/>
{col.help && <FlowFieldHelp text={col.help} />}
</div>
);
}
const cellRequired = requiredColumns?.has(col.key) === true;
return (
<div key={col.key} className="flex items-center gap-2">
<div key={col.key}>
<div className="flex items-center gap-2">
<Label className="w-24 shrink-0 text-[11px] text-muted-foreground">
{col.label}
{cellRequired && <RequiredMarker />}
</Label>
{cellControl(row, col, cellRequired)}
</div>
{col.help && <FlowFieldHelp text={col.help} className="mt-0.5 pl-[6.5rem]" />}
</div>
);
})}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,8 @@ const STUDIO_EN: Record<string, string> = {
'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',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,8 @@ const STUDIO_ZH: Record<string, string> = {
'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': '导航项',
Expand Down
Loading