From 88bfef068f5fde76624a9b0e5de76f2dde7b60e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:56:33 +0000 Subject: [PATCH] fix(plugin-form): each form section draws its own columns count per row (objectui#12548) A sectioned form draws every section in one field grid sized by the widest section, and each field spanned Math.round(grid / sectionColumns) cells. A section whose count did not fit that grid drew the wrong number per row: a columns: 2 section beside a columns: 3 one drew one field per row, and a columns: 3 section beside a columns: 4 one drew four. The five sectioned hosts now share one rule, sectionedGridLayout in autoLayout.ts. A form whose sections are all 1 or the grid width keeps its grid and spans byte for byte. Otherwise the grid is 12 tracks from @md and a field spans twelfths of the row; the components form renderer draws a twelfths colSpan with each count's existing step ladder (2 per row at @md, 3 at @2xl, 4 at @4xl). Claude-Session: https://claude.ai/code/session_01DN3fpUznSTqNMkk9nXd5BG Co-authored-by: Claude --- .../12548-section-columns-per-section.md | 17 + .../form-twelve-track-span-12548.test.tsx | 86 +++++ .../components/src/renderers/form/form.tsx | 58 +++- packages/plugin-form/src/ModalForm.tsx | 12 +- packages/plugin-form/src/ObjectForm.tsx | 14 +- packages/plugin-form/src/SplitForm.tsx | 13 +- packages/plugin-form/src/TabbedForm.tsx | 11 +- packages/plugin-form/src/WizardForm.tsx | 12 +- .../mixedSectionColumns-12548.test.tsx | 321 ++++++++++++++++++ packages/plugin-form/src/autoLayout.ts | 103 ++++++ 10 files changed, 620 insertions(+), 27 deletions(-) create mode 100644 .changeset/12548-section-columns-per-section.md create mode 100644 packages/components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx create mode 100644 packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx diff --git a/.changeset/12548-section-columns-per-section.md b/.changeset/12548-section-columns-per-section.md new file mode 100644 index 0000000000..5872a80751 --- /dev/null +++ b/.changeset/12548-section-columns-per-section.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-form': patch +'@object-ui/components': patch +--- + +A sectioned create or edit form draws every section at its own `columns` count per row, whatever the other sections declare (objectui#12548). + +`@objectstack/spec` `FormSectionSchema.columns` is a per-section count, but all of a form's sections share one field grid, sized by the widest section, and each field spanned `Math.round(grid / sectionColumns)` cells of it. A section whose count did not fit that grid drew the wrong number per row. A `columns: 2` section beside a `columns: 3` one spanned 2 of 3 cells, so it drew one field per row and left the right third empty. A `columns: 3` section beside a `columns: 4` one spanned 1 of 4 cells, so it drew four per row. + +`ModalForm`, `ObjectForm`, `SplitForm`, `TabbedForm` and `WizardForm` now share one rule: + +- When every section's count is 1 or the grid's own width, the form keeps the grid and spans it always had, class for class. +- Otherwise the field grid is 12 tracks wide from `@md` (`grid-cols-1 @md:grid-cols-12`). Each field spans 12 / 6 / 4 / 3 twelfths of the row for a 1 / 2 / 3 / 4-column section. `span: 'full'` and wide widgets take the row, and an authored `colSpan` counts its section's columns. + +The form renderer draws a twelfths `colSpan` with the step ladder each count has on its own grid: at most two per row at `@md`, three at `@2xl` and four at `@4xl`. Below `@md` the form stays one column. + +No type, prop or export changes. A form with no section `columns` is unchanged. diff --git a/packages/components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx b/packages/components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx new file mode 100644 index 0000000000..e5d027f9fc --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx @@ -0,0 +1,86 @@ +/** + * 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#12548 — the col-span classes the form renderer emits for a + * twelfths `colSpan` on a 12-track field container. + * + * `plugin-form` draws a sectioned form whose sections' `columns` counts do not + * all fit one 1–4 grid on `grid gap-4 grid-cols-1 @md:grid-cols-12`, with + * each field's `colSpan` in twelfths of the row (12 / 6 / 4 / 3 for a + * 1 / 2 / 3 / 4-column section). This file pins the emission itself; the + * cross-layer leg, per host, is + * `plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx`. The + * container class is written out as a literal for the reason the 9244 sibling + * gives: components must not depend on plugin-form. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import '../../../renderers'; + +afterEach(() => cleanup()); + +const TWELVE = 'grid gap-4 grid-cols-1 @md:grid-cols-12'; + +function spanClasses(colSpan: number | undefined, fieldContainerClass: string): string[] { + cleanup(); + const Form = ComponentRegistry.get('form')!; + render( +
, + ); + const cell = document.body.querySelector('[data-field="f"]'); + if (!cell) throw new Error('field not rendered: f'); + return (cell.getAttribute('class') || '').split(/\s+/).filter((t) => /(^|:)col-span-/.test(t)); +} + +describe('objectui#12548 — twelfths on a 12-track container keep each count’s step ladder', () => { + it('a whole row (1 per row) is 12 from @md', () => { + expect(spanClasses(12, TWELVE)).toEqual(['@md:col-span-12']); + }); + + it('2 per row is 6 from @md', () => { + expect(spanClasses(6, TWELVE)).toEqual(['@md:col-span-6']); + }); + + it('3 per row reads 2 at @md and 3 from @2xl', () => { + expect(spanClasses(4, TWELVE)).toEqual(['@md:col-span-6', '@2xl:col-span-4']); + }); + + it('4 per row reads 2 at @md, 3 at @2xl and 4 from @4xl', () => { + expect(spanClasses(3, TWELVE)).toEqual(['@md:col-span-6', '@2xl:col-span-4', '@4xl:col-span-3']); + }); + + it('2 of 3 and 3 of 4 take the row until their count fits', () => { + expect(spanClasses(8, TWELVE)).toEqual(['@md:col-span-12', '@2xl:col-span-8']); + expect(spanClasses(9, TWELVE)).toEqual(['@md:col-span-12', '@4xl:col-span-9']); + }); + + it('clamps an oversized colSpan to the row, and leaves an unset one at one track', () => { + expect(spanClasses(20, TWELVE)).toEqual(['@md:col-span-12']); + expect(spanClasses(undefined, TWELVE)).toEqual([]); + }); + + it('CONTROL: the 1–4 containers emit what they always did', () => { + const three = 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3'; + expect(spanClasses(3, three)).toEqual(['@md:col-span-2', '@2xl:col-span-3']); + expect(spanClasses(6, three)).toEqual(['@md:col-span-2', '@2xl:col-span-3']); + expect(spanClasses(2, three)).toEqual(['@md:col-span-2']); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 3664bbb220..b067131cdc 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -240,6 +240,60 @@ const BARE_SPAN_CLASS: Record = { 4: 'col-span-4', }; +/** + * The tier class of a 12-track field container — one column below `@md`, + * twelve tracks from `@md` up. `@object-ui/plugin-form` draws a sectioned form + * on it when the sections' `columns` counts do not all fit one 1–4 grid + * (objectui#12548), and a field's `colSpan` there is in TWELFTHS of the row. + * + * ⛔ Kept a whole literal: it is also what puts the utility into this + * package's published `style.css`, whose Tailwind scan reads only this + * package's source. + */ +const TWELVE_TRACK_TIER = '@md:grid-cols-12'; + +const isTwelveTrackContainer = (containerClass: string): boolean => + containerClass.split(/\s+/).includes(TWELVE_TRACK_TIER); + +/** + * The col-span classes of a twelfths `colSpan` on a {@link TWELVE_TRACK_TIER} container. + * + * A share of the row that a 1–4 column count yields (12 / 6 / 4 / 3, and the + * 8 and 9 an authored `colSpan` of 2 of 3 or 3 of 4 yields) keeps the step + * ladder the 1–4 container classes give that count: at most two fields per row + * at `@md`, three at `@2xl`, four at `@4xl` — the tiers of + * `grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4`. So a + * 3-column section reads two per row at `@md` and three from `@2xl`, exactly as + * it does on its own grid. A share reduces to lowest terms `k / d` and draws + * `min(k, p) / p` of the row at a tier allowing `p = min(d, tier)` per row. + * Other twelfths are no 1–4 count's share and are drawn as written. + * + * Every tier earns a class from `@md`, because the base tier is one column and + * the twelve-track tiers have no cell narrower than a twelfth to fall back to. + * + * ⛔ Literals, never assembled: Tailwind's scanner reads source text. + */ +const TWELVE_TRACK_SPAN_CLASS: Record = { + 1: '@md:col-span-1', + 2: '@md:col-span-2', + 3: '@md:col-span-6 @2xl:col-span-4 @4xl:col-span-3', + 4: '@md:col-span-6 @2xl:col-span-4', + 5: '@md:col-span-5', + 6: '@md:col-span-6', + 7: '@md:col-span-7', + 8: '@md:col-span-12 @2xl:col-span-8', + 9: '@md:col-span-12 @4xl:col-span-9', + 10: '@md:col-span-10', + 11: '@md:col-span-11', + 12: '@md:col-span-12', +}; + +/** {@link TWELVE_TRACK_SPAN_CLASS} for a field's `colSpan`; none set ⇒ one track, as the grid draws it. */ +function twelveTrackSpanClass(colSpan: number | undefined): string { + if (!colSpan || !(colSpan >= 1)) return ''; + return TWELVE_TRACK_SPAN_CLASS[Math.min(Math.floor(colSpan), 12)]; +} + /** * The col-span classes a field must carry to occupy `targetCols` cells of * `containerClass` — ONE CLASS PER TIER, not one class for the widest tier @@ -3328,7 +3382,9 @@ ComponentRegistry.register('form', const pickSpanClass = (targetCols: number): string => spanLadderFor(containerClass, targetCols); - const colSpanClass = colSpan && colSpan > 1 + const colSpanClass = isTwelveTrackContainer(containerClass) + ? twelveTrackSpanClass(colSpan) + : colSpan && colSpan > 1 ? colSpan === 2 ? pickSpanClass(2) : colSpan === 3 ? pickSpanClass(3) : colSpan >= 4 ? pickSpanClass(4) diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 7b295fa7d3..7d4829841f 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -48,6 +48,7 @@ import { filterSystemFields, inferColumns, inferModalSize, + sectionedGridLayout, CONTAINER_GRID_COLS, } from './autoLayout'; import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups'; @@ -834,7 +835,9 @@ export const ModalForm: React.FC = ({ // The form is ONE grid. Its width is the explicit form `columns`, else the // widest section; each section then lays ITS fields out at its own // declared density within that grid via colSpan (objectstack-ai/objectstack#2578), exactly like the - // full-page sectioned form. + // 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 clampCol = (n: unknown): number | undefined => typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; const declaredCols = sections @@ -842,7 +845,8 @@ export const ModalForm: React.FC = ({ .filter((c): c is number => c != null); const formColumns = (clampCol(schema.columns) ?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4; - const containerFieldClass = CONTAINER_GRID_COLS[formColumns]; + const sectionGrid = sectionedGridLayout(formColumns, sections.map((s) => clampCol(s.columns))); + const containerFieldClass = sectionGrid.fieldContainerClass; // FLS first, so a section whose every field is non-readable drops out // entirely (header included) instead of leaving an empty group. @@ -872,9 +876,7 @@ export const ModalForm: React.FC = ({ visibleWhen: section.visibleWhen, className: section.className, gridClassName: section.gridClassName, - fields: formColumns > 1 - ? applyAutoColSpan(body, formColumns, clampCol(section.columns)) - : body, + fields: sectionGrid.layOut(body, clampCol(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 35bf1b3eb0..71d19d3d5d 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -44,11 +44,10 @@ import { ModalForm } from './ModalForm'; import { MasterDetailForm } from './MasterDetailForm'; import { applyAutoLayout, - applyAutoColSpan, - containerGridColsFor, filterAutoGeneratedFields, filterSystemFields, inferColumns, + sectionedGridLayout, } from './autoLayout'; import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups'; import { mergeCustomFields } from './customFieldsMerge'; @@ -1648,6 +1647,13 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource 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. + const sectionGrid = sectionedGridLayout( + formColumns, + effectiveSections.map(s => clampCol((s as any).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 @@ -1778,7 +1784,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 = formColumns > 1 ? applyAutoColSpan(sectionFields, formColumns, secCols) : sectionFields; + const laid = sectionGrid.layOut(sectionFields, secCols); // Collapsed groups keep their fields registered (values preserved) but // hidden from the DOM. A section with no row is never collapsible, so it @@ -1796,7 +1802,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource // the form's own width: a grouped form in a wide dialog goes multi-column // while a narrow drawer stays stacked. Section dividers span the full row. const laidOutFields = groupedFields; - const fieldContainerClass = containerGridColsFor(formColumns); + const fieldContainerClass = sectionGrid.fieldContainerClass; return ( diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index dc5261306c..54da7c5070 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -35,7 +35,7 @@ import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWri import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { snapshotLoadedRecord, advanceLoadedRecord, type LoadedRecordSnapshot } from './sanitize'; import { formWritePayload } from './writePayload'; -import { applyAutoColSpan, containerGridColsFor } from './autoLayout'; +import { sectionedGridLayout } from './autoLayout'; import { useOccSave } from './occSave'; import { NO_LOAD_FAILURES, @@ -500,7 +500,10 @@ export const SplitForm: React.FC = ({ .filter((c): c is number => c != null); const formColumns = (clampCol(schema.columns) ?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4; - const containerFieldClass = containerGridColsFor(formColumns); + // 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))); + const containerFieldClass = sectionGrid.fieldContainerClass; /** * One panel's field list: each section contributes an inline `section-divider` @@ -528,11 +531,7 @@ export const SplitForm: React.FC = ({ className: section.className, } as any); } - out.push( - ...(formColumns > 1 - ? applyAutoColSpan(body, formColumns, clampCol(section.columns)) - : body), - ); + out.push(...sectionGrid.layOut(body, clampCol(section.columns))); }); return out; }; diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 7b8687eb4b..1152e89e8d 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -25,7 +25,7 @@ import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWri import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { snapshotLoadedRecord, advanceLoadedRecord, type LoadedRecordSnapshot } from './sanitize'; import { formWritePayload } from './writePayload'; -import { applyAutoColSpan, containerGridColsFor } from './autoLayout'; +import { sectionedGridLayout } from './autoLayout'; import { useOccSave } from './occSave'; import { NO_LOAD_FAILURES, @@ -590,7 +590,10 @@ export const TabbedForm: React.FC = ({ // 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); + // 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))); + const containerFieldClass = sectionGrid.fieldContainerClass; const tabGroups = schema.sections.map((section, index) => { const body = buildSectionFields(section); @@ -603,9 +606,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: formColumns > 1 - ? applyAutoColSpan(body, formColumns, clampCol(section.columns)) - : body, + fields: sectionGrid.layOut(body, clampCol(section.columns)), }; }); diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 9c3ecf1b47..0d0e1eef3e 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -29,7 +29,7 @@ import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWri import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { snapshotLoadedRecord, advanceLoadedRecord, type LoadedRecordSnapshot } from './sanitize'; import { formWritePayload } from './writePayload'; -import { applyAutoColSpan, containerGridColsFor } from './autoLayout'; +import { sectionedGridLayout } from './autoLayout'; import { resolveSuccessNavigate, type SubmitBehavior } from './successBehavior'; import { resolveSubmitRedirect, submitRedirectScope } from './submitRedirect'; import { @@ -1230,7 +1230,11 @@ export const WizardForm: React.FC = ({ 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; - const stepFieldContainerClass = containerGridColsFor(stepGridColumns); + // 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)]); + const stepFieldContainerClass = stepGrid.fieldContainerClass; // The current step's child `form` node, typed as the node it is // (objectui#11354). `SchemaRenderer`'s `schema` slot is `BaseSchema`, so a @@ -1251,9 +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: stepGridColumns > 1 - ? applyAutoColSpan(currentSectionFields, stepGridColumns, clampCol(currentSection?.columns)) - : currentSectionFields, + fields: stepGrid.layOut(currentSectionFields, clampCol(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 new file mode 100644 index 0000000000..f49f3ba337 --- /dev/null +++ b/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx @@ -0,0 +1,321 @@ +/** + * 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#12548 — every authored section renders its OWN `columns` count of + * fields per row, whatever the other sections declare. + * + * `@objectstack/spec` `FormSectionSchema.columns` (`1 | 2 | 3 | 4`) is a + * per-section column count. A sectioned host draws all its sections in ONE + * field grid (one form, one react-hook-form instance), and that grid used to be + * sized by the widest section, with every field spanning + * `Math.round(grid / sectionColumns)` cells. Whenever a section's count did not + * divide the grid, the section drew the wrong number per row: a `columns: 2` + * section on a 3-wide grid spans 2 of 3 cells — ONE field per row — and on a + * 4-wide grid a `columns: 3` section spans 1 cell — FOUR per row. + * + * The rule now (`sectionedGridLayout` in `autoLayout.ts`, called by all five + * hosts): a form whose sections all divide its grid keeps that grid, byte for + * byte; otherwise the grid is 12 tracks wide from `@md` and a field spans + * twelfths of the row. These pins read the class attributes the real renderer + * draws, per host, 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 { ComponentRegistry } from '@object-ui/core'; +import type { FormField } from '@object-ui/types'; +import '@object-ui/components'; +import { + CONTAINER_GRID_COLS, + TWELVE_TRACK_GRID_COLS, + applyAutoColSpan, + sectionedGridLayout, +} from '../autoLayout'; +import { ModalForm } from '../ModalForm'; +import { ObjectForm } from '../ObjectForm'; +import { SplitForm } from '../SplitForm'; +import { TabbedForm } from '../TabbedForm'; +import { WizardForm } from '../WizardForm'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const FIELD_NAMES = ['c1', 'c2', 'c3', 'c4', 'full', 'q1', 'q2', 'q3', 'd1', 'd2']; + +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[] }; + +/** HotCRM `crm_lead`'s shape, reduced: a 2 / 3 / 1 mixed form with a `span: 'full'` field. */ +const MIXED: Section[] = [ + { name: 'contact', label: 'Contact', columns: 2, fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3', 'c4'] }, + { name: 'classification', label: 'Classification', columns: 3, fields: ['q1', 'q2', 'q3'] }, + { name: 'notes', label: 'Notes', columns: 1, fields: ['d1', 'd2'] }, +]; + +const single = (columns: number): Section[] => [ + { name: 'contact', label: 'Contact', columns, fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3'] }, + { name: 'classification', label: 'Classification', columns, fields: ['q1', 'q2', 'q3'] }, +]; + +type Host = 'modal' | '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()); +} + +// The multi-column tiers of a container-query grid, narrowest first. A +// field's count per row at a tier is (tracks in force there) / (its span in +// force there); Tailwind's prefixes are min-width, so the class in force at a +// tier is the widest-prefixed one at or below it. +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 HOSTS: Host[] = ['modal', 'object', 'split', 'tabbed']; + +describe('objectui#12548 — a 2 / 3 / 1 mixed form draws 2 / 3 / 1 per row', () => { + for (const host of HOSTS) { + it(`${host}: each section at its own count, span: 'full' takes the row`, async () => { + mount(host, MIXED); + await settle('d2'); + const c1 = readField('c1'); + const q1 = readField('q1'); + const d1 = readField('d1'); + const full = readField('full'); + + // Below `@md` the grid is one column (the base class), whatever the section. + expect(c1.grid).toContain('grid-cols-1'); + + // At the widest tier each section reads its authored count. + expect(perRowAt(c1, '@4xl'), 'columns: 2 section').toBe(2); + expect(perRowAt(q1, '@4xl'), 'columns: 3 section').toBe(3); + expect(perRowAt(d1, '@4xl'), 'columns: 1 section').toBe(1); + expect(perRowAt(full, '@4xl'), "span: 'full'").toBe(1); + + // `@md` is where multi-column starts: two per row there for the 2- and + // 3-column sections (the 3-column step ladder 2 → 3 at `@2xl` is kept). + expect(perRowAt(c1, '@md')).toBe(2); + expect(perRowAt(q1, '@md')).toBe(2); + expect(perRowAt(q1, '@2xl')).toBe(3); + expect(perRowAt(c1, '@2xl')).toBe(2); + expect(perRowAt(d1, '@md')).toBe(1); + expect(perRowAt(full, '@md')).toBe(1); + }); + } + + it('wizard: a form-level grid wider than the step draws the step at its own count', async () => { + // A wizard shows one section per step and sizes its grid from the form + // `columns`, else the step's own — so a mix only arises under a form-level + // `columns` wider than the step. + mount('wizard', MIXED, { columns: 3 }); + await settle('c4'); + expect(perRowAt(readField('c1'), '@4xl')).toBe(2); + expect(perRowAt(readField('c1'), '@md')).toBe(2); + expect(perRowAt(readField('full'), '@4xl')).toBe(1); + }); + + it('a 3-column section beside a 4-column one draws three per row, not four', async () => { + mount('modal', [ + { name: 'a', label: 'A', columns: 4, fields: ['c1', 'c2', 'c3', 'c4'] }, + { name: 'b', label: 'B', columns: 3, fields: ['q1', 'q2', 'q3'] }, + ]); + await settle('q3'); + expect(perRowAt(readField('q1'), '@4xl')).toBe(3); + // The 4-column section keeps its own step ladder: 2 → 3 → 4. + expect(perRowAt(readField('c1'), '@md')).toBe(2); + expect(perRowAt(readField('c1'), '@2xl')).toBe(3); + expect(perRowAt(readField('c1'), '@4xl')).toBe(4); + }); +}); + +/** + * CONTROL — a single-density form keeps the grid it always had. The literal + * classes below are the ones `main` drew before objectui#12548; they must not + * move. + */ +describe('objectui#12548 CONTROL — single-density forms are byte-identical', () => { + const BEFORE: Record = { + 2: { grid: ['grid-cols-1', '@md:grid-cols-2'], plain: [], full: ['@md:col-span-2'] }, + 3: { + grid: ['grid-cols-1', '@md:grid-cols-2', '@2xl:grid-cols-3'], + plain: [], + full: ['@md:col-span-2', '@2xl:col-span-3'], + }, + }; + for (const host of HOSTS) { + for (const columns of [2, 3]) { + it(`${host}: all sections columns: ${columns}`, async () => { + mount(host, single(columns)); + await settle('q3'); + const c1 = readField('c1'); + expect(c1.grid).toEqual(BEFORE[columns].grid); + expect(c1.span).toEqual(BEFORE[columns].plain); + expect(readField('q1').span).toEqual(BEFORE[columns].plain); + expect(readField('full').span).toEqual(BEFORE[columns].full); + }); + } + } +}); + +describe('objectui#12548 — the 12-track ladder matches the 1–4 grids, density by density', () => { + // The drift guard between the two halves of this card: the renderer's + // twelfths table hard-codes the step ladder of the 1–4 container classes, + // so read the tiers out of `CONTAINER_GRID_COLS[4]` and require every + // density to draw `min(columns, tier)` per row at each of them. + const tiers = Array.from(CONTAINER_GRID_COLS[4]!.matchAll(/(@\w+):grid-cols-(\d+)/g)).map((m) => ({ + tier: m[1] as (typeof TIERS)[number], + cols: Number(m[2]), + })); + + it('reads the same three tiers this file measures', () => { + expect(tiers.map((t) => t.tier)).toEqual([...TIERS]); + }); + + for (const columns of [1, 2, 3, 4]) { + it(`a columns: ${columns} section on the 12-track grid`, () => { + const grid = sectionedGridLayout(4, [4, 3, 2, 1]); + expect(grid.fieldContainerClass).toBe(TWELVE_TRACK_GRID_COLS); + const [field] = grid.layOut([{ name: 'x', label: 'X', type: 'input' }] as FormField[], columns); + const Form = ComponentRegistry.get('form')!; + render( + , + ); + for (const { tier, cols } of tiers) { + expect(perRowAt(readField('x'), tier), `${tier}`).toBe(Math.min(columns, cols)); + } + }); + } +}); + +describe('objectui#12548 — sectionedGridLayout (the one rule)', () => { + const f = (over: Partial & Record = {}) => + ({ name: 'x', label: 'X', type: 'input', ...over }) as FormField; + + it('keeps the 1–4 grid and applyAutoColSpan when every section is 1 or the grid width', () => { + for (const [cols, sections] of [ + [2, [2, 2]], + [3, [3, 1, undefined]], + [4, [4, 1]], + ] as const) { + const grid = sectionedGridLayout(cols, sections); + expect(grid.fieldContainerClass).toBe(CONTAINER_GRID_COLS[cols]); + const fields = [f(), f({ name: 'y', span: 'full' }), f({ name: 'z', colSpan: 2 })]; + for (const s of sections) { + expect(grid.layOut(fields, s)).toEqual(applyAutoColSpan(fields, cols, s)); + } + } + }); + + it('leaves a one-column form alone', () => { + const grid = sectionedGridLayout(1, [1]); + expect(grid.fieldContainerClass).toBeUndefined(); + const fields = [f()]; + expect(grid.layOut(fields, 1)).toBe(fields); + }); + + it('switches to twelfths when a section count is neither 1 nor the grid width', () => { + const grid = sectionedGridLayout(3, [2, 3, 1]); + expect(grid.fieldContainerClass).toBe(TWELVE_TRACK_GRID_COLS); + expect(grid.columns).toBe(3); + 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. + expect(grid.layOut([f()], undefined)[0].colSpan).toBe(4); + }); + + 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); + expect(grid.layOut([f({ type: 'field:textarea' })], 2)[0].colSpan).toBe(12); + expect(grid.layOut([f({ colSpan: 2 })], 3)[0].colSpan).toBe(8); + // Clamped to the section's own columns. + expect(grid.layOut([f({ colSpan: 4 })], 2)[0].colSpan).toBe(12); + }); + + it('clamps a section count to the form grid — the form view outranks it', () => { + const grid = sectionedGridLayout(2, [4]); + expect(grid.fieldContainerClass).toBe(CONTAINER_GRID_COLS[2]); + }); + + it('never touches a section-divider row', () => { + const divider = { name: '__s', type: 'section-divider', colSpan: 4 } as unknown as FormField; + expect(sectionedGridLayout(3, [2, 3]).layOut([divider], 2)[0]).toBe(divider); + }); +}); diff --git a/packages/plugin-form/src/autoLayout.ts b/packages/plugin-form/src/autoLayout.ts index f97afabe20..2635fef127 100644 --- a/packages/plugin-form/src/autoLayout.ts +++ b/packages/plugin-form/src/autoLayout.ts @@ -328,6 +328,109 @@ export function containerGridColsFor(columns: number | undefined): string | unde return CONTAINER_GRID_COLS[cols]; } +/** + * The field grid of a sectioned form whose sections do not all fit one grid + * (objectui#12548): one column below `@md`, twelve tracks from `@md` up. + * + * Twelve is the least count every authored section density divides + * (`@objectstack/spec` `FormSectionSchema.columns` is `1 | 2 | 3 | 4`), so in + * this grid a field's colSpan is in TWELFTHS of the row: 12 / 6 / 4 / 3 for a + * 1 / 2 / 3 / 4-column section. The form renderer draws a twelfths colSpan as + * one class per tier, keeping each density's step ladder from + * {@link CONTAINER_GRID_COLS} (at most two per row at `@md`, three at `@2xl`, + * four at `@4xl`); its table is `TWELVE_TRACK_SPAN_CLASS` in + * `@object-ui/components`' form renderer. + * + * ⛔ A literal, never assembled: Tailwind's scanner reads source text. + */ +export const TWELVE_TRACK_GRID_COLS = 'grid gap-4 grid-cols-1 @md:grid-cols-12'; + +/** 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; + /** 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[]; +} + +/** + * The ONE layout rule of a sectioned form — `ModalForm`, `ObjectForm`, + * `SplitForm`, `TabbedForm` and `WizardForm` all call it (objectui#12548). + * + * Every section shares the form's single field grid (one form, one + * react-hook-form instance — #2153/#2959), and every authored section must + * still draw its OWN `columns` count per row: `FormSectionSchema.columns` is a + * per-section count, whatever the other sections declare. + * + * - When every section's count is 1 or the grid's own width, the grid is + * {@link CONTAINER_GRID_COLS}' and the spans are {@link applyAutoColSpan}'s, + * byte for byte what a single-density form always drew: on that grid a + * one-column section's fields take the row and a full-width section's take + * one cell each, which is exactly their count. + * - Otherwise a section's count does not fit that grid at some tier — a + * `columns: 2` section on a 3-wide grid spanned 2 of 3 cells, ONE field per + * row, and a `columns: 3` section on a 4-wide grid spanned 1 cell, FOUR per + * row (`Math.round(grid / sectionColumns)`). The grid becomes + * {@link TWELVE_TRACK_GRID_COLS} and each field spans twelfths of the row: + * 1. `span: 'full'`, or a wide widget type → 12, the whole row; + * 2. an authored `colSpan` → that many of its SECTION's columns, clamped + * to them (it keeps winning over the density); + * 3. otherwise → 12 / the section's columns. + * + * 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. + */ +export function sectionedGridLayout( + formColumns: number | undefined, + sectionColumns: ReadonlyArray, +): SectionedGridLayout { + const grid = clampGrid(formColumns); + const density = (declared: number | undefined) => + Math.min(clampGrid(declared ?? grid), grid); + const fits = sectionColumns.every((c) => { + const d = density(c); + return d === 1 || d === grid; + }); + + if (fits) { + return { + columns: grid, + fieldContainerClass: containerGridColsFor(grid), + layOut: (fields, declared) => + grid > 1 ? applyAutoColSpan(fields, grid, declared) : fields, + }; + } + + return { + columns: grid, + fieldContainerClass: TWELVE_TRACK_GRID_COLS, + layOut: (fields, declared) => { + const d = density(declared); + return fields.map((field) => { + if ((field as { type?: string }).type === 'section-divider') return field; + return { ...field, colSpan: twelfthsFor(field, d) }; + }); + }, + }; +} + +/** The twelfths of a row one field spans in a `sectionColumns`-wide section. */ +function twelfthsFor(field: FormField, sectionColumns: number): number { + const span = (field as { span?: 'auto' | 'full' }).span; + if (span === 'full') return 12; + if (field.colSpan != null) { + const cells = Math.min(Math.max(1, Math.floor(field.colSpan)), sectionColumns); + return (12 * cells) / sectionColumns; + } + if (field.type && isWideFieldType(field.type)) return 12; + return 12 / sectionColumns; +} + /** * Build the layout-related props for a *section's* inner form (`SchemaRenderer`). *