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
5 changes: 5 additions & 0 deletions .changeset/12803-form-modal-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

The console's create / edit record modal now opens at the size an object's default form view authors in `modalSize` (`sm`, `default`, `lg`, `xl` or `full`). On desktop the authored size wins over the derived default; on a phone the modal stays full-screen. With no `modalSize` authored, the modal sizes itself as before.
50 changes: 27 additions & 23 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { buildExpressionUser } from '../providers/expressionUser.js';
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
import { useHomePath } from '../hooks/useHomePath.js';
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
import { appRouteSegment, matchAppBySegment } from '../utils/appRoute.js';
Expand Down Expand Up @@ -1253,30 +1253,34 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
...(!editingRecord && (formLinkValues ?? overlayCreateValues)
? { initialValues: formLinkValues ?? overlayCreateValues }
: {}),
// objectstack-ai/objectstack#2604 D1: create/edit follow the flow-surface derivation.
// This host calls it with no `viewport`, so it gets the desktop
// answer: a bounded modal (`size: 'auto'`, ModalForm's bounded
// default when modalSize is unset) for field-heavy and light
// objects alike (objectstack-ai/objectstack#23734, objectui#12592);
// a phone gets ModalForm's full-screen sheet below `sm`. Derived
// default only — anything spread later (form view layout) would
// win. Child tasks size to the CHILD object's def.
...(deriveRecordFlowSurface(
formObjectDef,
isChildFormTask
? (editingRecord ? 'child-edit' : 'child-create')
: (editingRecord ? 'edit' : 'create'),
).size === 'full'
? { modalSize: 'full' as const }
: {}),
// Honor the object's DEFAULT FORM VIEW: curated sections (field
// selection + order + grouping), `contentLayout: 'tabbed'` when the
// view is tabbed, and inline child collections (master-detail).
// When the view declares sections they drive the modal layout and
// win over the flat `fields` list below; otherwise this resolves to
// {} and `fields` (every field, raw schema order) is used as before.
// `formType` stays 'modal' (the container). (objectstack-ai/objectstack#1890 / ADR-0050.)
...resolveFormViewLayout(formObjectDef as any),
// view is tabbed, inline child collections (master-detail), and its
// authored `modalSize`. When the view declares sections they drive
// the modal layout and win over the flat `fields` list below;
// otherwise `fields` (every field, raw schema order) is used as
// before. `formType` stays 'modal' (the container).
// (objectstack-ai/objectstack#1890 / ADR-0050.)
//
// The size: objectstack-ai/objectstack#2604 D1 — create/edit follow
// the flow-surface derivation. This host calls it with no
// `viewport`, so it gets the desktop answer, a bounded modal
// (`size: 'auto'`) for field-heavy and light objects alike
// (objectstack-ai/objectstack#23734, objectui#12592); a phone gets
// ModalForm's full-screen sheet below `sm`. objectui#12803: an
// authored `FormView.modalSize` wins over that derived default on
// desktop, and a derived `'full'` (the phone posture) stays full.
// The precedence lives in `resolveRecordFormModalLayout`, not in
// spread order. Child tasks size to the CHILD object's def.
...resolveRecordFormModalLayout(
formObjectDef as any,
deriveRecordFlowSurface(
formObjectDef,
isChildFormTask
? (editingRecord ? 'child-edit' : 'child-create')
: (editingRecord ? 'edit' : 'create'),
).size,
),
title: editingRecord
? t('form.editTitle', { object: objectLabel(formObjectDef as any) })
: t('form.createTitle', { object: objectLabel(formObjectDef as any) }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* 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#12803 — an authored `FormView.modalSize` reaches the console's
* create / edit modal.
*
* Measured from a real `AppContent` mount (the harness of
* `AppContent.recordDialogInterimBound-12521`): the global record-form modal
* records the schema it is handed and, where a cell reads the dialog itself,
* renders the real `ModalForm`. On desktop the authored size wins over the
* derived default; with nothing authored the schema is unchanged. This host
* calls `deriveRecordFlowSurface` with no viewport, so the phone is
* `ModalForm`'s full-screen sheet below `sm`; jsdom evaluates no media query,
* so "below `sm`" is read as the classes that carry no responsive prefix.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { registerAllFields } from '@object-ui/fields';
import { deriveRecordSurface } from '@object-ui/plugin-view';

registerAllFields();

const probe = vi.hoisted(() => ({
/** Every schema the global record-form modal was rendered with. */
modalSchemas: [] as any[],
/** Render the real `ModalForm` under the probe. */
realModal: false,
/** The real `ModalForm`, for cells that render a schema of their own. */
RealModalForm: null as null | React.ComponentType<any>,
}));

vi.mock('@object-ui/plugin-form', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
const RealModalForm = actual.ModalForm;
probe.RealModalForm = RealModalForm;
return {
...actual,
ModalForm: (props: any) => {
probe.modalSchemas.push(props.schema);
return probe.realModal ? <RealModalForm {...props} /> : <div data-testid="modal-form" />;
},
};
});

vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

vi.mock('../../layout/ConsoleLayout', () => ({
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="console-layout">{children}</div>
),
}));
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
vi.mock('../../views/RecordFormPage', () => ({
RecordFormPage: () => <div data-testid="record-form-page" />,
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

/** Fourteen plain fields: a field-heavy object, which the derivation pages. */
const HEAVY_FIELDS = Object.fromEntries(
Array.from({ length: 14 }, (_, i) => [`f${i}`, { type: 'text', label: `F${i}` }]),
);

/** A heavy object whose default form view authors `modalSize: 'lg'`. */
const SIZED = {
name: 'crm_lead',
label: 'Lead',
fields: HEAVY_FIELDS,
form: { type: 'modal', modalSize: 'lg' },
};
/** The same object with a default form view that authors no size. */
const PLAIN = {
name: 'crm_contact',
label: 'Contact',
fields: HEAVY_FIELDS,
form: { type: 'modal' },
};
const OBJECTS = [SIZED, PLAIN];

const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name)),
findOne: vi.fn(async () => ({ id: 'r1' })),
find: vi.fn(async () => ({ data: [] })),
create: vi.fn(),
update: vi.fn(),
};
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));

const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];

// One value for the whole run: effects key on these members.
const METADATA = {
apps: APPS,
objects: OBJECTS,
loading: false,
ensureType: undefined,
error: null,
refresh: async () => {},
};
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => METADATA,
}));

const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useActionRunner: () => ACTION_RUNNER,
useGlobalUndo: () => {},
useMutationInvalidationBridge: () => {},
}));

import { AppContent } from '../AppContent';

/** Open the record dialog at `url` and return the schema it was handed. */
async function openAt(url: string, objectName: string) {
render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(lastModal()?.open).toBe(true));
expect(lastModal().objectName).toBe(objectName);
return lastModal();
}

const lastModal = () => probe.modalSchemas[probe.modalSchemas.length - 1];

/** The rendered dialog, once its fields are drawn. */
async function dialogOnScreen(): Promise<HTMLElement> {
await waitFor(() => {
if (!document.body.querySelector('[role="dialog"] [name="f0"]')) throw new Error('form not drawn yet');
}, { timeout: 5000 });
return screen.getByRole('dialog');
}

const tokens = (el: HTMLElement) => el.className.split(/\s+/).filter(Boolean);
/** The classes that apply below `sm`: those with no responsive or state prefix. */
const belowSm = (el: HTMLElement) => tokens(el).filter((t) => !t.includes(':')).sort();

beforeEach(() => {
probe.modalSchemas.length = 0;
probe.realModal = false;
vi.clearAllMocks();
});
afterEach(cleanup);

describe('objectui#12803 — an authored FormView.modalSize sizes the console create / edit modal', () => {
it('the objects are field-heavy: the derivation would size them itself', () => {
expect(deriveRecordSurface(SIZED)).toBe('page');
expect(deriveRecordSurface(PLAIN)).toBe('page');
});

it.each([
['create', '/apps/crm/crm_lead?form=new'],
['edit', '/apps/crm/crm_lead?form=r1'],
['child-create', '/apps/crm/crm_contact?form=new&formObject=crm_lead'],
] as const)('%s on desktop: the authored lg reaches the ModalForm schema', async (_flow, url) => {
const schema = await openAt(url, 'crm_lead');
expect(schema.modalSize).toBe('lg');
});

it('create on desktop: the real dialog opens at the lg bound, not the derived xl', async () => {
probe.realModal = true;
await openAt('/apps/crm/crm_lead?form=new', 'crm_lead');
const dialog = await dialogOnScreen();
expect(dialog.className).toContain('sm:max-w-2xl');
expect(dialog.className).not.toContain('sm:max-w-5xl');
});

it.each([
['create', '/apps/crm/crm_contact?form=new'],
['edit', '/apps/crm/crm_contact?form=r1'],
] as const)('CONTROL %s: nothing authored hands the dialog no modalSize key', async (_flow, url) => {
const schema = await openAt(url, 'crm_contact');
expect('modalSize' in schema).toBe(false);
});

it('CONTROL: nothing authored, the real dialog keeps its own xl bound', async () => {
probe.realModal = true;
await openAt('/apps/crm/crm_contact?form=new', 'crm_contact');
const dialog = await dialogOnScreen();
expect(dialog.className).toContain('sm:max-w-5xl');
});

it('the phone sheet is unchanged: below sm the authored-lg dialog carries the classes the unsized one does', async () => {
probe.realModal = true;
await openAt('/apps/crm/crm_lead?form=new', 'crm_lead');
const sized = belowSm(await dialogOnScreen());
cleanup();
probe.modalSchemas.length = 0;

await openAt('/apps/crm/crm_contact?form=new', 'crm_contact');
const unsized = belowSm(await dialogOnScreen());

expect(sized).toEqual(unsized);
expect(sized).toEqual(expect.arrayContaining(['fixed', 'inset-0', 'w-full', 'h-[100dvh]']));
expect(sized.some((t) => t.startsWith('max-w-'))).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* 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#12803 — an authored `FormView.modalSize` reaches the console's
* create / edit modal.
*
* `resolveFormViewLayout` copies the default form view's `modalSize` when it
* is a value the spec's `FormView.modalSize` enum allows, and
* `resolveRecordFormModalLayout` states the precedence against the derived
* flow surface: a derived `'full'` (the phone posture) stays full, otherwise
* the authored size wins, otherwise no `modalSize` key at all.
*/

import { describe, it, expect } from 'vitest';
import { FormViewSchema } from '@objectstack/spec/ui';
import { resolveFormViewLayout, resolveRecordFormModalLayout } from '../recordFormNavigation';

const SECTIONS = [{ label: 'Details', fields: ['subject'] }];

describe('resolveFormViewLayout carries the authored modalSize (objectui#12803)', () => {
it.each(['sm', 'default', 'lg', 'xl', 'full'] as const)('copies modalSize %s', (size) => {
expect(resolveFormViewLayout({ form: { type: 'modal', modalSize: size } })).toStrictEqual({
modalSize: size,
});
});

it('the cases above are the whole spec enum', () => {
expect(FormViewSchema.shape.modalSize.unwrap().options).toEqual(['sm', 'default', 'lg', 'xl', 'full']);
});

it('copies modalSize beside sections, and from the legacy formViews.default', () => {
expect(resolveFormViewLayout({ form: { type: 'simple', sections: SECTIONS, modalSize: 'lg' } })).toStrictEqual({
sections: SECTIONS,
modalSize: 'lg',
});
expect(resolveFormViewLayout({ formViews: { default: { type: 'modal', modalSize: 'xl' } } })).toStrictEqual({
modalSize: 'xl',
});
});

it('copies no value the spec enum refuses', () => {
// `md` is a `navigation.size` bucket, not a `FormView.modalSize` one.
expect(resolveFormViewLayout({ form: { type: 'modal', modalSize: 'md' as any } })).toStrictEqual({});
expect(resolveFormViewLayout({ form: { type: 'modal', modalSize: 'auto' as any } })).toStrictEqual({});
expect(resolveFormViewLayout({ form: { type: 'modal', modalSize: 640 as any } })).toStrictEqual({});
});

it('CONTROL: no authored modalSize adds no key', () => {
expect('modalSize' in resolveFormViewLayout({ form: { type: 'simple', sections: SECTIONS } })).toBe(false);
expect(resolveFormViewLayout({ form: { type: 'modal' } })).toStrictEqual({});
});
});

describe('resolveRecordFormModalLayout: the create / edit modal size precedence (objectui#12803)', () => {
it('desktop (derived auto): the authored modalSize wins', () => {
expect(resolveRecordFormModalLayout({ form: { type: 'modal', modalSize: 'lg' } }, 'auto')).toStrictEqual({
modalSize: 'lg',
});
expect(
resolveRecordFormModalLayout({ form: { type: 'simple', sections: SECTIONS, modalSize: 'sm' } }, 'auto'),
).toStrictEqual({ sections: SECTIONS, modalSize: 'sm' });
});

it('mobile (derived full): an authored size does not shrink the full-screen modal', () => {
expect(resolveRecordFormModalLayout({ form: { type: 'modal', modalSize: 'lg' } }, 'full')).toStrictEqual({
modalSize: 'full',
});
expect(
resolveRecordFormModalLayout({ form: { type: 'simple', sections: SECTIONS, modalSize: 'sm' } }, 'full'),
).toStrictEqual({ sections: SECTIONS, modalSize: 'full' });
});

it('CONTROL desktop: nothing authored leaves no modalSize key, as before', () => {
expect(resolveRecordFormModalLayout(undefined, 'auto')).toStrictEqual({});
expect(resolveRecordFormModalLayout({ name: 'x' } as any, 'auto')).toStrictEqual({});
expect(
resolveRecordFormModalLayout({ form: { type: 'simple', sections: SECTIONS } }, 'auto'),
).toStrictEqual({ sections: SECTIONS });
});

it('CONTROL mobile: nothing authored still derives the full-screen modal, as before', () => {
expect(resolveRecordFormModalLayout(undefined, 'full')).toStrictEqual({ modalSize: 'full' });
expect(
resolveRecordFormModalLayout({ form: { type: 'simple', sections: SECTIONS } }, 'full'),
).toStrictEqual({ sections: SECTIONS, modalSize: 'full' });
});
});
Loading
Loading