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
22 changes: 22 additions & 0 deletions .changeset/splitform-section-pane.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
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,
})),
Expand Down
31 changes: 27 additions & 4 deletions packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -229,9 +238,23 @@ export const SplitForm: React.FC<SplitFormProps> = ({
}
}, [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;
Expand Down
86 changes: 86 additions & 0 deletions packages/plugin-form/src/sectionedFormValues.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
sections: PANED_SECTIONS,
}}
dataSource={makeDataSource() as any}
/>,
);

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(
<SplitForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
// Same sections, reversed — the hazard the key exists to kill: with
// the positional rule this reorder silently relaid the whole form.
sections: [...PANED_SECTIONS].reverse(),
}}
dataSource={makeDataSource() as any}
/>,
);

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(
<ObjectForm
schema={{
type: 'object-form',
formType: 'split',
objectName: 'case',
mode: 'create',
sections: [
{ name: 'triage', label: 'Triage', pane: 'secondary', fields: ['status'] },
{ name: 'basics', label: 'Basics', pane: 'primary', fields: ['subject'] },
],
} as any}
dataSource={makeDataSource() as any}
/>,
);

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();
Expand Down
12 changes: 11 additions & 1 deletion packages/types/src/objectql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading