Skip to content

Commit aa35561

Browse files
os-zhuangclaude
andauthored
fix(form): a split form keeps BOTH panels' values (#2153) (#3012)
SplitForm rendered one SchemaRenderer — one react-hook-form instance and one <form> element — PER section, and its two groups of sections live in separate ResizablePanel children. So each panel owned isolated form state: submitting from one panel's action bar sent only that section's fields and silently dropped everything typed on the other side of the divider. Filling both panels and clicking Create persisted { subject } alone. The same isolation killed cross-panel field rules: a visibleWhen in the right panel referencing a left-panel field never saw that field in its rule record, so the predicate faulted and failed OPEN — the field the author meant to hide was always shown. Both panels are now ONE form, the same way #2959 fixed the tabbed hosts. A single <form> cannot straddle two panels from INSIDE them, so the panel group became a layout the form renderer owns: a new FormSchema.fieldPanes (+ fieldPanesOrientation, fieldPanesResizable) mirroring fieldTabs, where the <form> wraps the whole ResizablePanelGroup and each pane holds only fields. Sections inside a pane render behind the inline section-divider header, each at its own declared column density within the form's shared grid. Also fixed by moving the panels into the renderer: splitResizable: false now actually pins the divider (it only hid the grip before, leaving the separator draggable — nothing passed the panel library's `disabled`). Each pane is its own @container, so a multi-column section collapses to fewer columns as its panel is dragged narrower instead of overflowing. Verified in a real browser (zero-backend console harness): one <form> holding both panes, typing 'urgent' in the left pane reveals the right pane's conditional field, and the submit payload carries every field from both sides. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8997889 commit aa35561

9 files changed

Lines changed: 716 additions & 82 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@object-ui/types": patch
3+
"@object-ui/components": patch
4+
"@object-ui/plugin-form": patch
5+
---
6+
7+
fix(form): a split create/edit form no longer loses the panel you are not submitting from (#2153)
8+
9+
`SplitForm` rendered one `SchemaRenderer` — one react-hook-form instance and one
10+
`<form>` element — **per section**, and its two groups of sections live in
11+
separate resizable panels. So each panel owned isolated form state: submitting
12+
from one panel's action bar sent only that section's fields and silently dropped
13+
everything the user had typed on the other side of the divider. Filling both
14+
panels and clicking Create persisted `{ subject }` alone.
15+
16+
The same isolation killed cross-panel field rules: a `visibleWhen` in the right
17+
panel referencing a left-panel field never saw that field in its record, so the
18+
predicate faulted and failed **open** — the field the author meant to hide was
19+
always shown.
20+
21+
Both panels are now ONE form. The panel group became a layout the form renderer
22+
owns, via a new `FormSchema.fieldPanes` (+ `fieldPanesOrientation`,
23+
`fieldPanesResizable`) that mirrors `fieldTabs` (#2959): the `<form>` wraps the
24+
whole `ResizablePanelGroup` and each pane holds only fields, which is what lets a
25+
single react-hook-form instance span the divider. Sections inside a pane render
26+
behind the inline `section-divider` header, each at its own declared column
27+
density within the form's shared grid.
28+
29+
One more fix falls out of moving the panels into the renderer: `splitResizable:
30+
false` now actually pins the divider. It previously only hid the grip — the
31+
separator stayed draggable, because nothing passed the panel library's
32+
`disabled`.
33+
34+
Each pane is its own `@container`, so a multi-column section collapses to fewer
35+
columns as its panel is dragged narrower instead of overflowing.

content/docs/plugins/plugin-form.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,36 @@ inactive tab unmount along with its values).
9191

9292
`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` build on this.
9393

94+
### Split field layout (`fieldPanes`)
95+
96+
Side-by-side panels follow the same rule: the `<form>` wraps the whole panel
97+
group and each pane holds only fields, so one react-hook-form instance spans the
98+
divider.
99+
100+
```plaintext
101+
{
102+
type: 'form',
103+
fields: [/* every pane's fields, in one flat list */],
104+
fieldPanes: [
105+
{ key: 'primary', fields: ['subject'], defaultSize: 50 },
106+
{ key: 'secondary', fields: ['status', 'priority'], defaultSize: 50, minSize: 20 },
107+
],
108+
fieldPanesOrientation?: 'horizontal', // 'horizontal' | 'vertical'
109+
fieldPanesResizable?: true, // false pins the divider
110+
}
111+
```
112+
113+
- A submit from anywhere carries every pane's values, and a field rule in one
114+
pane can read a field in another — neither works with a form per panel.
115+
- `defaultSize` / `minSize` are percentages of the group.
116+
- Each pane is its own `@container`, so a multi-column group collapses as the
117+
divider is dragged narrower.
118+
- Fields no pane claims render above the panel group rather than disappearing.
119+
- Needs at least two panes; ignored when the form uses `children` or `fieldTabs`.
120+
121+
`SplitForm` builds on this: section 1 becomes the primary pane, the rest stack in
122+
the secondary one behind inline section headers.
123+
94124
## Usage
95125

96126
### Auto-registration (Side-effect Import)
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* #2153: `FormSchema.fieldPanes` — a split field layout inside ONE form.
11+
*
12+
* The sibling of `fieldTabs` (#2959), for side-by-side panels. A `<form>` cannot
13+
* live inside each panel and still be one form, so the renderer puts the `<form>`
14+
* around the whole panel group and the panels hold only fields.
15+
*
16+
* The contract these pin:
17+
* - one `<form>` / react-hook-form instance across every pane, so a submit
18+
* carries all of them and a field rule can read across the divider;
19+
* - a field no pane claims is still rendered (never silently dropped);
20+
* - the pane schema keys never reach the `<form>` DOM element;
21+
* - `fieldPanesResizable: false` actually pins the divider.
22+
*
23+
* Pane SIZES cannot be asserted here: react-resizable-panels resolves them
24+
* against the measured group width, which is 0 under happy-dom, so every panel
25+
* comes out an even `flex-grow: 50`. They are verified in a real browser.
26+
*/
27+
28+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
29+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
30+
import { ComponentRegistry } from '@object-ui/core';
31+
32+
beforeAll(async () => {
33+
await import('../../../renderers');
34+
}, 30000);
35+
36+
beforeEach(() => {
37+
if (!(Element.prototype as any).scrollIntoView) {
38+
(Element.prototype as any).scrollIntoView = () => {};
39+
}
40+
});
41+
42+
afterEach(() => {
43+
cleanup();
44+
vi.restoreAllMocks();
45+
});
46+
47+
const FIELDS = [
48+
{ name: 'subject', label: 'Subject', type: 'input' },
49+
{ name: 'status', label: 'Status', type: 'input' },
50+
{ name: 'priority', label: 'Priority', type: 'input' },
51+
];
52+
53+
const PANES = [
54+
{ key: 'primary', fields: ['subject'], defaultSize: 40 },
55+
{ key: 'secondary', fields: ['status', 'priority'], defaultSize: 60 },
56+
];
57+
58+
function renderSplitForm(overrides: Record<string, unknown> = {}) {
59+
const onSubmit = vi.fn();
60+
const Form = ComponentRegistry.get('form')!;
61+
const utils = render(
62+
<Form
63+
schema={{
64+
type: 'form',
65+
mode: 'create',
66+
showSubmit: true,
67+
showCancel: false,
68+
submitLabel: 'Create',
69+
fields: FIELDS,
70+
fieldPanes: PANES,
71+
onSubmit,
72+
...overrides,
73+
}}
74+
/>,
75+
);
76+
return { ...utils, onSubmit };
77+
}
78+
79+
const fill = (name: string, value: string) => {
80+
const input = document.body.querySelector<HTMLInputElement>(`[data-field="${name}"] input`);
81+
if (!input) throw new Error(`field not rendered: ${name}`);
82+
fireEvent.change(input, { target: { value } });
83+
};
84+
85+
const pane = (key: string) => document.body.querySelector(`[data-testid="form-pane:${key}"]`);
86+
87+
describe('FormSchema.fieldPanes — one form across the split (#2153)', () => {
88+
it('renders one <form> and lays each pane out in its own panel', () => {
89+
const { container } = renderSplitForm();
90+
91+
expect(container.querySelectorAll('form')).toHaveLength(1);
92+
expect(pane('primary')?.querySelector('[data-field="subject"]')).toBeTruthy();
93+
expect(pane('secondary')?.querySelector('[data-field="status"]')).toBeTruthy();
94+
expect(pane('secondary')?.querySelector('[data-field="priority"]')).toBeTruthy();
95+
// Both panes are inside the single form, not the other way round (which is
96+
// the arrangement that cannot work: a <form> per panel).
97+
const form = container.querySelector('form')!;
98+
expect(form.querySelector('[data-testid="form-pane:primary"]')).toBeTruthy();
99+
expect(form.querySelector('[data-testid="form-pane:secondary"]')).toBeTruthy();
100+
});
101+
102+
it('submits every pane’s values', async () => {
103+
const { onSubmit } = renderSplitForm();
104+
105+
fill('subject', 'Printer on fire');
106+
fill('status', 'open');
107+
fill('priority', 'high');
108+
109+
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
110+
111+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
112+
expect(onSubmit.mock.calls[0][0]).toMatchObject({
113+
subject: 'Printer on fire',
114+
status: 'open',
115+
priority: 'high',
116+
});
117+
});
118+
119+
it('evaluates a field rule across the divider', async () => {
120+
renderSplitForm({
121+
fields: [
122+
...FIELDS,
123+
{
124+
name: 'escalation',
125+
label: 'Escalation',
126+
type: 'input',
127+
// Watches a field in the OTHER pane — only one shared react-hook-form
128+
// instance can see it. With a form per panel the predicate faulted on
129+
// the missing key and failed open, so the field was always visible.
130+
visibleWhen: "record.subject == 'urgent'",
131+
},
132+
],
133+
fieldPanes: [
134+
{ key: 'primary', fields: ['subject'] },
135+
{ key: 'secondary', fields: ['status', 'escalation'] },
136+
],
137+
});
138+
139+
expect(document.body.querySelector('[data-field="escalation"]')).toBeNull();
140+
141+
fill('subject', 'urgent');
142+
143+
await waitFor(() =>
144+
expect(pane('secondary')?.querySelector('[data-field="escalation"]')).toBeTruthy(),
145+
);
146+
});
147+
148+
it('keeps the pane schema keys off the <form> DOM element', () => {
149+
// Callers / the SDUI dispatch spread the whole form node at the top level as
150+
// well, so an unconsumed FormSchema key leaks onto the DOM and React logs
151+
// "does not recognize the `fieldPanes` prop on a DOM element".
152+
const Form = ComponentRegistry.get('form')!;
153+
const { container } = render(
154+
<Form
155+
schema={{ type: 'form', fields: FIELDS, fieldPanes: PANES }}
156+
// The top-level spread that reproduces the leak.
157+
fields={FIELDS}
158+
fieldPanes={PANES}
159+
fieldPanesOrientation="vertical"
160+
fieldPanesResizable={false}
161+
/>,
162+
);
163+
const form = container.querySelector('form')!;
164+
for (const attr of ['fieldpanes', 'fieldpanesorientation', 'fieldpanesresizable']) {
165+
expect(form.hasAttribute(attr)).toBe(false);
166+
}
167+
});
168+
169+
it('renders fields no pane claims instead of dropping them', () => {
170+
const { container } = renderSplitForm({
171+
fieldPanes: [
172+
{ key: 'primary', fields: ['subject'] },
173+
{ key: 'secondary', fields: ['status'] },
174+
],
175+
});
176+
177+
// `priority` belongs to no pane — still in the DOM, outside the panels.
178+
const priority = container.querySelector('[data-field="priority"]')!;
179+
expect(priority).toBeTruthy();
180+
expect(priority.closest('[data-panel]')).toBeNull();
181+
});
182+
183+
it('falls back to a plain field list for a single pane', () => {
184+
const { container } = renderSplitForm({
185+
fieldPanes: [{ key: 'only', fields: ['subject'] }],
186+
});
187+
188+
expect(container.querySelector('[data-panel]')).toBeNull();
189+
expect(pane('only')).toBeNull();
190+
// …and every field still renders, not just the one the lone pane claimed.
191+
expect(container.querySelector('[data-field="priority"]')).toBeTruthy();
192+
});
193+
194+
it('lets a tabbed layout win when a caller declares both', () => {
195+
renderSplitForm({
196+
fieldTabs: [
197+
{ key: 'basics', label: 'Basics', fields: ['subject'] },
198+
{ key: 'triage', label: 'Triage', fields: ['status', 'priority'] },
199+
],
200+
});
201+
202+
expect(screen.getAllByRole('tab')).toHaveLength(2);
203+
expect(pane('primary')).toBeNull();
204+
});
205+
206+
it('pins the divider when fieldPanesResizable is false', () => {
207+
const { container } = renderSplitForm({ fieldPanesResizable: false });
208+
const separator = container.querySelector('[role="separator"]')!;
209+
expect(separator.getAttribute('aria-disabled')).toBe('true');
210+
});
211+
212+
it('leaves the divider draggable by default', () => {
213+
const { container } = renderSplitForm();
214+
const separator = container.querySelector('[role="separator"]')!;
215+
expect(separator.getAttribute('aria-disabled')).not.toBe('true');
216+
});
217+
});

0 commit comments

Comments
 (0)