From eb2061388813b6e5e94ad13d65effd59cd7f3e76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:43:35 +0000 Subject: [PATCH 1/3] fix(app-shell): a served form item takes `.form` only when it is the default `applyViewItem` no longer makes the first form item to arrive the object's create/edit `.form` when no item carries `isDefault`. `ViewSchema.form` is a container's default form view and `formViews` are additional named form views, so a container with no `form` leaves `.form` unset, and a named form is reached only by its name. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../app-shell/src/providers/MetadataProvider.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index e017e84787..6a7b90772f 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -409,6 +409,20 @@ function viewItemBody(view: any): Record { * with the id so primary-view promotion (which matches on `list.name`) finds * this entry by its listViews key. * FORM-family views land in `formViews` only, never in the list-view switcher. + * + * `bucket.form` — what create and edit render — is set ONLY from a form item + * that carries `isDefault` (objectui#11539). `@objectstack/spec` `ViewSchema` + * calls a container's `form` its "Default form view" and `formViews` + * "Additional named form views", and the served rows say which one is default: + * `OBJECT.form` alone carries `isDefault`. There is no "first form wins" + * fallback, so arrival order decides nothing: a default arriving after a named + * form still takes `.form`, and a named form arriving after the default does + * not displace it. A container with no `form` serves its named forms with no + * `isDefault`, and `.form` stays unset. Create and edit then take the path an + * object with no default form view takes: `resolveFormViewLayout` returns no + * sections, and the dialog lays out the object's own fields. A named form — + * which can be a public anonymous intake form or a wizard — is reached only by + * its name, through `formViews`. */ function applyViewItem(bucket: ViewBucket, view: any): void { const key = view.name || `${view.object}.${view.viewKind}`; @@ -421,7 +435,7 @@ function applyViewItem(bucket: ViewBucket, view: any): void { }; if (view.viewKind === 'form') { bucket.formViews[key] = entry; - if (view.isDefault || !bucket.form) bucket.form = entry; + if (view.isDefault) bucket.form = entry; } else { bucket.listViews[key] = entry; if (view.isDefault) bucket.primary = entry; From 69c5bc46ef7d7dedffaa145a7459f3aed0975cc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:45:34 +0000 Subject: [PATCH 2/3] test(app-shell): pin `.form` to the served default form only Served-row fixtures for the three shapes the ruling names: `showcase_inquiry` (named forms only) yields no `.form` and the create/edit layout an object with no form view gets; `crm_lead` and `showcase_contact` keep their default in either arrival order. The create modal renders the object's own fields for a named-only object. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../createModalHonorsFormView.test.tsx | 39 ++++++ .../providers/MetadataProvider.merge.test.ts | 132 ++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/packages/app-shell/src/hooks/__tests__/createModalHonorsFormView.test.tsx b/packages/app-shell/src/hooks/__tests__/createModalHonorsFormView.test.tsx index 04c9b05014..c50dde3506 100644 --- a/packages/app-shell/src/hooks/__tests__/createModalHonorsFormView.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/createModalHonorsFormView.test.tsx @@ -27,6 +27,7 @@ import React from 'react'; import { registerAllFields } from '@object-ui/fields'; import { ModalForm } from '@object-ui/plugin-form'; import { resolveFormViewLayout } from '../../utils/recordFormNavigation'; +import { mergeViewsIntoObjects } from '../../providers/MetadataProvider'; registerAllFields(); @@ -116,4 +117,42 @@ describe('create modal honors the object form view (sections + field selection)' expect(screen.getByText('Summary')).toBeTruthy(); expect(screen.getByText('Secret Internal')).toBeTruthy(); }); + + it('takes that same path when the object serves only NAMED forms, never the first named one (objectui#11539)', async () => { + // A container with no `form` serves its named forms with no `isDefault` + // (the `showcase_inquiry` shape: one named, public contact-us form). The + // merge leaves `.form` unset, so the create modal renders the object's own + // fields, not the named form's curated section. + const [merged] = mergeViewsIntoObjects([{ name: 'project', fields: {} }], [{ + name: 'project.contact', + object: 'project', + viewKind: 'form', + config: { + type: 'simple', + sections: [{ name: 'contact_us', label: 'Contact us', fields: [{ field: 'title' }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }, + }, + }]); + const layout = resolveFormViewLayout(merged); + expect(layout).toEqual({}); + + render( + , + ); + + await waitFor(() => expect(screen.getByText('Title')).toBeTruthy()); + expect(screen.getByText('Summary')).toBeTruthy(); + expect(screen.getByText('Secret Internal')).toBeTruthy(); + expect(screen.queryByText('Contact us')).toBeNull(); + }); }); diff --git a/packages/app-shell/src/providers/MetadataProvider.merge.test.ts b/packages/app-shell/src/providers/MetadataProvider.merge.test.ts index 64f422ded9..138164892b 100644 --- a/packages/app-shell/src/providers/MetadataProvider.merge.test.ts +++ b/packages/app-shell/src/providers/MetadataProvider.merge.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { mergeViewsIntoObjects } from './MetadataProvider'; +import { resolveFormViewLayout } from '../utils/recordFormNavigation'; /** * `mergeViewsIntoObjects` folds the `view` metadata type into object @@ -147,6 +148,137 @@ describe('mergeViewsIntoObjects', () => { // …and the list family is untouched by the form entry. expect(Object.keys(obj.listViews)).toEqual(['crm_activity.default']); }); + + it('takes a container\'s declared `form` as `.form`, never a named form beside it', () => { + // The default form is expanded AFTER the named ones, so arrival order + // cannot be what picks it. + const container = { + name: 'crm_activity', + list: { type: 'grid', columns: [{ field: 'subject' }] }, + form: { type: 'simple', label: 'Activity', sections: [{ name: 'details', label: 'Details', fields: [{ field: 'subject' }] }] }, + formViews: { quick: { type: 'simple', label: 'Quick log', sections: [{ name: 'quick', label: 'Quick', fields: [{ field: 'subject' }] }] } }, + }; + const [obj] = mergeViewsIntoObjects(objects, [container]); + expect(obj.form.name).toBe('crm_activity.form'); + expect(obj.form.sections.map((s: any) => s.name)).toEqual(['details']); + expect(obj.formViews['crm_activity.quick'].isDefault).toBe(false); + }); + }); +}); + +/** + * `.form` is the served DEFAULT form only (objectui#11539). + * + * `@objectstack/spec` `ViewSchema`: `form` is a container's "Default form + * view", `formViews` are "Additional named form views". The served rows carry + * that: the container's own `form` arrives as `OBJECT.form` with `isDefault`, + * and a container with no `form` serves its named forms with no `isDefault` + * key at all (objectstack#21500). The console used to make the FIRST form item + * to arrive its create/edit `.form` whenever none was default, so a named-only + * container rendered its first named form — in the `showcase_inquiry` case the + * public, anonymous contact-us form — as the create and edit form. + * + * Fixtures are served `view` rows in the shape the expansion emits + * (`{ name, object, viewKind, label, config, order, scope, isDefault? }`), in + * the order it emits them: named entries first, the container's own default + * after them. + */ +describe('mergeViewsIntoObjects — `.form` is the served default form only (objectui#11539)', () => { + const row = (object: string, key: string, viewKind: 'list' | 'form', config: Record, extra: Record = {}) => ({ + name: `${object}.${key}`, + object, + viewKind, + label: config.label, + config, + scope: 'package', + ...extra, + }); + + // examples/app-showcase `showcase_inquiry`: a default list, a named list, and + // ONE named form — the public contact-us form. No `form`, so no form row is + // default. + const inquiryData = { provider: 'object', object: 'showcase_inquiry' }; + const inquiryRows = [ + row('showcase_inquiry', 'triage', 'list', { type: 'grid', label: 'Inquiry Triage', data: inquiryData, columns: [{ field: 'name' }] }, { order: 0 }), + row('showcase_inquiry', 'default', 'list', { type: 'grid', label: 'Inquiries', data: inquiryData, columns: [{ field: 'name' }] }, { order: 1, isDefault: true }), + row('showcase_inquiry', 'contact', 'form', { + type: 'simple', + data: inquiryData, + sections: [{ name: 'tell_us_about_yourself', label: 'Tell us about yourself', fields: [{ field: 'name' }, { field: 'email' }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }, + }, { order: 2 }), + ]; + + // examples/app-crm `crm_lead`: the named Web-to-Lead form, then the + // container's own `form`, the one default. + const leadData = { provider: 'object', object: 'crm_lead' }; + const leadWebToLead = row('crm_lead', 'web_to_lead', 'form', { + type: 'simple', + data: leadData, + sections: [{ name: 'contact_us', label: 'Contact us', fields: [{ field: 'name' }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }, + }, { order: 2 }); + const leadForm = row('crm_lead', 'form', 'form', { + type: 'simple', + sections: [{ name: 'lead_information', label: 'Lead Information', fields: [{ field: 'name' }] }], + }, { order: 3, isDefault: true }); + + // examples/app-showcase `showcase_contact`: a named `create` form, then the + // container's own `form`, the one default. + const contactCreate = row('showcase_contact', 'create', 'form', { + type: 'simple', + sections: [{ name: 'quick', label: 'Quick add', fields: [{ field: 'first_name' }] }], + }, { order: 1 }); + const contactForm = row('showcase_contact', 'form', 'form', { + type: 'simple', + sections: [{ name: 'identity', label: 'Identity', fields: [{ field: 'first_name' }] }], + }, { order: 2, isDefault: true }); + + const objects = [ + { name: 'showcase_inquiry', fields: {} }, + { name: 'crm_lead', fields: {} }, + { name: 'showcase_contact', fields: {} }, + ]; + const byName = (merged: any[], name: string) => merged.find((o) => o.name === name); + + it('a container with only named forms (`showcase_inquiry`) gets no `.form`; its named form stays reachable by name', () => { + const inquiry = byName(mergeViewsIntoObjects(objects, inquiryRows), 'showcase_inquiry'); + expect(inquiry).not.toHaveProperty('form'); + expect(Object.keys(inquiry.formViews)).toEqual(['showcase_inquiry.contact']); + expect(inquiry.formViews['showcase_inquiry.contact'].isDefault).toBe(false); + // The list family keeps its own default. + expect(inquiry.list.name).toBe('showcase_inquiry.default'); + }); + + it('create and edit take the no-default-form path for `showcase_inquiry`: no layout comes from the named form', () => { + const inquiry = byName(mergeViewsIntoObjects(objects, inquiryRows), 'showcase_inquiry'); + // The read `RecordFormPage`, `ScreenView` and `resolveFormViewLayout` share + // (`.form`, then `formViews.default`) finds nothing… + expect(inquiry.form ?? inquiry.formViews?.default).toBeUndefined(); + // …so the New/Edit dialog (AppContent, `useActionModal`) gets no sections, + // the same answer as an object that declares no form view at all. + expect(resolveFormViewLayout(inquiry)).toEqual({}); + expect(resolveFormViewLayout(inquiry)).toEqual(resolveFormViewLayout({ name: 'showcase_inquiry' } as any)); + }); + + it('`crm_lead` keeps its default when the default arrives AFTER the named Web-to-Lead form', () => { + const lead = byName(mergeViewsIntoObjects(objects, [leadWebToLead, leadForm]), 'crm_lead'); + expect(lead.form.name).toBe('crm_lead.form'); + expect(resolveFormViewLayout(lead).sections?.map((s: any) => s.name)).toEqual(['lead_information']); + expect(lead.formViews['crm_lead.web_to_lead'].isDefault).toBe(false); + }); + + it('`showcase_contact` keeps its default whichever order its two forms arrive in', () => { + for (const rows of [[contactCreate, contactForm], [contactForm, contactCreate]]) { + const contact = byName(mergeViewsIntoObjects(objects, rows), 'showcase_contact'); + expect(contact.form.name).toBe('showcase_contact.form'); + expect(Object.keys(contact.formViews).sort()).toEqual(['showcase_contact.create', 'showcase_contact.form']); + } + }); + + it('a named form arriving AFTER the default does not displace it', () => { + const lead = byName(mergeViewsIntoObjects(objects, [leadForm, leadWebToLead]), 'crm_lead'); + expect(lead.form.name).toBe('crm_lead.form'); }); }); From 8828bfa33068544612ea912052e4cbc93e2d22d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:47:08 +0000 Subject: [PATCH 3/3] chore(changeset): app-shell patch for the default-form-only merge rule Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../11539-form-only-from-served-default.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/11539-form-only-from-served-default.md diff --git a/.changeset/11539-form-only-from-served-default.md b/.changeset/11539-form-only-from-served-default.md new file mode 100644 index 0000000000..42f109d77d --- /dev/null +++ b/.changeset/11539-form-only-from-served-default.md @@ -0,0 +1,24 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): create and edit no longer render a container's first named form when it declares no default form + +`@objectstack/spec` `ViewSchema` calls a view container's `form` its "Default form view" and +`formViews` its "Additional named form views". The console's view merge (`applyViewItem` in +`MetadataProvider`) made the FIRST form item to arrive an object's create/edit `.form` whenever no +served form item carried `isDefault`. So a container with only named forms rendered its first named +form as the create and edit form. In `examples/app-showcase`, `showcase_inquiry`'s only form is the +public, anonymous contact-us form. + +`.form` now comes only from a served form item that carries `isDefault`. Arrival order decides +nothing: a default form arriving after a named one still wins, and a named form arriving after the +default does not displace it. A container with no `form` leaves `.form` unset, and create and edit +take the path an object with no form view takes: no curated sections, and the dialog lays out the +object's own fields. A named form is reached only by its name, through `formViews`. + +Servers that still stamp `isDefault` on a promoted named form see no change. The change takes effect +with servers that serve a named-only container without a default form item (objectstack#21500). A +stack-packaged container that reaches the console unexpanded is expanded by the bundled +`@objectstack/spec`, which still flags its first named form as the default until that dependency +carries the same fix.