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.
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');
});
});
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;