diff --git a/.changeset/splitform-section-pane.md b/.changeset/splitform-section-pane.md new file mode 100644 index 000000000..9e009ac2a --- /dev/null +++ b/.changeset/splitform-section-pane.md @@ -0,0 +1,22 @@ +--- +"@object-ui/types": minor +"@object-ui/plugin-form": minor +--- + +feat(form): `SplitForm` honours the spec's new `FormSection.pane` + +A split form's panel assignment was a hardcoded positional rule — first section +left, everything else right. The rule was invisible in the metadata, so +reordering sections silently moved them across the divider, and an author could +not place two sections in the left pane at all. + +Sections now declare their panel: `pane: 'primary' | 'secondary'` +(@objectstack/spec `FormSection.pane`, objectstack#4160). Placement follows the +key, not the array position — reordering paned sections never changes the +layout. Omitted keys keep the exact legacy rule (first section `primary`, rest +`secondary`), so existing metadata renders unchanged. + +`ObjectForm`'s split dispatch copies the key through its per-key section mapping +(the path that once silently dropped `visibleOn`), and `ObjectFormSection` +declares it. The spec side rejects `pane` on non-split form types at parse, so +the key can never be an accepted-but-ignored no-op. diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 38ed3a250..8bd156ec6 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -145,8 +145,11 @@ divider. - Fields no pane claims render above the panel group rather than disappearing. - Needs at least two panes; ignored when the form uses `children` or `fieldTabs`. -`SplitForm` builds on this: section 1 becomes the primary pane, the rest stack in -the secondary one behind inline section headers. +`SplitForm` builds on this. Each section declares its panel via `pane: 'primary' +| 'secondary'` (spec `FormSection.pane`) — explicit placement that survives +reordering. When omitted, the legacy rule applies: section 1 becomes the primary +pane, the rest stack in the secondary one behind inline section headers. (The +spec rejects `pane` on non-split form types at parse.) ## Usage diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index b484fb4bf..77cef9e50 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -182,8 +182,12 @@ divider. - Fields no pane claims render above the panel group rather than disappearing. - Needs at least two panes; ignored when the form uses `children` or `fieldTabs`. -`SplitForm` is built on this: section 1 becomes the primary pane, the rest stack -in the secondary one behind inline section headers. +`SplitForm` is built on this. Each section declares its panel via `pane: +'primary' | 'secondary'` (spec `FormSection.pane`) — explicit per-section +placement, so reordering sections never silently moves them across the divider. +When omitted, the legacy positional rule applies: the first section becomes the +primary pane, the rest stack in the secondary one behind inline section headers. +(The spec rejects `pane` on non-split form types at parse.) ## Examples diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 87dbec881..692df9f30 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -268,6 +268,10 @@ export const ObjectForm: React.FC = ({ description: s.description, columns: s.columns, fields: s.fields, + // Explicit pane placement (spec FormSection.pane). This mapping + // rebuilds each section key by key, so a key it doesn't copy is + // silently dropped — exactly how `visibleOn` once vanished here. + pane: s.pane, className: (s as any).className, gridClassName: (s as any).gridClassName, })), diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index 6accee80c..7c1f250e5 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -10,7 +10,9 @@ * SplitForm Component * * A form variant that displays sections in a resizable split-panel layout. - * The first section renders in the left/top panel, remaining sections in the right/bottom panel. + * Each section declares its panel via `pane` (spec FormSection.pane); when + * omitted the legacy positional rule applies — the first section renders in the + * left/top panel, every other section in the right/bottom one. * Aligns with @objectstack/spec FormView type: 'split' * * Both panels are ONE form (#2153). The panel group is a layout the form @@ -33,6 +35,13 @@ export interface SplitFormSectionConfig { label?: string; description?: string; columns?: 1 | 2 | 3 | 4; + /** + * Which panel this section renders in. Aligns with @objectstack/spec + * FormSection.pane — explicit per-section placement, so reordering sections + * never silently moves them across the divider. Omitted → the legacy + * positional rule (first section 'primary', every other 'secondary'). + */ + pane?: 'primary' | 'secondary'; fields: (string | FormField)[]; /** Custom CSS class for the section's header row. */ className?: string; @@ -229,9 +238,23 @@ export const SplitForm: React.FC = ({ } }, [schema]); - // Split sections: first section in panel 1, rest in panel 2 - const leftSections = useMemo(() => schema.sections.slice(0, 1), [schema.sections]); - const rightSections = useMemo(() => schema.sections.slice(1), [schema.sections]); + // Which panel each section renders in: explicit `section.pane` first (spec + // FormSection.pane), else the legacy positional rule — first section primary, + // rest secondary — so keyless metadata keeps its exact layout. Placement + // follows the KEY, not the array position: reordering sections with panes + // declared never moves them across the divider. + const paneOf = (section: SplitFormSectionConfig, index: number): 'primary' | 'secondary' => + section.pane === 'primary' || section.pane === 'secondary' + ? section.pane + : index === 0 ? 'primary' : 'secondary'; + const leftSections = useMemo( + () => schema.sections.filter((s, i) => paneOf(s, i) === 'primary'), + [schema.sections], + ); + const rightSections = useMemo( + () => schema.sections.filter((s, i) => paneOf(s, i) === 'secondary'), + [schema.sections], + ); const direction = schema.splitDirection || 'horizontal'; const panelSize = schema.splitSize || 50; diff --git a/packages/plugin-form/src/sectionedFormValues.test.tsx b/packages/plugin-form/src/sectionedFormValues.test.tsx index a6c24b3ec..735acede7 100644 --- a/packages/plugin-form/src/sectionedFormValues.test.tsx +++ b/packages/plugin-form/src/sectionedFormValues.test.tsx @@ -37,6 +37,7 @@ import { registerAllFields } from '@object-ui/fields'; import { ModalForm } from './ModalForm'; import { TabbedForm } from './TabbedForm'; import { SplitForm } from './SplitForm'; +import { ObjectForm } from './ObjectForm'; registerAllFields(); @@ -370,6 +371,91 @@ describe('SplitForm — one form across BOTH panels (#2153)', () => { }); }); +describe('SplitForm — explicit `section.pane` placement (spec FormSection.pane)', () => { + const paneEl = (key: string) => + document.body.querySelector(`[data-testid="form-pane:${key}"]`)!; + + const PANED_SECTIONS = [ + // Declared order deliberately disagrees with the panes: the SECOND and + // THIRD sections are the primary pane. Impossible to express before — + // the renderer hardcoded first-section-left / rest-right. + { name: 'triage', label: 'Triage', pane: 'secondary' as const, fields: ['status'] }, + { name: 'basics', label: 'Basics', pane: 'primary' as const, fields: ['subject'] }, + { name: 'detail', label: 'Detail', pane: 'primary' as const, fields: ['description'] }, + ]; + + it('groups sections by their declared pane, not by array position', async () => { + render( + , + ); + + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + + // Two sections side by side in the primary pane… + expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy(); + expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy(); + // …and the first-declared section sits in the secondary pane. + expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy(); + expect(paneEl('secondary').querySelector('[data-field="subject"]')).toBeNull(); + }); + + it('reordering sections does not move them across the divider', async () => { + render( + , + ); + + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + + expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy(); + expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy(); + expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy(); + }); + + it('ObjectForm forwards `pane` through its split mapping', async () => { + // The dispatch mapping rebuilds sections key by key — a key it does not + // copy is silently dropped (how `visibleOn` once vanished). Pin the copy. + render( + , + ); + + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy(); + expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy(); + }); +}); + describe('TabbedForm — one form for all tabs (#2959)', () => { it('submits every tab’s values after the user moves between tabs', async () => { const dataSource = makeDataSource(); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index ef15a7657..197a7bd70 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -805,7 +805,17 @@ export interface ObjectFormSection { * @default 1 */ columns?: 1 | 2 | 3 | 4; - + + /** + * Which panel of a split form this section renders in. Aligns with + * @objectstack/spec FormSection.pane (split forms only — the spec rejects the + * key on other form types at parse). Explicit per-section placement, so + * reordering sections never silently moves them across the divider. + * Omitted → the legacy positional rule: first section 'primary', every + * other section 'secondary'. + */ + pane?: 'primary' | 'secondary'; + /** * Field names or inline field configurations for this section */