From 35c873dedbf02969a77e22145700a7c551715dd8 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:13:19 +0800 Subject: [PATCH] feat(form): SplitForm honours the spec's new `FormSection.pane` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split form's panel assignment was a hardcoded positional rule — first section left, everything else right (slice(0,1)/slice(1)). The rule was invisible in the metadata: nothing in the JSON records the assignment, so reordering sections silently moved them across the divider, and an author (human or AI) 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 (it is the "aligns with spec FormSection" runtime type). The spec side rejects `pane` on non-split form types at parse, so the key can never be an accepted-but-ignored no-op. Tests (verified failing against the unfixed renderer first — 3 red / 9 green): sections group by declared pane not position (two sections side by side in the primary pane, previously inexpressible); reversing the section array changes nothing; ObjectForm forwards the key through its mapping. No compile-time dependency on the new spec version — the key arrives as plain JSON — but objectstack#4160 merges first per cross-repo convention. Co-Authored-By: Claude Opus 5 --- .changeset/splitform-section-pane.md | 22 +++++ content/docs/plugins/plugin-form.mdx | 7 +- packages/plugin-form/README.md | 8 +- packages/plugin-form/src/ObjectForm.tsx | 4 + packages/plugin-form/src/SplitForm.tsx | 31 ++++++- .../src/sectionedFormValues.test.tsx | 86 +++++++++++++++++++ packages/types/src/objectql.ts | 12 ++- 7 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 .changeset/splitform-section-pane.md 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 */