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
35 changes: 35 additions & 0 deletions .changeset/split-form-keeps-both-panels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@object-ui/types": patch
"@object-ui/components": patch
"@object-ui/plugin-form": patch
---

fix(form): a split create/edit form no longer loses the panel you are not submitting from (#2153)

`SplitForm` rendered one `SchemaRenderer` — one react-hook-form instance and one
`<form>` element — **per section**, and its two groups of sections live in
separate resizable panels. So each panel owned isolated form state: submitting
from one panel's action bar sent only that section's fields and silently dropped
everything the user had 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 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 panel group became a layout the form renderer
owns, via a new `FormSchema.fieldPanes` (+ `fieldPanesOrientation`,
`fieldPanesResizable`) that mirrors `fieldTabs` (#2959): the `<form>` wraps the
whole `ResizablePanelGroup` and each pane holds only fields, which is what lets a
single react-hook-form instance span the divider. Sections inside a pane render
behind the inline `section-divider` header, each at its own declared column
density within the form's shared grid.

One more fix falls out of moving the panels into the renderer: `splitResizable:
false` now actually pins the divider. It previously only hid the grip — the
separator stayed draggable, because 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.
30 changes: 30 additions & 0 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,36 @@ inactive tab unmount along with its values).

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

### Split field layout (`fieldPanes`)

Side-by-side panels follow the same rule: the `<form>` wraps the whole panel
group and each pane holds only fields, so one react-hook-form instance spans the
divider.

```plaintext
{
type: 'form',
fields: [/* every pane's fields, in one flat list */],
fieldPanes: [
{ key: 'primary', fields: ['subject'], defaultSize: 50 },
{ key: 'secondary', fields: ['status', 'priority'], defaultSize: 50, minSize: 20 },
],
fieldPanesOrientation?: 'horizontal', // 'horizontal' | 'vertical'
fieldPanesResizable?: true, // false pins the divider
}
```

- A submit from anywhere carries every pane's values, and a field rule in one
pane can read a field in another — neither works with a form per panel.
- `defaultSize` / `minSize` are percentages of the group.
- Each pane is its own `@container`, so a multi-column group collapses as the
divider is dragged narrower.
- 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.

## Usage

### Auto-registration (Side-effect Import)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* #2153: `FormSchema.fieldPanes` — a split field layout inside ONE form.
*
* The sibling of `fieldTabs` (#2959), for side-by-side panels. A `<form>` cannot
* live inside each panel and still be one form, so the renderer puts the `<form>`
* around the whole panel group and the panels hold only fields.
*
* The contract these pin:
* - one `<form>` / react-hook-form instance across every pane, so a submit
* carries all of them and a field rule can read across the divider;
* - a field no pane claims is still rendered (never silently dropped);
* - the pane schema keys never reach the `<form>` DOM element;
* - `fieldPanesResizable: false` actually pins the divider.
*
* Pane SIZES cannot be asserted here: react-resizable-panels resolves them
* against the measured group width, which is 0 under happy-dom, so every panel
* comes out an even `flex-grow: 50`. They are verified in a real browser.
*/

import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';

beforeAll(async () => {
await import('../../../renderers');
}, 30000);

beforeEach(() => {
if (!(Element.prototype as any).scrollIntoView) {
(Element.prototype as any).scrollIntoView = () => {};
}
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

const FIELDS = [
{ name: 'subject', label: 'Subject', type: 'input' },
{ name: 'status', label: 'Status', type: 'input' },
{ name: 'priority', label: 'Priority', type: 'input' },
];

const PANES = [
{ key: 'primary', fields: ['subject'], defaultSize: 40 },
{ key: 'secondary', fields: ['status', 'priority'], defaultSize: 60 },
];

function renderSplitForm(overrides: Record<string, unknown> = {}) {
const onSubmit = vi.fn();
const Form = ComponentRegistry.get('form')!;
const utils = render(
<Form
schema={{
type: 'form',
mode: 'create',
showSubmit: true,
showCancel: false,
submitLabel: 'Create',
fields: FIELDS,
fieldPanes: PANES,
onSubmit,
...overrides,
}}
/>,
);
return { ...utils, onSubmit };
}

const fill = (name: string, value: string) => {
const input = document.body.querySelector<HTMLInputElement>(`[data-field="${name}"] input`);
if (!input) throw new Error(`field not rendered: ${name}`);
fireEvent.change(input, { target: { value } });
};

const pane = (key: string) => document.body.querySelector(`[data-testid="form-pane:${key}"]`);

describe('FormSchema.fieldPanes — one form across the split (#2153)', () => {
it('renders one <form> and lays each pane out in its own panel', () => {
const { container } = renderSplitForm();

expect(container.querySelectorAll('form')).toHaveLength(1);
expect(pane('primary')?.querySelector('[data-field="subject"]')).toBeTruthy();
expect(pane('secondary')?.querySelector('[data-field="status"]')).toBeTruthy();
expect(pane('secondary')?.querySelector('[data-field="priority"]')).toBeTruthy();
// Both panes are inside the single form, not the other way round (which is
// the arrangement that cannot work: a <form> per panel).
const form = container.querySelector('form')!;
expect(form.querySelector('[data-testid="form-pane:primary"]')).toBeTruthy();
expect(form.querySelector('[data-testid="form-pane:secondary"]')).toBeTruthy();
});

it('submits every pane’s values', async () => {
const { onSubmit } = renderSplitForm();

fill('subject', 'Printer on fire');
fill('status', 'open');
fill('priority', 'high');

fireEvent.click(screen.getByRole('button', { name: 'Create' }));

await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({
subject: 'Printer on fire',
status: 'open',
priority: 'high',
});
});

it('evaluates a field rule across the divider', async () => {
renderSplitForm({
fields: [
...FIELDS,
{
name: 'escalation',
label: 'Escalation',
type: 'input',
// Watches a field in the OTHER pane — only one shared react-hook-form
// instance can see it. With a form per panel the predicate faulted on
// the missing key and failed open, so the field was always visible.
visibleWhen: "record.subject == 'urgent'",
},
],
fieldPanes: [
{ key: 'primary', fields: ['subject'] },
{ key: 'secondary', fields: ['status', 'escalation'] },
],
});

expect(document.body.querySelector('[data-field="escalation"]')).toBeNull();

fill('subject', 'urgent');

await waitFor(() =>
expect(pane('secondary')?.querySelector('[data-field="escalation"]')).toBeTruthy(),
);
});

it('keeps the pane schema keys off the <form> DOM element', () => {
// Callers / the SDUI dispatch spread the whole form node at the top level as
// well, so an unconsumed FormSchema key leaks onto the DOM and React logs
// "does not recognize the `fieldPanes` prop on a DOM element".
const Form = ComponentRegistry.get('form')!;
const { container } = render(
<Form
schema={{ type: 'form', fields: FIELDS, fieldPanes: PANES }}
// The top-level spread that reproduces the leak.
fields={FIELDS}
fieldPanes={PANES}
fieldPanesOrientation="vertical"
fieldPanesResizable={false}
/>,
);
const form = container.querySelector('form')!;
for (const attr of ['fieldpanes', 'fieldpanesorientation', 'fieldpanesresizable']) {
expect(form.hasAttribute(attr)).toBe(false);
}
});

it('renders fields no pane claims instead of dropping them', () => {
const { container } = renderSplitForm({
fieldPanes: [
{ key: 'primary', fields: ['subject'] },
{ key: 'secondary', fields: ['status'] },
],
});

// `priority` belongs to no pane — still in the DOM, outside the panels.
const priority = container.querySelector('[data-field="priority"]')!;
expect(priority).toBeTruthy();
expect(priority.closest('[data-panel]')).toBeNull();
});

it('falls back to a plain field list for a single pane', () => {
const { container } = renderSplitForm({
fieldPanes: [{ key: 'only', fields: ['subject'] }],
});

expect(container.querySelector('[data-panel]')).toBeNull();
expect(pane('only')).toBeNull();
// …and every field still renders, not just the one the lone pane claimed.
expect(container.querySelector('[data-field="priority"]')).toBeTruthy();
});

it('lets a tabbed layout win when a caller declares both', () => {
renderSplitForm({
fieldTabs: [
{ key: 'basics', label: 'Basics', fields: ['subject'] },
{ key: 'triage', label: 'Triage', fields: ['status', 'priority'] },
],
});

expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(pane('primary')).toBeNull();
});

it('pins the divider when fieldPanesResizable is false', () => {
const { container } = renderSplitForm({ fieldPanesResizable: false });
const separator = container.querySelector('[role="separator"]')!;
expect(separator.getAttribute('aria-disabled')).toBe('true');
});

it('leaves the divider draggable by default', () => {
const { container } = renderSplitForm();
const separator = container.querySelector('[role="separator"]')!;
expect(separator.getAttribute('aria-disabled')).not.toBe('true');
});
});
Loading
Loading