diff --git a/.changeset/split-form-keeps-both-panels.md b/.changeset/split-form-keeps-both-panels.md new file mode 100644 index 000000000..6926e59f1 --- /dev/null +++ b/.changeset/split-form-keeps-both-panels.md @@ -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 +`
` 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 `` 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. diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 5af61278f..6b2cf498f 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -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 `` 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) diff --git a/packages/components/src/renderers/form/__tests__/form-field-panes.test.tsx b/packages/components/src/renderers/form/__tests__/form-field-panes.test.tsx new file mode 100644 index 000000000..f7db5d830 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-field-panes.test.tsx @@ -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 `` cannot + * live inside each panel and still be one form, so the renderer puts the `` + * around the whole panel group and the panels hold only fields. + * + * The contract these pin: + * - one `` / 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 `` 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 = {}) { + const onSubmit = vi.fn(); + const Form = ComponentRegistry.get('form')!; + const utils = render( + , + ); + return { ...utils, onSubmit }; +} + +const fill = (name: string, value: string) => { + const input = document.body.querySelector(`[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 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 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 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( + , + ); + 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'); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index a78c03df2..e85b760ca 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -7,7 +7,7 @@ */ import { ComponentRegistry, resolveFieldRuleState, evalFieldPredicate, resolveCascadingOptions, isValueStillOffered } from '@object-ui/core'; -import type { FormSchema, FormField as FormFieldConfig, FormFieldTab, ValidationRule, FieldCondition, SelectOption } from '@object-ui/types'; +import type { FormSchema, FormField as FormFieldConfig, FormFieldTab, FormFieldPane, ValidationRule, FieldCondition, SelectOption } from '@object-ui/types'; import { useForm } from 'react-hook-form'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from '../../ui/form'; import { Button } from '../../ui/button'; @@ -24,6 +24,7 @@ import { } from '../../ui/select'; import { renderChildren } from '../../lib/utils'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../../ui/resizable'; import { Alert, AlertDescription } from '../../ui/alert'; import { toast } from '../../ui/sonner'; import { AlertCircle, ChevronDown, ChevronRight, Loader2, Maximize2, Check, X } from 'lucide-react'; @@ -75,6 +76,36 @@ function SectionDivider({ label, description, collapsible, collapsed, onToggle, ); } +/** Field name → config, first declaration wins. */ +function indexFieldsByName(fields: FormFieldConfig[]): Map { + const byName = new Map(); + for (const f of fields) if (f?.name && !byName.has(f.name)) byName.set(f.name, f); + return byName; +} + +/** + * The form's fields that no layout group claimed. `fieldTabs` / `fieldPanes` are + * authored against field NAMES, so a field the author forgot to mention must + * still render (above the group) instead of silently disappearing. + */ +function unclaimedFields( + groups: Array<{ fields: FormFieldConfig[] }>, + fields: FormFieldConfig[], +): FormFieldConfig[] { + const claimed = new Set(); + for (const group of groups) for (const f of group.fields) claimed.add(f.name); + return fields.filter((f) => f?.name && !claimed.has(f.name)); +} + +/** + * Pane sizes in the JSON protocol are percentages (spec `splitSize`). react-resizable-panels + * documents a NUMERIC size as pixels and an unsuffixed STRING as a percentage — + * in practice v4 resolves both the same way, but the string says which unit we + * mean, so the intent survives a library change. + */ +const panePercent = (size: number | undefined): string | undefined => + typeof size === 'number' && Number.isFinite(size) ? String(size) : undefined; + const useSafeFormTranslation = createSafeTranslation( { 'common.selectOption': 'Select an option', @@ -462,10 +493,7 @@ ComponentRegistry.register('form', // Resolve each tab's declared field names against the form's field list. const fieldTabGroups = React.useMemo(() => { if (!fieldTabs) return null; - const byName = new Map(); - for (const f of fields as FormFieldConfig[]) { - if (f?.name && !byName.has(f.name)) byName.set(f.name, f); - } + const byName = indexFieldsByName(fields as FormFieldConfig[]); return fieldTabs.map((tab) => ({ key: tab.key, label: tab.label || tab.key, @@ -479,12 +507,48 @@ ComponentRegistry.register('form', // A field no tab claimed must not vanish — it renders above the tab strip, // visible from every tab. - const untabbedFields = React.useMemo(() => { - if (!fieldTabGroups) return [] as FormFieldConfig[]; - const claimed = new Set(); - for (const group of fieldTabGroups) for (const f of group.fields) claimed.add(f.name); - return (fields as FormFieldConfig[]).filter((f) => f?.name && !claimed.has(f.name)); - }, [fieldTabGroups, fields]); + const untabbedFields = React.useMemo( + () => (fieldTabGroups ? unclaimedFields(fieldTabGroups, fields as FormFieldConfig[]) : []), + [fieldTabGroups, fields], + ); + + // --- Split field layout (#2153) ---------------------------------------- + // `fieldPanes` places this form's fields in resizable panels. The + // wraps the whole panel GROUP, so — exactly as with `fieldTabs` — there is + // still ONE react-hook-form instance behind the split: a submit from either + // side carries every pane's values, and a rule in one pane can read a field + // in another. A INSIDE each panel could do neither: its submit saw + // only its own fields, and its rule record didn't even contain the other + // pane's names, so a cross-pane predicate faulted and failed open. + // + // Tabbed wins if a caller somehow declares both: a form is one or the other. + const fieldPanes = React.useMemo(() => { + const declared = schema.fieldPanes; + if (schema.children || fieldTabs || !Array.isArray(declared)) return null; + const usable = declared.filter((p) => p && typeof p.key === 'string'); + return usable.length > 1 ? usable : null; + }, [schema.fieldPanes, schema.children, fieldTabs]); + + const fieldPaneGroups = React.useMemo(() => { + if (!fieldPanes) return null; + const byName = indexFieldsByName(fields as FormFieldConfig[]); + return fieldPanes.map((pane) => ({ + key: pane.key, + defaultSize: panePercent(pane.defaultSize), + minSize: panePercent(pane.minSize) ?? '20', + containerClass: pane.containerClass, + fields: (pane.fields ?? []) + .map((name) => byName.get(name)) + .filter((f): f is FormFieldConfig => Boolean(f)), + })); + }, [fieldPanes, fields]); + + // Same guarantee as `untabbedFields`: an unclaimed field renders above the + // panel group rather than being dropped. + const unpanedFields = React.useMemo( + () => (fieldPaneGroups ? unclaimedFields(fieldPaneGroups, fields as FormFieldConfig[]) : []), + [fieldPaneGroups, fields], + ); // Tabs holding a field the LAST submit rejected, so their trigger can carry // a marker — otherwise a rejection on a background tab is invisible and the @@ -1154,6 +1218,9 @@ ComponentRegistry.register('form', fieldTabs: _fieldTabs, defaultFieldTab: _defaultFieldTab, fieldTabsPosition: _fieldTabsPosition, + fieldPanes: _fieldPanes, + fieldPanesOrientation: _fieldPanesOrientation, + fieldPanesResizable: _fieldPanesResizable, ...formProps } = props; @@ -1254,6 +1321,45 @@ ComponentRegistry.register('form', + ) : fieldPaneGroups ? ( + // Split field layout (#2153): one , one react-hook-form + // instance, N resizable panels. The wraps the GROUP and the + // panels hold only fields, so the split is purely presentational — + // it cannot influence which values a submit collects. + <> + {unpanedFields.length > 0 && ( +
+ {unpanedFields.map(renderFormField)} +
+ )} + + {fieldPaneGroups.map((group, index) => ( + + {index > 0 && ( + + )} + + {/* Each pane is its own `@container`: a pane's field grid + must measure the PANE, so a 2-column group collapses to + one when the divider is dragged narrow — measuring the + whole form would keep it 2-up and overflow. */} +
+ {group.fields.map(renderFormField)} +
+
+
+ ))} +
+ ) : ( // Otherwise render fields from schema
diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 607be1bd7..539e248f8 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -114,6 +114,37 @@ the renderer distribute the fields: `ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this. +### Split field layout (`fieldPanes`) + +The same rule for side-by-side panels: the `` wraps the whole panel group +and each pane holds only fields, so one react-hook-form instance spans the +divider. + +```typescript +{ + 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 + (`visibleWhen` / `requiredWhen` / …) in one pane can read a field in another — + neither is possible with a form per panel. +- `defaultSize` / `minSize` are **percentages** of the group. +- Each pane is its own `@container`, so a multi-column group inside it 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` is built on this: section 1 becomes the primary pane, the rest stack +in the secondary one behind inline section headers. + ## Examples ### Basic Form diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index a164242aa..69757e24e 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -8,24 +8,25 @@ /** * 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. * Aligns with @objectstack/spec FormView type: 'split' + * + * Both panels are ONE form (#2153). The panel group is a layout the form + * renderer owns via `FormSchema.fieldPanes`, so a single `` / + * react-hook-form instance spans the divider. Rendering a form per panel — per + * SECTION, in fact — meant an action bar submitted only its own section and + * silently dropped everything typed on the other side, and a field condition + * could not see across the split at all. */ import React, { useState, useCallback, useEffect, useMemo } from 'react'; import type { FormField, DataSource } from '@object-ui/types'; -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, - cn, -} from '@object-ui/components'; -import { FormSection } from './FormSection'; +import { cn } from '@object-ui/components'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; -import { sectionFormLayout } from './autoLayout'; +import { applyAutoColSpan, containerGridColsFor } from './autoLayout'; export interface SplitFormSectionConfig { name?: string; @@ -33,9 +34,14 @@ export interface SplitFormSectionConfig { description?: string; columns?: 1 | 2 | 3 | 4; fields: (string | FormField)[]; - /** Custom CSS class for the section's Card wrapper. */ + /** Custom CSS class for the section's header row. */ className?: string; - /** Custom CSS class for the section's field grid. */ + /** + * Custom CSS class for the section's field grid. Applied to the panel's grid + * when that panel holds this section alone (the common case — the first + * section owns the left/top panel); a panel stacking several sections shares + * one grid inside the single form (#2153), so there is none to override. + */ gridClassName?: string; } @@ -196,12 +202,6 @@ export const SplitForm: React.FC = ({ const leftSections = useMemo(() => schema.sections.slice(0, 1), [schema.sections]); const rightSections = useMemo(() => schema.sections.slice(1), [schema.sections]); - // Collect all fields for a unified form submission - const allFields: FormField[] = useMemo( - () => schema.sections.flatMap(section => buildSectionFields(section)), - [schema.sections, buildSectionFields] - ); - const direction = schema.splitDirection || 'horizontal'; const panelSize = schema.splitSize || 50; @@ -223,64 +223,92 @@ export const SplitForm: React.FC = ({ ); } - // Build base form schema for SchemaRenderer - const baseFormSchema = { - type: 'form' as const, - objectName: schema.objectName, - layout: 'vertical' as const, - defaultValues: formData, - onSubmit: handleSubmit, - onCancel: handleCancel, + // The form is ONE grid, as wide as the widest section; each section then lays + // ITS fields out at its own declared density within that grid via colSpan + // (#2578) — the same arrangement the stacked/tabbed sectioned forms use. The + // grid lives on the FIELD container inside the form, never wrapped around the + // form (that leaves the extra columns permanently empty, #2128). + const clampCol = (n: unknown): number | undefined => + typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; + const declaredCols = schema.sections + .map((s) => clampCol(s.columns)) + .filter((c): c is number => c != null); + const formColumns = (declaredCols.length ? Math.max(...declaredCols) : 1) as 1 | 2 | 3 | 4; + const containerFieldClass = containerGridColsFor(formColumns); + + /** + * One panel's field list: each section contributes an inline `section-divider` + * header row followed by its fields. A per-section Card would need a + * per-section form, which is the defect above. + */ + const paneFields = (sections: SplitFormSectionConfig[], paneKey: string): FormField[] => { + const out: FormField[] = []; + sections.forEach((section, index) => { + const body = buildSectionFields(section); + if (!body.length) return; + if (section.label || section.description) { + out.push({ + name: `__section_${section.name || `${paneKey}_${index}`}`, + label: section.label, + description: section.description, + type: 'section-divider', + colSpan: 4, + className: section.className, + } as any); + } + out.push( + ...(formColumns > 1 + ? applyAutoColSpan(body, formColumns, clampCol(section.columns)) + : body), + ); + }); + return out; }; - const renderSections = (sections: SplitFormSectionConfig[], showButtons: boolean) => ( -
- {sections.map((section, index) => ( - - - - ))} -
- ); + // A pane holding exactly one section can still honour that section's + // `gridClassName` — it owns the panel's grid outright. + const panes = [ + { key: 'primary', sections: leftSections, defaultSize: panelSize }, + { key: 'secondary', sections: rightSections, defaultSize: 100 - panelSize }, + ] + .map((pane) => ({ + ...pane, + fields: paneFields(pane.sections, pane.key), + containerClass: pane.sections.length === 1 ? pane.sections[0].gridClassName : undefined, + })) + .filter((pane) => pane.fields.length > 0); return ( -
- - {/* Left / Top Panel */} - - {renderSections(leftSections, rightSections.length === 0)} - - - {rightSections.length > 0 && ( - <> - - - {/* Right / Bottom Panel */} - - {renderSections(rightSections, true)} - - - )} - +
+ pane.fields), + columns: formColumns, + ...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}), + layout: 'vertical' as const, + defaultValues: formData, + submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'), + cancelLabel: schema.cancelText, + showSubmit: schema.showSubmit !== false && schema.mode !== 'view', + showCancel: schema.showCancel !== false, + onSubmit: handleSubmit, + onCancel: handleCancel, + // A single pane is not a split — the renderer then falls back to a + // plain field list rather than a one-panel resizable group. + fieldPanes: panes.map((pane) => ({ + key: pane.key, + fields: pane.fields.map((f) => f.name), + defaultSize: pane.defaultSize, + ...(pane.containerClass ? { containerClass: pane.containerClass } : {}), + })), + fieldPanesOrientation: direction, + fieldPanesResizable: schema.splitResizable !== false, + }} + />
); }; diff --git a/packages/plugin-form/src/sectionedFormValues.test.tsx b/packages/plugin-form/src/sectionedFormValues.test.tsx index 847e06146..a6c24b3ec 100644 --- a/packages/plugin-form/src/sectionedFormValues.test.tsx +++ b/packages/plugin-form/src/sectionedFormValues.test.tsx @@ -23,6 +23,11 @@ * These tests pin the contract that fixes both: one `` for all sections, * with the tab panels force-mounted so a tab the user left keeps its values AND * its validation. + * + * `split` is the same defect in a different layout: its two resizable panels + * each held their own ``, so submitting from one panel's action bar + * dropped everything typed in the other and no condition could see across the + * divider. Its panels are covered at the bottom of this file. */ import React from 'react'; @@ -31,6 +36,7 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/re import { registerAllFields } from '@object-ui/fields'; import { ModalForm } from './ModalForm'; import { TabbedForm } from './TabbedForm'; +import { SplitForm } from './SplitForm'; registerAllFields(); @@ -246,6 +252,124 @@ describe('ModalForm stacked sections — one form for all sections (#2153)', () }); }); +describe('SplitForm — one form across BOTH panels (#2153)', () => { + it('submits the values typed in both panels', async () => { + const dataSource = makeDataSource(); + render( + , + ); + + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + + fill('subject', 'Printer on fire'); // left panel + fill('status', 'open'); // right panel + fill('priority', 'high'); // right panel + fill('description', 'Smoke coming out of tray 2'); // right panel + + submitForm(); + + await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1)); + expect(dataSource.create.mock.calls[0][1]).toMatchObject({ + subject: 'Printer on fire', + status: 'open', + priority: 'high', + description: 'Smoke coming out of tray 2', + }); + + // ONE form spanning both panels. A `` per panel (per SECTION, in + // fact) is what stranded the other panel's input outside the submit: each + // one owned an isolated react-hook-form instance, so the payload above + // arrived holding only the section whose action bar was clicked. + expect(document.body.querySelectorAll('form')).toHaveLength(1); + }); + + it('keeps every section’s header, in the panel that owns it', async () => { + render( + , + ); + + // Headers survive the move to inline `section-divider` rows... + expect(await screen.findByText('Basics')).toBeTruthy(); + expect(screen.getByText('Who is asking')).toBeTruthy(); + expect(screen.getByText('Triage')).toBeTruthy(); + + // ...and each stays on its own side of the split. + const panes = document.body.querySelectorAll('[data-testid^="form-pane:"]'); + expect(panes).toHaveLength(2); + expect(panes[0].textContent).toContain('Basics'); + expect(panes[0].querySelector('[data-field="subject"]')).toBeTruthy(); + expect(panes[1].textContent).toContain('Triage'); + expect(panes[1].querySelector('[data-field="status"]')).toBeTruthy(); + }); + + it('evaluates a right-panel field’s condition against a left-panel field', async () => { + const dataSource = { + ...makeDataSource(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'case', + fields: { + subject: { type: 'text', label: 'Subject' }, + status: { type: 'text', label: 'Status' }, + // Cross-panel condition: it watches a field in the OTHER panel, which + // only one shared react-hook-form instance can see. + escalation: { + type: 'text', + label: 'Escalation', + visibleWhen: "record.subject == 'urgent'", + }, + }, + }), + }; + + render( + , + ); + + await waitFor(() => expect(valueOf('subject')).toBe('')); + expect(document.body.querySelector('[data-field="escalation"]')).toBeNull(); + + fill('subject', 'urgent'); + + await waitFor(() => + expect(document.body.querySelector('[data-field="escalation"]')).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/form.ts b/packages/types/src/form.ts index 7898b807c..3af02eb5c 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -815,6 +815,45 @@ export interface FormFieldTab { containerClass?: string; } +/** + * One pane of a split field layout (see `FormSchema.fieldPanes`). + * + * Like {@link FormFieldTab}, a pane claims a SUBSET of the form's `fields` by + * name, and the form renderer still renders exactly ONE `` element / + * react-hook-form instance — it merely places each pane's fields in its own + * resizable panel. The `` wraps the whole panel group, which is what lets + * a submit from anywhere collect every pane's values and lets a condition in one + * pane read a field in another (objectui#2153). + */ +export interface FormFieldPane { + /** + * Stable pane key — used for the panel's `data-testid` + * (`form-pane:{key}`). + */ + key: string; + /** + * Names of the fields (as declared in `FormSchema.fields`) that belong to + * this pane, in render order. Unknown names are ignored; fields claimed by no + * pane render in a leading block above the panel group (never dropped). + */ + fields: string[]; + /** + * Initial pane size, as a percentage of the group (1–99). Defaults to an even + * split. + */ + defaultSize?: number; + /** + * Minimum pane size, as a percentage of the group. + * @default 20 + */ + minSize?: number; + /** + * Override the pane's field-grid classes (same role as + * `FormSchema.fieldContainerClass`, scoped to this pane). + */ + containerClass?: string; +} + /** * Form field configuration */ @@ -1071,6 +1110,29 @@ export interface FormSchema extends BaseSchema { * @default 'top' */ fieldTabsPosition?: 'top' | 'bottom' | 'left' | 'right'; + /** + * Split field layout (objectui#2153): each entry claims a subset of `fields` + * and renders it in its own resizable panel — inside the SAME `` / + * react-hook-form instance as every other pane, because the `` wraps the + * whole panel group. That is what a form PER panel could not do: its submit + * reached only its own panel's fields, and its conditions could not see the + * fields on the other side of the divider. + * + * Ignored when `children` or `fieldTabs` is set, or when fewer than two panes + * are given (the form then renders as a plain field list). + */ + fieldPanes?: FormFieldPane[]; + /** + * Direction the `fieldPanes` are laid out in: `horizontal` = side by side, + * `vertical` = stacked. + * @default 'horizontal' + */ + fieldPanesOrientation?: 'horizontal' | 'vertical'; + /** + * Whether the `fieldPanes` divider offers a drag handle. + * @default true + */ + fieldPanesResizable?: boolean; /** * Child components (alternative to fields array) */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0143ca61b..a88543055 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -136,6 +136,7 @@ export type { FieldCondition, FormField, FormFieldTab, + FormFieldPane, ComboboxSchema, CommandSchema, InputOTPSchema,