Skip to content

Commit aeb0bd2

Browse files
os-zhuangclaude
andauthored
fix(form): a tabbed/split form honours the form view's own columns (#3018)
FormView.columns is a spec key (view.zod), but only ObjectForm's simple path and ModalForm read it. TabbedForm and SplitForm derived the grid width from the per-section `columns` alone, so a view declaring `columns: 3` rendered 3 columns in a modal and single-column as a tab or split — the same metadata laying out differently depending on which host picked it up. Both now resolve the grid the way the other hosts already did: explicit form `columns` ?? widest section's `columns` ?? 1 The two keys answer different questions and the precedence reflects that: the view's `columns` is how wide the grid is, a section's `columns` is how densely that section fills it (via per-field colSpan). `columns` is declared on TabbedFormSchema / SplitFormSchema accordingly — ObjectForm already spread it through, it was simply being dropped on arrival. WizardForm is the remaining host that ignores it; left out deliberately, since a wizard's grid is per-STEP and needs the density/width split introduced properly rather than a one-line precedence swap. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b0172c7 commit aeb0bd2

6 files changed

Lines changed: 169 additions & 10 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@object-ui/plugin-form": patch
3+
---
4+
5+
fix(form): a tabbed/split form honours the form view's own `columns`
6+
7+
`FormView.columns` is a spec key, but only `ObjectForm`'s simple path and
8+
`ModalForm` read it. `TabbedForm` and `SplitForm` derived the grid width from the
9+
per-section `columns` alone, so a view declaring `columns: 3` rendered 3 columns
10+
in a modal and **single-column** as a tab or split — the same metadata laying out
11+
differently depending on which host picked it up.
12+
13+
Both now resolve the grid the way the other hosts already did:
14+
15+
explicit form `columns` ?? widest section's `columns` ?? 1
16+
17+
The two keys answer different questions and the precedence reflects that: the
18+
view's `columns` is how wide the grid is, a section's `columns` is how densely
19+
that section fills it (via per-field `colSpan`). `columns` is declared on
20+
`TabbedFormSchema` / `SplitFormSchema` accordingly — `ObjectForm` already spread
21+
it through, it was simply being dropped on arrival.

content/docs/plugins/plugin-form.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ interface FormField {
6262
}
6363
```
6464

65+
### Column width of a sectioned form
66+
67+
A sectioned form renders as ONE grid. The form view's `columns` (spec
68+
`FormView.columns`) sets how wide that grid is; a section's `columns` sets how
69+
densely that section fills it. The view's value wins; without it the grid takes
70+
the widest section's density, and without either it is single-column.
71+
72+
`ObjectForm` (simple), `ModalForm`, `TabbedForm` and `SplitForm` all resolve it
73+
that way, so the same metadata lays out identically in every host. The grid is
74+
applied to the field container inside the form, never wrapped around the `<form>`.
75+
6576
### Tabbed field layout (`fieldTabs`)
6677

6778
A sectioned form stays **one** form: declare the tabs on it and the renderer

packages/plugin-form/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,24 @@ interface FormField {
8383
}
8484
```
8585

86+
### Column width of a sectioned form
87+
88+
A sectioned form renders as ONE grid, and two keys decide its shape:
89+
90+
| Key | Meaning |
91+
|---|---|
92+
| the form view's `columns` | how wide the grid is (1–4) — spec `FormView.columns` |
93+
| a section's `columns` | how densely THAT section fills the grid (via per-field `colSpan`) |
94+
95+
The view's `columns` wins; without it the grid takes the widest section's
96+
density, and without either it is single-column. Every sectioned host resolves it
97+
the same way — `ObjectForm` (simple), `ModalForm`, `TabbedForm`, `SplitForm`
98+
so one piece of metadata lays out identically wherever it is rendered.
99+
100+
The grid is applied to the field container **inside** the form, never wrapped
101+
around the `<form>` (which would put the whole form in cell 1 and leave the other
102+
columns empty — #2128).
103+
86104
### Tabbed field layout (`fieldTabs`)
87105

88106
A sectioned form is **one** form. Instead of rendering a form per section — which

packages/plugin-form/src/SplitForm.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export interface SplitFormSchema {
7171
*/
7272
splitResizable?: boolean;
7373

74+
/**
75+
* Grid width for the whole form (1–4). Aligns with @objectstack/spec
76+
* FormView.columns and OUTRANKS the per-section `columns`, which say how a
77+
* section fills the grid rather than how wide it is. Omitted = the widest
78+
* section's density.
79+
*/
80+
columns?: number;
81+
7482
// Common form props
7583
showSubmit?: boolean;
7684
submitText?: string;
@@ -246,17 +254,22 @@ export const SplitForm: React.FC<SplitFormProps> = ({
246254
);
247255
}
248256

249-
// The form is ONE grid, as wide as the widest section; each section then lays
250-
// ITS fields out at its own declared density within that grid via colSpan
251-
// (#2578) — the same arrangement the stacked/tabbed sectioned forms use. The
252-
// grid lives on the FIELD container inside the form, never wrapped around the
253-
// form (that leaves the extra columns permanently empty, #2128).
257+
// The form is ONE grid; each section then lays ITS fields out at its own
258+
// declared density within that grid via colSpan (#2578) — the same arrangement
259+
// the stacked/tabbed sectioned forms use. The grid lives on the FIELD
260+
// container inside the form, never wrapped around the form (that leaves the
261+
// extra columns permanently empty, #2128).
262+
//
263+
// Grid width: the form view's own `columns` first (spec FormView.columns),
264+
// else the widest section. Same precedence ObjectForm's simple path and
265+
// ModalForm use, so one piece of metadata lays out the same in every host.
254266
const clampCol = (n: unknown): number | undefined =>
255267
typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined;
256268
const declaredCols = schema.sections
257269
.map((s) => clampCol(s.columns))
258270
.filter((c): c is number => c != null);
259-
const formColumns = (declaredCols.length ? Math.max(...declaredCols) : 1) as 1 | 2 | 3 | 4;
271+
const formColumns = (clampCol(schema.columns)
272+
?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4;
260273
const containerFieldClass = containerGridColsFor(formColumns);
261274

262275
/**

packages/plugin-form/src/TabbedForm.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,19 @@ export interface TabbedFormSchema {
8686
*/
8787
sections: FormSectionConfig[];
8888

89+
/**
90+
* Grid width for the whole form (1–4). Aligns with @objectstack/spec
91+
* FormView.columns and OUTRANKS the per-section `columns`, which say how a
92+
* section fills the grid rather than how wide it is. Omitted = the widest
93+
* section's density.
94+
*/
95+
columns?: number;
96+
8997
/**
9098
* Default active tab (section name)
9199
*/
92100
defaultTab?: string;
93-
101+
94102
/**
95103
* Tab position
96104
* @default 'top'
@@ -347,7 +355,11 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
347355
const declaredCols = schema.sections
348356
.map((s) => clampCol(s.columns))
349357
.filter((c): c is number => c != null);
350-
const formColumns = declaredCols.length ? Math.max(...declaredCols) : 1;
358+
// Grid width: the form view's own `columns` first (spec FormView.columns),
359+
// else the widest section. Same precedence ObjectForm's simple path and
360+
// ModalForm use, so one piece of metadata lays out the same in every host.
361+
const formColumns =
362+
clampCol(schema.columns) ?? (declaredCols.length ? Math.max(...declaredCols) : 1);
351363
const containerFieldClass = containerGridColsFor(formColumns);
352364

353365
const tabGroups = schema.sections.map((section, index) => {

packages/plugin-form/src/sectionColumns.test.tsx

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
* lone <form> — can't silently regress.
2323
*/
2424

25-
import { describe, it, expect, vi } from 'vitest';
26-
import { render, waitFor } from '@testing-library/react';
25+
import { describe, it, expect, vi, afterEach } from 'vitest';
26+
import { render, waitFor, cleanup } from '@testing-library/react';
2727
import React from 'react';
2828
import { registerAllFields } from '@object-ui/fields';
2929
import { ModalForm } from './ModalForm';
30+
import { TabbedForm } from './TabbedForm';
31+
import { SplitForm } from './SplitForm';
3032
import { sectionFormLayout } from './autoLayout';
3133

3234
registerAllFields();
@@ -67,6 +69,88 @@ const makeDataSource = (): any => ({
6769
findOne: vi.fn(),
6870
});
6971

72+
afterEach(() => {
73+
cleanup();
74+
});
75+
76+
/**
77+
* The form view's OWN `columns` is a spec key (view.zod FormView.columns), so a
78+
* sectioned host must honour it — and it OUTRANKS the per-section densities,
79+
* which describe how a section fills the grid rather than how wide the grid is.
80+
*
81+
* `ObjectForm` (simple path) and `ModalForm` already resolved it as
82+
* `explicit form columns ?? widest section ?? 1`. `TabbedForm` and `SplitForm`
83+
* read only the sections, so a view that declared `columns: 3` silently
84+
* rendered single-column in a tabbed or split form while the same metadata gave
85+
* 3 columns in a modal.
86+
*/
87+
describe('view-level `columns` outranks per-section columns', () => {
88+
const SECTIONS = [
89+
{ name: 's1', label: 'One', fields: ['a', 'b'] },
90+
{ name: 's2', label: 'Two', fields: ['c', 'd'] },
91+
];
92+
93+
it('TabbedForm honours the form view’s columns', async () => {
94+
render(
95+
<TabbedForm
96+
schema={{
97+
type: 'object-form',
98+
formType: 'tabbed',
99+
objectName: 'lead',
100+
mode: 'create',
101+
columns: 3,
102+
sections: SECTIONS,
103+
}}
104+
dataSource={makeDataSource()}
105+
/>,
106+
);
107+
108+
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
109+
expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull();
110+
});
111+
112+
it('SplitForm honours the form view’s columns', async () => {
113+
render(
114+
<SplitForm
115+
schema={{
116+
type: 'object-form',
117+
formType: 'split',
118+
objectName: 'lead',
119+
mode: 'create',
120+
columns: 3,
121+
sections: SECTIONS,
122+
}}
123+
dataSource={makeDataSource()}
124+
/>,
125+
);
126+
127+
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
128+
expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull();
129+
});
130+
131+
it('falls back to the widest section when the view declares no columns', async () => {
132+
render(
133+
<SplitForm
134+
schema={{
135+
type: 'object-form',
136+
formType: 'split',
137+
objectName: 'lead',
138+
mode: 'create',
139+
sections: [
140+
{ name: 's1', label: 'One', columns: 2, fields: ['a', 'b'] },
141+
{ name: 's2', label: 'Two', fields: ['c', 'd'] },
142+
],
143+
}}
144+
dataSource={makeDataSource()}
145+
/>,
146+
);
147+
148+
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
149+
expect(document.body.querySelector('[class*="@md:grid-cols-2"]')).not.toBeNull();
150+
expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).toBeNull();
151+
});
152+
});
153+
70154
describe('ModalForm — grouped section multi-column layout (#2128)', () => {
71155
it('renders a 2-column section grid whose direct children are the fields, not a lone <form>', async () => {
72156
render(

0 commit comments

Comments
 (0)