Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/11539-form-only-from-served-default.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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(
<ModalForm
schema={{
objectName: 'project',
mode: 'create',
title: 'New Project',
open: true,
onOpenChange: vi.fn(),
...layout,
} as any}
dataSource={makeDataSource()}
/>,
);

await waitFor(() => expect(screen.getByText('Title')).toBeTruthy());
expect(screen.getByText('Summary')).toBeTruthy();
expect(screen.getByText('Secret Internal')).toBeTruthy();
expect(screen.queryByText('Contact us')).toBeNull();
});
});
132 changes: 132 additions & 0 deletions packages/app-shell/src/providers/MetadataProvider.merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, any>, extra: Record<string, any> = {}) => ({
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');
});
});

Expand Down
16 changes: 15 additions & 1 deletion packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,20 @@ function viewItemBody(view: any): Record<string, any> {
* 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}`;
Expand All @@ -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;
Expand Down
Loading