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
7 changes: 7 additions & 0 deletions .changeset/12592-undeclared-section-density.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 19 additions & 51 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -284,56 +286,23 @@ const modalSizeClasses: Record<string, string> = {
* 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<ModalFormProps> = ({
schema,
dataSource,
Expand Down Expand Up @@ -465,7 +434,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
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)
Expand Down Expand Up @@ -903,15 +872,14 @@ export const ModalForm: React.FC<ModalFormProps> = ({
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
Expand Down Expand Up @@ -942,7 +910,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
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);
Expand Down
33 changes: 10 additions & 23 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import {
applyAutoLayout,
filterAutoGeneratedFields,
filterSystemFields,
inferColumns,
sectionedGridLayout,
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 10 additions & 14 deletions packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,19 +490,15 @@ export const SplitForm: React.FC<SplitFormProps> = ({
// 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;

/**
Expand Down Expand Up @@ -531,7 +527,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
className: section.className,
} as any);
}
out.push(...sectionGrid.layOut(body, clampCol(section.columns)));
out.push(...sectionGrid.layOut(body, section.columns));
});
return out;
};
Expand Down
23 changes: 9 additions & 14 deletions packages/plugin-form/src/TabbedForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,19 +580,14 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
// 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) => {
Expand All @@ -606,7 +601,7 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
// 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),
};
});

Expand Down
14 changes: 7 additions & 7 deletions packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1227,13 +1227,13 @@ export const WizardForm: React.FC<WizardFormProps> = ({
}

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
Expand All @@ -1255,7 +1255,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
// 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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading