diff --git a/.changeset/12803-form-modal-size.md b/.changeset/12803-form-modal-size.md new file mode 100644 index 0000000000..fba2c0dee9 --- /dev/null +++ b/.changeset/12803-form-modal-size.md @@ -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. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 2d9eec3ad3..f208362e5d 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -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'; @@ -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) }), diff --git a/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx new file mode 100644 index 0000000000..8a2c860ec2 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx @@ -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, +})); + +vi.mock('@object-ui/plugin-form', async (importOriginal) => { + const actual = await importOriginal>(); + const RealModalForm = actual.ModalForm; + probe.RealModalForm = RealModalForm; + return { + ...actual, + ModalForm: (props: any) => { + probe.modalSchemas.push(props.schema); + return probe.realModal ? :
; + }, + }; +}); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ ObjectView: () =>
})); +vi.mock('../../views/RecordFormPage', () => ({ + RecordFormPage: () =>
, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + 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>()), + 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>()), + useMetadata: () => METADATA, +})); + +const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + 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( + + + } /> + + , + ); + 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 { + 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); + }); +}); diff --git a/packages/app-shell/src/utils/__tests__/recordFormModalSize-12803.test.ts b/packages/app-shell/src/utils/__tests__/recordFormModalSize-12803.test.ts new file mode 100644 index 0000000000..11eb64c6cc --- /dev/null +++ b/packages/app-shell/src/utils/__tests__/recordFormModalSize-12803.test.ts @@ -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' }); + }); +}); diff --git a/packages/app-shell/src/utils/recordFormNavigation.ts b/packages/app-shell/src/utils/recordFormNavigation.ts index 72a6b2b206..cec1174494 100644 --- a/packages/app-shell/src/utils/recordFormNavigation.ts +++ b/packages/app-shell/src/utils/recordFormNavigation.ts @@ -8,6 +8,9 @@ * @module utils/recordFormNavigation */ +import { FormViewSchema, type FormView } from '@objectstack/spec/ui'; +import type { RecordFlowSurface } from '@objectstack/spec/data'; + /** * Subset of the object metadata shape consumed by the resolver. Mirrors the * fields read from the runtime objects list (`useMetadata().objects`). @@ -152,6 +155,8 @@ export interface FormViewDefinition { sections?: any[]; /** Inline child collections (master-detail). */ subforms?: any[]; + /** The modal's size bucket, as the spec's `FormView.modalSize` declares it. */ + modalSize?: FormView['modalSize']; } /** @@ -174,6 +179,12 @@ export interface FormViewModalLayout { contentLayout?: 'tabbed'; /** Inline child collections for a master-detail modal. */ subforms?: any[]; + /** + * The form view's authored `modalSize` (objectui#12803), copied only when + * it is a value the spec's `FormView.modalSize` enum allows. Omitted when + * the form view authors none, so the modal keeps its own default size. + */ + modalSize?: FormView['modalSize']; } /** @@ -189,10 +200,17 @@ export interface FormViewModalLayout { * ViewItem) and fall back to `objectDef.formViews.default` (legacy container). * * Returns an EMPTY object when the object has no form view, or a form view - * that declares no sections — the caller then keeps its existing behavior - * (a flat field list, i.e. the raw object schema). Empty `sections` / - * `subforms` arrays are treated as absent so an empty curation never blanks - * out the form. + * that declares no sections, subforms or `modalSize` — the caller then keeps + * its existing behavior (a flat field list, i.e. the raw object schema, at the + * modal's own default size). Empty `sections` / `subforms` arrays are treated + * as absent so an empty curation never blanks out the form. + * + * `modalSize` is copied independently of `sections` (objectui#12803): a form + * view can size the dialog without curating its fields. Only a value the + * spec's `FormView.modalSize` enum accepts is copied; the enum is read from + * `FormViewSchema` itself, so this file keeps no second copy of it. Where the + * console's create / edit modal combines it with the derived flow surface, + * see {@link resolveRecordFormModalLayout}. */ export function resolveFormViewLayout( objectDef: ObjectDefinitionForFormView | null | undefined, @@ -213,9 +231,40 @@ export function resolveFormViewLayout( layout.subforms = formView.subforms; } + const authoredSize = formView.modalSize; + if (authoredSize !== undefined && FormViewSchema.shape.modalSize.safeParse(authoredSize).success) { + layout.modalSize = authoredSize; + } + return layout; } +/** + * The layout the console's create / edit `` is handed: the default + * form view's layout ({@link resolveFormViewLayout}) with its `modalSize` + * resolved against the record flow's derived surface (objectui#12803). + * + * Precedence, stated here rather than left to spread order: + * + * 1. a derived `size: 'full'` — the phone posture of `deriveRecordFlowSurface` + * — stays `'full'`. It is a viewport rule, not an authored bucket, so an + * authored size never shrinks a phone's full-screen modal; + * 2. otherwise the form view's authored `modalSize` wins on desktop; + * 3. otherwise no `modalSize` key at all, so `` sizes the dialog + * itself (its bounded default) exactly as before. + * + * `derivedSize` is the `size` of `deriveRecordFlowSurface(objectDef, flow)` + * for the flow being opened. + */ +export function resolveRecordFormModalLayout( + objectDef: ObjectDefinitionForFormView | null | undefined, + derivedSize: RecordFlowSurface['size'], +): FormViewModalLayout { + const { modalSize: authoredSize, ...layout } = resolveFormViewLayout(objectDef); + const modalSize = derivedSize === 'full' ? 'full' : authoredSize; + return modalSize ? { ...layout, modalSize } : layout; +} + /** * Result of the post-create-save navigation decision (objectstack-ai/objectstack#2604 save invariant: * *create takes you to the record you made*). `kind: 'none'` means stay put