Skip to content

Commit 7ba8b11

Browse files
fix(app-shell): Studio's form view canvas honours layout horizontal and the other form view keys (#29759)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb1jwz5572SRdM7sFPV66BJ3)_ Fixes #29676 Clause-②: no Metadata-protocol: none Claim 6593560214, surface amendment 6593906746. ## 维护者速读 - **改了什么**:Studio「界面」里打开表单视图时,画布(设计和运行两种模式)现在按视图的 `layout` 画字段:`horizontal` 时标签在输入框左侧同一行,`vertical` 或未设置时标签在上方,和以前一样。画布同时会用上视图的 `columns`、`defaultTab`、`tabPosition`(分页表单)、`allowSkip`、`showStepIndicator`(向导表单)。 - **为什么改**:画布拼表单时只拿了 `type`、`sections`、`fields`,其余键全部丢掉。作者在属性面板里选了 horizontal,画布毫无变化,而终端用户的表单页其实已经按 horizontal 画了,两边看到的不一样。 - **风险与回滚**:只改 Studio 画布的显示,不改保存内容,也不改终端用户的表单页。`layout` 走的是控制台表单宿主自己用的同一个映射(`resolveFormViewChrome`),所以两边读法一致。回滚即 revert 本 PR。 - **先看**:`ViewPreview.tsx` 的 `buildFormPreviewSchema`;新测试 `ViewPreview.formLayout-29676.test.tsx`。 - **两分钟验证**:在可写包里存一个 `config.layout: 'horizontal'` 的表单视图并发布,打开 `/studio/包ID/interfaces?surface=view:视图名`:设计和运行两种模式下,"Project Name" 标签都在输入框左边同一行;手机宽度下仍是上下排列(断点设计如此)。 - **需要你做的**:无,席位落地。 ## What changes - **`buildFormPreviewSchema` (the form view canvas, shared by Studio Design and Run) forwards the form view keys `ObjectForm` reads.** It built the `object-form` schema from `type`, `sections` and `fields` only, so a view stored with `config.layout: 'horizontal'` drew every label above its control in Studio while the console's form page drew it beside. - **`layout` is read through `resolveFormViewChrome`**, the mapping the console's form hosts (`RecordFormPage`, the New/Edit modal) already use, so the canvas and the runtime read the key one way. Only its `layout` is taken: the canvas draws no submit or cancel and seeds no create values. - **`columns`, `defaultTab`, `tabPosition`, `allowSkip` and `showStepIndicator` are passed as authored**, as `RecordFormPage` passes them: `columns` to the field grid, `defaultTab` / `tabPosition` to the tabbed layout, `allowSkip` / `showStepIndicator` to the wizard. A tabbed view now opens on its authored tab in Studio. - **`title` and `description` are deliberately not forwarded.** Measured in `@object-ui/plugin-form`: only `DrawerForm`, `ModalForm` and `MasterDetailForm` read `schema.title` / `schema.description`. The canvas folds `drawer` / `modal` to `simple` (`toInlineFormType`), and `simple`, `tabbed` and `wizard` read neither key, so forwarding them would be a pin on a value nothing draws. Drawing a form's heading and description on the canvas would need new canvas markup, so it is listed under Acceptance notes rather than done here. - `buildFormPreviewSchema` is now a named export, so the test can pin it directly. No other module reads it. It adds one `react-refresh/only-export-components` warning of the same kind the file's existing `resolveObjectName` export carries. - The renderer's horizontal branch (`components/src/renderers/form/form.tsx`, #26843) is unchanged; it was already correct. ## Why Studio's form view inspector offers Layout, so an author could pick `horizontal`, publish it, and see nothing change on the canvas, even though end users got it (#29672 acceptance[1]). Declared and enforced at runtime, but misdrawn where it is authored. ## Tests `ViewPreview.formLayout-29676.test.tsx` (new, real `ViewPreview`, `@object-ui/plugin-form` registered): - `horizontal` draws the field item with the renderer's label-column grid (`sm:grid` plus the two-column template) in Run, in Design (`editing`), and on a `tabbed` view. - CONTROL: `vertical` and no layout draw the stacked item, with no `sm:grid`. - The built schema carries `layout` through the console hosts' mapping (`horizontal` kept, `vertical` kept, unset becomes the renderer default `vertical`), and carries `columns`, `defaultTab`, `tabPosition`, `allowSkip` and `showStepIndicator` as authored. - CONTROL: unauthored keys stay unset, and the canvas still draws no submit or cancel (`mode: 'create'`). - `defaultTab: 'details'` opens the Details tab on the drawn tabbed form (`aria-selected`). ## Acceptance notes - **The canvas draws no form heading or description.** A form view's `title` / `description` show on the console's `/forms` page (its own heading and description) but not on the Studio canvas, whose inline layouts read neither key. Out of scope here (see What changes); noted, not filed. - **Phone width:** below the `sm` breakpoint a horizontal form stacks by design, on the canvas and on the console's form page alike (measured at 390 px below). - **No end-user host drops `layout`.** The console's `/forms/NAME` page draws horizontal beside (measured), and `RecordFormPage` and AppContent's New/Edit modal forward it through `resolveFormViewChrome` (pinned by the #26843 tests). ## Verification <details><summary>Live, on the showcase (dev --fresh, Vite source console, scratch package over REST)</summary> Three published form views on `showcase_project` (`layout: horizontal`, `vertical`, unset), each with one two-column section (Project Name, Status). Label and control positions from `getBoundingClientRect` at 1440 px, x,y in px: | surface | view | before (c76cdeb) | after (623f76a) | |:---|:---|:---|:---| | Studio Design | horizontal | label 274,282 · control 274,300 (stacked, no grid) | label 274,290 · control 450,276 (beside, grid) | | Studio Run | horizontal | label 274,282 · control 274,300 (stacked) | label 274,290 · control 450,276 (beside) | | Studio Design / Run | vertical, unset | label 274,282 · control 274,300 | unchanged | | console `/forms/NAME` | horizontal | label 429,191 · control 605,177 (beside) | unchanged | | console `/forms/NAME` | vertical, unset | label 429,177 · control 429,197 | unchanged | The label's y sits inside the control's height because the grid aligns the label on the control's first text line (`items-baseline`). At 390 px the horizontal view stacks on both surfaces (Studio label 34,351 · control 34,369; console label 33,165 · control 33,185). </details> <details><summary>Ablations (ablation-replace.mjs on 623f76a, src-resolved; each restored blob == HEAD, git diff HEAD empty)</summary> - A: `layout: resolveFormViewChrome(` replaced by `layout: undefined && resolveFormViewChrome(` gives 4 failed / 5 passed (the three horizontal renders and the layout schema pin). - B: `defaultTab: body.defaultTab,` replaced by `defaultTab: undefined,` gives 2 failed / 7 passed (the schema pin and the drawn tab). - C: `columns: body.columns,` replaced by `columns: undefined,` gives 1 failed / 8 passed. </details> <details><summary>Local runs</summary> - New file: 9/9 tests passed (623f76a). - Neighbour sweep, every ui test file naming `ViewPreview` or `resolveFormViewChrome` (18 files, locked, 2 workers): 443 tests passed (1718156). - `turbo run type-check --filter=@object-ui/app-shell`: 65/65 (623f76a). - eslint on the touched files: 0 errors. - `dispatch-gates --commands` derived 35 commands; all 35 exit 0 (1718156), incl. `check-sdui-manifest` and its self-test, `check:type-check-debt` (locked), and `pnpm --dir ui exec vitest run scripts/` (170 files passed, 2 skipped, at `--maxWorkers=2`). `--ran` reconciliation 35/35, 0 NOT-MEASURED. - After merging `origin/main` again (0d3b9c0, 9 commits, none touching these files; rebuilt 118/118): the 18-file neighbour sweep, 443 tests passed, and the app-shell type-check, 65/65. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files, 0 errors; the repo-wide lint 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: its generated artifacts are regenerated and committed. (Not touched.) - [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. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSTfFyWwerNYfs13mT --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSTfFyWwerNYfs13mT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0712470 commit 7ba8b11

3 files changed

Lines changed: 220 additions & 2 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+
---
4+
5+
fix(app-shell): Studio's form view canvas draws a horizontal layout with the label beside its control
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A form view saved with `config.layout: 'horizontal'` drew every label stacked above its control in Studio Interfaces, in both Design and Run, while the console's form pages drew it beside. The canvas built its form from the view's `type`, `sections` and `fields` only. It now reads `layout` through the same mapping the console's form hosts use, so the canvas matches what end users see. `vertical` and an unset layout draw as before. Below the `sm` breakpoint a horizontal form still stacks, as it does everywhere.
11+
12+
The canvas also passes the other form view keys the form reads: `columns`, `defaultTab` and `tabPosition` (tabbed forms), and `allowSkip` and `showStepIndicator` (wizards). A tabbed view now opens on its authored tab in Studio, as it does on the record page.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29676 — the Studio form view canvas honours the view's `layout`.
5+
*
6+
* A form view stores `config.layout: 'horizontal'` (the label beside its
7+
* control). The renderer draws that since objectstack#26843, and the console's
8+
* form hosts forward it, but `ViewPreview`'s form route built its
9+
* `object-form` schema from `type` / `sections` / `fields` alone, so Studio
10+
* Design and Run (one route, `designMode` only changes the hint) drew every
11+
* label stacked above its control.
12+
*
13+
* Asserted on the renderer's own arrangement: a field item under
14+
* `horizontal` carries the label-column grid classes (`sm:grid`, the
15+
* two-column template), and one under `vertical` or no layout carries none.
16+
* Pixel positions are the live measurement's (PR body), not jsdom's.
17+
*
18+
* The same builder dropped the other form view keys `ObjectForm` and its
19+
* layouts read — `columns`, `defaultTab`, `tabPosition`, `allowSkip`,
20+
* `showStepIndicator` — so each is pinned on the schema the canvas renders,
21+
* and `defaultTab` once more on the drawn tab strip.
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import '@object-ui/plugin-form';
26+
import * as React from 'react';
27+
import { describe, it, expect, vi, afterEach } from 'vitest';
28+
import { render, cleanup, waitFor, screen } from '@testing-library/react';
29+
import { SchemaRendererProvider } from '@object-ui/react';
30+
import type { DataSource } from '@object-ui/types';
31+
import { ViewPreview, buildFormPreviewSchema } from './ViewPreview';
32+
33+
afterEach(cleanup);
34+
35+
const objectDef = {
36+
name: 'task',
37+
label: 'Task',
38+
fields: {
39+
title: { name: 'title', label: 'Title', type: 'text' },
40+
status: { name: 'status', label: 'Status', type: 'text' },
41+
},
42+
};
43+
44+
function createDataSource(): DataSource {
45+
return {
46+
find: vi.fn(async () => ({ data: [], total: 0 })),
47+
findOne: vi.fn(async () => null),
48+
create: vi.fn(async () => ({})),
49+
update: vi.fn(async () => ({})),
50+
delete: vi.fn(async () => ({})),
51+
getObjectSchema: vi.fn(async () => objectDef),
52+
} as unknown as DataSource;
53+
}
54+
55+
/** A published form view; `layout` is set only when the case names one. */
56+
function formView(layout?: 'horizontal' | 'vertical', type = 'simple') {
57+
return {
58+
name: 'task.intake',
59+
object: 'task',
60+
viewKind: 'form',
61+
label: 'Task intake',
62+
config: {
63+
type,
64+
...(layout ? { layout } : {}),
65+
sections: [{ name: 'overview', label: 'Overview', fields: ['title', 'status'] }],
66+
},
67+
};
68+
}
69+
70+
/** The field item the renderer draws for `name`, once the object schema loads. */
71+
async function renderItem(draft: ReturnType<typeof formView>, editing = false): Promise<HTMLElement> {
72+
const { container } = render(
73+
<SchemaRendererProvider dataSource={createDataSource()}>
74+
<ViewPreview type="view" name="task.intake" draft={draft} locale="en-US" editing={editing} />
75+
</SchemaRendererProvider>,
76+
);
77+
let item: HTMLElement | null = null;
78+
await waitFor(() => {
79+
item = container.querySelector<HTMLElement>('[data-field="title"]');
80+
expect(item).not.toBeNull();
81+
});
82+
return item!;
83+
}
84+
85+
/** The renderer's horizontal arrangement (`HORIZONTAL_FIELD_ITEM_CLASS`). */
86+
const isHorizontal = (item: HTMLElement) =>
87+
item.classList.contains('sm:grid') && item.classList.contains('sm:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]');
88+
89+
describe('objectstack#29676 — the form view canvas honours layout', () => {
90+
it('horizontal: the field item draws the label column beside its control (Run)', async () => {
91+
const item = await renderItem(formView('horizontal'));
92+
expect(isHorizontal(item)).toBe(true);
93+
// The label is still the item's own, the one the vertical item carries.
94+
expect(item.querySelector('label')).toHaveTextContent('Title');
95+
});
96+
97+
it('horizontal: the same in Design, which renders through the same route', async () => {
98+
const item = await renderItem(formView('horizontal'), true);
99+
expect(isHorizontal(item)).toBe(true);
100+
});
101+
102+
it('horizontal reaches a tabbed view too (its layout reads the same key)', async () => {
103+
const item = await renderItem(formView('horizontal', 'tabbed'));
104+
expect(isHorizontal(item)).toBe(true);
105+
});
106+
107+
it('CONTROL: vertical draws the stacked item it always drew', async () => {
108+
const item = await renderItem(formView('vertical'));
109+
expect(isHorizontal(item)).toBe(false);
110+
expect(item.className).not.toContain('sm:grid');
111+
});
112+
113+
it('CONTROL: no layout draws the stacked item it always drew', async () => {
114+
const item = await renderItem(formView());
115+
expect(isHorizontal(item)).toBe(false);
116+
expect(item.className).not.toContain('sm:grid');
117+
});
118+
});
119+
120+
describe('objectstack#29676 — the canvas schema carries the form view keys ObjectForm reads', () => {
121+
const build = (config: Record<string, unknown>) => buildFormPreviewSchema('task', config, 'en-US');
122+
123+
it('layout: the console hosts\' mapping (resolveFormViewChrome), horizontal kept', () => {
124+
expect(build({ type: 'simple', layout: 'horizontal' }).layout).toBe('horizontal');
125+
expect(build({ type: 'simple', layout: 'vertical' }).layout).toBe('vertical');
126+
// Unset reads the renderer default, as on the record page.
127+
expect(build({ type: 'simple' }).layout).toBe('vertical');
128+
});
129+
130+
it('columns, defaultTab, tabPosition, allowSkip and showStepIndicator reach the schema as authored', () => {
131+
const schema = build({
132+
type: 'tabbed',
133+
columns: 3,
134+
defaultTab: 'details',
135+
tabPosition: 'left',
136+
allowSkip: true,
137+
showStepIndicator: false,
138+
});
139+
expect(schema).toMatchObject({
140+
type: 'object-form',
141+
formType: 'tabbed',
142+
columns: 3,
143+
defaultTab: 'details',
144+
tabPosition: 'left',
145+
allowSkip: true,
146+
showStepIndicator: false,
147+
});
148+
});
149+
150+
it('CONTROL: unauthored keys stay unset, and the canvas still draws no submit or cancel', () => {
151+
const schema = build({ type: 'simple' });
152+
for (const key of ['columns', 'defaultTab', 'tabPosition', 'allowSkip', 'showStepIndicator']) {
153+
expect(schema[key]).toBeUndefined();
154+
}
155+
expect(schema).toMatchObject({ showSubmit: false, showCancel: false, mode: 'create' });
156+
});
157+
158+
it('defaultTab opens the authored tab on the drawn tabbed form', async () => {
159+
const draft = {
160+
...formView(undefined, 'tabbed'),
161+
config: {
162+
type: 'tabbed',
163+
defaultTab: 'details',
164+
sections: [
165+
{ name: 'overview', label: 'Overview', fields: ['title'] },
166+
{ name: 'details', label: 'Details', fields: ['status'] },
167+
],
168+
},
169+
};
170+
render(
171+
<SchemaRendererProvider dataSource={createDataSource()}>
172+
<ViewPreview type="view" name="task.intake" draft={draft} locale="en-US" />
173+
</SchemaRendererProvider>,
174+
);
175+
const details = await screen.findByRole('tab', { name: 'Details' });
176+
expect(details).toHaveAttribute('aria-selected', 'true');
177+
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'false');
178+
});
179+
});

‎ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShe
3939
import { primaryVariantBinding } from '../view-variant-model.js';
4040
import { t as tr, tFormat } from '../i18n.js';
4141
import { withNodes } from './row-nodes.js';
42+
import { resolveFormViewChrome, type FormViewDefinition } from '../../../utils/recordFormNavigation.js';
4243

4344
/**
4445
* The object a stored `view` draft is bound to, read under the spellings the
@@ -79,9 +80,15 @@ function toFormFieldEntry(f: unknown): string | Record<string, unknown> {
7980
return String(f);
8081
}
8182

82-
function buildFormPreviewSchema(
83+
/**
84+
* The `object-form` schema the form view canvas renders from a form view's
85+
* `config` body: Studio Design and Run both draw through it. Exported for its
86+
* pin (objectstack#29676); no other module reads it.
87+
*/
88+
export function buildFormPreviewSchema(
8389
objectName: string,
8490
body: Record<string, unknown>,
91+
locale: string | undefined,
8592
): Record<string, unknown> {
8693
const rawType = typeof body.type === 'string' ? body.type : 'simple';
8794
const formType = toInlineFormType(rawType);
@@ -100,6 +107,26 @@ function buildFormPreviewSchema(
100107
formType,
101108
sections,
102109
fields: Array.isArray(body.fields) ? body.fields : undefined,
110+
// objectstack#29676 — the form view's keys `ObjectForm` and its layouts
111+
// read, which this canvas dropped, so Studio Design and Run drew a view
112+
// otherwise than the console does. `layout` (the label above or beside its
113+
// control) goes through the console form hosts' own mapping,
114+
// `resolveFormViewChrome`, so the canvas and the runtime read it one way;
115+
// only its layout is taken, as the canvas draws no submit / cancel and
116+
// seeds no create values. The rest are read as the record page reads
117+
// them: `columns` by the field grid, `defaultTab` / `tabPosition` by the
118+
// tabbed layout, `allowSkip` / `showStepIndicator` by the wizard.
119+
layout: resolveFormViewChrome(body as FormViewDefinition, {
120+
flow: 'create',
121+
language: locale ?? 'en',
122+
submitText: '',
123+
cancelText: '',
124+
}).layout,
125+
columns: body.columns,
126+
defaultTab: body.defaultTab,
127+
tabPosition: body.tabPosition,
128+
allowSkip: body.allowSkip,
129+
showStepIndicator: body.showStepIndicator,
103130
showSubmit: false,
104131
showCancel: false,
105132
};
@@ -267,7 +294,7 @@ export function ViewPreview({ name, draft, editing, locale, diagnostics }: Metad
267294
// -------------------------------------------------------------------------
268295
if ((draft as any).viewKind === 'form' && body) {
269296
const rawType = String((body as any).type ?? 'simple');
270-
const formSchema = buildFormPreviewSchema(objectName, body as Record<string, unknown>);
297+
const formSchema = buildFormPreviewSchema(objectName, body as Record<string, unknown>, locale);
271298
return (
272299
<PreviewShell hint={`view · ${rawType} · form${designMode ? ' · design' : ''}`}>
273300
<ViewLabelHeading label={viewLabel} />

0 commit comments

Comments
 (0)