Skip to content
Merged
9 changes: 9 additions & 0 deletions .changeset/12559-dialog-section-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/plugin-form': patch
---

Opening a collapsed section in a record dialog no longer takes the whole console off screen (objectui#12559).

The field widgets `@object-ui/fields` registers load on demand: a type's code is fetched the first time a form draws it. Nothing inside the modal form caught that wait, so it landed on the host's nearest loading boundary. In the console's record create and edit dialog that is the boundary around the whole console, because the dialog mounts beside the pages rather than inside one. Opening a collapsed section whose fields used a type not loaded yet, for example a currency or address field, swapped the whole console, dialog included, for the loading screen until the code arrived. The full-page form hid only its page.

The modal form now owns that wait: the dialog keeps its header and footer, its body shows a placeholder until the widget's code arrives, and the form underneath keeps its state, so the section the user opened is open and everything typed elsewhere is kept. Nothing is added to the package entry: no export, prop or type member.
44 changes: 42 additions & 2 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* Aligns with @objectstack/spec FormView type: 'modal'
*/

import React, { useState, useCallback, useEffect, useMemo, useId, useRef } from 'react';
import React, { Suspense, useState, useCallback, useEffect, useMemo, useId, useRef } from 'react';
import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types';
import {
Dialog,
Expand Down Expand Up @@ -1068,6 +1068,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
</DialogHeader>
)}
<div className="@container flex-1 overflow-y-auto px-4 sm:px-6 py-4">
<Suspense fallback={<ModalFormBodyPending />}>
<MasterDetailForm
schema={{
type: 'object-master-detail-form',
Expand All @@ -1090,6 +1091,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
}}
dataSource={dataSource}
/>
</Suspense>
</div>
</MobileDialogContent>
</Dialog>
Expand Down Expand Up @@ -1127,7 +1129,11 @@ export const ModalForm: React.FC<ModalFormProps> = ({
a section, a tab, a subform row (objectui#10166). */}
<UploadGateProvider gate={uploadGate}>
{closedAffordanceNotice}
{renderContent()}
{/* The dialog owns its body's wait (objectui#12559) — see
`ModalFormBodyPending`. */}
<Suspense fallback={<ModalFormBodyPending />}>
{renderContent()}
</Suspense>
</UploadGateProvider>
</div>

Expand Down Expand Up @@ -1198,4 +1204,38 @@ export const ModalForm: React.FC<ModalFormProps> = ({
);
};

/**
* What the dialog's body shows while a field widget's code loads
* (objectui#12559).
*
* The field widgets `@object-ui/fields` registers are `React.lazy`: a type's
* chunk is fetched the first time a form draws it, and that first render
* SUSPENDS. With no boundary inside the dialog, the suspension left the dialog
* and landed on the HOST's nearest `<Suspense>`. A record dialog is an overlay
* a host mounts beside its pages — the console mounts this one beside its
* routes, under the shell's root boundary — so expanding a collapsed section
* whose fields used a type not loaded yet swapped the WHOLE console, dialog
* included, for the root fallback while the chunk loaded. A full-page form
* sits inside its route's boundary and hid only its page.
*
* So the dialog owns its body's wait, the shape `FlowRunner` gives its screen
* body: the header and footer stay, the body shows this placeholder, and the
* form underneath is hidden, not unmounted — React keeps its state, so the
* section the user opened is open and every typed value is kept when the
* widget lands. Distinct from `modal-form-skeleton`, which is the record READ's
* loading state.
*/
function ModalFormBodyPending() {
return (
<div className="space-y-4" data-testid="modal-form-body-pending" aria-hidden="true">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
);
}

export default ModalForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/**
* 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.
*/

/**
* objectui#12559 — expanding a collapsed section of the console's record
* dialog must open it IN PLACE.
*
* THE PRODUCER. The field widgets `@object-ui/fields` registers are
* `React.lazy`: a type's chunk is fetched the first time a form draws it. A
* section that starts collapsed holds its fields out of the DOM, so the click
* that opens it is often the first render of a type (`currency`, `url`,
* `address`…). Nothing inside the dialog caught that suspension, so it left
* `ModalForm` and landed on the HOST's nearest `<Suspense>`. The console mounts
* the record dialog beside its routes, under the shell's root boundary, with no
* route boundary in between — so the whole console, dialog included, was
* swapped for the root fallback while the chunk loaded. The full-page form hid
* only its page: its nearest boundary is the route's. The fix is a boundary
* around the dialog's body (`ModalFormBodyPending` in `ModalForm.tsx`).
*
* NOT the producer, read off the code: the collapse state lives in
* `ModalForm`'s own `useState` (not above the dialog's key), and the console's
* key for the dialog is the object name plus the record id or `new`, which a
* toggle cannot change.
*
* THE HOST HERE is the console's shape: a root `<Suspense>` whose fallback
* counts its mounts, a host component that mints a mount id once, and the
* dialog under a stable key, handed a FRESH schema object on every render as
* `AppContent` hands it. The `url` widget is replaced by one whose chunk
* resolves only when the test releases it, so the suspension is observable
* mid-flight instead of racing the module loader.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor, fireEvent, screen, act } from '@testing-library/react';
import React, { Suspense, useState } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { registerAllFields } from '@object-ui/fields';
import { ModalForm } from '../ModalForm';
import { ObjectForm } from '../ObjectForm';

registerAllFields();

/** Replace the `url` widget with one whose chunk lands only on `release()`. */
function deferUrlWidget(): { release: () => void } {
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
const Deferred = React.lazy(async () => {
await gate;
return {
default: (props: { id?: string; value?: unknown; onChange?: (v: unknown) => void }) => (
<input
data-testid="deferred-url-widget"
id={props.id}
value={(props.value as string) ?? ''}
onChange={(e) => props.onChange?.(e.target.value)}
/>
),
};
});
ComponentRegistry.register('url', Deferred, { namespace: 'field', skipFallback: true });
return { release: () => act(() => release()) as unknown as void };
}

const LEAD = {
name: 'lead',
fields: {
first_name: { type: 'text', label: 'First Name' },
company: { type: 'text', label: 'Company' },
website: { type: 'url', label: 'Website' },
},
};

const makeDataSource = () =>
({
getObjectSchema: vi.fn().mockResolvedValue(LEAD),
findOne: vi.fn(),
create: vi.fn().mockResolvedValue({ id: '1' }),
update: vi.fn(),
}) as any;

const SECTIONS = (company: Record<string, unknown>) => [
{ name: 'basic', label: 'Basic', fields: ['first_name'] },
{ name: 'company_info', label: 'Company Information', ...company, fields: ['company', 'website'] },
];

/** The console's mount, reduced: root boundary → host → keyed dialog. */
function mountConsoleShaped(ds: any, company: Record<string, unknown>) {
const counts = { hostFallbackMounts: 0, hostMounts: 0 };
const handle = { rerender: () => {} };
function HostFallback() {
React.useEffect(() => { counts.hostFallbackMounts++; }, []);
return <div data-testid="host-fallback">loading</div>;
}
function Host() {
const [mountId] = useState(() => ++counts.hostMounts);
const [, setTick] = useState(0);
React.useEffect(() => {
handle.rerender = () => act(() => setTick((n) => n + 1));
}, []);
return (
<div data-testid="host-mount-id" data-mount={mountId}>
<ModalForm
key="lead:new"
// A fresh object every render, as `AppContent` builds it.
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'lead',
mode: 'create',
open: true,
title: 'Create Lead',
sections: SECTIONS(company),
} as any}
dataSource={ds}
/>
</div>
);
}
render(
<Suspense fallback={<HostFallback />}>
<Host />
</Suspense>,
);
return { counts, rerenderHost: () => handle.rerender() };
}

const sectionHeader = (label: string) =>
[...document.body.querySelectorAll('[role="button"][aria-expanded]')].find((el) =>
el.textContent?.includes(label),
) as HTMLElement | undefined;

const drawnFields = () =>
[...document.body.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field'));

const firstNameInput = () =>
document.body.querySelector('[data-field="first_name"] input') as HTMLInputElement | null;

/** Whether React's Suspense hiding has taken the dialog off screen. */
const dialogShown = () => {
const dialog = screen.queryByRole('dialog');
return !!dialog && dialog.style.display !== 'none';
};

async function formReady() {
await waitFor(() => expect(firstNameInput()).not.toBeNull());
}

describe('ModalForm — a collapsed section opens in place (objectui#12559)', () => {
it('a header click expands the section with no host fallback, no remount, and the typed value kept', async () => {
const { release } = deferUrlWidget();
const ds = makeDataSource();
const { counts } = mountConsoleShaped(ds, { collapsed: true });
await formReady();

fireEvent.change(firstNameInput()!, { target: { value: 'Ada' } });
expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('false');
expect(drawnFields()).not.toContain('website');
const fallbacksBefore = counts.hostFallbackMounts;

fireEvent.click(sectionHeader('Company Information')!);

// Mid-load: the dialog's body waits; nothing above the dialog does.
expect(counts.hostFallbackMounts).toBe(fallbacksBefore);
expect(screen.queryByTestId('host-fallback')).toBeNull();
expect(dialogShown()).toBe(true);
expect(screen.getByRole('dialog').textContent).toContain('Create Lead');
expect(screen.getByRole('dialog').querySelector('[data-testid="modal-form-body-pending"]')).not.toBeNull();

release();
await screen.findByTestId('deferred-url-widget');

// The same mount throughout: one host mount, one schema read, the value kept.
expect(counts.hostFallbackMounts).toBe(fallbacksBefore);
expect(counts.hostMounts).toBe(1);
expect(ds.getObjectSchema).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('modal-form-skeleton')).toBeNull();
expect(screen.queryByTestId('modal-form-body-pending')).toBeNull();
expect(firstNameInput()!.value).toBe('Ada');
expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('true');
expect(drawnFields()).toEqual(expect.arrayContaining(['company', 'website']));
});

it('the toggled state is owned by the dialog: a host re-render with a fresh schema keeps the section open', async () => {
const { release } = deferUrlWidget();
release();
const ds = makeDataSource();
const { rerenderHost } = mountConsoleShaped(ds, { collapsed: true });
await formReady();

fireEvent.click(sectionHeader('Company Information')!);
await screen.findByTestId('deferred-url-widget');
rerenderHost();

expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('true');
expect(drawnFields()).toContain('website');
});

it('control: a section that starts expanded still collapses on a header click', async () => {
const { release } = deferUrlWidget();
release();
const ds = makeDataSource();
mountConsoleShaped(ds, { collapsible: true });
await formReady();
await screen.findByTestId('deferred-url-widget');
expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('true');

fireEvent.click(sectionHeader('Company Information')!);

expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('false');
expect(drawnFields()).not.toContain('website');
});

it('control: the full-page form (ObjectForm, simple) still expands a collapsed section', async () => {
const { release } = deferUrlWidget();
const ds = makeDataSource();
render(
<Suspense fallback={<div data-testid="route-fallback">loading</div>}>
<ObjectForm
schema={{
type: 'object-form',
formType: 'simple',
objectName: 'lead',
mode: 'create',
sections: SECTIONS({ collapsed: true }),
} as any}
dataSource={ds}
/>
</Suspense>,
);
await formReady();
expect(drawnFields()).not.toContain('website');

fireEvent.click(sectionHeader('Company Information')!);
release();

await screen.findByTestId('deferred-url-widget');
expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('true');
expect(drawnFields()).toEqual(expect.arrayContaining(['company', 'website']));
});
});
4 changes: 4 additions & 0 deletions packages/plugin-form/src/sectionedFormValues.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ describe('ModalForm tabbed sections — per-tab form state (#2959)', () => {
);

await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy());
// The footer is drawn while the body still waits on a field widget's code
// (objectui#12559: the dialog owns that wait), so the form is read once
// the body's placeholder has gone.
await waitFor(() => expect(screen.queryByTestId('modal-form-body-pending')).toBeNull());

// ONE form for all three sections — the shared-`formId` duplicate <form>
// elements are what stranded section 2+ outside the submit (#2153).
Expand Down
Loading