Skip to content

Commit 2d05cd4

Browse files
fix(ui): a form view's layout horizontal puts labels beside fields, and the form page draws tabbed views as tabs (#27222)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8XQi523WdBYxKuvgxjLJtnJ)_ Fixes #26843 Clause-②: no Metadata-protocol: semantic ## 维护者速读 - 改了什么:表单视图写 `layout: 'horizontal'` 时,字段标签在 sm 及以上宽度放到输入框左侧(手机上仍是上下排);分页表单不再把它改回 vertical;`/forms/:name` 与 `/f/:slug` 表单页把 `type: 'tabbed'` 画成每节一个页签。 - 为什么改:这三项都能保存发布,但控制台画出来和没写一样,作者以为生效了其实没有(声明即生效)。 - 风险与回滚:只改字段的排布 class 与表单页的分节渲染;vertical 与不写 layout 的表单输出逐字节不变(有测试钉住);回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/components/FormPage.tsx`(分页渲染与提交时跳到出错页签)、`ui/packages/components/src/renderers/form/form.tsx`(`HORIZONTAL_FIELD_ITEM_CLASS`)、`ui/packages/plugin-form/src/TabbedForm.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/components/FormPage.tabbedLayout-26843.test.tsx`,预期 13 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes 1. **`layout: 'horizontal'` draws something.** The shared form renderer (`@object-ui/components` `form`) used `layout` only to align its action row. A horizontal field item is now a two-column grid from `sm` up: a fixed label column (`minmax(0,10rem)`) and the control column, label on the control's first text baseline. Every child except the `label` (the control, its description, its message) is placed in the second column, so a label-less field and the help text line up under the control. Every class the arrangement adds is `sm:`-prefixed, so below `sm` the item is the vertical one. `vertical` and an absent `layout` take the old code path unchanged: same classes, same DOM. The label element, its `for` / `id`, and the control's `aria-*` attributes are not touched. 2. **`TabbedForm` passes `layout` through.** It built its form node with `layout: 'vertical'` whatever the form view said; it now forwards `schema.layout` (declared on `TabbedFormSchema` as `ObjectFormSchema['layout']`, the key `ObjectForm` already hands over in its spread). 3. **The console form page draws `type: 'tabbed'`.** `FormPage` (`/forms/:name`, `/f/:slug`) drew every section stacked. A tabbed form view with more than one section now draws one tab per section with the app's tabbed-form semantics: the tab key is the section's `name`, else its heading, else its position (`TabbedForm`'s rule), `defaultTab` opens first, `tabPosition` places the strip (top / bottom / left / right), a section hidden by its `visibleWhen` draws no tab, and the panels are force-mounted and CSS-hidden when closed. A submit carries every section's fields from the page's one `values` map, closed tabs included; a submit refused for a missing or faulted field opens the tab that holds it. A one-section tabbed view draws stacked, as `TabbedForm` draws no strip for one tab. 4. **In-place fix, same defect class: the form page's rows honour `layout: 'horizontal'` too.** `FormPage` renders its own rows (not the shared renderer) and never read `layout`, so after item 1 the form page would have been the one entry point still ignoring it. Its rows take the same arrangement (`HORIZONTAL_ROW_CLASS`). Same file as item 3, same gate families. Docs: the `layout` row in `ui/packages/plugin-form/README.md` and the `FormSchema` block in `ui/content/docs/components/form/form.mdx` now say what each value draws. The `object-form` registration's input help already read "`horizontal` beside it" and is unchanged. Changeset: `.changeset/26843-form-layout-horizontal-tabbed-page.md`, `minor` for `@object-ui/components`, `@object-ui/plugin-form` and `@object-ui/console`. ## Why The card's rule is declared equals effective (ADR-0049): `layout: 'horizontal'` and `type: 'tabbed'` validate, save and publish, and the console drew them exactly as if they were absent. PR #27027 made the keys reach every console form entry point; this PR makes them draw. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (ESLint on the changed files: 0 errors; the repo-wide run is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the 维护者速读 section, which the dispatch order requires in Chinese. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. ## Tests - `ui/packages/components/src/renderers/form/__tests__/form-layout-horizontal-26843.test.tsx` (5): the grid classes and the label as first child on a horizontal item; every class horizontal adds is `sm:`-prefixed and none is removed (stacked below `sm`); a label-less field keeps its control in column two; the labels still name their controls (text, textarea, checkbox); CONTROL: absent `layout` renders byte-identical to `vertical` (ids normalised), and horizontal differs. - `ui/packages/plugin-form/src/__tests__/tabbedFormLayout-26843.test.tsx` (3): a tabbed `ObjectForm` with `layout: 'horizontal'` draws both tabs' fields horizontal; CONTROLS for no `layout` and `vertical`. - `ui/apps/console/src/components/FormPage.tabbedLayout-26843.test.tsx` (13): one tab per section named by its heading with every panel mounted; `defaultTab`; tab click; `tabPosition: left` gives a vertical strip; a submit carries `title`, `notes` and a never-opened tab's `defaults` value; a refused submit opens the tab of the missing required field; CONTROLS: one-section tabbed and `type: 'simple'` draw stacked with no tab strip; horizontal rows (classes, label first, label names the control), kept on every tab; CONTROL: no `layout` and `vertical` draw no grid row; the two tab-rule helpers. ## Acceptance notes - The horizontal arrangement keys on the viewport `sm` breakpoint, as the dispatch reading asked, not on a container query: a form with no container ancestor would never engage a container variant. Inside a narrow dialog at a wide viewport the label column is still 10rem. - `SplitForm` and `WizardForm` (plugin-form) also hard-code `layout: 'vertical'` on the form node they build, so a split or wizard form view drops `horizontal` the same way `TabbedForm` did. Outside this card's file surface; reported to the seat. - On the form page, `type` values `wizard`, `split`, `drawer` and `modal` still draw stacked sections (unchanged; outside this card, reported to the seat). - A tabbed form-page section with neither `name` nor heading labels its tab with its position number (the app's `TabbedForm` writes an English `Tab N`); no catalogue key exists for either. ## Verification All readings on head `763ea858` (branch tip after merging `origin/main`) unless noted. <details><summary>New tests and wider suites</summary> - New files: 3 files, 21 tests, all pass (5 + 3 + 13). - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/components/src/renderers/form/ packages/plugin-form/ apps/console/src/components/` under the verify lock: Test Files 333 passed (333), Tests 3538 passed, 18 skipped; VERDICT command-exit 0. - Type check: `pnpm --filter @object-ui/components run type-check`, `@object-ui/plugin-form`, `@object-ui/console` each exit 0 (console after its closure build `--filter '@object-ui/console^...'`, VERDICT command-exit 0). - ESLint on the six changed source and test files: 0 errors (warnings are pre-existing `any` / exhaustive-deps lines). - `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> <details><summary>Ablations (each restored, blob equal to HEAD, git diff HEAD empty)</summary> Run with `node scripts/ablation-replace.mjs` on the pre-merge head `51be1b76`; each anchor hit once and the mutation was verified on disk. The three test files import their subject by relative path, so no dist leg. - form renderer: `? cn(colSpanClass, HORIZONTAL_FIELD_ITEM_CLASS)` replaced by `? colSpanClass || undefined` turns 3 tests red (grid classes, sm-prefix, label-less field). - TabbedForm: `layout: schema.layout ?? 'vertical',` replaced by `layout: 'vertical',` turns 1 test red (every field on every tab horizontal). - FormPage tabs: `tabbedView` forced false turns 5 tests red (strip, defaultTab, tab click, tabPosition, refused submit opens the tab). The submit-carries-every-field test stays green on stacked sections, as a guard should. - FormPage rows: `horizontalRows` forced false turns 2 tests red. </details> <details><summary>Geometry in Chromium</summary> The item and row classes compiled with the repository's Tailwind 4 (`compile` from `tailwindcss`, which emitted the `not(label)` child rule) and drawn in the preinstalled Chromium. At 1024px: label x 16 width 160, input x 192, label vertically inside the input's box, description under the input at x 192. At 375px: label above the input, both at x 16. One-off measurement, not committed. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 34 families on this change set (same list before and after the merge). Each ran on `763ea858` with its exit code captured before any pipe: 34 of 34 exit 0. `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED. `node scripts/check-sdui-manifest.mjs` is among them (exit 0). On the pre-merge head the `ui/scripts` vitest family first exited 1 on two tree-walking tests that timed out at 15 s while a 20-minute locked suite shared the box; rerun under the lock it passed (175 files), and it passed again on `763ea858`. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbXNVYZR3yMKLPJJvzL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48bffe3 commit 2d05cd4

9 files changed

Lines changed: 779 additions & 93 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/components': minor
3+
'@object-ui/plugin-form': minor
4+
'@object-ui/console': minor
5+
---
6+
7+
fix(ui): a form view's `layout: 'horizontal'` draws each label beside its field, a tabbed form keeps it, and the form page draws `type: 'tabbed'` as tabs (#26843)
8+
9+
Clause-②: no
10+
Metadata-protocol: semantic
11+
12+
A form view's `layout: 'horizontal'` reached every console form but drew nothing different, and the form page ignored `type: 'tabbed'`:
13+
14+
- **`layout: 'horizontal'`** now places each field's label in a fixed column beside its control from the `sm` breakpoint up; below `sm` (a phone) the field stays stacked, label above control, as `vertical` draws it. The description and validation message stay under the control. `vertical`, and a form view with no `layout`, draw exactly as before. Before, the form renderer read the key only to align its action row.
15+
- **A tabbed form** (`type: 'tabbed'`) now keeps an authored `layout: 'horizontal'` on every tab. It used to draw its fields `vertical` whatever the form view said.
16+
- **The form page** (`/forms/:name`, and the public `/f/:slug`) now draws a `type: 'tabbed'` form view as one tab per section: `defaultTab` opens first, `tabPosition` places the strip, and a section hidden by its `visibleWhen` draws no tab. A submit carries every section's fields, the closed tabs' included, and a submit refused for a missing field opens the tab that holds it. A tabbed form view with a single section draws it stacked, as before. The page also draws `layout: 'horizontal'` rows. Before, it drew every section stacked and every label above its field.
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#26843 — the form page draws a form view's
5+
* `type: 'tabbed'` and `layout: 'horizontal'`, which it never read.
6+
*
7+
* - `type: 'tabbed'`: one tab per section, the form view's `defaultTab` open
8+
* first and `tabPosition` placing the strip — the semantics of the app's
9+
* tabbed form. A submit carries every section's fields, the closed tabs'
10+
* included, and a refused submit opens the tab holding the field it names.
11+
* - `layout: 'horizontal'`: each row draws its label beside the control from
12+
* `sm` up, the arrangement the form renderer gives the same key.
13+
*
14+
* CONTROLS: a `type: 'simple'` view, and a tabbed view with one section, draw
15+
* stacked sections and no tab strip; no `layout` draws no grid row.
16+
*/
17+
18+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
19+
import { render, screen, waitFor, cleanup, within } from '@testing-library/react';
20+
import userEvent from '@testing-library/user-event';
21+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
22+
import { createI18n } from '@object-ui/i18n';
23+
import { FormPage, resolveActiveTab, sectionTabKey } from './FormPage';
24+
25+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
26+
27+
const OBJECT_SCHEMA = {
28+
name: 'showcase_ticket',
29+
label: 'Ticket',
30+
fields: {
31+
title: { type: 'text', label: 'Title' },
32+
notes: { type: 'text', label: 'Notes' },
33+
channel: { type: 'text', label: 'Channel' },
34+
},
35+
};
36+
37+
const THREE_SECTIONS = [
38+
{ name: 'main', label: 'Main', fields: ['title'] },
39+
{ name: 'more', label: 'More', fields: ['notes'] },
40+
{ name: 'routing', label: 'Routing', fields: ['channel'] },
41+
];
42+
43+
let submits: Array<{ url: string; body: Record<string, unknown> }> = [];
44+
45+
function stubFetch(form: Record<string, unknown>, objectSchema: unknown = OBJECT_SCHEMA) {
46+
return vi.fn(async (url: string, init?: RequestInit) => {
47+
const path = String(url);
48+
if (init?.method === 'POST') {
49+
submits.push({ url: path, body: JSON.parse(String(init.body)) });
50+
}
51+
const body = path.includes('/meta/view/')
52+
? {
53+
name: 'showcase_ticket.intake',
54+
object: 'showcase_ticket',
55+
viewKind: 'form',
56+
label: 'Intake',
57+
config: { type: 'tabbed', sections: THREE_SECTIONS, ...form },
58+
}
59+
: path.includes('/meta/object/')
60+
? objectSchema
61+
: path.includes('/data/showcase_ticket')
62+
? { object: 'showcase_ticket', id: 't1', record: { id: 't1' } }
63+
: null;
64+
if (!body) throw new Error(`unstubbed fetch: ${path}`);
65+
return {
66+
ok: true,
67+
status: 200,
68+
statusText: 'OK',
69+
json: async () => body,
70+
text: async () => JSON.stringify(body),
71+
} as unknown as Response;
72+
});
73+
}
74+
75+
function renderPage() {
76+
return render(
77+
<MemoryRouter initialEntries={['/forms/showcase_ticket.intake']}>
78+
<Routes>
79+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
80+
</Routes>
81+
</MemoryRouter>,
82+
);
83+
}
84+
85+
const input = (name: string) => document.querySelector(`[name="${name}"]`) as HTMLInputElement | null;
86+
const tab = (key: string) => screen.queryByTestId(`form-tab:${key}`);
87+
const panel = (key: string) => screen.queryByTestId(`form-tab-panel:${key}`);
88+
const isOpen = (key: string) => tab(key)?.getAttribute('data-state') === 'active';
89+
90+
beforeEach(() => {
91+
submits = [];
92+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
93+
});
94+
95+
afterEach(() => {
96+
cleanup();
97+
vi.unstubAllGlobals();
98+
});
99+
100+
describe('FormPage draws a tabbed form view as tabs (#26843)', () => {
101+
it('one tab per section, named by its heading, every panel mounted', async () => {
102+
vi.stubGlobal('fetch', stubFetch({}));
103+
renderPage();
104+
await waitFor(() => expect(tab('main')).not.toBeNull());
105+
const strip = screen.getByRole('tablist');
106+
expect(within(strip).getAllByRole('tab').map((t) => t.textContent)).toEqual(['Main', 'More', 'Routing']);
107+
// No stacked section card beside the strip.
108+
expect(document.querySelector('form section')).toBeNull();
109+
// Force-mounted: a closed tab's control exists, only CSS-hidden. (The
110+
// widgets are lazy, so they arrive after the strip.)
111+
await waitFor(() => {
112+
for (const name of ['title', 'notes', 'channel']) expect(input(name), name).not.toBeNull();
113+
});
114+
expect(panel('more')?.getAttribute('data-state')).toBe('inactive');
115+
expect(panel('more')?.className).toContain('data-[state=inactive]:hidden');
116+
// With no `defaultTab`, the first tab opens.
117+
expect(isOpen('main')).toBe(true);
118+
});
119+
120+
it('the form view\'s defaultTab opens first', async () => {
121+
vi.stubGlobal('fetch', stubFetch({ defaultTab: 'routing' }));
122+
renderPage();
123+
await waitFor(() => expect(tab('routing')).not.toBeNull());
124+
expect(isOpen('routing')).toBe(true);
125+
expect(isOpen('main')).toBe(false);
126+
});
127+
128+
it('a tab click switches the open panel', async () => {
129+
vi.stubGlobal('fetch', stubFetch({}));
130+
renderPage();
131+
await waitFor(() => expect(tab('more')).not.toBeNull());
132+
await userEvent.click(tab('more')!);
133+
expect(isOpen('more')).toBe(true);
134+
expect(panel('main')?.getAttribute('data-state')).toBe('inactive');
135+
});
136+
137+
it('tabPosition: left draws a vertical strip', async () => {
138+
vi.stubGlobal('fetch', stubFetch({ tabPosition: 'left' }));
139+
renderPage();
140+
await waitFor(() => expect(tab('main')).not.toBeNull());
141+
expect(screen.getByRole('tablist').getAttribute('aria-orientation')).toBe('vertical');
142+
});
143+
144+
it('a submit carries every section\'s fields, the closed tabs\' included', async () => {
145+
vi.stubGlobal('fetch', stubFetch({ defaults: { channel: 'web' } }));
146+
renderPage();
147+
await waitFor(() => expect(input('title')).not.toBeNull());
148+
await userEvent.type(input('title')!, 'Printer jam');
149+
await userEvent.click(tab('more')!);
150+
await userEvent.type(input('notes')!, 'Third floor');
151+
// `routing` is never opened: its field carries the form view's default.
152+
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
153+
await waitFor(() => expect(submits).toHaveLength(1));
154+
expect(submits[0].body).toMatchObject({ title: 'Printer jam', notes: 'Third floor', channel: 'web' });
155+
});
156+
157+
it('a refused submit opens the tab holding the field it names', async () => {
158+
const schema = {
159+
...OBJECT_SCHEMA,
160+
fields: { ...OBJECT_SCHEMA.fields, channel: { type: 'text', label: 'Channel', required: true } },
161+
};
162+
vi.stubGlobal('fetch', stubFetch({}, schema));
163+
renderPage();
164+
await waitFor(() => expect(input('title')).not.toBeNull());
165+
expect(isOpen('main')).toBe(true);
166+
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
167+
await waitFor(() => expect(isOpen('routing')).toBe(true));
168+
expect(submits).toHaveLength(0);
169+
});
170+
171+
it('CONTROL: a single-section tabbed view draws its section stacked, no strip', async () => {
172+
vi.stubGlobal('fetch', stubFetch({ sections: [THREE_SECTIONS[0]] }));
173+
renderPage();
174+
await waitFor(() => expect(input('title')).not.toBeNull());
175+
expect(screen.queryByRole('tablist')).toBeNull();
176+
expect(document.querySelectorAll('form section')).toHaveLength(1);
177+
});
178+
179+
it('CONTROL: a simple view draws every section stacked, no strip', async () => {
180+
vi.stubGlobal('fetch', stubFetch({ type: 'simple', defaultTab: 'routing' }));
181+
renderPage();
182+
await waitFor(() => expect(input('title')).not.toBeNull());
183+
expect(screen.queryByRole('tablist')).toBeNull();
184+
expect([...document.querySelectorAll('form section h2')].map((h) => h.textContent)).toEqual([
185+
'Main',
186+
'More',
187+
'Routing',
188+
]);
189+
});
190+
});
191+
192+
describe('FormPage draws layout: horizontal rows (#26843)', () => {
193+
const LABELS: Record<string, string> = { title: 'Title', notes: 'Notes', channel: 'Channel' };
194+
/** The row a field's `<label>` sits in. */
195+
const row = (name: string) => screen.getByText(LABELS[name]).closest('label')!.parentElement!;
196+
197+
it('each row puts its label beside the control at sm, stacked below', async () => {
198+
vi.stubGlobal('fetch', stubFetch({ type: 'simple', layout: 'horizontal' }));
199+
renderPage();
200+
await waitFor(() => expect(input('title')).not.toBeNull());
201+
const classes = row('title').className.split(/\s+/);
202+
expect(classes).toContain('sm:grid');
203+
expect(classes).toContain('sm:[&>:not(label)]:col-start-2');
204+
// Below sm nothing changes: every class the row carries is sm:-prefixed.
205+
expect(classes.filter((c) => !c.startsWith('sm:'))).toEqual([]);
206+
expect(row('title').firstElementChild?.tagName).toBe('LABEL');
207+
// The label still names its control.
208+
expect(screen.getByLabelText('Title')).toBe(input('title'));
209+
});
210+
211+
it('a tabbed view keeps it on every tab', async () => {
212+
vi.stubGlobal('fetch', stubFetch({ layout: 'horizontal' }));
213+
renderPage();
214+
await waitFor(() => expect(input('notes')).not.toBeNull());
215+
for (const name of ['title', 'notes', 'channel']) {
216+
expect(row(name).className.split(/\s+/), name).toContain('sm:grid');
217+
}
218+
});
219+
220+
it('CONTROL: no layout, and vertical — no grid row', async () => {
221+
for (const layout of [undefined, 'vertical']) {
222+
vi.stubGlobal('fetch', stubFetch({ type: 'simple', ...(layout ? { layout } : {}) }));
223+
renderPage();
224+
await waitFor(() => expect(input('title')).not.toBeNull());
225+
expect(row('title').className.split(/\s+/), String(layout)).not.toContain('sm:grid');
226+
cleanup();
227+
}
228+
});
229+
});
230+
231+
describe('the tab rules (#26843)', () => {
232+
it('a section\'s tab is its name, else its heading, else its position — TabbedForm\'s rule', () => {
233+
expect(sectionTabKey({ name: 'main', label: 'Main' }, 0)).toBe('main');
234+
expect(sectionTabKey({ label: 'Main' }, 0)).toBe('Main');
235+
expect(sectionTabKey({}, 2)).toBe('tab-2');
236+
});
237+
238+
it('the open tab is the pick, else defaultTab, else the first — each only if drawn', () => {
239+
const drawn = ['a', 'b', 'c'];
240+
expect(resolveActiveTab(drawn, 'c', 'b')).toBe('c');
241+
expect(resolveActiveTab(drawn, null, 'b')).toBe('b');
242+
expect(resolveActiveTab(drawn, 'gone', 'b')).toBe('b');
243+
expect(resolveActiveTab(drawn, null, 'hidden')).toBe('a');
244+
expect(resolveActiveTab([], null, 'b')).toBeUndefined();
245+
});
246+
});

0 commit comments

Comments
 (0)