From 83ac907c201e49f55e783befa3c7a8ac5efe67a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:56:34 +0000 Subject: [PATCH] fix(plugin-form): page form hosts draw an undeclared section as the dialog does @objectstack/spec 18.0.0-next.2 dropped FormSectionSchema.columns' default(1), so a section with no `columns` reaches every sectioned host. The dialog drew it two per row from `@md`; ObjectForm sized it by field count (inferColumns) and SplitForm, TabbedForm and WizardForm drew it one per row, or at the widest declared count. The rule (section columns, else form columns, else 2) now lives once in autoLayout.ts: sectionedGridLayout takes the AUTHORED counts and resolves an undeclared section and form itself, through sectionColumnsFor and sectionedFormColumns. All five hosts hand it the raw declared values; ModalForm's private copy of the rule is gone. A declared `columns` (1 included) is drawn as written. Pins per page host (undeclared, mixed, declared controls) in undeclaredSectionDensity-12592.test.tsx. Claude-Session: https://claude.ai/code/session_013MpUUZWPR76Hx2czmmtfu9 Co-authored-by: Claude --- .../12592-undeclared-section-density.md | 7 + packages/plugin-form/src/ModalForm.tsx | 70 ++---- packages/plugin-form/src/ObjectForm.tsx | 33 +-- packages/plugin-form/src/SplitForm.tsx | 24 +- packages/plugin-form/src/TabbedForm.tsx | 23 +- packages/plugin-form/src/WizardForm.tsx | 14 +- .../mixedSectionColumns-12548.test.tsx | 15 +- .../undeclaredSectionDensity-12592.test.tsx | 212 ++++++++++++++++++ packages/plugin-form/src/autoLayout.ts | 74 +++++- 9 files changed, 351 insertions(+), 121 deletions(-) create mode 100644 .changeset/12592-undeclared-section-density.md create mode 100644 packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx diff --git a/.changeset/12592-undeclared-section-density.md b/.changeset/12592-undeclared-section-density.md new file mode 100644 index 0000000000..9f93653b5c --- /dev/null +++ b/.changeset/12592-undeclared-section-density.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-form': patch +--- + +A form section that declares no `columns` now draws two fields per row from the container's `@md` width (one below it) on every page form host — `ObjectForm`, `SplitForm`, `TabbedForm` and `WizardForm` — as the record dialog already did (objectui#12592). Before this, `ObjectForm` sized it by field count and the other three drew it one per row, or at the widest declared count. `@objectstack/spec` no longer defaults a section's `columns` to `1`, so this reaches every section an author left undeclared, a field-group section included. The rule lives once, in the shared sectioned-form layout, and a declared `columns` (`1` included) is drawn as written. + +Clause-② reading: no. Only how many columns an undeclared section draws changes; no published type, prop, export, schema read or pack key changes, and the ruling on objectui#12521 makes an undeclared density the renderer's choice by available width. diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 39a080aac4..ae780c8245 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -49,8 +49,10 @@ import { filterSystemFields, inferColumns, inferModalSize, + sectionedFormColumns, sectionedGridLayout, CONTAINER_GRID_COLS, + UNDECLARED_SECTION_COLUMNS, } from './autoLayout'; import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups'; import { resolveSectionGroupReferences } from './sectionGroups'; @@ -284,56 +286,23 @@ const modalSizeClasses: Record = { * the whole row. Line items never reach this grid: they arrive as `subforms`, * which the master-detail arm below lays out across the dialog. * - * It applies to a flat form with no `columns`, to the object's `fieldGroups`, - * and to every SECTION that declares no `columns` while the form declares - * none either: an undeclared section density is the renderer's choice, and a - * declared one — `1` included — is drawn as written (the section-`columns` - * fork on objectui#12521, ruled 6204264665). + * It applies to a flat form with no `columns`. A SECTION that declares no + * `columns` — the object's `fieldGroups` among them — gets the same count + * through the one sectioned-form rule, `sectionedGridLayout` + * (`UNDECLARED_SECTION_COLUMNS`), which every page host shares + * (objectui#12592): an undeclared section density is the renderer's choice, + * and a declared one — `1` included — is drawn as written (the + * section-`columns` fork on objectui#12521, ruled 6204264665). * - * ⚠️ Until `@objectstack/spec`'s `FormSectionSchema.columns` drops its - * `default(1)` (objectstack-ai/objectstack#23740), the spec's parse writes - * `columns: 1` into every section that omitted it — `defineView` and - * `defineStack` both return the parsed form — so a section from an authored - * stack reaches this dialog DECLARED and draws one column. The undeclared - * rule then covers only sections that arrive unparsed. ⛔ Never read a - * declared `1` as undeclared to get ahead of that release. - * - * Dialog-only on purpose: a page form, a drawer and the detail view keep - * `inferColumns`, which they mirror between them. + * The flat default is dialog-only on purpose: a flat page form, a drawer and + * the detail view keep `inferColumns`, which they mirror between them. */ -const DIALOG_DEFAULT_COLUMNS = 2; +const DIALOG_DEFAULT_COLUMNS = UNDECLARED_SECTION_COLUMNS; /** Clamp an authored column count into the 1–4 grid keys; anything else reads as not authored. */ const clampColumns = (n: unknown): number | undefined => typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; -/** - * One section's fields per row in the dialog: its declared `columns` as - * written, else the form's declared `columns` (what an undeclared section - * always filled), else {@link DIALOG_DEFAULT_COLUMNS}. - */ -const dialogSectionColumns = (sectionColumns: unknown, formColumns: unknown): number => - clampColumns(sectionColumns) ?? clampColumns(formColumns) ?? DIALOG_DEFAULT_COLUMNS; - -/** - * The ONE grid width of a sectioned dialog: the form's own `columns`, else the - * widest section's {@link dialogSectionColumns}. Read by the stacked and - * tabbed layouts and by the dialog's derived size, so the size is chosen for - * the grid that renders. Each section then draws its own count on it through - * `sectionedGridLayout`, which switches to the 12-track grid when the counts - * do not all fit (objectui#12548) — so an undeclared section beside a - * `columns: 3` one draws two per row, not three. - */ -function sectionedDialogColumns( - sections: ReadonlyArray<{ columns?: unknown }>, - formColumns: unknown, -): 1 | 2 | 3 | 4 { - const widest = sections.length - ? Math.max(...sections.map((s) => dialogSectionColumns(s.columns, formColumns))) - : DIALOG_DEFAULT_COLUMNS; - return (clampColumns(formColumns) ?? widest) as 1 | 2 | 3 | 4; -} - export const ModalForm: React.FC = ({ schema, dataSource, @@ -465,7 +434,7 @@ export const ModalForm: React.FC = ({ const effectiveModalSize = useMemo(() => { if (schema.modalSize) return schema.modalSize; const columns = effectiveSections?.length - ? sectionedDialogColumns(effectiveSections, schema.columns) + ? sectionedFormColumns(schema.columns, effectiveSections.map((s) => s.columns)) : (autoLayoutResult?.columns ?? DIALOG_DEFAULT_COLUMNS); const fieldCount = effectiveSections?.length ? effectiveSections.reduce((n, s) => n + (Array.isArray(s.fields) ? s.fields.length : 0), 0) @@ -903,15 +872,14 @@ export const ModalForm: React.FC = ({ sec.name ? sectionLabel(schema.objectName, sec.name, sec.label || sec.name) : sec.label; // The form is ONE grid. Its width is the explicit form `columns`, else the - // widest section's count, an undeclared section counting the dialog's - // default (objectui#12521); each section then lays ITS fields out at its + // widest section's count, an undeclared section counting two + // (objectui#12521, #12592); each section then lays ITS fields out at its // own count within that grid via colSpan (objectstack-ai/objectstack#2578), exactly like the // full-page sectioned form — by the one sectioned-form rule, which // switches to a 12-track grid when the sections' counts do not all fit - // the widest one (objectui#12548). - const sectionColumnsOf = (sec: ModalFormSectionConfig) => dialogSectionColumns(sec.columns, schema.columns); - const formColumns = sectionedDialogColumns(sections, schema.columns); - const sectionGrid = sectionedGridLayout(formColumns, sections.map(sectionColumnsOf)); + // the widest one (objectui#12548). It is handed the AUTHORED counts. + const sectionGrid = sectionedGridLayout(schema.columns, sections.map((sec) => sec.columns)); + const formColumns = sectionGrid.columns; const containerFieldClass = sectionGrid.fieldContainerClass; // FLS first, so a section whose every field is non-readable drops out @@ -942,7 +910,7 @@ export const ModalForm: React.FC = ({ visibleWhen: section.visibleWhen, className: section.className, gridClassName: section.gridClassName, - fields: sectionGrid.layOut(body, sectionColumnsOf(section)), + fields: sectionGrid.layOut(body, section.columns), }; }) .filter((g) => g.fields.length > 0); diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 71d19d3d5d..e743bddfe4 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -46,7 +46,6 @@ import { applyAutoLayout, filterAutoGeneratedFields, filterSystemFields, - inferColumns, sectionedGridLayout, } from './autoLayout'; import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups'; @@ -1632,28 +1631,17 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource const sourceFields = fieldGroupSections ? groupableFields : formFields; // objectstack-ai/objectstack#2578: honour per-section `columns`. The form renders as ONE grid (one // react-hook-form instance); each section lays its OWN fields out at its - // declared density within that grid. Grid width = explicit form `columns`, - // else the widest section, else inferred from field count (the - // fieldGroup-derived path declares no per-section columns and keeps its - // historical inferred multi-column layout). - const clampCol = (n: unknown): number | undefined => - typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; - const declaredSectionCols = effectiveSections - .map(s => clampCol((s as any).columns)) - .filter((c): c is number => c != null); - const approxInputs = effectiveSections.reduce( - (n, s) => n + (Array.isArray(s.fields) ? s.fields.length : 0), 0, - ); - const formColumns = - clampCol(schema.columns) ?? - (declaredSectionCols.length ? Math.max(...declaredSectionCols) : inferColumns(approxInputs)); - // The one sectioned-form rule (objectui#12548): every section at its own - // declared count on the shared grid, 12 tracks wide when the counts do - // not all fit the grid `formColumns` names. + // declared density within that grid. The one sectioned-form rule + // (objectui#12548) is handed the AUTHORED counts and resolves the rest: grid + // width = explicit form `columns`, else the widest section, a section that + // declares nothing — a fieldGroup-derived one included — drawing two per + // row from `@md`, as the dialog does (objectui#12592). 12 tracks wide when + // the counts do not all fit. const sectionGrid = sectionedGridLayout( - formColumns, - effectiveSections.map(s => clampCol((s as any).columns)), + schema.columns, + effectiveSections.map(s => (s as any).columns), ); + const formColumns = sectionGrid.columns; const groupedFields: FormField[] = []; // The section builder every other arm uses (objectui#10475), handed this // arm's parent field POOL: a section's members come out in the section's @@ -1783,8 +1771,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // objectstack-ai/objectstack#2578: lay THIS section's fields out at its declared column density // within the shared form grid (span-aware; wide fields still full-row). - const secCols = clampCol((section as any).columns); - const laid = sectionGrid.layOut(sectionFields, secCols); + const laid = sectionGrid.layOut(sectionFields, (section as any).columns); // Collapsed groups keep their fields registered (values preserved) but // hidden from the DOM. A section with no row is never collapsible, so it diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index 54da7c5070..d881a63fd7 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -490,19 +490,15 @@ export const SplitForm: React.FC = ({ // 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 = (clampCol(schema.columns) - ?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4; - // The one sectioned-form rule (objectui#12548): each section at its own - // count on the shared grid — 12 tracks wide when the counts do not all fit. - const sectionGrid = sectionedGridLayout(formColumns, schema.sections.map((s) => clampCol(s.columns))); + // The one sectioned-form rule (objectui#12548), handed the AUTHORED counts: + // grid width = the form view's own `columns` (spec FormView.columns), else + // the widest section, an undeclared section drawing two per row from `@md` + // (objectui#12592); each section at its own count on the shared grid — 12 + // tracks wide when the counts do not all fit. ModalForm, ObjectForm, + // TabbedForm and WizardForm call the same rule, so one piece of metadata + // lays out the same in every host. + const sectionGrid = sectionedGridLayout(schema.columns, schema.sections.map((s) => s.columns)); + const formColumns = sectionGrid.columns; const containerFieldClass = sectionGrid.fieldContainerClass; /** @@ -531,7 +527,7 @@ export const SplitForm: React.FC = ({ className: section.className, } as any); } - out.push(...sectionGrid.layOut(body, clampCol(section.columns))); + out.push(...sectionGrid.layOut(body, section.columns)); }); return out; }; diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 1152e89e8d..07196060e0 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -580,19 +580,14 @@ export const TabbedForm: React.FC = ({ // carry their own colSpan against the shared grid (sectionFormLayout parity), // never a grid wrapped around the form — that would leave the extra columns // empty (#2128). - 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); - // 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); - // The one sectioned-form rule (objectui#12548): each tab at its own count on - // the shared grid — 12 tracks wide when the counts do not all fit. - const sectionGrid = sectionedGridLayout(formColumns, schema.sections.map((s) => clampCol(s.columns))); + // The one sectioned-form rule (objectui#12548), handed the AUTHORED counts: + // grid width = the form view's own `columns` (spec FormView.columns), else + // the widest section, an undeclared tab drawing two per row from `@md` + // (objectui#12592); each tab at its own count on the shared grid — 12 tracks + // wide when the counts do not all fit. Every sectioned host calls the same + // rule, so one piece of metadata lays out the same in every host. + const sectionGrid = sectionedGridLayout(schema.columns, schema.sections.map((s) => s.columns)); + const formColumns = sectionGrid.columns; const containerFieldClass = sectionGrid.fieldContainerClass; const tabGroups = schema.sections.map((section, index) => { @@ -606,7 +601,7 @@ export const TabbedForm: React.FC = ({ // BOTH synthesis paths below can read it — the tab arm and the sub-two-tab // degradation — instead of each re-deriving it from `schema.sections`. visibleWhen: section.visibleWhen, - fields: sectionGrid.layOut(body, clampCol(section.columns)), + fields: sectionGrid.layOut(body, section.columns), }; }); diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 0d0e1eef3e..b95293f29a 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -1227,13 +1227,13 @@ export const WizardForm: React.FC = ({ } const currentSection = schema.sections[currentStep]; - const clampCol = (n: unknown): number | undefined => - typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; - const stepGridColumns = clampCol(schema.columns) ?? clampCol(currentSection?.columns) ?? 1; // The one sectioned-form rule (objectui#12548), over the one section this - // step draws: a form-level grid wider than the step's own count goes 12 - // tracks wide, so the step still draws its own count per row. - const stepGrid = sectionedGridLayout(stepGridColumns, [clampCol(currentSection?.columns)]); + // step draws, handed the AUTHORED counts: a form-level grid wider than the + // step's own count goes 12 tracks wide, so the step still draws its own count + // per row, and a step that declares nothing draws two per row from `@md`, as + // the dialog does (objectui#12592). + const stepGrid = sectionedGridLayout(schema.columns, [currentSection?.columns]); + const stepGridColumns = stepGrid.columns; const stepFieldContainerClass = stepGrid.fieldContainerClass; // The current step's child `form` node, typed as the node it is @@ -1255,7 +1255,7 @@ export const WizardForm: React.FC = ({ // Unlike the tabbed/split hosts there is no widest-section // fallback: wizard steps never share a viewport, so there is no // shared grid to size, and each step keeps its authored width. - fields: stepGrid.layOut(currentSectionFields, clampCol(currentSection?.columns)), + fields: stepGrid.layOut(currentSectionFields, currentSection?.columns), columns: stepGridColumns, ...(stepFieldContainerClass ? { fieldContainerClass: stepFieldContainerClass } : {}), layout: 'vertical', diff --git a/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx b/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx index f49f3ba337..0cc6fc89d9 100644 --- a/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx +++ b/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx @@ -296,10 +296,23 @@ describe('objectui#12548 — sectionedGridLayout (the one rule)', () => { expect(grid.layOut([f()], 2)[0].colSpan).toBe(6); expect(grid.layOut([f()], 3)[0].colSpan).toBe(4); expect(grid.layOut([f()], 1)[0].colSpan).toBe(12); - // A section that declares nothing fills the grid, as it always did. + // A section that declares nothing fills the form's DECLARED grid, as it + // always did; with no form `columns` it draws two (objectui#12592). expect(grid.layOut([f()], undefined)[0].colSpan).toBe(4); }); + it('an undeclared section draws two when the form declares no columns (objectui#12592)', () => { + expect(sectionedGridLayout(undefined, [undefined, undefined]).fieldContainerClass).toBe( + CONTAINER_GRID_COLS[2], + ); + const mixed = sectionedGridLayout(undefined, [undefined, 3]); + expect(mixed.columns).toBe(3); + expect(mixed.fieldContainerClass).toBe(TWELVE_TRACK_GRID_COLS); + expect(mixed.layOut([f()], undefined)[0].colSpan).toBe(6); + // A declared 1 is drawn as written, never read as undeclared. + expect(sectionedGridLayout(undefined, [1, 1]).fieldContainerClass).toBeUndefined(); + }); + it("span: 'full' and a wide widget take the row; an authored colSpan keeps winning", () => { const grid = sectionedGridLayout(3, [2, 3]); expect(grid.layOut([f({ span: 'full' })], 3)[0].colSpan).toBe(12); diff --git a/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx b/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx new file mode 100644 index 0000000000..54ee9ef742 --- /dev/null +++ b/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx @@ -0,0 +1,212 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12592 — a section that declares no `columns` draws on every page + * host as it does in the dialog: two per row from the container's `@md` tier, + * one below it. + * + * `@objectstack/spec` dropped `FormSectionSchema.columns`' `default(1)` + * (objectstack-ai/objectstack#23740), so an undeclared section reaches every + * sectioned host. The fork on objectui#12521 (ruled 6204264665) made an + * undeclared density the renderer's choice by available width; `ModalForm` + * drew it two per row, while `ObjectForm` drew it by field count + * (`inferColumns`) and `SplitForm`, `TabbedForm` and `WizardForm` one per row + * or at the widest declared count. The rule now lives once, in + * `sectionedGridLayout` (`autoLayout.ts`), and every host hands it the authored + * counts. A declared `columns` — `1` included — is drawn as written. + * + * These pins read the class attributes the real renderer draws, per host, and + * compare each page host with the dialog, because the claim is about what + * renders. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { registerAllFields } from '@object-ui/fields'; +import '@object-ui/components'; +import { ModalForm } from '../ModalForm'; +import { ObjectForm } from '../ObjectForm'; +import { SplitForm } from '../SplitForm'; +import { TabbedForm } from '../TabbedForm'; +import { WizardForm } from '../WizardForm'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const MORE = Array.from({ length: 12 }, (_, i) => `x${i}`); +const FIELD_NAMES = ['c1', 'c2', 'c3', 'c4', 'full', 'q1', 'q2', 'q3', ...MORE]; + +const makeDataSource = (): any => ({ + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'lead', + fields: Object.fromEntries( + FIELD_NAMES.map((n) => [n, { type: 'text', label: `Field ${n}` }]), + ), + }), + create: vi.fn().mockResolvedValue({ id: '1' }), + update: vi.fn().mockResolvedValue({ id: '1' }), + findOne: vi.fn().mockResolvedValue({}), +}); + +type Section = { name: string; label: string; columns?: number; fields: unknown[] }; +type Host = 'modal' | 'object' | 'split' | 'tabbed' | 'wizard'; + +const PAGE_HOSTS: Exclude[] = ['object', 'split', 'tabbed', 'wizard']; + +function mount(host: Host, sections: Section[], extra: Record = {}) { + const schema = { + type: 'object-form', + objectName: 'lead', + mode: 'create', + title: 'New Lead', + sections, + ...extra, + } as any; + const ds = makeDataSource(); + switch (host) { + case 'modal': + return render(); + case 'object': + return render(); + case 'split': + return render(); + case 'tabbed': + return render(); + case 'wizard': + return render(); + } +} + +/** The grid classes of the container holding `name`, and that field's own span classes. */ +function readField(name: string): { grid: string[]; span: string[] } { + const cell = document.body.querySelector(`[data-field="${name}"]`); + if (!cell) throw new Error(`field not rendered: ${name}`); + const tokens = (el: Element | null) => (el?.getAttribute('class') || '').split(/\s+/).filter(Boolean); + return { + grid: tokens(cell.parentElement).filter((t) => /(^|:)grid-cols-/.test(t)), + span: tokens(cell).filter((t) => /(^|:)col-span-/.test(t)), + }; +} + +async function settle(name: string) { + await waitFor(() => expect(document.body.querySelector(`[data-field="${name}"]`)).not.toBeNull()); +} + +// A field's count per row at a container tier: (tracks in force) / (its span +// in force). Tailwind's prefixes are min-width, so the class in force at a +// tier is the widest-prefixed one at or below it; below `@md` the base class. +const TIERS = ['@md', '@2xl', '@4xl'] as const; +function perRowAt(read: { grid: string[]; span: string[] }, tier: (typeof TIERS)[number]): number { + const upTo = TIERS.slice(0, TIERS.indexOf(tier) + 1); + const inForce = (list: string[], stem: string) => { + let v: number | undefined; + for (const t of upTo) { + const hit = list.find((c) => c.startsWith(`${t}:${stem}`)); + if (hit) v = Number(hit.split('-').pop()); + } + return v; + }; + const tracks = inForce(read.grid, 'grid-cols-') ?? 1; + const span = inForce(read.span, 'col-span-') ?? 1; + return tracks / span; +} +const ladder = (read: { grid: string[]; span: string[] }) => TIERS.map((t) => perRowAt(read, t)); + +/** What the dialog draws for `name` under `sections` — the reference every page host must match. */ +async function dialogReading(sections: Section[], name: string) { + mount('modal', sections); + await settle(name); + const read = readField(name); + cleanup(); + return read; +} + +/** Two undeclared sections. 16 fields, so `inferColumns` would have said four. */ +const UNDECLARED: Section[] = [ + { name: 'contact', label: 'Contact', fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3', 'c4'] }, + { name: 'more', label: 'More', fields: ['q1', ...MORE] }, +]; + +/** Undeclared first (the wizard's first step), then a declared `columns: 3`. */ +const MIXED: Section[] = [ + { name: 'contact', label: 'Contact', fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3', 'c4'] }, + { name: 'classification', label: 'Classification', columns: 3, fields: ['q1', 'q2', 'q3'] }, +]; + +describe('objectui#12592 — an undeclared section draws as the dialog does, on every page host', () => { + for (const host of PAGE_HOSTS) { + it(`${host}: every section undeclared → two per row from @md, one below`, async () => { + const reference = await dialogReading(UNDECLARED, 'c1'); + mount(host, UNDECLARED); + await settle('c1'); + const c1 = readField('c1'); + + expect(c1.grid, 'one column below @md').toContain('grid-cols-1'); + expect(ladder(c1), '@md / @2xl / @4xl').toEqual([2, 2, 2]); + expect(perRowAt(readField('full'), '@4xl'), "span: 'full' takes the row").toBe(1); + // Byte for byte the dialog's classes, not merely the same count. + expect(c1).toEqual(reference); + }); + + it(`${host}: an undeclared section beside a declared columns: 3 one still draws two`, async () => { + const reference = await dialogReading(MIXED, 'c1'); + mount(host, MIXED); + await settle('c1'); + const c1 = readField('c1'); + expect(ladder(c1)).toEqual([2, 2, 2]); + expect(ladder(c1)).toEqual(ladder(reference)); + if (host === 'wizard') { + // A wizard draws one step per grid, so the step never shares the + // dialog's 12-track grid with its declared neighbour: same count per + // row, on the plain two-column grid. + expect(c1.grid).toEqual(['grid-cols-1', '@md:grid-cols-2']); + } else { + expect(c1).toEqual(reference); + // The declared neighbour keeps its own count. + expect(ladder(readField('q1'))).toEqual([2, 3, 3]); + } + }); + } +}); + +/** + * CONTROL — a declared `columns` is drawn as written on every host, `1` + * included: the undeclared rule never reaches a section that declares. + */ +describe('objectui#12592 CONTROL — declared sections keep their declared count', () => { + const ONES: Section[] = [ + { name: 'contact', label: 'Contact', columns: 1, fields: ['c1', 'c2', 'c3', 'c4'] }, + { name: 'more', label: 'More', columns: 1, fields: ['q1', ...MORE] }, + ]; + const THREES: Section[] = [ + { name: 'contact', label: 'Contact', columns: 3, fields: ['c1', 'c2', 'c3', 'c4'] }, + { name: 'more', label: 'More', columns: 3, fields: ['q1', 'q2', 'q3'] }, + ]; + for (const host of PAGE_HOSTS) { + it(`${host}: columns: 1 stays one per row`, async () => { + mount(host, ONES); + await settle('c1'); + expect(ladder(readField('c1'))).toEqual([1, 1, 1]); + }); + + it(`${host}: columns: 3 keeps its 2 → 3 ladder`, async () => { + mount(host, THREES); + await settle('c1'); + expect(ladder(readField('c1'))).toEqual([2, 3, 3]); + }); + + it(`${host}: a declared form columns fills an undeclared section, as it always did`, async () => { + mount(host, UNDECLARED, { columns: 3 }); + await settle('c1'); + expect(ladder(readField('c1'))).toEqual([2, 3, 3]); + }); + } +}); diff --git a/packages/plugin-form/src/autoLayout.ts b/packages/plugin-form/src/autoLayout.ts index afd645b53a..d240f93c97 100644 --- a/packages/plugin-form/src/autoLayout.ts +++ b/packages/plugin-form/src/autoLayout.ts @@ -345,14 +345,60 @@ export function containerGridColsFor(columns: number | undefined): string | unde */ export const TWELVE_TRACK_GRID_COLS = 'grid gap-4 grid-cols-1 @md:grid-cols-12'; +/** + * How many fields per row a section draws where neither it nor its form + * declares `columns` (objectui#12592): two from the container's `@md` tier up, + * one below it, so a phone keeps its single column. + * + * An undeclared density is the renderer's choice by available width, and a + * declared one — `1` included — is drawn as written (the section-`columns` + * fork on objectui#12521, ruled 6204264665; `@objectstack/spec` dropped + * `FormSectionSchema.columns`' `default(1)` in objectstack-ai/objectstack#23740, + * so an undeclared section reaches every host). The container query makes + * "available width" literal: the same section draws one per row in a narrow + * drawer or on a phone, and two in a dialog or a page. + */ +export const UNDECLARED_SECTION_COLUMNS = 2; + +/** An authored column count clamped into the 1–4 grid keys; anything else reads as not declared. */ +function declaredColumns(n: unknown): number | undefined { + return typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; +} + +/** + * One section's fields per row: its declared `columns` as written, else the + * form's declared `columns` (what an undeclared section always filled), else + * {@link UNDECLARED_SECTION_COLUMNS}. + */ +export function sectionColumnsFor(sectionColumns: unknown, formColumns: unknown): number { + return declaredColumns(sectionColumns) ?? declaredColumns(formColumns) ?? UNDECLARED_SECTION_COLUMNS; +} + +/** + * The ONE grid width of a sectioned form: the form's declared `columns`, else + * the widest section's {@link sectionColumnsFor}. + * + * @param formColumns The form's DECLARED `columns` (`undefined` when none). + * @param sectionColumns The DECLARED `columns` of every section the form draws. + */ +export function sectionedFormColumns( + formColumns: unknown, + sectionColumns: ReadonlyArray, +): 1 | 2 | 3 | 4 { + const widest = sectionColumns.length + ? Math.max(...sectionColumns.map((c) => sectionColumnsFor(c, formColumns))) + : UNDECLARED_SECTION_COLUMNS; + return (declaredColumns(formColumns) ?? widest) as 1 | 2 | 3 | 4; +} + /** The field grid a sectioned host draws, and how it lays out one section's fields on it. */ export interface SectionedGridLayout { /** The form's grid width in the 1–4 vocabulary (`FormSchema.columns`). */ - columns: number; + columns: 1 | 2 | 3 | 4; /** The field container's grid classes; `undefined` for a one-column form. */ fieldContainerClass: string | undefined; - /** Lay one section's fields out at that section's declared `columns`. */ - layOut: (fields: FormField[], sectionColumns?: number) => FormField[]; + /** Lay one section's fields out at that section's DECLARED `columns` (`undefined` when none). */ + layOut: (fields: FormField[], sectionColumns?: unknown) => FormField[]; } /** @@ -382,16 +428,22 @@ export interface SectionedGridLayout { * A section's count is clamped to the grid width, as it always was: the form * view's own `columns` outranks how a section fills it. * - * @param formColumns The form's grid width (form `columns`, else the widest section). - * @param sectionColumns The declared `columns` of every section the form draws. + * Every host hands it what the AUTHOR wrote, never a count it resolved itself + * (objectui#12592): an undeclared section and an undeclared form resolve here, + * through {@link sectionColumnsFor} and {@link sectionedFormColumns}, so a + * section that declares nothing draws {@link UNDECLARED_SECTION_COLUMNS} per + * row in the dialog and on every page host alike. + * + * @param formColumns The form's DECLARED `columns` (`undefined` when none). + * @param sectionColumns The DECLARED `columns` of every section the form draws. */ export function sectionedGridLayout( - formColumns: number | undefined, - sectionColumns: ReadonlyArray, + formColumns: unknown, + sectionColumns: ReadonlyArray, ): SectionedGridLayout { - const grid = clampGrid(formColumns); - const density = (declared: number | undefined) => - Math.min(clampGrid(declared ?? grid), grid); + const grid = sectionedFormColumns(formColumns, sectionColumns); + const density = (declared: unknown) => + Math.min(sectionColumnsFor(declared, formColumns), grid); const fits = sectionColumns.every((c) => { const d = density(c); return d === 1 || d === grid; @@ -402,7 +454,7 @@ export function sectionedGridLayout( columns: grid, fieldContainerClass: containerGridColsFor(grid), layOut: (fields, declared) => - grid > 1 ? applyAutoColSpan(fields, grid, declared) : fields, + grid > 1 ? applyAutoColSpan(fields, grid, density(declared)) : fields, }; }