Skip to content

Commit 03bd53b

Browse files
os-zhuangclaude
andauthored
feat(form): SplitForm honours the spec's new FormSection.pane (#3041)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b5b97e2 commit 03bd53b

7 files changed

Lines changed: 161 additions & 9 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@object-ui/types": minor
3+
"@object-ui/plugin-form": minor
4+
---
5+
6+
feat(form): `SplitForm` honours the spec's new `FormSection.pane`
7+
8+
A split form's panel assignment was a hardcoded positional rule — first section
9+
left, everything else right. The rule was invisible in the metadata, so
10+
reordering sections silently moved them across the divider, and an author could
11+
not place two sections in the left pane at all.
12+
13+
Sections now declare their panel: `pane: 'primary' | 'secondary'`
14+
(@objectstack/spec `FormSection.pane`, objectstack#4160). Placement follows the
15+
key, not the array position — reordering paned sections never changes the
16+
layout. Omitted keys keep the exact legacy rule (first section `primary`, rest
17+
`secondary`), so existing metadata renders unchanged.
18+
19+
`ObjectForm`'s split dispatch copies the key through its per-key section mapping
20+
(the path that once silently dropped `visibleOn`), and `ObjectFormSection`
21+
declares it. The spec side rejects `pane` on non-split form types at parse, so
22+
the key can never be an accepted-but-ignored no-op.

content/docs/plugins/plugin-form.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,11 @@ divider.
145145
- Fields no pane claims render above the panel group rather than disappearing.
146146
- Needs at least two panes; ignored when the form uses `children` or `fieldTabs`.
147147

148-
`SplitForm` builds on this: section 1 becomes the primary pane, the rest stack in
149-
the secondary one behind inline section headers.
148+
`SplitForm` builds on this. Each section declares its panel via `pane: 'primary'
149+
| 'secondary'` (spec `FormSection.pane`) — explicit placement that survives
150+
reordering. When omitted, the legacy rule applies: section 1 becomes the primary
151+
pane, the rest stack in the secondary one behind inline section headers. (The
152+
spec rejects `pane` on non-split form types at parse.)
150153

151154
## Usage
152155

packages/plugin-form/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,12 @@ divider.
182182
- Fields no pane claims render above the panel group rather than disappearing.
183183
- Needs at least two panes; ignored when the form uses `children` or `fieldTabs`.
184184

185-
`SplitForm` is built on this: section 1 becomes the primary pane, the rest stack
186-
in the secondary one behind inline section headers.
185+
`SplitForm` is built on this. Each section declares its panel via `pane:
186+
'primary' | 'secondary'` (spec `FormSection.pane`) — explicit per-section
187+
placement, so reordering sections never silently moves them across the divider.
188+
When omitted, the legacy positional rule applies: the first section becomes the
189+
primary pane, the rest stack in the secondary one behind inline section headers.
190+
(The spec rejects `pane` on non-split form types at parse.)
187191

188192
## Examples
189193

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
268268
description: s.description,
269269
columns: s.columns,
270270
fields: s.fields,
271+
// Explicit pane placement (spec FormSection.pane). This mapping
272+
// rebuilds each section key by key, so a key it doesn't copy is
273+
// silently dropped — exactly how `visibleOn` once vanished here.
274+
pane: s.pane,
271275
className: (s as any).className,
272276
gridClassName: (s as any).gridClassName,
273277
})),

packages/plugin-form/src/SplitForm.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* SplitForm Component
1111
*
1212
* A form variant that displays sections in a resizable split-panel layout.
13-
* The first section renders in the left/top panel, remaining sections in the right/bottom panel.
13+
* Each section declares its panel via `pane` (spec FormSection.pane); when
14+
* omitted the legacy positional rule applies — the first section renders in the
15+
* left/top panel, every other section in the right/bottom one.
1416
* Aligns with @objectstack/spec FormView type: 'split'
1517
*
1618
* Both panels are ONE form (#2153). The panel group is a layout the form
@@ -33,6 +35,13 @@ export interface SplitFormSectionConfig {
3335
label?: string;
3436
description?: string;
3537
columns?: 1 | 2 | 3 | 4;
38+
/**
39+
* Which panel this section renders in. Aligns with @objectstack/spec
40+
* FormSection.pane — explicit per-section placement, so reordering sections
41+
* never silently moves them across the divider. Omitted → the legacy
42+
* positional rule (first section 'primary', every other 'secondary').
43+
*/
44+
pane?: 'primary' | 'secondary';
3645
fields: (string | FormField)[];
3746
/** Custom CSS class for the section's header row. */
3847
className?: string;
@@ -229,9 +238,23 @@ export const SplitForm: React.FC<SplitFormProps> = ({
229238
}
230239
}, [schema]);
231240

232-
// Split sections: first section in panel 1, rest in panel 2
233-
const leftSections = useMemo(() => schema.sections.slice(0, 1), [schema.sections]);
234-
const rightSections = useMemo(() => schema.sections.slice(1), [schema.sections]);
241+
// Which panel each section renders in: explicit `section.pane` first (spec
242+
// FormSection.pane), else the legacy positional rule — first section primary,
243+
// rest secondary — so keyless metadata keeps its exact layout. Placement
244+
// follows the KEY, not the array position: reordering sections with panes
245+
// declared never moves them across the divider.
246+
const paneOf = (section: SplitFormSectionConfig, index: number): 'primary' | 'secondary' =>
247+
section.pane === 'primary' || section.pane === 'secondary'
248+
? section.pane
249+
: index === 0 ? 'primary' : 'secondary';
250+
const leftSections = useMemo(
251+
() => schema.sections.filter((s, i) => paneOf(s, i) === 'primary'),
252+
[schema.sections],
253+
);
254+
const rightSections = useMemo(
255+
() => schema.sections.filter((s, i) => paneOf(s, i) === 'secondary'),
256+
[schema.sections],
257+
);
235258

236259
const direction = schema.splitDirection || 'horizontal';
237260
const panelSize = schema.splitSize || 50;

packages/plugin-form/src/sectionedFormValues.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { registerAllFields } from '@object-ui/fields';
3737
import { ModalForm } from './ModalForm';
3838
import { TabbedForm } from './TabbedForm';
3939
import { SplitForm } from './SplitForm';
40+
import { ObjectForm } from './ObjectForm';
4041

4142
registerAllFields();
4243

@@ -370,6 +371,91 @@ describe('SplitForm — one form across BOTH panels (#2153)', () => {
370371
});
371372
});
372373

374+
describe('SplitForm — explicit `section.pane` placement (spec FormSection.pane)', () => {
375+
const paneEl = (key: string) =>
376+
document.body.querySelector(`[data-testid="form-pane:${key}"]`)!;
377+
378+
const PANED_SECTIONS = [
379+
// Declared order deliberately disagrees with the panes: the SECOND and
380+
// THIRD sections are the primary pane. Impossible to express before —
381+
// the renderer hardcoded first-section-left / rest-right.
382+
{ name: 'triage', label: 'Triage', pane: 'secondary' as const, fields: ['status'] },
383+
{ name: 'basics', label: 'Basics', pane: 'primary' as const, fields: ['subject'] },
384+
{ name: 'detail', label: 'Detail', pane: 'primary' as const, fields: ['description'] },
385+
];
386+
387+
it('groups sections by their declared pane, not by array position', async () => {
388+
render(
389+
<SplitForm
390+
schema={{
391+
type: 'object-form',
392+
formType: 'split',
393+
objectName: 'case',
394+
mode: 'create',
395+
sections: PANED_SECTIONS,
396+
}}
397+
dataSource={makeDataSource() as any}
398+
/>,
399+
);
400+
401+
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
402+
403+
// Two sections side by side in the primary pane…
404+
expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy();
405+
expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy();
406+
// …and the first-declared section sits in the secondary pane.
407+
expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy();
408+
expect(paneEl('secondary').querySelector('[data-field="subject"]')).toBeNull();
409+
});
410+
411+
it('reordering sections does not move them across the divider', async () => {
412+
render(
413+
<SplitForm
414+
schema={{
415+
type: 'object-form',
416+
formType: 'split',
417+
objectName: 'case',
418+
mode: 'create',
419+
// Same sections, reversed — the hazard the key exists to kill: with
420+
// the positional rule this reorder silently relaid the whole form.
421+
sections: [...PANED_SECTIONS].reverse(),
422+
}}
423+
dataSource={makeDataSource() as any}
424+
/>,
425+
);
426+
427+
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
428+
429+
expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy();
430+
expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy();
431+
expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy();
432+
});
433+
434+
it('ObjectForm forwards `pane` through its split mapping', async () => {
435+
// The dispatch mapping rebuilds sections key by key — a key it does not
436+
// copy is silently dropped (how `visibleOn` once vanished). Pin the copy.
437+
render(
438+
<ObjectForm
439+
schema={{
440+
type: 'object-form',
441+
formType: 'split',
442+
objectName: 'case',
443+
mode: 'create',
444+
sections: [
445+
{ name: 'triage', label: 'Triage', pane: 'secondary', fields: ['status'] },
446+
{ name: 'basics', label: 'Basics', pane: 'primary', fields: ['subject'] },
447+
],
448+
} as any}
449+
dataSource={makeDataSource() as any}
450+
/>,
451+
);
452+
453+
await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy());
454+
expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy();
455+
expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy();
456+
});
457+
});
458+
373459
describe('TabbedForm — one form for all tabs (#2959)', () => {
374460
it('submits every tab’s values after the user moves between tabs', async () => {
375461
const dataSource = makeDataSource();

packages/types/src/objectql.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,17 @@ export interface ObjectFormSection {
805805
* @default 1
806806
*/
807807
columns?: 1 | 2 | 3 | 4;
808-
808+
809+
/**
810+
* Which panel of a split form this section renders in. Aligns with
811+
* @objectstack/spec FormSection.pane (split forms only — the spec rejects the
812+
* key on other form types at parse). Explicit per-section placement, so
813+
* reordering sections never silently moves them across the divider.
814+
* Omitted → the legacy positional rule: first section 'primary', every
815+
* other section 'secondary'.
816+
*/
817+
pane?: 'primary' | 'secondary';
818+
809819
/**
810820
* Field names or inline field configurations for this section
811821
*/

0 commit comments

Comments
 (0)