From 4f106100c0b4fd681d2b0ad42898783115e4a71e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:43:36 +0000 Subject: [PATCH] fix(app-shell): an authored FormView.modalSize sizes the console create/edit modal resolveFormViewLayout now copies the default form view's modalSize when the spec's FormView.modalSize enum accepts it. resolveRecordFormModalLayout states the precedence against the derived flow surface instead of leaving it to spread order: a derived 'full' (the phone posture) stays full, otherwise the authored size wins on desktop, otherwise no modalSize key, as before. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/12803-form-modal-size.md | 5 + packages/app-shell/src/console/AppContent.tsx | 50 ++-- ...ppContent.formViewModalSize-12803.test.tsx | 233 ++++++++++++++++++ .../recordFormModalSize-12803.test.ts | 93 +++++++ .../src/utils/recordFormNavigation.ts | 57 ++++- 5 files changed, 411 insertions(+), 27 deletions(-) create mode 100644 .changeset/12803-form-modal-size.md create mode 100644 packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx create mode 100644 packages/app-shell/src/utils/__tests__/recordFormModalSize-12803.test.ts 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