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/tabbed-modal-keeps-every-tab.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 tabbed/sectioned create-edit form no longer loses the tabs you are not looking at (#2959, #2153)

The explicit-`sections` path rendered one `SchemaRenderer` — one react-hook-form
instance and one `<form>` element — **per section**, all sharing the same
`formId`. Two failures compounded:

1. the footer submit button (`form={formId}`) can only be associated with the
**first** of those forms, so section 2+ never reached the payload; and
2. in the `tabbed` variant Radix unmounted the inactive panel, destroying that
tab's form state outright.

Reported flow (HotCRM, 3 tabs, required `description` on tab 3): fill tab 1 →
submit → server 400 `description is required` → switch to tab 3, fill it →
submit → the server now reports `subject; description; status; priority` **all**
missing, because the second submit's body had lost every earlier value.

`ModalForm` (stacked and `contentLayout: 'tabbed'`) and `TabbedForm` now render
ONE form for all sections, matching `ObjectForm` / `DrawerForm`. Stacked sections
use the existing inline `section-divider` header (which now also renders the
section's `description`); tabbed sections go through a new
`FormSchema.fieldTabs` (+ `defaultFieldTab`, `fieldTabsPosition`) that the form
renderer distributes into **force-mounted** Radix panels — CSS-hidden rather
than unmounted, since react-hook-form skips validation for unmounted fields,
which is how a required field on a tab nobody opened used to sail past the
client and come back as a server 400.

Validation feedback now points at the tab: a rejected field activates its tab and
every tab holding one is marked on its trigger, for client-side rules and server
`fields[]` rejections alike.
29 changes: 29 additions & 0 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,35 @@ interface FormField {
}
```

### Tabbed field layout (`fieldTabs`)

A sectioned form stays **one** form: declare the tabs on it and the renderer
distributes the fields into panels, instead of rendering a form per section
(which strands every section but the first outside the submit, and lets an
inactive tab unmount along with its values).

```plaintext
{
type: 'form',
fields: [/* every tab's fields, in one flat list */],
fieldTabs: [
{ key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
{ key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
],
defaultFieldTab?: 'basics', // defaults to the first tab
fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right'
}
```

- Panels are force-mounted and only CSS-hidden, so a tab the user leaves keeps
its values **and** its validation.
- A failed submit activates the tab holding the first offending field and marks
every tab with a rejected field — client rules and server `fields[]` alike.
- Fields no tab claims render above the tab strip rather than disappearing.
- Needs at least two tabs; ignored when the form uses `children`.

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

## Usage

### Auto-registration (Side-effect Import)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* 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.
*/

/**
* #2959: `FormSchema.fieldTabs` — a tabbed field layout inside ONE form.
*
* The contract these pin:
* - one `<form>` / react-hook-form instance for every tab;
* - panels are force-mounted (only CSS-hidden), so a tab the user leaves keeps
* its values AND its validation — react-hook-form skips *unmounted* fields,
* which is how a required field on an unopened tab used to reach the server;
* - a failed submit activates the tab holding the first offending field and
* marks every tab that has one;
* - a field no tab claims is still rendered (never silently dropped).
*/

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';
import { toast } from '../../../ui/sonner';

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

beforeEach(() => {
vi.spyOn(toast, 'error').mockImplementation(() => 'id' as any);
if (!(Element.prototype as any).scrollIntoView) {
(Element.prototype as any).scrollIntoView = () => {};
}
});

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

const FIELDS = [
{ name: 'subject', label: 'Subject', type: 'input', required: true },
{ name: 'status', label: 'Status', type: 'input' },
{ name: 'description', label: 'Description', type: 'input', required: true },
];

const TABS = [
{ key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
{ key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
];

function renderTabbedForm(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,
fieldTabs: TABS,
onSubmit,
...overrides,
}}
/>,
);
return { ...utils, onSubmit };
}

const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
const fill = (name: string, value: string) => {
const input = document.body.querySelector<HTMLInputElement>(`[data-field="${name}"] input`)!;
fireEvent.change(input, { target: { value } });
};
const tab = (name: RegExp) => screen.getByRole('tab', { name });

describe('form renderer — fieldTabs (#2959)', () => {
it('renders one form and one panel per tab, with every field mounted', () => {
const { container } = renderTabbedForm();

expect(container.querySelectorAll('form')).toHaveLength(1);
expect(screen.getAllByRole('tab')).toHaveLength(2);
// Both panels exist up front — the inactive one is hidden by CSS, not
// unmounted, so its fields are present and registered.
expect(screen.getByTestId('form-tab-panel:basics')).toBeTruthy();
expect(screen.getByTestId('form-tab-panel:detail')).toBeTruthy();
for (const name of ['subject', 'status', 'description']) {
expect(container.querySelector(`[data-field="${name}"]`)).toBeTruthy();
}
// The tab's blurb rides along with its panel.
expect(screen.getByText('Anything else')).toBeTruthy();
});

it('marks the inactive panel with data-state=inactive (CSS-hidden, still mounted)', () => {
renderTabbedForm();
expect(screen.getByTestId('form-tab-panel:basics')).toHaveAttribute('data-state', 'active');
expect(screen.getByTestId('form-tab-panel:detail')).toHaveAttribute('data-state', 'inactive');
expect(screen.getByTestId('form-tab-panel:detail').className).toContain(
'data-[state=inactive]:hidden',
);
});

it('submits values entered across different tabs', async () => {
const { onSubmit } = renderTabbedForm();

fill('subject', 'From tab 1');
fireEvent.click(tab(/Detail/));
fill('description', 'From tab 2');
submit();

await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({
subject: 'From tab 1',
description: 'From tab 2',
});
});

it('validates fields on tabs the user never opened, and jumps to the offender', async () => {
const { onSubmit } = renderTabbedForm();

fill('subject', 'Only tab 1 filled');
submit();

// `description` is required and lives on a tab that was never opened.
await waitFor(() => expect(tab(/Detail/)).toHaveAttribute('data-state', 'active'));
expect(onSubmit).not.toHaveBeenCalled();
expect(tab(/Detail/)).toHaveAttribute('data-error', 'true');
expect(tab(/Basics/)).not.toHaveAttribute('data-error');
});

it('honors defaultFieldTab', () => {
renderTabbedForm({ defaultFieldTab: 'detail' });
expect(screen.getByTestId('form-tab-panel:detail')).toHaveAttribute('data-state', 'active');
});

it('keeps the tab schema keys off the <form> DOM element', () => {
// Callers/the SDUI dispatch spread the whole form node at the top level too,
// so a FormSchema key that isn't consumed leaks onto the DOM and React logs
// "does not recognize the `fieldTabs` prop on a DOM element".
const Form = ComponentRegistry.get('form')!;
const { container } = render(
<Form
schema={{ type: 'form', fields: FIELDS, fieldTabs: TABS }}
// The top-level spread that reproduces the leak.
fields={FIELDS}
fieldTabs={TABS}
defaultFieldTab="detail"
fieldTabsPosition="top"
/>,
);
const form = container.querySelector('form')!;
for (const attr of ['fieldtabs', 'defaultfieldtab', 'fieldtabsposition']) {
expect(form.hasAttribute(attr)).toBe(false);
}
});

it('renders fields no tab claims instead of dropping them', () => {
const { container } = renderTabbedForm({
fieldTabs: [
{ key: 'basics', label: 'Basics', fields: ['subject'] },
{ key: 'detail', label: 'Detail', fields: ['description'] },
],
});
// `status` belongs to no tab — it must still be in the DOM, outside the panels.
const status = container.querySelector('[data-field="status"]')!;
expect(status).toBeTruthy();
expect(status.closest('[role="tabpanel"]')).toBeNull();
});

it('falls back to a plain field list for a single tab', () => {
renderTabbedForm({ fieldTabs: [{ key: 'only', label: 'Only', fields: ['subject'] }] });
expect(screen.queryAllByRole('tab')).toHaveLength(0);
// …and every field still renders, not just the one the lone tab claimed.
expect(document.body.querySelector('[data-field="description"]')).toBeTruthy();
});
});
Loading
Loading