Skip to content

Commit 69c5bc4

Browse files
committed
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 <noreply@anthropic.com>
1 parent eb20613 commit 69c5bc4

2 files changed

Lines changed: 171 additions & 0 deletions

File tree

‎packages/app-shell/src/hooks/__tests__/createModalHonorsFormView.test.tsx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import React from 'react';
2727
import { registerAllFields } from '@object-ui/fields';
2828
import { ModalForm } from '@object-ui/plugin-form';
2929
import { resolveFormViewLayout } from '../../utils/recordFormNavigation';
30+
import { mergeViewsIntoObjects } from '../../providers/MetadataProvider';
3031

3132
registerAllFields();
3233

@@ -116,4 +117,42 @@ describe('create modal honors the object form view (sections + field selection)'
116117
expect(screen.getByText('Summary')).toBeTruthy();
117118
expect(screen.getByText('Secret Internal')).toBeTruthy();
118119
});
120+
121+
it('takes that same path when the object serves only NAMED forms, never the first named one (objectui#11539)', async () => {
122+
// A container with no `form` serves its named forms with no `isDefault`
123+
// (the `showcase_inquiry` shape: one named, public contact-us form). The
124+
// merge leaves `.form` unset, so the create modal renders the object's own
125+
// fields, not the named form's curated section.
126+
const [merged] = mergeViewsIntoObjects([{ name: 'project', fields: {} }], [{
127+
name: 'project.contact',
128+
object: 'project',
129+
viewKind: 'form',
130+
config: {
131+
type: 'simple',
132+
sections: [{ name: 'contact_us', label: 'Contact us', fields: [{ field: 'title' }] }],
133+
sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' },
134+
},
135+
}]);
136+
const layout = resolveFormViewLayout(merged);
137+
expect(layout).toEqual({});
138+
139+
render(
140+
<ModalForm
141+
schema={{
142+
objectName: 'project',
143+
mode: 'create',
144+
title: 'New Project',
145+
open: true,
146+
onOpenChange: vi.fn(),
147+
...layout,
148+
} as any}
149+
dataSource={makeDataSource()}
150+
/>,
151+
);
152+
153+
await waitFor(() => expect(screen.getByText('Title')).toBeTruthy());
154+
expect(screen.getByText('Summary')).toBeTruthy();
155+
expect(screen.getByText('Secret Internal')).toBeTruthy();
156+
expect(screen.queryByText('Contact us')).toBeNull();
157+
});
119158
});

‎packages/app-shell/src/providers/MetadataProvider.merge.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import { mergeViewsIntoObjects } from './MetadataProvider';
5+
import { resolveFormViewLayout } from '../utils/recordFormNavigation';
56

67
/**
78
* `mergeViewsIntoObjects` folds the `view` metadata type into object
@@ -147,6 +148,137 @@ describe('mergeViewsIntoObjects', () => {
147148
// …and the list family is untouched by the form entry.
148149
expect(Object.keys(obj.listViews)).toEqual(['crm_activity.default']);
149150
});
151+
152+
it('takes a container\'s declared `form` as `.form`, never a named form beside it', () => {
153+
// The default form is expanded AFTER the named ones, so arrival order
154+
// cannot be what picks it.
155+
const container = {
156+
name: 'crm_activity',
157+
list: { type: 'grid', columns: [{ field: 'subject' }] },
158+
form: { type: 'simple', label: 'Activity', sections: [{ name: 'details', label: 'Details', fields: [{ field: 'subject' }] }] },
159+
formViews: { quick: { type: 'simple', label: 'Quick log', sections: [{ name: 'quick', label: 'Quick', fields: [{ field: 'subject' }] }] } },
160+
};
161+
const [obj] = mergeViewsIntoObjects(objects, [container]);
162+
expect(obj.form.name).toBe('crm_activity.form');
163+
expect(obj.form.sections.map((s: any) => s.name)).toEqual(['details']);
164+
expect(obj.formViews['crm_activity.quick'].isDefault).toBe(false);
165+
});
166+
});
167+
});
168+
169+
/**
170+
* `.form` is the served DEFAULT form only (objectui#11539).
171+
*
172+
* `@objectstack/spec` `ViewSchema`: `form` is a container's "Default form
173+
* view", `formViews` are "Additional named form views". The served rows carry
174+
* that: the container's own `form` arrives as `OBJECT.form` with `isDefault`,
175+
* and a container with no `form` serves its named forms with no `isDefault`
176+
* key at all (objectstack#21500). The console used to make the FIRST form item
177+
* to arrive its create/edit `.form` whenever none was default, so a named-only
178+
* container rendered its first named form — in the `showcase_inquiry` case the
179+
* public, anonymous contact-us form — as the create and edit form.
180+
*
181+
* Fixtures are served `view` rows in the shape the expansion emits
182+
* (`{ name, object, viewKind, label, config, order, scope, isDefault? }`), in
183+
* the order it emits them: named entries first, the container's own default
184+
* after them.
185+
*/
186+
describe('mergeViewsIntoObjects — `.form` is the served default form only (objectui#11539)', () => {
187+
const row = (object: string, key: string, viewKind: 'list' | 'form', config: Record<string, any>, extra: Record<string, any> = {}) => ({
188+
name: `${object}.${key}`,
189+
object,
190+
viewKind,
191+
label: config.label,
192+
config,
193+
scope: 'package',
194+
...extra,
195+
});
196+
197+
// examples/app-showcase `showcase_inquiry`: a default list, a named list, and
198+
// ONE named form — the public contact-us form. No `form`, so no form row is
199+
// default.
200+
const inquiryData = { provider: 'object', object: 'showcase_inquiry' };
201+
const inquiryRows = [
202+
row('showcase_inquiry', 'triage', 'list', { type: 'grid', label: 'Inquiry Triage', data: inquiryData, columns: [{ field: 'name' }] }, { order: 0 }),
203+
row('showcase_inquiry', 'default', 'list', { type: 'grid', label: 'Inquiries', data: inquiryData, columns: [{ field: 'name' }] }, { order: 1, isDefault: true }),
204+
row('showcase_inquiry', 'contact', 'form', {
205+
type: 'simple',
206+
data: inquiryData,
207+
sections: [{ name: 'tell_us_about_yourself', label: 'Tell us about yourself', fields: [{ field: 'name' }, { field: 'email' }] }],
208+
sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' },
209+
}, { order: 2 }),
210+
];
211+
212+
// examples/app-crm `crm_lead`: the named Web-to-Lead form, then the
213+
// container's own `form`, the one default.
214+
const leadData = { provider: 'object', object: 'crm_lead' };
215+
const leadWebToLead = row('crm_lead', 'web_to_lead', 'form', {
216+
type: 'simple',
217+
data: leadData,
218+
sections: [{ name: 'contact_us', label: 'Contact us', fields: [{ field: 'name' }] }],
219+
sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' },
220+
}, { order: 2 });
221+
const leadForm = row('crm_lead', 'form', 'form', {
222+
type: 'simple',
223+
sections: [{ name: 'lead_information', label: 'Lead Information', fields: [{ field: 'name' }] }],
224+
}, { order: 3, isDefault: true });
225+
226+
// examples/app-showcase `showcase_contact`: a named `create` form, then the
227+
// container's own `form`, the one default.
228+
const contactCreate = row('showcase_contact', 'create', 'form', {
229+
type: 'simple',
230+
sections: [{ name: 'quick', label: 'Quick add', fields: [{ field: 'first_name' }] }],
231+
}, { order: 1 });
232+
const contactForm = row('showcase_contact', 'form', 'form', {
233+
type: 'simple',
234+
sections: [{ name: 'identity', label: 'Identity', fields: [{ field: 'first_name' }] }],
235+
}, { order: 2, isDefault: true });
236+
237+
const objects = [
238+
{ name: 'showcase_inquiry', fields: {} },
239+
{ name: 'crm_lead', fields: {} },
240+
{ name: 'showcase_contact', fields: {} },
241+
];
242+
const byName = (merged: any[], name: string) => merged.find((o) => o.name === name);
243+
244+
it('a container with only named forms (`showcase_inquiry`) gets no `.form`; its named form stays reachable by name', () => {
245+
const inquiry = byName(mergeViewsIntoObjects(objects, inquiryRows), 'showcase_inquiry');
246+
expect(inquiry).not.toHaveProperty('form');
247+
expect(Object.keys(inquiry.formViews)).toEqual(['showcase_inquiry.contact']);
248+
expect(inquiry.formViews['showcase_inquiry.contact'].isDefault).toBe(false);
249+
// The list family keeps its own default.
250+
expect(inquiry.list.name).toBe('showcase_inquiry.default');
251+
});
252+
253+
it('create and edit take the no-default-form path for `showcase_inquiry`: no layout comes from the named form', () => {
254+
const inquiry = byName(mergeViewsIntoObjects(objects, inquiryRows), 'showcase_inquiry');
255+
// The read `RecordFormPage`, `ScreenView` and `resolveFormViewLayout` share
256+
// (`.form`, then `formViews.default`) finds nothing…
257+
expect(inquiry.form ?? inquiry.formViews?.default).toBeUndefined();
258+
// …so the New/Edit dialog (AppContent, `useActionModal`) gets no sections,
259+
// the same answer as an object that declares no form view at all.
260+
expect(resolveFormViewLayout(inquiry)).toEqual({});
261+
expect(resolveFormViewLayout(inquiry)).toEqual(resolveFormViewLayout({ name: 'showcase_inquiry' } as any));
262+
});
263+
264+
it('`crm_lead` keeps its default when the default arrives AFTER the named Web-to-Lead form', () => {
265+
const lead = byName(mergeViewsIntoObjects(objects, [leadWebToLead, leadForm]), 'crm_lead');
266+
expect(lead.form.name).toBe('crm_lead.form');
267+
expect(resolveFormViewLayout(lead).sections?.map((s: any) => s.name)).toEqual(['lead_information']);
268+
expect(lead.formViews['crm_lead.web_to_lead'].isDefault).toBe(false);
269+
});
270+
271+
it('`showcase_contact` keeps its default whichever order its two forms arrive in', () => {
272+
for (const rows of [[contactCreate, contactForm], [contactForm, contactCreate]]) {
273+
const contact = byName(mergeViewsIntoObjects(objects, rows), 'showcase_contact');
274+
expect(contact.form.name).toBe('showcase_contact.form');
275+
expect(Object.keys(contact.formViews).sort()).toEqual(['showcase_contact.create', 'showcase_contact.form']);
276+
}
277+
});
278+
279+
it('a named form arriving AFTER the default does not displace it', () => {
280+
const lead = byName(mergeViewsIntoObjects(objects, [leadForm, leadWebToLead]), 'crm_lead');
281+
expect(lead.form.name).toBe('crm_lead.form');
150282
});
151283
});
152284

0 commit comments

Comments
 (0)