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
21 changes: 21 additions & 0 deletions .changeset/form-view-columns-every-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@object-ui/plugin-form": patch
---

fix(form): a tabbed/split form honours the form view's own `columns`

`FormView.columns` is a spec key, 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.
11 changes: 11 additions & 0 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ interface FormField {
}
```

### Column width of a sectioned form

A sectioned form renders as ONE grid. The form view's `columns` (spec
`FormView.columns`) sets how wide that grid is; a section's `columns` sets how
densely that section fills it. The view's value wins; without it the grid takes
the widest section's density, and without either it is single-column.

`ObjectForm` (simple), `ModalForm`, `TabbedForm` and `SplitForm` all resolve it
that way, so the same metadata lays out identically in every host. The grid is
applied to the field container inside the form, never wrapped around the `<form>`.

### Tabbed field layout (`fieldTabs`)

A sectioned form stays **one** form: declare the tabs on it and the renderer
Expand Down
18 changes: 18 additions & 0 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ interface FormField {
}
```

### Column width of a sectioned form

A sectioned form renders as ONE grid, and two keys decide its shape:

| Key | Meaning |
|---|---|
| the form view's `columns` | how wide the grid is (1–4) — spec `FormView.columns` |
| a section's `columns` | how densely THAT section fills the grid (via per-field `colSpan`) |

The view's `columns` wins; without it the grid takes the widest section's
density, and without either it is single-column. Every sectioned host resolves it
the same way — `ObjectForm` (simple), `ModalForm`, `TabbedForm`, `SplitForm` —
so one piece of metadata lays out identically wherever it is rendered.

The grid is applied to the field container **inside** the form, never wrapped
around the `<form>` (which would put the whole form in cell 1 and leave the other
columns empty — #2128).

### Tabbed field layout (`fieldTabs`)

A sectioned form is **one** form. Instead of rendering a form per section — which
Expand Down
25 changes: 19 additions & 6 deletions packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export interface SplitFormSchema {
*/
splitResizable?: boolean;

/**
* Grid width for the whole form (1–4). Aligns with @objectstack/spec
* FormView.columns and OUTRANKS the per-section `columns`, which say how a
* section fills the grid rather than how wide it is. Omitted = the widest
* section's density.
*/
columns?: number;

// Common form props
showSubmit?: boolean;
submitText?: string;
Expand Down Expand Up @@ -246,17 +254,22 @@ export const SplitForm: React.FC<SplitFormProps> = ({
);
}

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

/**
Expand Down
16 changes: 14 additions & 2 deletions packages/plugin-form/src/TabbedForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,19 @@ export interface TabbedFormSchema {
*/
sections: FormSectionConfig[];

/**
* Grid width for the whole form (1–4). Aligns with @objectstack/spec
* FormView.columns and OUTRANKS the per-section `columns`, which say how a
* section fills the grid rather than how wide it is. Omitted = the widest
* section's density.
*/
columns?: number;

/**
* Default active tab (section name)
*/
defaultTab?: string;

/**
* Tab position
* @default 'top'
Expand Down Expand Up @@ -347,7 +355,11 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
const declaredCols = schema.sections
.map((s) => clampCol(s.columns))
.filter((c): c is number => c != null);
const formColumns = declaredCols.length ? Math.max(...declaredCols) : 1;
// Grid width: the form view's own `columns` first (spec FormView.columns),
// else the widest section. Same precedence ObjectForm's simple path and
// ModalForm use, so one piece of metadata lays out the same in every host.
const formColumns =
clampCol(schema.columns) ?? (declaredCols.length ? Math.max(...declaredCols) : 1);
const containerFieldClass = containerGridColsFor(formColumns);

const tabGroups = schema.sections.map((section, index) => {
Expand Down
88 changes: 86 additions & 2 deletions packages/plugin-form/src/sectionColumns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@
* lone <form> — can't silently regress.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import React from 'react';
import { registerAllFields } from '@object-ui/fields';
import { ModalForm } from './ModalForm';
import { TabbedForm } from './TabbedForm';
import { SplitForm } from './SplitForm';
import { sectionFormLayout } from './autoLayout';

registerAllFields();
Expand Down Expand Up @@ -67,6 +69,88 @@ const makeDataSource = (): any => ({
findOne: vi.fn(),
});

afterEach(() => {
cleanup();
});

/**
* The form view's OWN `columns` is a spec key (view.zod FormView.columns), so a
* sectioned host must honour it — and it OUTRANKS the per-section densities,
* which describe how a section fills the grid rather than how wide the grid is.
*
* `ObjectForm` (simple path) and `ModalForm` already resolved it as
* `explicit form columns ?? widest section ?? 1`. `TabbedForm` and `SplitForm`
* read only the sections, so a view that declared `columns: 3` silently
* rendered single-column in a tabbed or split form while the same metadata gave
* 3 columns in a modal.
*/
describe('view-level `columns` outranks per-section columns', () => {
const SECTIONS = [
{ name: 's1', label: 'One', fields: ['a', 'b'] },
{ name: 's2', label: 'Two', fields: ['c', 'd'] },
];

it('TabbedForm honours the form view’s columns', async () => {
render(
<TabbedForm
schema={{
type: 'object-form',
formType: 'tabbed',
objectName: 'lead',
mode: 'create',
columns: 3,
sections: SECTIONS,
}}
dataSource={makeDataSource()}
/>,
);

await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull();
});

it('SplitForm honours the form view’s columns', async () => {
render(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'lead',
mode: 'create',
columns: 3,
sections: SECTIONS,
}}
dataSource={makeDataSource()}
/>,
);

await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull();
});

it('falls back to the widest section when the view declares no columns', async () => {
render(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'lead',
mode: 'create',
sections: [
{ name: 's1', label: 'One', columns: 2, fields: ['a', 'b'] },
{ name: 's2', label: 'Two', fields: ['c', 'd'] },
],
}}
dataSource={makeDataSource()}
/>,
);

await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
expect(document.body.querySelector('[class*="@md:grid-cols-2"]')).not.toBeNull();
expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).toBeNull();
});
});

describe('ModalForm — grouped section multi-column layout (#2128)', () => {
it('renders a 2-column section grid whose direct children are the fields, not a lone <form>', async () => {
render(
Expand Down
Loading