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
30 changes: 30 additions & 0 deletions .changeset/adaptive-record-surface-2578.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@object-ui/plugin-form": minor
"@object-ui/plugin-view": minor
"@object-ui/app-shell": minor
"@object-ui/types": minor
---

feat: adaptive record surface + semantic field span + responsive columns (framework#2578)

Field-heavy objects (all metadata is AI-authored) now present themselves without
any authored presentation config:

- **Adaptive surface** — a record's create/edit/detail opens as a full page when
the object is field-heavy, or a drawer when it is light. Derived from field
count (`deriveRecordSurface`), not authored; mobile always pages. Wired into the
app-shell ObjectView detail navigation (an authored view/object `navigation`
still wins).
- **Semantic field span** — `FormField.span` (`auto`/`full`) is a width primitive
decoupled from the (per-surface derived) column count; legacy `colSpan` is
clamped so it never overflows. `ObjectForm` now honours per-section `columns`
and carries `span`/`colSpan` from section defs — fixes the bug where
`type:'simple'` ignored `section.columns` and grouped fields rendered single
column.
- **Responsive columns** — `inferColumns` scales the column CAP with field count
(≤3→1, ≤8→2, ≤15→3, 16+→4); the ACTUAL column count follows the form's real
width via CSS container queries, so the same form goes 1→2→3→4 columns as a
drawer widens or becomes a page.
- **Runtime overlay width** — `NavigationConfig.size` bucket is resolved to a
viewport-clamped width at runtime (`overlayWidthFor`); a pixel width is never
authored (the author cannot know the client viewport).
29 changes: 24 additions & 5 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const ImportWizard = lazy(() =>
);
import { ListView } from '@object-ui/plugin-list';
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view';
import type { ViewTabItem } from '@object-ui/plugin-view';
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
Expand Down Expand Up @@ -955,10 +955,29 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
// is drawer-by-default. Per-view config can still override (e.g. a heavy
// detail object can set `navigation.mode = 'page'`).
const detailNavigation: ViewNavigationConfig = useMemo(
() =>
activeView?.navigation ??
objectDef.navigation ?? { mode: 'drawer', width: 'min(92vw, 1280px)' },
[activeView?.navigation, objectDef.navigation]
() => {
const authored = activeView?.navigation ?? objectDef.navigation;
if (authored) {
// Authored config wins. For an overlay, resolve the `size`
// bucket (or 'auto') to a viewport-clamped width when no explicit
// width was given — #2578: a pixel width can't be authored blind.
if (authored.mode === 'page') return authored;
return {
...authored,
width: authored.width ?? overlayWidthFor(
(authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size,
objectDef,
),
};
}
// #2578: derive surface + width from FIELD COUNT. Field-heavy → full
// page (cramped in a drawer); light → a drawer sized to the content
// and clamped to the viewport. Mobile always pages (in deriveRecordSurface).
return deriveRecordSurface(objectDef) === 'page'
? { mode: 'page' }
: { mode: 'drawer', width: overlayWidthFor('auto', objectDef) };
},
[activeView?.navigation, objectDef]
);
const drawerRecordId = searchParams.get('recordId');

Expand Down
63 changes: 43 additions & 20 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,23 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
// Derived (fieldGroup) sections were computed from the filtered field list;
// explicit sections keep the authored field selection as-is.
const sourceFields = fieldGroupSections ? groupableFields : formFields;
// #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));
const groupedFields: FormField[] = [];
effectiveSections.forEach((section, index) => {
// Section field defs may carry a per-field `visibleOn` predicate (spec
Expand All @@ -753,8 +770,17 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
const sectionFields = applyFieldPerms(sourceFields.filter(f => sectionFieldNames.includes(f.name)))
.map(f => {
const def = sectionDefByName.get(f.name);
const visibleOn = def && typeof def === 'object' ? (def as any).visibleOn : undefined;
return visibleOn != null ? ({ ...f, visibleOn } as FormField) : f;
if (!def || typeof def !== 'object') return f;
// Carry the section field def's layout/visibility overrides onto the
// resolved field — the name-only filter above would otherwise drop
// them. #2578: `span`/`colSpan` are how a section controls per-field
// width; #2212: `visibleOn`.
const d = def as any;
const merged: any = { ...f };
if (d.visibleOn != null) merged.visibleOn = d.visibleOn;
if (d.colSpan != null) merged.colSpan = d.colSpan;
if (d.span != null) merged.span = d.span;
return merged as FormField;
});
if (sectionFields.length === 0) return;

Expand All @@ -779,30 +805,27 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
} as FormField);
}

// #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;

// Collapsed groups keep their fields registered (values preserved) but
// hidden from the DOM. An untitled bucket is never collapsible.
if (label && isCollapsed) {
groupedFields.push(...sectionFields.map(f => ({ ...f, hidden: true })));
groupedFields.push(...laid.map(f => ({ ...f, hidden: true })));
} else {
groupedFields.push(...sectionFields);
groupedFields.push(...laid);
}
});

// Multi-column layout parity with the flat path: infer a column count from
// the rendered field count and let wide fields (textarea/markdown/…) span
// the full row. The field grid uses container-query classes — with the
// @container wrapper below — so it tracks the form's own width: a grouped
// form in a wide dialog goes 2-up while a narrow drawer stays stacked.
// Section dividers span the full row via their own `col-span-full`.
const inputCount = groupedFields.filter(
f => (f as any).type !== 'section-divider' && !(f as any).hidden,
).length;
const columns =
schema.columns && schema.columns > 0
? Math.min(Math.floor(schema.columns), 4)
: inferColumns(inputCount);
const laidOutFields = columns > 1 ? applyAutoColSpan(groupedFields, columns) : groupedFields;
const fieldContainerClass = containerGridColsFor(columns);
// Per-section colSpan was applied in the loop above — each section at its
// own density within the shared `formColumns` grid. The field grid uses
// container-query classes (with the @container wrapper below), so it tracks
// 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);

return (
<div className="w-full @container">
Expand All @@ -812,7 +835,7 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
objectName: schema.objectName,
fields: laidOutFields,
layout: formLayout,
columns,
columns: formColumns,
...(fieldContainerClass ? { fieldContainerClass } : {}),
defaultValues: finalDefaultValues,
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
Expand Down
70 changes: 68 additions & 2 deletions packages/plugin-form/src/__tests__/autoLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
inferColumns,
inferModalSize,
applyAutoColSpan,
resolveColSpan,
filterCreateModeFields,
filterSystemFields,
applyAutoLayout,
Expand Down Expand Up @@ -64,10 +65,13 @@ describe('autoLayout', () => {
expect(inferColumns(3)).toBe(1);
});

it('returns 2 columns for 4+ fields', () => {
it('scales the column CAP with field count (#2578)', () => {
expect(inferColumns(4)).toBe(2);
expect(inferColumns(8)).toBe(2);
expect(inferColumns(20)).toBe(2);
expect(inferColumns(9)).toBe(3);
expect(inferColumns(15)).toBe(3);
expect(inferColumns(16)).toBe(4);
expect(inferColumns(60)).toBe(4);
});
});

Expand Down Expand Up @@ -446,4 +450,66 @@ describe('autoLayout', () => {
expect(result.columns).toBe(1);
});
});

describe('resolveColSpan (#2578 — span-aware, clamping)', () => {
it("span: 'full' spans the whole row at any grid width", () => {
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', span: 'full' } as any, 2)).toBe(2);
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', span: 'full' } as any, 4)).toBe(4);
});

it('clamps a legacy colSpan to the current grid (never overflows)', () => {
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', colSpan: 4 } as any, 2)).toBe(2);
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', colSpan: 3 } as any, 4)).toBe(3);
});

it("span: 'auto' (default) gives wide widgets the full row, scalars one cell", () => {
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:textarea' } as any, 2)).toBe(2);
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 2)).toBe(1);
});

it('honours a section density narrower than the grid', () => {
// grid 2, section wants 1 column → each field takes the whole row
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 2, 1)).toBe(2);
// grid 4, section wants 2 columns → each field takes half
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 4, 2)).toBe(2);
});
});

describe('applyAutoColSpan with per-section density (#2578)', () => {
it('lays a 1-column section out full-width within a 2-column grid', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:text' },
];
expect(applyAutoColSpan(fields, 2, 1).map(f => f.colSpan)).toEqual([2, 2]);
});

it('leaves a 2-column section at one cell per field in a 2-column grid', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:text' },
];
expect(applyAutoColSpan(fields, 2, 2).map(f => f.colSpan)).toEqual([undefined, undefined]);
});

it("respects span: 'full' on a scalar field", () => {
const fields: FormField[] = [
{ name: 'summary', label: 'Summary', type: 'field:text', span: 'full' } as any,
{ name: 'code', label: 'Code', type: 'field:text' },
];
const result = applyAutoColSpan(fields, 2, 2);
expect(result[0].colSpan).toBe(2);
expect(result[1].colSpan).toBeUndefined();
});

it('leaves section-divider rows untouched but fills their fields', () => {
const fields: FormField[] = [
{ name: '__section_x', label: 'X', type: 'section-divider', colSpan: 4 } as any,
{ name: 'a', label: 'A', type: 'field:text' },
];
const result = applyAutoColSpan(fields, 2, 1);
expect(result[0].colSpan).toBe(4); // divider unchanged
expect(result[1].colSpan).toBe(2); // field filled to full row
});
});
});
98 changes: 80 additions & 18 deletions packages/plugin-form/src/autoLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,36 +78,98 @@ export function isAutoGeneratedFieldType(type: string): boolean {
}

/**
* Infer optimal number of columns based on the number of visible fields
* and whether any wide fields are present.
* Infer the MAX column count from the field count (#2578). This is an UPPER
* BOUND — the actual column count at render time is decided by the CSS
* container queries in {@link CONTAINER_GRID_COLS} against the form's REAL
* width, so a narrow drawer stays 1–2 columns while the SAME form on a wide
* page spreads out to this cap. The cap scales with field count so a light
* form is never thinned across too many columns, while a field-heavy record
* can use the full width.
*
* Rules:
* - 0-3 fields → 1 column
* - 4+ fields → 2 columns
* Rules (upper bound; container queries clamp to the actual width):
* - 0–3 fields → 1 column
* - 4–8 fields → 2 columns
* - 9–15 fields → 3 columns
* - 16+ fields → 4 columns
*/
export function inferColumns(fieldCount: number): number {
if (fieldCount <= 3) return 1;
return 2;
if (fieldCount <= 8) return 2;
if (fieldCount <= 15) return 3;
return 4;
}

/** Clamp a raw column-ish number into 1..4 (the grid keys we support). */
function clampGrid(n: number | undefined): number {
return n && n > 0 ? Math.min(Math.floor(n), 4) : 1;
}

/**
* Apply colSpan to wide fields so they span the full row.
* Only sets colSpan if the field does not already have one explicitly set.
* Resolve the effective colSpan (grid cells) a field occupies, given the form's
* grid width and the section's desired column count (#2578).
*
* @returns A new array of fields with colSpan applied where needed.
* The column count is derived per surface (mobile 1 / modal 2 / page 3-4), so
* the robust field-width primitive is the relative `span`, NOT an absolute
* colSpan. Precedence:
* 1. `span: 'full'` → whole row (grid cells).
* 2. legacy `colSpan` → honoured but CLAMPED to the grid (never overflows).
* 3. wide widget type → whole row (textarea/markdown/… — the `span:'auto'` default).
* 4. section density → grid / sectionColumns, so `sectionColumns` fields fill a row.
*
* `sectionColumns` defaults to the grid width, i.e. one field per cell (the flat
* form's historical behaviour) when there is no narrower section.
*/
export function applyAutoColSpan(fields: FormField[], columns: number): FormField[] {
if (columns <= 1) return fields;
export function resolveColSpan(
field: FormField,
gridColumns: number,
sectionColumns?: number,
): number {
const grid = clampGrid(gridColumns);
if (grid <= 1) return 1;

const span = (field as { span?: 'auto' | 'full' }).span;
if (span === 'full') return grid;

// Legacy absolute colSpan — honoured but clamped so it can never overflow the
// current grid (the fragility `span` exists to replace).
if (field.colSpan != null) {
return Math.min(Math.max(1, Math.floor(field.colSpan)), grid);
}

return fields.map(field => {
// User-defined colSpan takes priority
if (field.colSpan !== undefined) return field;
// `span: 'auto'` (or unset): wide widgets take the whole row.
if (field.type && isWideFieldType(field.type)) return grid;

// Wide field types should span full row
if (field.type && isWideFieldType(field.type)) {
return { ...field, colSpan: columns };
}
// Otherwise fill the section's density: N per row → grid/N cells each.
const secCols = Math.min(Math.max(1, Math.floor(sectionColumns || grid)), grid);
return Math.min(grid, Math.max(1, Math.round(grid / secCols)));
}

/**
* Apply the resolved colSpan to each field so the form's CSS grid lays them out
* at the intended density. Span-aware and clamping (#2578); wide fields still
* span the full row (the `span: 'auto'` default). `section-divider` rows are
* left untouched (they carry their own full-row span).
*
* @param gridColumns The form's grid column count (1-4).
* @param sectionColumns The desired columns for THIS group of fields (defaults
* to the grid width = one field per cell).
* @returns A new array of fields with colSpan applied where needed.
*/
export function applyAutoColSpan(
fields: FormField[],
gridColumns: number,
sectionColumns?: number,
): FormField[] {
const grid = clampGrid(gridColumns);
if (grid <= 1) return fields;

return fields.map(field => {
if ((field as { type?: string }).type === 'section-divider') return field;
const eff = resolveColSpan(field, grid, sectionColumns);
if (eff > 1) return { ...field, colSpan: eff };
// eff === 1: strip any stale larger colSpan so it doesn't overflow a
// narrower grid than the author imagined.
if (field.colSpan != null && field.colSpan !== 1) return { ...field, colSpan: 1 };
return field;
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-form/src/sectionFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function normalizeSectionField(
if (fd.immutable != null) base.immutable = fd.immutable;
if (fd.hidden != null) base.hidden = fd.hidden;
if (fd.colSpan != null) base.colSpan = fd.colSpan;
if (fd.span != null) base.span = fd.span;
if (fd.options != null) base.options = fd.options;
if (fd.multiple != null) base.multiple = fd.multiple;
if (fd.reference != null) base.reference = fd.reference;
Expand Down
Loading
Loading