From 38c8664ac680836907bcb3dad368e9a7d86dcf68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:55:07 +0000 Subject: [PATCH 1/4] fix(console): form entry points render the form view's buttons, layout, defaults and tab keys (wip) Claude-Session: https://claude.ai/code/session_01JQwxbabN2bfLWeaoNk6Jue Co-authored-by: Claude --- ui/apps/console/src/components/FormPage.tsx | 46 +++++- .../app-shell/src/console/AppContent.tsx | 34 +++-- .../src/utils/recordFormNavigation.ts | 134 +++++++++++++++++- .../app-shell/src/views/RecordFormPage.tsx | 28 ++-- .../plugin-form/src/MasterDetailForm.tsx | 12 ++ ui/packages/plugin-form/src/ModalForm.tsx | 28 +++- ui/packages/plugin-form/src/ObjectForm.tsx | 11 +- 7 files changed, 259 insertions(+), 34 deletions(-) diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index e8e5c0833b1..7514b8dd79a 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -1254,6 +1254,12 @@ export function readLoadedRecord( * then write; * 3. an explicit `?prefill_=` query param, which wins over both. * + * A CREATE also reads the form view's own `defaults` (objectstack-ai/objectstack#26843), + * the author's opening values for this form: they sit over source 1, since a + * form view is narrower than its object, and under source 3, since a `prefill_` + * param outranks every default. An edit never reads them: the stored record is + * the truth there. + * * Point 3 is the ruling: "explicit `prefill_` params override the loaded * record's values for the fields they name … record values fill the rest". A * producer that forwards `?recordId=` AND `?prefill_x=` in one URL is @@ -1331,6 +1337,7 @@ export function readPrefill( fields: RenderableField[], search: URLSearchParams, record?: Record | null, + formDefaults?: Record | null, ): Record { const out: Record = {}; for (const f of fields) { @@ -1343,6 +1350,10 @@ export function readPrefill( } else if (f.defaultValue !== undefined && !isRuntimeDefault(f.defaultValue)) { out[f.name] = f.defaultValue; } + // The form view's `defaults`, on a create only — see the docblock. + if (!record && formDefaults && Object.prototype.hasOwnProperty.call(formDefaults, f.name)) { + out[f.name] = formDefaults[f.name]; + } // `hasOwnProperty` rather than a truthiness/undefined test: a stored null // or empty string is a real value on an edit form, and must beat the // default. @@ -1355,6 +1366,20 @@ export function readPrefill( return out; } +/** + * One `FormView.buttons` entry as this page draws it (objectstack-ai/objectstack#26843): + * shown unless the author wrote `show: false`, and the authored label — an + * `I18nLabel`, resolved against the visitor's language — or `''` when there is + * none, so the caller's own label shows. `buttons.reset` is never read: this + * page draws no reset button. + */ +export function readFormButton( + config: { show?: boolean; label?: unknown } | undefined, + language: string, +): { show: boolean; label: string } { + return { show: config?.show !== false, label: pickLocalized(config?.label, language) }; +} + /** * Drop the keys a CREATE payload must leave to the producer (objectui#5883). * @@ -2261,7 +2286,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { // build needs no language and the load does not re-run on a switch. const sections = buildSections(result.form, result.objectSchema); const allFields = sections.flatMap((s) => s.fields); - setValues(readPrefill(allFields, search, result.record)); + setValues(readPrefill(allFields, search, result.record, result.form?.defaults)); }) .catch((e) => { if (!cancelled) { @@ -2284,6 +2309,15 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { [loaded, language], ); + /** + * The form view's `buttons` (objectstack-ai/objectstack#26843), which this + * page never read: an authored `show: false` removes the button, an authored + * `label` replaces this page's own. The public route draws no Cancel, + * authored or not — see {@link FormPageProps.cancelPath}. + */ + const submitButton = readFormButton(loaded?.form?.buttons?.submit, language); + const cancelButton = readFormButton(loaded?.form?.buttons?.cancel, language); + const behavior: EffectiveSubmitBehavior = resolveSubmitBehavior( mode, loaded?.form?.submitBehavior, @@ -2460,7 +2494,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { // means back to the record as loaded — NOT to blank, which would // stage a wipe of every field on the next save. const allFields = sections.flatMap((s) => s.fields); - setValues(readPrefill(allFields, search, loaded.record)); + setValues(readPrefill(allFields, search, loaded.record, loaded.form?.defaults)); break; } case 'next-record': @@ -2706,14 +2740,14 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { )}
- {mode === 'internal' && ( + {mode === 'internal' && cancelButton.show && ( )} {/* Blocked while an upload is in flight (objectui#10167): a file's @@ -2721,6 +2755,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { submit that beats it writes an empty field and still reports success. The widget contract exists for this — see `FieldInputProps.onUploadingChange`. */} + {submitButton.show && (
diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 3d610b8dc92..9b5f24a2633 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -34,7 +34,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, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js'; +import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, 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 { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js'; @@ -322,7 +322,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // worse answer than the literal it replaced. const homePath = useHomePath(); const previewDrafts = usePreviewDrafts(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel, objectPluralLabel } = useObjectLabel(); @@ -1500,9 +1500,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // history entry carries. Each is handed as the object it is, // never a merge rebuilt per render: the form re-seeds whenever // `initialValues` is a new object. - ...(!editingRecord && (formLinkValues ?? overlayCreateValues) - ? { initialValues: formLinkValues ?? overlayCreateValues } - : {}), // Honor the object's DEFAULT FORM VIEW: curated sections (field // selection + order + grouping), `contentLayout: 'tabbed'` when the // view is tabbed, inline child collections (master-detail), and its @@ -1542,7 +1539,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = : t('form.createTitle', { object: objectLabel(formObjectDef as any) }), open: isDialogOpen, onOpenChange: (open: boolean) => { if (!open) closeRecordForm(); }, - layout: 'vertical', // objectui#6514 — the same declared-key gate `RecordFormPage` // runs, through the same helper. This read `f.visible`, a key // `FieldSchema` refuses by name, so nothing an author could @@ -1563,12 +1559,26 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = : [], onSuccess: handleRecordFormSuccess, onCancel: handleDialogCancel, - showSubmit: true, - showCancel: true, - submitText: editingRecord - ? t('form.update', { defaultValue: 'Update' }) - : t('form.create', { defaultValue: 'Create' }), - cancelText: t('common.cancel'), + // objectstack-ai/objectstack#26843 — the selected form view's + // `buttons`, `layout` and `defaults` over this dialog's own + // defaults (`resolveFormViewChrome` states the precedence). A + // create's opening values: the `formLink` pre-link + // (objectstack-ai/objectstack#2604 D3, refresh-safe), else the + // values its opener's history entry carries (objectui#12291), + // over the form view's `defaults`; each handed on as the object + // it is, as the form re-seeds on a new `initialValues`. + ...resolveFormViewChrome( + selectRecordFormView(formObjectDef as any, editingRecord ? 'edit' : 'create'), + { + flow: editingRecord ? 'edit' : 'create', + language, + submitText: editingRecord + ? t('form.update', { defaultValue: 'Update' }) + : t('form.create', { defaultValue: 'Create' }), + cancelText: t('common.cancel'), + createValues: editingRecord ? undefined : formLinkValues ?? overlayCreateValues, + }, + ), }} dataSource={dataSource} /> diff --git a/ui/packages/app-shell/src/utils/recordFormNavigation.ts b/ui/packages/app-shell/src/utils/recordFormNavigation.ts index df4f0b151ec..03039a8619f 100644 --- a/ui/packages/app-shell/src/utils/recordFormNavigation.ts +++ b/ui/packages/app-shell/src/utils/recordFormNavigation.ts @@ -10,6 +10,7 @@ import { FormViewSchema, type FormView } from '@objectstack/spec/ui'; import type { RecordFlowSurface } from '@objectstack/spec/data'; +import { pickLocalized } from '@object-ui/i18n'; /** * Subset of the object metadata shape consumed by the resolver. Mirrors the @@ -157,6 +158,16 @@ export interface FormViewDefinition { subforms?: any[]; /** The modal's size bucket, as the spec's `FormView.modalSize` declares it. */ modalSize?: FormView['modalSize']; + /** Initially active tab of a `tabbed` form view (a section name). */ + defaultTab?: FormView['defaultTab']; + /** Tab strip position of a `tabbed` form view. */ + tabPosition?: FormView['tabPosition']; + /** Field layout direction — see {@link resolveFormViewChrome}. */ + layout?: FormView['layout']; + /** Per-button visibility and label — see {@link resolveFormViewChrome}. */ + buttons?: FormView['buttons']; + /** Create-mode initial values — see {@link resolveFormViewChrome}. */ + defaults?: FormView['defaults']; } /** @@ -227,6 +238,13 @@ export interface FormViewModalLayout { * the form view authors none, so the modal keeps its own default size. */ modalSize?: FormView['modalSize']; + /** + * A `tabbed` form view's `defaultTab` and `tabPosition` + * (objectstack-ai/objectstack#26843), copied only beside + * `contentLayout: 'tabbed'` — the one layout that draws a tab strip. + */ + defaultTab?: string; + tabPosition?: FormView['tabPosition']; } /** @@ -271,7 +289,21 @@ export function resolveFormViewLayout( layout.sections = formView.sections; // Only 'tabbed' maps to a modal content layout; 'wizard'/'split' have no // modal equivalent and degrade to a stacked section list. - if (formView.type === 'tabbed') layout.contentLayout = 'tabbed'; + if (formView.type === 'tabbed') { + layout.contentLayout = 'tabbed'; + // objectstack-ai/objectstack#26843: the tab the form opens on and where + // its strip sits, which the modal dropped. A position is copied only when + // the spec's enum allows it, read from `FormViewSchema` as `modalSize` is. + if (typeof formView.defaultTab === 'string' && formView.defaultTab) { + layout.defaultTab = formView.defaultTab; + } + if ( + formView.tabPosition !== undefined + && FormViewSchema.shape.tabPosition.safeParse(formView.tabPosition).success + ) { + layout.tabPosition = formView.tabPosition; + } + } } if (Array.isArray(formView.subforms) && formView.subforms.length > 0) { @@ -314,6 +346,106 @@ export function resolveRecordFormModalLayout( return modalSize ? { ...layout, modalSize } : layout; } +/** + * The form chrome a console form host draws from the form view it opens + * (objectstack-ai/objectstack#26843), ready to spread into an `` or + * `` schema: the action buttons' visibility and labels, the field + * layout, and a create's opening values. + */ +export interface FormViewChrome { + showSubmit: boolean; + submitText: string; + showCancel: boolean; + cancelText: string; + layout: 'vertical' | 'horizontal'; + /** Omitted when the flow opens with no values (an edit, or a bare create). */ + initialValues?: Record; +} + +/** + * The opening values of a create that carries both a form view's `defaults` + * and the host's own values, per `defaults` payload. The forms re-seed whenever + * `initialValues` is a new object, so a merge rebuilt per render would wipe + * what the user typed. The cache keys on the metadata payload and on the + * host's values by CONTENT, never on a memoised identity (ui/AGENTS.md #10). + */ +const mergedCreateValues = new WeakMap }>(); + +function createSeed( + defaults: Record | undefined, + createValues: Record | undefined, +): Record | undefined { + if (!defaults || Object.keys(defaults).length === 0) return createValues; + if (!createValues) return defaults; + let key: string; + try { + key = JSON.stringify(createValues); + } catch { + return { ...defaults, ...createValues }; + } + const hit = mergedCreateValues.get(defaults); + if (hit && hit.key === key) return hit.values; + const values = { ...defaults, ...createValues }; + mergedCreateValues.set(defaults, { key, values }); + return values; +} + +/** + * Resolve the {@link FormViewChrome} of the form view a record flow opens. + * + * Every console form host used to hard-set these props — `showSubmit: true`, + * its own submit label, `layout: 'vertical'` — over the keys the form view + * declares, so an authored `buttons`, `layout` or `defaults` saved, published + * and never rendered. No flat `showSubmit` / `submitText` key is an author's + * (the spec's form view refuses them), so the host's values are console + * defaults and the author's keys win: + * + * - `buttons.submit` / `buttons.cancel`: an authored `show` over the host's + * `true`; an authored `label`, an `I18nLabel` resolved against `language`, + * over the host's label. `buttons.reset` is not read: no host draws a + * reset button. + * - `layout`: the authored value when the spec's enum allows it, else + * `'vertical'`, the renderer default. + * - `defaults`, on a create only: the opening values under the ones the host + * already carries (`createValues`: a URL prefill, a parent pre-link, a + * seeded day), which win per field. + */ +export function resolveFormViewChrome( + formView: FormViewDefinition | null | undefined, + opts: { + flow: RecordFormFlow; + /** The UI language an authored `I18nLabel` resolves against. */ + language: string; + /** The host's submit label when the form view authors none. */ + submitText: string; + /** The host's cancel label when the form view authors none. */ + cancelText: string; + /** The values the host opens a create with, if any. */ + createValues?: Record; + }, +): FormViewChrome { + const { submit, cancel } = formView?.buttons ?? {}; + const layout = formView?.layout !== undefined + && FormViewSchema.shape.layout.safeParse(formView.layout).success + ? formView.layout + : undefined; + const chrome: FormViewChrome = { + showSubmit: submit?.show ?? true, + submitText: pickLocalized(submit?.label, opts.language) || opts.submitText, + showCancel: cancel?.show ?? true, + cancelText: pickLocalized(cancel?.label, opts.language) || opts.cancelText, + layout: layout ?? 'vertical', + }; + const defaults = formView?.defaults && typeof formView.defaults === 'object' && !Array.isArray(formView.defaults) + ? formView.defaults + : undefined; + const initialValues = opts.flow === 'create' + ? createSeed(defaults, opts.createValues) + : opts.createValues; + if (initialValues) chrome.initialValues = initialValues; + return chrome; +} + /** * 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 diff --git a/ui/packages/app-shell/src/views/RecordFormPage.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index 4442d066e0b..8d53e9aec6a 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.tsx @@ -48,7 +48,7 @@ import { import { buildExpressionUser } from '../providers/expressionUser.js'; import { SkeletonDetail } from '../skeletons/index.js'; import { ManagedByBadge } from '../components/ManagedByBadge.js'; -import { selectRecordFormView } from '../utils/recordFormNavigation.js'; +import { resolveFormViewChrome, selectRecordFormView } from '../utils/recordFormNavigation.js'; import { useAuth } from '@object-ui/auth'; export interface RecordFormPageProps { @@ -74,7 +74,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { const [searchParams] = useSearchParams(); const dataSource = useAdapter(); const { objects, loading: metadataLoading } = useMetadata(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel, objectPluralLabel } = useObjectLabel(); const { user, getAuthConfig, activeOrganization } = useAuth(); @@ -341,6 +341,17 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { splitResizable: formDef.splitResizable, } : {}; + // objectstack-ai/objectstack#26843 — the form view's `buttons`, `layout` + // and `defaults`, which this page used to hard-set over (`showSubmit: true`, + // `layout: 'vertical'`, its own labels). `defaults` seed a create under the + // URL prefill, which still wins per field. + const formChrome = resolveFormViewChrome(formDef, { + flow: mode, + language, + submitText: t('form.saveRecord'), + cancelText: t('common.cancel'), + createValues: prefillValues, + }); return ( @@ -410,15 +421,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { objectName: objectDef.name, mode, recordId: mode === 'edit' ? recordId : undefined, - ...(prefillValues && { initialValues: prefillValues }), - // framework#1894 / objectstack-ai/objectstack#2998: honor the spec-aligned structured - // `buttons`/`defaults` from the object's form view. ObjectForm - // folds them onto its flat props; an explicit `initialValues` - // (URL prefill above) still wins over `defaults`. - ...(formDef.buttons && { buttons: formDef.buttons }), - ...(formDef.defaults && { defaults: formDef.defaults }), title: pageTitle, - layout: 'vertical', fields, // Master-detail by config: if the object's form view declares // inline child collections, ObjectForm renders them as an atomic @@ -427,10 +430,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { ?? (objectDef as any).formViews?.default?.subforms, onSuccess: handleSuccess, onCancel: handleCancel, - showSubmit: true, - showCancel: true, - submitText: t('form.saveRecord'), - cancelText: t('common.cancel'), + ...formChrome, }} dataSource={dataSource ?? undefined} /> diff --git a/ui/packages/plugin-form/src/MasterDetailForm.tsx b/ui/packages/plugin-form/src/MasterDetailForm.tsx index 961c4b48dc1..88308eb4f9b 100644 --- a/ui/packages/plugin-form/src/MasterDetailForm.tsx +++ b/ui/packages/plugin-form/src/MasterDetailForm.tsx @@ -141,6 +141,15 @@ export interface MasterDetailFormSchema { sections?: any[]; fields?: any[]; formType?: 'simple' | 'tabbed'; + /** + * The parent header form's initially active tab (a section name) and tab + * strip position, read when `formType` is `'tabbed'`, and its field layout — + * passed straight through to the header `ObjectForm`, as `sections` are + * (objectstack-ai/objectstack#26843). + */ + defaultTab?: string; + tabPosition?: 'top' | 'bottom' | 'left' | 'right'; + layout?: 'vertical' | 'horizontal'; /** * `title`, `submitText` and `cancelText` are the spec's `I18nLabel`, as * `ComponentPropsMap['object-master-detail-form']` in `@objectstack/spec` @@ -1969,6 +1978,9 @@ export const MasterDetailForm: React.FC = ({ initialValues: schema.initialValues, initialData: schema.initialData, formType: schema.formType, + defaultTab: schema.defaultTab, + tabPosition: schema.tabPosition, + layout: schema.layout, sections: schema.sections, fields: schema.fields, // The resolved string: `ObjectFormSchema.title` is a `string`. diff --git a/ui/packages/plugin-form/src/ModalForm.tsx b/ui/packages/plugin-form/src/ModalForm.tsx index 7f87066f814..24da657ed65 100644 --- a/ui/packages/plugin-form/src/ModalForm.tsx +++ b/ui/packages/plugin-form/src/ModalForm.tsx @@ -167,6 +167,14 @@ export interface ModalFormSchema { /** Internal content layout (ADR-0050, objectstack-ai/objectstack#1890): 'tabbed' renders sections as * tabs inside the modal, so "modal + tabbed" composes. Default stacks them. */ contentLayout?: 'simple' | 'tabbed'; + /** + * The `tabbed` content layout's initially active tab (a section name) and + * tab strip position, as the spec's `FormView.defaultTab` / `tabPosition` + * declare them (objectstack-ai/objectstack#26843). Read by the tabbed arm + * only, the subforms arm included. + */ + defaultTab?: string; + tabPosition?: 'top' | 'bottom' | 'left' | 'right'; fields?: string[]; customFields?: FormField[]; @@ -968,6 +976,11 @@ export const ModalForm: React.FC = ({ visibleWhen: g.visibleWhen, visibleWhenFault: g.visibleWhenFault, })), + // objectstack-ai/objectstack#26843 — the authored opening tab and + // strip position, which this arm dropped. The renderer opens on + // `defaultFieldTab` only when it names a tab it drew. + defaultFieldTab: schema.defaultTab, + fieldTabsPosition: schema.tabPosition, }; return ; } @@ -1150,12 +1163,25 @@ export const ModalForm: React.FC = ({ // way the flat form path does. initialValues: schema.initialValues, initialData: schema.initialData, + // objectstack-ai/objectstack#26843 — the content layout, its + // tab keys and the field layout reach the parent header form, + // as the flat arm draws them; this call used to pass none, so + // a tabbed form with subforms opened stacked. + formType: schema.contentLayout === 'tabbed' ? 'tabbed' : 'simple', + defaultTab: schema.defaultTab, + tabPosition: schema.tabPosition, + layout: formLayout, submitText: submitLabel, cancelText: cancelLabel, + // The action bar answers the same two flags the flat arm's + // footer does: no Save bar when submit is hidden, and the + // Cancel button is drawn only when this host hands an + // `onCancel` (the dialog still closes on Escape and its X). + showSubmit, details: subforms as any, onSuccess: async (rec: any) => { await schema.onSuccess?.(rec); schema.onOpenChange?.(false); }, onError: schema.onError, - onCancel: () => schema.onOpenChange?.(false), + onCancel: showCancel ? () => schema.onOpenChange?.(false) : undefined, }} dataSource={dataSource} /> diff --git a/ui/packages/plugin-form/src/ObjectForm.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index b41f9006841..a95aa409359 100644 --- a/ui/packages/plugin-form/src/ObjectForm.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.tsx @@ -382,6 +382,12 @@ export const ObjectForm: React.FC = ({ initialValues: (schema as any).initialValues, initialData: (schema as any).initialData, formType: schema.formType === 'tabbed' ? 'tabbed' : 'simple', + // objectstack-ai/objectstack#26843 — the tabbed header's opening tab + // and strip position, and the field layout, which this rebuild + // dropped before the header form could read them. + defaultTab: schema.defaultTab, + tabPosition: schema.tabPosition, + layout: schema.layout, sections: schema.sections as any, fields: schema.fields as any, title: schema.title, @@ -393,7 +399,10 @@ export const ObjectForm: React.FC = ({ details: (schema as any).subforms, onSuccess: schema.onSuccess, onError: schema.onError, - onCancel: schema.onCancel, + // The master-detail action bar draws Cancel only when handed an + // `onCancel`, so a hidden cancel button is no handler + // (objectstack-ai/objectstack#26843). + onCancel: schema.showCancel === false ? undefined : schema.onCancel, className: schema.className, }} dataSource={dataSource} From afd3b4c0acf6adde321fd929a86f888a177e6157 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:59:08 +0000 Subject: [PATCH 2/4] test(console): pin the form view's buttons, layout, defaults and tab keys on every form entry point Claude-Session: https://claude.ai/code/session_01JQwxbabN2bfLWeaoNk6Jue Co-authored-by: Claude --- .../FormPage.formViewChrome-26843.test.tsx | 158 +++++++++++++ .../AppContent.formViewChrome-26843.test.tsx | 219 ++++++++++++++++++ .../__tests__/formViewChrome-26843.test.ts | 122 ++++++++++ ...cordFormPage.formViewChrome-26843.test.tsx | 126 ++++++++++ .../formViewTabKeysAndButtons-26843.test.tsx | 198 ++++++++++++++++ 5 files changed, 823 insertions(+) create mode 100644 ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx create mode 100644 ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx create mode 100644 ui/packages/app-shell/src/utils/__tests__/formViewChrome-26843.test.ts create mode 100644 ui/packages/app-shell/src/views/RecordFormPage.formViewChrome-26843.test.tsx create mode 100644 ui/packages/plugin-form/src/__tests__/formViewTabKeysAndButtons-26843.test.tsx diff --git a/ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx b/ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx new file mode 100644 index 00000000000..ab9e61baf69 --- /dev/null +++ b/ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#26843 — the form page renders the form view's + * `buttons` and `defaults`, which it never read. + * + * - `buttons.submit` / `buttons.cancel`: an authored `label` replaces the + * page's own, an authored `show: false` removes the button. The public + * route still draws no Cancel at all. + * - `defaults`: a create opens with them, over the object's own field + * defaults and under a `?prefill_` param; an edit never reads them. + * + * CONTROL: a form view authoring neither keeps the page's own `Submit` / + * `Cancel` and opens blank. + */ + +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createI18n } from '@object-ui/i18n'; +import { FormPage, readFormButton, readPrefill } from './FormPage'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const OBJECT_SCHEMA = { + name: 'showcase_inquiry', + fields: { + title: { type: 'text', label: 'Title' }, + channel: { type: 'text', label: 'Channel', defaultValue: 'phone' }, + }, +}; + +function viewBody(form: Record) { + return { + name: 'showcase_inquiry.intake', + object: 'showcase_inquiry', + viewKind: 'form', + label: 'Intake', + config: { type: 'simple', sections: [{ fields: ['title', 'channel'] }], ...form }, + }; +} + +function stubFetch(form: Record) { + return vi.fn(async (url: string) => { + const path = String(url); + const body = path.includes('/meta/view/') + ? viewBody(form) + : path.includes('/meta/object/') + ? OBJECT_SCHEMA + : path.includes('/forms/intake') + ? { slug: 'intake', object: 'showcase_inquiry', form: viewBody(form).config, objectSchema: OBJECT_SCHEMA } + : path.includes('/data/showcase_inquiry/r1') + ? { record: { id: 'r1', title: 'Stored', channel: 'email' } } + : null; + if (!body) throw new Error(`unstubbed fetch: ${path}`); + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; + }); +} + +function renderAt(url: string, mode: 'internal' | 'public') { + createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + return render( + + + } /> + } /> + + , + ); +} + +const input = (name: string) => document.querySelector(`[name="${name}"]`) as HTMLInputElement | null; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('FormPage renders the form view\'s buttons (#26843)', () => { + it('internal: the authored submit label shows, and a hidden cancel is gone', async () => { + vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'File inquiry' }, cancel: { show: false } } })); + renderAt('/forms/showcase_inquiry.intake', 'internal'); + expect(await screen.findByRole('button', { name: 'File inquiry' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); + }); + + it('internal: an authored cancel label shows; a hidden submit is gone', async () => { + vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { show: false }, cancel: { label: 'Back' } } })); + const { container } = renderAt('/forms/showcase_inquiry.intake', 'internal'); + expect(await screen.findByRole('button', { name: 'Back' })).toBeInTheDocument(); + expect(container.querySelector('button[type="submit"]')).toBeNull(); + }); + + it('public: the authored submit label shows, and there is still no Cancel', async () => { + vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'Send' }, cancel: { label: 'Back' } } })); + renderAt('/f/intake', 'public'); + expect(await screen.findByRole('button', { name: 'Send' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); + }); + + it('CONTROL: no `buttons` — the page\'s own Submit and Cancel', async () => { + vi.stubGlobal('fetch', stubFetch({})); + renderAt('/forms/showcase_inquiry.intake', 'internal'); + expect(await screen.findByRole('button', { name: 'Submit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); +}); + +describe('FormPage seeds a create with the form view\'s defaults (#26843)', () => { + it('a create opens with them, over the object\'s own field default', async () => { + vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } })); + renderAt('/forms/showcase_inquiry.intake', 'internal'); + await waitFor(() => expect(input('title')?.value).toBe('Website lead')); + expect(input('channel')?.value).toBe('web'); + }); + + it('a `prefill_` param still wins per field', async () => { + vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } })); + renderAt('/forms/showcase_inquiry.intake?prefill_channel=chat', 'internal'); + await waitFor(() => expect(input('channel')?.value).toBe('chat')); + expect(input('title')?.value).toBe('Website lead'); + }); + + it('an edit reads the stored record, never the defaults', async () => { + vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } })); + renderAt('/forms/showcase_inquiry.intake?recordId=r1', 'internal'); + await waitFor(() => expect(input('title')?.value).toBe('Stored')); + expect(input('channel')?.value).toBe('email'); + }); + + it('CONTROL: no defaults — the object\'s own field default, else blank', async () => { + vi.stubGlobal('fetch', stubFetch({})); + renderAt('/forms/showcase_inquiry.intake', 'internal'); + await waitFor(() => expect(input('channel')?.value).toBe('phone')); + expect(input('title')?.value).toBe(''); + }); +}); + +describe('the two helpers, directly', () => { + it('readFormButton: shown unless `show: false`; a per-locale label resolves', () => { + expect(readFormButton(undefined, 'en')).toStrictEqual({ show: true, label: '' }); + expect(readFormButton({ show: false }, 'en').show).toBe(false); + expect(readFormButton({ label: { en: 'Send', 'zh-CN': '发送' } }, 'zh-CN').label).toBe('发送'); + }); + + it('readPrefill: defaults only for a field the form draws, and never on an edit', () => { + const fields = [{ name: 'title' }, { name: 'channel', defaultValue: 'phone' }] as any; + const search = new URLSearchParams(); + expect(readPrefill(fields, search, null, { title: 'T', unknown_field: 'x' })).toStrictEqual({ title: 'T', channel: 'phone' }); + expect(readPrefill(fields, search, { title: 'Stored' }, { title: 'T' })).toStrictEqual({ title: 'Stored', channel: 'phone' }); + }); +}); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx new file mode 100644 index 00000000000..1b80c739d73 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx @@ -0,0 +1,219 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#26843 — the console's New / Edit dialog hands the + * selected form view's `buttons`, `layout`, `defaults`, `defaultTab` and + * `tabPosition` to the `ModalForm`, instead of setting `showSubmit: true`, its + * own labels and `layout: 'vertical'` over them. + * + * The `ModalForm` is a probe recording each schema it is handed; one cell + * renders the real one to show the authored label on the dialog's button. + * CONTROL: an object whose form view authors none of the keys keeps the + * dialog's own labels, both buttons and the vertical layout. + */ + +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'; + +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 }), +})); + +const FIELDS = { + subject: { type: 'text', label: 'Subject' }, + status: { type: 'text', label: 'Status' }, + account: { type: 'text', label: 'Account' }, +}; + +const AUTHORED = { + name: 'crm_case', + label: 'Case', + fields: FIELDS, + form: { + type: 'tabbed', + layout: 'horizontal', + defaultTab: 'more', + tabPosition: 'left', + buttons: { submit: { label: 'File case' }, cancel: { show: false } }, + defaults: { status: 'new', account: 'acct-default' }, + sections: [ + { name: 'main', label: 'Main', fields: ['subject'] }, + { name: 'more', label: 'More', fields: ['status', 'account'] }, + ], + }, +}; +const PLAIN = { name: 'crm_note', label: 'Note', fields: FIELDS, form: { type: 'simple' } }; +const OBJECTS = [AUTHORED, 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'; + +const lastModal = () => probe.modalSchemas[probe.modalSchemas.length - 1]; + +/** 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(); +} + +beforeEach(() => { + probe.modalSchemas.length = 0; + probe.realModal = false; + vi.clearAllMocks(); +}); +afterEach(cleanup); + +describe('#26843 — the New dialog renders the form view\'s buttons, layout and defaults', () => { + it('the authored submit label and hidden cancel reach the ModalForm', async () => { + const schema = await openAt('/apps/crm/crm_case?form=new', 'crm_case'); + expect(schema).toMatchObject({ showSubmit: true, submitText: 'File case', showCancel: false }); + }); + + it('the authored horizontal layout reaches the ModalForm', async () => { + expect((await openAt('/apps/crm/crm_case?form=new', 'crm_case')).layout).toBe('horizontal'); + }); + + it('defaults seed the create', async () => { + expect((await openAt('/apps/crm/crm_case?form=new', 'crm_case')).initialValues) + .toStrictEqual({ status: 'new', account: 'acct-default' }); + }); + + it('a parent pre-link wins per field over the defaults, and the seed keeps its identity across renders', async () => { + const schema = await openAt('/apps/crm/crm_note?form=new&formObject=crm_case&formLink=account:acct-parent', 'crm_case'); + expect(schema.initialValues).toStrictEqual({ status: 'new', account: 'acct-parent' }); + const seeds = new Set(probe.modalSchemas.filter((s) => s.open && s.objectName === 'crm_case').map((s) => s.initialValues)); + expect(seeds.size).toBe(1); + }); + + it('the tabbed form view\'s defaultTab and tabPosition reach the ModalForm', async () => { + expect(await openAt('/apps/crm/crm_case?form=new', 'crm_case')) + .toMatchObject({ contentLayout: 'tabbed', defaultTab: 'more', tabPosition: 'left' }); + }); + + it('an edit keeps the buttons and layout and reads no defaults', async () => { + const schema = await openAt('/apps/crm/crm_case?form=r1', 'crm_case'); + expect(schema).toMatchObject({ mode: 'edit', submitText: 'File case', showCancel: false, layout: 'horizontal' }); + expect(schema.initialValues).toBeUndefined(); + }); + + it('the real dialog draws the authored label and no Cancel', async () => { + probe.realModal = true; + await openAt('/apps/crm/crm_case?form=new', 'crm_case'); + const dialog = await screen.findByRole('dialog', undefined, { timeout: 5000 }); + await waitFor(() => expect(dialog.querySelector('[data-testid="modal-form-footer"]')).not.toBeNull(), { timeout: 5000 }); + expect(screen.getByRole('button', { name: 'File case' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); + }); + + it('CONTROL: a form view authoring none of them keeps the dialog\'s own chrome', async () => { + const schema = await openAt('/apps/crm/crm_note?form=new', 'crm_note'); + expect(schema).toMatchObject({ showSubmit: true, submitText: 'Create', showCancel: true, layout: 'vertical' }); + expect(schema.initialValues).toBeUndefined(); + expect(schema.contentLayout).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/utils/__tests__/formViewChrome-26843.test.ts b/ui/packages/app-shell/src/utils/__tests__/formViewChrome-26843.test.ts new file mode 100644 index 00000000000..20ef42776d1 --- /dev/null +++ b/ui/packages/app-shell/src/utils/__tests__/formViewChrome-26843.test.ts @@ -0,0 +1,122 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#26843 — the form view's `buttons`, `layout`, + * `defaults`, `defaultTab` and `tabPosition` reach the console's form hosts. + * + * `resolveFormViewChrome` is the one place the full-page record form and the + * New / Edit dialog read the first three from; `resolveFormViewLayout` carries + * the tab keys beside the modal's tabbed content layout. Each block has a + * CONTROL with the key absent, which keeps the host's own answer. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveFormViewChrome, resolveFormViewLayout } from '../recordFormNavigation'; + +const HOST = { language: 'en', submitText: 'Save', cancelText: 'Cancel' } as const; + +describe('resolveFormViewChrome — buttons', () => { + it('an authored label and show win over the host defaults', () => { + const chrome = resolveFormViewChrome( + { buttons: { submit: { label: 'Send request' }, cancel: { show: false, label: 'Back' } } }, + { ...HOST, flow: 'create' }, + ); + expect(chrome).toMatchObject({ + showSubmit: true, + submitText: 'Send request', + showCancel: false, + cancelText: 'Back', + }); + }); + + it('an authored per-locale label resolves against the UI language', () => { + const buttons = { submit: { label: { en: 'Send', 'zh-CN': '发送' } } }; + expect(resolveFormViewChrome({ buttons }, { ...HOST, flow: 'edit' }).submitText).toBe('Send'); + expect(resolveFormViewChrome({ buttons }, { ...HOST, language: 'zh-CN', flow: 'edit' }).submitText).toBe('发送'); + }); + + it('`show: false` on submit hides it', () => { + expect(resolveFormViewChrome({ buttons: { submit: { show: false } } }, { ...HOST, flow: 'edit' }).showSubmit).toBe(false); + }); + + it('CONTROL: no form view, or no `buttons` — the host labels, both shown', () => { + for (const formView of [undefined, {}, { buttons: {} }]) { + expect(resolveFormViewChrome(formView, { ...HOST, flow: 'create' })).toStrictEqual({ + showSubmit: true, + submitText: 'Save', + showCancel: true, + cancelText: 'Cancel', + layout: 'vertical', + }); + } + }); +}); + +describe('resolveFormViewChrome — layout', () => { + it('an authored horizontal reaches the form', () => { + expect(resolveFormViewChrome({ layout: 'horizontal' }, { ...HOST, flow: 'edit' }).layout).toBe('horizontal'); + }); + + it('a value the spec enum refuses (the retired `grid`) is not copied', () => { + expect(resolveFormViewChrome({ layout: 'grid' as any }, { ...HOST, flow: 'edit' }).layout).toBe('vertical'); + }); +}); + +describe('resolveFormViewChrome — defaults', () => { + const defaults = { status: 'new', priority: 'high' }; + + it('seed a create', () => { + expect(resolveFormViewChrome({ defaults }, { ...HOST, flow: 'create' }).initialValues).toBe(defaults); + }); + + it('the host values (a URL prefill) win per field over the defaults', () => { + const chrome = resolveFormViewChrome({ defaults }, { ...HOST, flow: 'create', createValues: { priority: 'low', account: 'a1' } }); + expect(chrome.initialValues).toStrictEqual({ status: 'new', priority: 'low', account: 'a1' }); + }); + + it('the merged seed keeps its identity across calls with equal inputs — the forms re-seed on a new object', () => { + const first = resolveFormViewChrome({ defaults }, { ...HOST, flow: 'create', createValues: { priority: 'low' } }); + const second = resolveFormViewChrome({ defaults }, { ...HOST, flow: 'create', createValues: { priority: 'low' } }); + expect(second.initialValues).toBe(first.initialValues); + const changed = resolveFormViewChrome({ defaults }, { ...HOST, flow: 'create', createValues: { priority: 'urgent' } }); + expect(changed.initialValues).not.toBe(first.initialValues); + expect(changed.initialValues).toMatchObject({ priority: 'urgent' }); + }); + + it('an edit never reads them', () => { + expect(resolveFormViewChrome({ defaults }, { ...HOST, flow: 'edit' }).initialValues).toBeUndefined(); + }); + + it('CONTROL: no defaults — the host values pass through as the object they are', () => { + const createValues = { due_date: '2026-10-20' }; + expect(resolveFormViewChrome({}, { ...HOST, flow: 'create', createValues }).initialValues).toBe(createValues); + expect('initialValues' in resolveFormViewChrome({}, { ...HOST, flow: 'create' })).toBe(false); + }); +}); + +describe('resolveFormViewLayout — the tab keys ride with the tabbed content layout', () => { + const sections = [{ name: 'a', fields: ['x'] }, { name: 'b', fields: ['y'] }]; + + it('a tabbed form view carries defaultTab and tabPosition', () => { + expect(resolveFormViewLayout({ form: { type: 'tabbed', sections, defaultTab: 'b', tabPosition: 'left' } })).toStrictEqual({ + sections, + contentLayout: 'tabbed', + defaultTab: 'b', + tabPosition: 'left', + }); + }); + + it('a position the spec enum refuses is not copied', () => { + expect(resolveFormViewLayout({ form: { type: 'tabbed', sections, tabPosition: 'middle' as any } }).tabPosition).toBeUndefined(); + }); + + it('CONTROL: a stacked form view carries neither, even when it authors them', () => { + expect(resolveFormViewLayout({ form: { type: 'simple', sections, defaultTab: 'b', tabPosition: 'left' } })).toStrictEqual({ sections }); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordFormPage.formViewChrome-26843.test.tsx b/ui/packages/app-shell/src/views/RecordFormPage.formViewChrome-26843.test.tsx new file mode 100644 index 00000000000..7f07c79abaa --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordFormPage.formViewChrome-26843.test.tsx @@ -0,0 +1,126 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#26843 — the full-page record form hands the form + * view's `buttons`, `layout` and `defaults` to the form, instead of setting its + * own `showSubmit: true`, submit label and `layout: 'vertical'` over them. + * + * The form is a stand-in that records the schema the page hands it. CONTROL: + * an object whose form view authors none of the keys keeps the page's own + * labels, both buttons and the vertical layout. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { RecordFormPage } from './RecordFormPage'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'admin' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: null, + }), +})); + +const { seen } = vi.hoisted(() => ({ seen: [] as Array> })); + +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: ({ schema }: { schema: Record }) => { + seen.push(schema); + return ; + }, +})); + +const FIELDS = { subject: { type: 'text' }, status: { type: 'text' }, account: { type: 'text' } }; + +const OBJECTS = [ + { + name: 'crm_case', + label: 'Case', + editMode: 'page', + fields: FIELDS, + form: { + type: 'simple', + layout: 'horizontal', + buttons: { submit: { label: 'File case' }, cancel: { show: false } }, + defaults: { status: 'new', account: 'acct-default' }, + sections: [{ name: 'main', fields: ['subject', 'status', 'account'] }], + }, + }, + { + name: 'crm_note', + label: 'Note', + editMode: 'page', + fields: FIELDS, + form: { type: 'simple', sections: [{ name: 'main', fields: ['subject'] }] }, + }, +]; + +vi.mock('../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ objects: OBJECTS, loading: false }), +})); +vi.mock('../providers/AdapterProvider', () => ({ + useAdapter: () => null, +})); + +afterEach(() => { + cleanup(); + seen.length = 0; +}); + +function formSchema(path: string, mode: 'create' | 'edit'): Record { + const route = mode === 'create' ? '/apps/:appName/:objectName/new' : '/apps/:appName/:objectName/record/:recordId/edit'; + render( + + + } /> + + , + ); + return seen[seen.length - 1]; +} + +describe('RecordFormPage renders the form view\'s buttons, layout and defaults (#26843)', () => { + it('the authored submit label and hidden cancel reach the form', () => { + const schema = formSchema('/apps/demo/crm_case/new', 'create'); + expect(schema).toMatchObject({ showSubmit: true, submitText: 'File case', showCancel: false }); + }); + + it('the authored horizontal layout reaches the form', () => { + expect(formSchema('/apps/demo/crm_case/new', 'create').layout).toBe('horizontal'); + }); + + it('defaults seed a create, and a URL prefill still wins per field', () => { + expect(formSchema('/apps/demo/crm_case/new', 'create').initialValues) + .toStrictEqual({ status: 'new', account: 'acct-default' }); + cleanup(); + expect(formSchema('/apps/demo/crm_case/new?account=acct-parent', 'create').initialValues) + .toStrictEqual({ status: 'new', account: 'acct-parent' }); + }); + + it('an edit keeps the buttons and layout and reads no defaults', () => { + const schema = formSchema('/apps/demo/crm_case/record/c1/edit', 'edit'); + expect(schema).toMatchObject({ submitText: 'File case', showCancel: false, layout: 'horizontal' }); + expect(schema.initialValues).toBeUndefined(); + }); + + it('CONTROL: a form view authoring none of them keeps the page\'s own chrome', () => { + const schema = formSchema('/apps/demo/crm_note/new', 'create'); + expect(schema).toMatchObject({ showSubmit: true, showCancel: true, layout: 'vertical' }); + expect(schema.submitText).toBeTruthy(); + expect(schema.submitText).not.toBe('File case'); + expect(schema.initialValues).toBeUndefined(); + }); +}); diff --git a/ui/packages/plugin-form/src/__tests__/formViewTabKeysAndButtons-26843.test.tsx b/ui/packages/plugin-form/src/__tests__/formViewTabKeysAndButtons-26843.test.tsx new file mode 100644 index 00000000000..b2a478e2a9f --- /dev/null +++ b/ui/packages/plugin-form/src/__tests__/formViewTabKeysAndButtons-26843.test.tsx @@ -0,0 +1,198 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#26843 — a tabbed form view's `defaultTab` / + * `tabPosition`, and the cancel button's visibility, on the arms that dropped + * them. + * + * - A tabbed form WITH subforms renders through `MasterDetailForm`, whose + * header form `ObjectForm` rebuilt key by key without the tab keys, so it + * opened on its first tab with the strip on top whatever the view said. + * - `ModalForm`'s tabbed content layout read neither key, and its subforms + * arm passed no `formType` at all, so a tabbed modal with subforms opened + * stacked. + * - The master-detail action bar draws Cancel whenever it is handed an + * `onCancel`, and both arms handed one whatever `showCancel` said. + * + * Each case has a CONTROL with the key absent, which keeps today's answer. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import React from 'react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; +import { ModalForm } from '../ModalForm'; + +vi.mock('@object-ui/components', async (orig) => { + const actual = await (orig as any)(); + return { ...actual, toast: { ...actual.toast, success: vi.fn(), error: vi.fn() } }; +}); + +registerAllFields(); + +const parentObject = { + name: 'po', + fields: { ref: { type: 'text', label: 'Ref' }, memo: { type: 'text', label: 'Memo' } }, +}; + +const SECTIONS = [ + { name: 's1', label: 'Sec One', fields: ['ref'] }, + { name: 's2', label: 'Sec Two', fields: ['memo'] }, +]; + +const SUBFORMS = [ + { + childObject: 'po_line', + relationshipField: 'po', + columns: [{ name: 'qty', label: 'Qty', type: 'number' }], + }, +]; + +let dataSource: any; + +beforeEach(() => { + dataSource = { + getObjectSchema: vi.fn().mockResolvedValue(parentObject), + find: vi.fn().mockResolvedValue({ data: [] }), + findOne: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: 'po1' }), + update: vi.fn().mockResolvedValue({ id: 'po1' }), + delete: vi.fn(), + bulk: vi.fn(), + batchTransaction: vi.fn().mockResolvedValue({ results: [{ id: 'po1' }] }), + }; +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const trigger = (key: string) => screen.queryByTestId(`form-tab:${key}`); + +async function tabsDrawn() { + await waitFor(() => expect(trigger('s1')).not.toBeNull()); +} + +function renderObjectForm(extra: Record) { + render( + {}, + ...extra, + } as any} + dataSource={dataSource} + />, + ); +} + +function renderModalForm(extra: Record) { + render( + , + ); +} + +describe('a tabbed form with subforms opens on its declared tab (ObjectForm → MasterDetailForm)', () => { + it('defaultTab selects the tab and tabPosition places the strip', async () => { + renderObjectForm({ defaultTab: 's2', tabPosition: 'left' }); + await tabsDrawn(); + + expect(trigger('s2')).toHaveAttribute('data-state', 'active'); + expect(trigger('s1')).toHaveAttribute('data-state', 'inactive'); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('CONTROL: neither key — the first tab, the strip on top', async () => { + renderObjectForm({}); + await tabsDrawn(); + + expect(trigger('s1')).toHaveAttribute('data-state', 'active'); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal'); + }); +}); + +describe('the modal tabbed layout honours the tab keys, with and without subforms', () => { + it('flat: defaultTab selects the tab and tabPosition places the strip', async () => { + renderModalForm({ defaultTab: 's2', tabPosition: 'right' }); + await tabsDrawn(); + + expect(trigger('s2')).toHaveAttribute('data-state', 'active'); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('subforms: the header form is tabbed and opens on defaultTab', async () => { + renderModalForm({ defaultTab: 's2', subforms: SUBFORMS }); + await tabsDrawn(); + + expect(trigger('s2')).toHaveAttribute('data-state', 'active'); + }); + + it('CONTROL: subforms with no contentLayout stay stacked — no tab strip', async () => { + renderModalForm({ contentLayout: undefined, subforms: SUBFORMS }); + await waitFor(() => expect(screen.getByText('Sec Two')).toBeInTheDocument()); + + expect(trigger('s1')).toBeNull(); + }); +}); + +describe('the master-detail action bar draws Cancel only when it is shown', () => { + it('ObjectForm: showCancel false removes Cancel and keeps Save', async () => { + renderObjectForm({ showCancel: false }); + await tabsDrawn(); + + expect(screen.queryByTestId('md-form-cancel')).toBeNull(); + expect(screen.getByTestId('md-form-submit')).toBeInTheDocument(); + }); + + it('ObjectForm CONTROL: showCancel unset keeps Cancel', async () => { + renderObjectForm({}); + await tabsDrawn(); + + expect(screen.getByTestId('md-form-cancel')).toBeInTheDocument(); + }); + + it('ModalForm: showCancel false removes Cancel; showSubmit false removes the Save bar', async () => { + renderModalForm({ subforms: SUBFORMS, showCancel: false }); + await tabsDrawn(); + expect(screen.queryByTestId('md-form-cancel')).toBeNull(); + expect(screen.getByTestId('md-form-submit')).toBeInTheDocument(); + + cleanup(); + renderModalForm({ subforms: SUBFORMS, showSubmit: false }); + await tabsDrawn(); + expect(screen.queryByTestId('md-form-submit')).toBeNull(); + }); + + it('ModalForm CONTROL: neither flag — Cancel and Save both drawn', async () => { + renderModalForm({ subforms: SUBFORMS }); + await tabsDrawn(); + + expect(screen.getByTestId('md-form-cancel')).toBeInTheDocument(); + expect(screen.getByTestId('md-form-submit')).toBeInTheDocument(); + }); +}); From 98d7db8c569bb28ec080263a72e399c8a90ab2b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:03:17 +0000 Subject: [PATCH 3/4] chore(changeset): form view keys on every console form entry point Claude-Session: https://claude.ai/code/session_01JQwxbabN2bfLWeaoNk6Jue Co-authored-by: Claude --- .../26843-form-view-keys-every-entry-point.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/26843-form-view-keys-every-entry-point.md diff --git a/.changeset/26843-form-view-keys-every-entry-point.md b/.changeset/26843-form-view-keys-every-entry-point.md new file mode 100644 index 00000000000..06822d6444e --- /dev/null +++ b/.changeset/26843-form-view-keys-every-entry-point.md @@ -0,0 +1,19 @@ +--- +'@object-ui/plugin-form': minor +'@object-ui/console': minor +--- + +fix(console): every console form entry point renders the form view's `buttons`, `layout` and `defaults`, and a tabbed form with subforms opens on its `defaultTab` (#26843) + +Clause-②: no +Metadata-protocol: semantic + +A form view's `buttons`, `layout` and `defaults` saved and published, but the console set its own values over them, depending on which entry point opened the form: + +- **The full-page record form** (`/new`, `/record/:id/edit`) and **the New / Edit dialog** set `showSubmit: true`, their own submit and cancel labels and `layout: 'vertical'`, so an authored `buttons.submit.label`, `buttons.*.show: false` or `layout: 'horizontal'` did nothing. Both now read them from the form view the flow selects. An authored label may be a per-locale map. The New dialog also ignored `defaults`; a create there now opens with them. +- **`defaults` and the host's own opening values merge per field.** A URL prefill, a parent pre-link or a seeded calendar day still wins for the fields it names, and the form view's `defaults` fill the rest. Before, a create carrying any prefill dropped `defaults` entirely. An edit never reads `defaults`. +- **The form page** (`/forms/:name`, and the public `/f/:slug`) never read `buttons` or `defaults`. An authored submit or cancel label now shows, `show: false` removes the button (the public form still draws no Cancel), and a create opens with `defaults`, over the object's own field defaults and under a `?prefill_` param. +- **A tabbed form with subforms** opened on its first tab with the strip on top. `ObjectForm` now hands `defaultTab` / `tabPosition` (and `layout`) through `MasterDetailForm` to the header form. The modal's tabbed layout reads both keys, and its subforms arm now renders the header tabbed instead of stacked. +- **A hidden Cancel on a master-detail form** (`showCancel: false`, or `buttons.cancel.show: false`) is no longer drawn: the action bar draws Cancel only when it is handed `onCancel`, and both form arms now hand none. + +`buttons.reset` and the drawer keys (`drawerSide` / `drawerWidth`) are not covered: no console entry point draws a reset button or opens a form view as a drawer. From 7e4a12495b9cd63075a46b4864e33b8c756830d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:43:48 +0000 Subject: [PATCH 4/4] test(console): drop an unused parameter from the form-view chrome pin Claude-Session: https://claude.ai/code/session_01JQwxbabN2bfLWeaoNk6Jue Co-authored-by: Claude --- .../FormPage.formViewChrome-26843.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx b/ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx index ab9e61baf69..f559c133e0d 100644 --- a/ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx +++ b/ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx @@ -63,7 +63,7 @@ function stubFetch(form: Record) { }); } -function renderAt(url: string, mode: 'internal' | 'public') { +function renderAt(url: string) { createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); return render( @@ -85,28 +85,28 @@ afterEach(() => { describe('FormPage renders the form view\'s buttons (#26843)', () => { it('internal: the authored submit label shows, and a hidden cancel is gone', async () => { vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'File inquiry' }, cancel: { show: false } } })); - renderAt('/forms/showcase_inquiry.intake', 'internal'); + renderAt('/forms/showcase_inquiry.intake'); expect(await screen.findByRole('button', { name: 'File inquiry' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); }); it('internal: an authored cancel label shows; a hidden submit is gone', async () => { vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { show: false }, cancel: { label: 'Back' } } })); - const { container } = renderAt('/forms/showcase_inquiry.intake', 'internal'); + const { container } = renderAt('/forms/showcase_inquiry.intake'); expect(await screen.findByRole('button', { name: 'Back' })).toBeInTheDocument(); expect(container.querySelector('button[type="submit"]')).toBeNull(); }); it('public: the authored submit label shows, and there is still no Cancel', async () => { vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'Send' }, cancel: { label: 'Back' } } })); - renderAt('/f/intake', 'public'); + renderAt('/f/intake'); expect(await screen.findByRole('button', { name: 'Send' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); }); it('CONTROL: no `buttons` — the page\'s own Submit and Cancel', async () => { vi.stubGlobal('fetch', stubFetch({})); - renderAt('/forms/showcase_inquiry.intake', 'internal'); + renderAt('/forms/showcase_inquiry.intake'); expect(await screen.findByRole('button', { name: 'Submit' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); @@ -115,28 +115,28 @@ describe('FormPage renders the form view\'s buttons (#26843)', () => { describe('FormPage seeds a create with the form view\'s defaults (#26843)', () => { it('a create opens with them, over the object\'s own field default', async () => { vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } })); - renderAt('/forms/showcase_inquiry.intake', 'internal'); + renderAt('/forms/showcase_inquiry.intake'); await waitFor(() => expect(input('title')?.value).toBe('Website lead')); expect(input('channel')?.value).toBe('web'); }); it('a `prefill_` param still wins per field', async () => { vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } })); - renderAt('/forms/showcase_inquiry.intake?prefill_channel=chat', 'internal'); + renderAt('/forms/showcase_inquiry.intake?prefill_channel=chat'); await waitFor(() => expect(input('channel')?.value).toBe('chat')); expect(input('title')?.value).toBe('Website lead'); }); it('an edit reads the stored record, never the defaults', async () => { vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } })); - renderAt('/forms/showcase_inquiry.intake?recordId=r1', 'internal'); + renderAt('/forms/showcase_inquiry.intake?recordId=r1'); await waitFor(() => expect(input('title')?.value).toBe('Stored')); expect(input('channel')?.value).toBe('email'); }); it('CONTROL: no defaults — the object\'s own field default, else blank', async () => { vi.stubGlobal('fetch', stubFetch({})); - renderAt('/forms/showcase_inquiry.intake', 'internal'); + renderAt('/forms/showcase_inquiry.intake'); await waitFor(() => expect(input('channel')?.value).toBe('phone')); expect(input('title')?.value).toBe(''); });