diff --git a/.changeset/9884-master-detail-fields-intersection.md b/.changeset/9884-master-detail-fields-intersection.md new file mode 100644 index 0000000000..6f9fa35ef3 --- /dev/null +++ b/.changeset/9884-master-detail-fields-intersection.md @@ -0,0 +1,47 @@ +--- +'@object-ui/plugin-form': patch +--- + +`object-master-detail-form`'s `fields` registration no longer claims to be +"Ignored when `sections` is given", and a section member the top-level `fields` +excludes is now reported instead of vanishing (objectui#9884). + +The declaration was the wrong half, ruled from the tree rather than from the +principle. One `SimpleObjectForm` renders this block's parent form and +`object-form` alike — `MasterDetailForm`'s `parentSchema` memo literally builds +a `{ type: 'object-form', ... }` node and renders it through a directly +imported `` — and the three sibling `fields` registrations +(`object-form`, `form`, `embeddable-form`) all declare the key as the field +selection with no such exemption, with `objectFormFieldsMembers-8071` pinning +it as one. Honouring the exemption would have falsified three declarations to +satisfy one, and it would have done so on a pool that is not only the layout: +`fields` builds the parent field set that also feeds create defaults, the +`initialValues` merge and the values a submit carries. + +So the rendered outcome is unchanged and the intersection stands: the parent +field pool is built from `fields` first, and each section resolves its members +against that pool. What changed is that the loss is audible. +`warnSectionMemberExcludedByFields` (`sectionFields.ts`, beside the two +warnings objectui#8738 and objectui#3090 added) names the section, the member +and the two keys that collided, once per distinct pair, whenever a member the +object really declares is dropped for the sole reason that `fields` omits it — +including the expensive case where it was the section's last surviving member +and the section disappears with its heading. A member the object never declares +at all is deliberately NOT recruited into this warning: it resolves to nothing +whether or not `fields` is authored, which is a different silence with a +different remedy. + +The `object-master-detail-form.sections` member pin moves in the same change +rather than after it: its sharp row keeps the two DOM assertions objectui#8071 +slice 15 wrote, and gains the warning legs plus a firing control and a leg +keeping the warning off the other silence. Nothing in it was relaxed. + +The member-pin ledger moves with it. `apps/console`'s registry-inputs parity +suite carried the old reading in two prose passages — the +`object-master-detail-form.sections` entry quoting the retired sentence and +recording the drop as having no diagnostic and the finding as not acted on, and +the slice-15 narrative repeating the quote. Both now state the ruling. That file +is a test and releases nothing: `@object-ui/console` ships no source from it, so +this declaration covers `@object-ui/plugin-form` alone. + +Refs objectui#9884, objectui#8071. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index d2351b8217..4f2632749e 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2440,7 +2440,7 @@ const MEMBER_PINS: Record = { }, 'object-master-detail-form.sections': { file: 'packages/plugin-form/src/__tests__/masterDetailSectionMembers-8071.test.tsx', - pins: 'Members are section OBJECTS shaping the PARENT half only \u2014 a member\'s `fields` are parent field names read as a SET, so the OBJECT\'s field order wins over the authored member order, and the block\'s `details` collections keep their own columns through every row. A member that resolves to NO parent field is dropped WHOLE, heading included, measured with a DETAIL column name as the member because that is the mistake this composition invites: one node declares two field vocabularies and only one of them is this key\'s. \u2b50 The sharp row is a DIVERGENCE from the block\'s own registration: `fields` is declared "Ignored when `sections` is given \u2014 sections carry their own field lists", and it is not ignored. `ObjectForm` builds the parent pool from `schema.fields` FIRST and a section filters against that pool, so authoring both INTERSECTS them \u2014 a section member outside `fields` is dropped with no diagnostic and a section whose every member is outside it disappears heading and all. Pinned as behaviour and handed back as a finding, not fixed, because fixing it changes either the renderer or the declaration. The key has no read site in this block: `MasterDetailForm`\'s `parentSchema` memo copies it onto an `object-form`-shaped object rendered through a DIRECTLY imported ``, the same hand-written carrier whose dropped keys `object-form.sections` records twice (objectui#9779 / objectui#9834) \u2014 which is why the pin is taken here rather than delegated. \u26d4 Deliberately NOT re-asserted, because they are owned next door by `objectFormSectionMembers-8071.test.tsx` and duplicating them buys nothing: `collapsed` vs `collapsible`, the `name`-alone heading, the untitled trailing bucket and the `description` blurb; the `simple` / `tabbed` presentation routing is owned by `masterDetailFormTypeVocabulary.test.tsx`. A no-sections row is the non-vacuity control. Both declared sides are unconstrained (bare `type: \'array\'` with no `of`; `sections?: any[]`), so the read site is the whole member contract. New file (objectui#8071 slice 15).', + pins: 'Members are section OBJECTS shaping the PARENT half only \u2014 a member\'s `fields` are parent field names read as a SET, so the OBJECT\'s field order wins over the authored member order, and the block\'s `details` collections keep their own columns through every row. A member that resolves to NO parent field is dropped WHOLE, heading included, measured with a DETAIL column name as the member because that is the mistake this composition invites: one node declares two field vocabularies and only one of them is this key\'s. \u2b50 The sharp row, and it now AGREES with the block\'s own registration. `ObjectForm` builds the parent pool from `schema.fields` FIRST and a section filters against that pool, so authoring both INTERSECTS them \u2014 a section member outside `fields` does not render, and a section whose every member is outside it disappears heading and all. \u26a0 It used to record this as a DIVERGENCE, because the registration declared `fields` "Ignored when `sections` is given \u2014 sections carry their own field lists", and to record the loss as having NO DIAGNOSTIC; objectui#8071 slice 15 handed both back as a finding rather than acting on them, because acting changes either the renderer or the declaration and that card wrote pins only. objectui#9884 ruled it, and the DECLARATION was the wrong half: one `SimpleObjectForm` renders this block\'s parent half and `object-form` alike, and the three sibling `fields` registrations (`object-form`, `form`, `embeddable-form`) declare the key as the field selection with no such exemption, so honouring it would have falsified three declarations to satisfy one \u2014 on a pool that also feeds create defaults, the seed merge and the values a submit carries. The sentence was corrected and the SILENCE closed instead: `warnSectionMemberExcludedByFields` names every such drop, once per section-and-member pair, and is measured BEFORE `applyFieldPerms` so a perms-gated field is never reported as an authoring mistake. \u26d4 So the diagnostic is part of what this row pins now, and slice 15\'s two DOM assertions stand unchanged beside it \u2014 the rendered outcome did not move. Rows 3b, 3c and 3d carry the last-member case (with a counterfactual beside it), the firing control, and the leg keeping the warning off the DIFFERENT silence row 2 owns. The key has no read site in this block: `MasterDetailForm`\'s `parentSchema` memo copies it onto an `object-form`-shaped object rendered through a DIRECTLY imported ``, the same hand-written carrier whose dropped keys `object-form.sections` records twice (objectui#9779 / objectui#9834) \u2014 which is why the pin is taken here rather than delegated. \u26d4 Deliberately NOT re-asserted, because they are owned next door by `objectFormSectionMembers-8071.test.tsx` and duplicating them buys nothing: `collapsed` vs `collapsible`, the `name`-alone heading, the untitled trailing bucket and the `description` blurb; the `simple` / `tabbed` presentation routing is owned by `masterDetailFormTypeVocabulary.test.tsx`. A no-sections row is the non-vacuity control. Both declared sides are unconstrained (bare `type: \'array\'` with no `of`; `sections?: any[]`), so the read site is the whole member contract. New file (objectui#8071 slice 15).', }, 'object-metric.aggregate': { file: 'packages/plugin-dashboard/src/__tests__/objectMetricQueryMembers-8071.test.tsx', @@ -3402,12 +3402,26 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = []; * ⚠️ Two member facts handed back as findings rather than frozen into a pin, * slice 9's choice for slice 9's reason. (a) An EMPTY `initialData` shadows a * populated `initialValues` completely, because the resolution is `||` on the - * OBJECT. (b) `fields` is declared "Ignored when `sections` is given" and is - * not ignored: the parent field pool is built from `fields` first and a section - * resolves against that pool, so the two INTERSECT and a section member outside - * `fields` is dropped silently. Both are pinned as behaviour and stated as - * limits; an assertion that either can never change would have to be deleted - * before anyone could change it. + * OBJECT. (b) `fields` WAS declared "Ignored when `sections` is given" and + * was not ignored: the parent field pool is built from `fields` first and a + * section resolves against that pool, so the two INTERSECT and a section member + * outside `fields` was dropped silently. Both were pinned as behaviour and + * stated as limits; an assertion that either can never change would have to be + * deleted before anyone could change it. + * + * ⭐ (b) has since been RULED, and is no longer a limit: objectui#9884 took + * the DECLARATION as the wrong half. This block’s parent form is rendered by + * the same `SimpleObjectForm` as `object-form` — the `parentSchema` memo named + * above builds an `object-form`-shaped node — and `object-form`’s own `fields` + * registration, like `form`’s and `embeddable-form`’s, declares the key as the + * field selection with no such exemption, so honouring the exemption would have + * falsified three declarations to satisfy one. The sentence was corrected, and + * the SILENCE — the half that was a defect under either reading — was closed + * with `warnSectionMemberExcludedByFields`, which names every drop the + * intersection causes. The intersection itself stands and no rendered outcome + * moved, so slice 15’s two DOM assertions are untouched; the + * `object-master-detail-form.sections` entry above carries the current reading. + * (a) is untouched and remains a limit. * * ⚠️ Unchanged by this slice: `NEWLY_JUDGED_UNPINNED_MEMBERS` (no block it names * was touched) and `record:related_list.actions`, whose `NO_READ_SITE_TO_PIN` diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index b183d84be4..42c347ab03 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -27,7 +27,7 @@ import { } from './submitRedirectNavigation'; import { usePermissions } from '@object-ui/permissions'; import { sectionPredicateUnsupportedWarning } from './sectionPredicateDiagnostic'; -import { warnUnresolvedTopLevelField } from './sectionFields'; +import { warnUnresolvedTopLevelField, warnSectionMemberExcludedByFields } from './sectionFields'; import { TabbedForm } from './TabbedForm'; import { WizardForm, NAVIGATE_ON_SUCCESS_REFUSED_NOTE } from './WizardForm'; import { SplitForm } from './SplitForm'; @@ -1470,6 +1470,50 @@ const SimpleObjectForm: React.FC = ({ (section.fields ?? []).map(f => [typeof f === 'string' ? f : ((f as any).field ?? f.name), f]), ); const sectionFieldNames = Array.from(sectionDefByName.keys()); + + // objectui#9884 — make the INTERSECTION audible. + // + // The filter below resolves a section's members against the parent field + // POOL, and that pool was built from `schema.fields` (`fieldsToShow` + // above). So top-level `fields` and `sections` intersect: a member this + // section names, that the object really declares, is dropped for the one + // reason that `fields` does not list it — and when it was the section's + // last surviving member, `sectionFields.length === 0` below drops the + // section whole, heading included. + // + // ⛔ The intersection itself is NOT the defect and is deliberately left + // standing: `fields` is the parent field pool for values, create + // defaults and the submitted set as well as for layout, so resolving + // these members here would change what a landed schema writes. What WAS + // the defect is that the loss was silent, plus this block's registration + // claiming `fields` is "Ignored when `sections` is given" — a claim its + // three sibling `fields` registrations never made and the one shared + // renderer never honoured. objectui#9884 corrected the sentence and + // added this warning; see `warnSectionMemberExcludedByFields`. + // + // Measured BEFORE `applyFieldPerms`, on purpose: a field the pool holds + // and per-caller permissions then remove is not an authoring mistake and + // must not be reported as one. + if (schema.fields != null && schema.sections?.length) { + const pooled = new Set(sourceFields.map(f => f.name)); + sectionFieldNames.forEach(memberName => { + if (typeof memberName !== 'string' || pooled.has(memberName)) return; + // Only when the member WOULD have resolved without the `fields` + // narrowing. A member naming nothing the form could ever draw is the + // sibling silence pinned by row 2 of both `sections` member pins, and + // it has a different remedy. + const declared = + objectSchema?.fields?.[memberName] != null || + (schema.customFields ?? []).some((f: any) => f?.name === memberName); + if (!declared) return; + warnSectionMemberExcludedByFields( + memberName, + schema.objectName, + section.name || section.label, + ); + }); + } + const sectionFields = applyFieldPerms(sourceFields.filter(f => sectionFieldNames.includes(f.name))) .map(f => { const def = sectionDefByName.get(f.name); diff --git a/packages/plugin-form/src/__tests__/masterDetailSectionMembers-8071.test.tsx b/packages/plugin-form/src/__tests__/masterDetailSectionMembers-8071.test.tsx index 58de794bb5..f03bcc76b9 100644 --- a/packages/plugin-form/src/__tests__/masterDetailSectionMembers-8071.test.tsx +++ b/packages/plugin-form/src/__tests__/masterDetailSectionMembers-8071.test.tsx @@ -44,16 +44,34 @@ * included — silently. Measured with a DETAIL column name as the member, * the mistake this composition invites: two field vocabularies on one * node, and only one of them is `sections`'. - * 3. ⭐ the sharp row, and a DIVERGENCE from the block's own registration: - * `fields` is declared "Ignored when `sections` is given — sections carry - * their own field lists", and it is not ignored. `ObjectForm` builds the - * parent field pool from `schema.fields` FIRST and a section resolves its - * members against that pool, so authoring both INTERSECTS them: a section - * member absent from `fields` is dropped with no diagnostic, and a section - * whose every member is absent disappears heading and all. Pinned as the - * renderer's behaviour and handed back as a finding on objectui#8071 — - * ⛔ not fixed here, because fixing it changes either the renderer or the - * declaration, and this card writes pins only. + * 3. ⭐ the sharp row. `ObjectForm` builds the parent field pool from + * `schema.fields` FIRST and a section resolves its members against that + * pool, so authoring both INTERSECTS them: a section member absent from + * `fields` does not render, and a section whose every member is absent + * disappears heading and all. + * + * ⚠ FLIPPED by objectui#9884, in the direction the sibling rows 4, 6 and + * 7 next door flipped: it used to pin this as a DIVERGENCE from the + * block's own registration, which declared `fields` "Ignored when + * `sections` is given — sections carry their own field lists", and to + * record the loss as having "no diagnostic". objectui#8071 slice 15 handed + * that back as a finding rather than fixing it, because fixing it changes + * either the renderer or the declaration and that card wrote pins only. + * objectui#9884 ruled it: the DECLARATION was the wrong half. One + * `SimpleObjectForm` renders this block's parent half and `object-form` + * alike (`MasterDetailForm`'s `parentSchema` memo literally builds a + * `{ type: 'object-form', ... }` node), and the three sibling `fields` + * registrations — `object-form`, `form`, `embeddable-form` — all declare + * the key as the field selection with no such exemption, with + * `objectFormFieldsMembers-8071` pinning it as one. Honouring the + * exemption would therefore have falsified three declarations to satisfy + * one, on a pool that also feeds values, create defaults and the submitted + * set. So the sentence was corrected and the SILENCE — the half that was + * a defect under either reading — was closed instead: rows 3 and 3b pin + * that every loss is now named by `warnSectionMemberExcludedByFields`, 3c + * is the firing control, and 3d keeps the warning off row 2's different + * silence. ⛔ The rendered outcome did not move: row 3's two DOM + * assertions are the ones slice 15 wrote. * 4. the non-vacuity control: with no `sections` the same parent fields * render flat and no divider is drawn, so every absence above is about a * form that drew something. @@ -157,24 +175,130 @@ describe('`object-master-detail-form` — the member shape of `sections`', () => ); }); - it('3. `fields` is NOT ignored when `sections` is given — the two INTERSECT, and the loser is silent', async () => { - const c = await mount({ - fields: ['ref'], - sections: [ - { label: 'Header', fields: ['ref', 'memo'] }, - { label: 'Notes', fields: ['memo'] }, - ], - }); - expect( - drawnFields(c), - 'the registration declares `fields` "Ignored when `sections` is given"; the renderer builds ' + - 'the parent pool from `fields` first and the section filters against THAT, so `memo` — a ' + - 'member the section names and the object declares — is dropped with no diagnostic', - ).toEqual(['ref']); - expect( - headings(c), - 'a section whose every member lost that intersection disappears heading and all', - ).toEqual(['Header']); + it('3. `fields` is NOT ignored when `sections` is given — the two INTERSECT, and every loser is NAMED (objectui#9884)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let c: HTMLElement; + try { + c = await mount({ + fields: ['ref'], + sections: [ + { label: 'Header', fields: ['ref', 'memo'] }, + { label: 'Notes', fields: ['memo'] }, + ], + }); + // The RENDER half is byte-identical to what this row pinned before + // objectui#9884: the intersection is the behaviour, deliberately kept. + expect( + drawnFields(c), + 'the renderer builds the parent pool from `fields` first and the section filters against ' + + 'THAT, so `memo` — a member the section names and the object declares — does not render', + ).toEqual(['ref']); + expect( + headings(c), + 'a section whose every member lost that intersection disappears heading and all', + ).toEqual(['Header']); + + // The half objectui#9884 added, and the whole reason the row's title + // changed: the loss is no longer silent. One warning per (section, + // member) pair, so BOTH losses are reported — the one that cost a + // control and the one that cost a whole heading. + const said = warn.mock.calls.map((call) => String(call[0])); + expect( + said.filter((m) => m.includes("names 'memo'")).length, + 'both sections named `memo`, and each lost it to the same intersection', + ).toBe(2); + expect(said.some((m) => m.includes("section 'Header'"))).toBe(true); + expect(said.some((m) => m.includes("section 'Notes'"))).toBe(true); + expect( + said.every((m) => m.includes('INTERSECT')), + 'the warning has to say WHICH two keys collided, or the author cannot act on it', + ).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it('3b. the last-member case: the section that vanishes heading and all is named too — the ONE loss with no other trace', async () => { + // Row 3 proves the pair; this row isolates the expensive half on its own + // fixture, because it is the only loss that leaves NOTHING on the page — + // no control missing from a section the author can still see, just an + // absent section. Fresh member/label so the module-scoped dedupe Set in + // `warnSectionMemberExcludedByFields` cannot answer for row 3's entry. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const c = await mount({ + fields: ['ref'], + sections: [{ label: 'Money', fields: ['amount'] }], + }); + expect(headings(c), 'nothing of the section survives').toEqual([]); + expect( + drawnFields(c), + '\u2b50 and it costs MORE than the section: the grouped branch renders ONLY what the ' + + 'sections resolved, so with the form\u2019s only section gone the parent half draws no ' + + 'control at all \u2014 `ref`, which IS in `fields` and named by no section, never appears', + ).toEqual([]); + const said = warn.mock.calls.map((call) => String(call[0])); + expect(said.filter((m) => m.includes("names 'amount'")).length).toBe(1); + expect(said[0]).toContain("section 'Money'"); + expect( + said[0], + 'and it has to state the consequence, because the page shows no sign of it', + ).toContain('disappears with its heading'); + + // The counterfactual, on the SAME section: drop `fields` and the member + // the intersection ate renders under its heading. Without this leg the + // assertions above would also pass on a form that draws nothing for + // reasons having nothing to do with this card. + const without = await mount({ sections: [{ label: 'Money', fields: ['amount'] }] }); + expect(headings(without)).toEqual(['Money']); + expect(drawnFields(without)).toEqual(['amount']); + } finally { + warn.mockRestore(); + } + }); + + it('3c. the firing control: `fields` listing every section member warns about NOTHING', async () => { + // Without this leg row 3 would still pass on a warning that fired on every + // render of every sectioned form. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const c = await mount({ + fields: ['ref', 'memo'], + sections: [{ label: 'Both', fields: ['ref', 'memo'] }], + }); + expect(drawnFields(c)).toEqual(['ref', 'memo']); + expect(headings(c)).toEqual(['Both']); + expect( + warn.mock.calls.map((call) => String(call[0])).filter((m) => m.includes('INTERSECT')), + 'nothing was lost, so nothing is reported', + ).toEqual([]); + } finally { + warn.mockRestore(); + } + }); + + it('3d. the OTHER silence is left alone: a member the object never declares still goes unreported here', async () => { + // Row 2's `qty` resolves to nothing whether or not `fields` is authored, so + // it is a different defect with a different remedy (and row 2 pins the drop + // itself). Authoring `fields` alongside it must NOT recruit it into this + // warning — that would make row 2 and its `object-form` twin fire a + // diagnostic about a collision that never happened. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const c = await mount({ + fields: ['ref'], + sections: [ + { label: 'Lines', fields: ['qty'] }, + { label: 'Head', fields: ['ref'] }, + ], + }); + expect(headings(c)).toEqual(['Head']); + expect( + warn.mock.calls.map((call) => String(call[0])).filter((m) => m.includes('INTERSECT')), + ).toEqual([]); + } finally { + warn.mockRestore(); + } }); it('4. control: with NO `sections` the same parent fields render flat and no divider is drawn', async () => { diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index 7afac36069..bf28bef446 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -427,7 +427,7 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer // Declaring them would mint choices an authoring UI offers and this block // cannot honour. { name: 'formType', type: 'enum', enum: ['simple', 'tabbed'], description: 'How the PARENT half of the form is presented. The detail grids below it are unaffected.' }, - { name: 'fields', type: 'array', description: 'Which parent fields to show, in order. Ignored when `sections` is given — sections carry their own field lists. Members are bare field names (`{ name }` tolerated); NOT the spec `FormFieldSchema` object `sections[].fields` accepts (identity key `field`) — that shape resolves to no name here and is silently skipped (the parent form renders through the same `ObjectForm` / `SimpleObjectForm` as `object-form` — see its `fields` description).' }, + { name: 'fields', type: 'array', description: 'Which parent fields to show, in order — and it is NOT ignored when `sections` is given: the two INTERSECT. The parent field pool is built from this key first and every section then resolves its own members against that pool, so a section member this key does not list is dropped from the rendered form, and a section that loses EVERY member that way disappears with its heading. Each such drop is reported once via `console.warn` (objectui#9884); it is not repaired, because this key is also the parent pool for values, create defaults and the submitted set. Author one or the other, or list every section member here too. Members are bare field names (`{ name }` tolerated); NOT the spec `FormFieldSchema` object `sections[].fields` accepts (identity key `field`) — that shape resolves to no name here and is silently skipped (the parent form renders through the same `ObjectForm` / `SimpleObjectForm` as `object-form` — see its `fields` description).' }, { name: 'title', type: 'string' }, { name: 'submitText', type: 'string', description: 'Label of the button that saves the parent and every detail row in one batch.' }, { name: 'cancelText', type: 'string' }, diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index 9dc3d7c76e..3c134bb1ec 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -24,12 +24,18 @@ * `name`, merging object-schema metadata (type/options/validation) with the * spec-level overrides. * - * Also hosts `warnUnresolvedTopLevelField` (objectui#8738 route 1) — the - * top-level `fields` counterpart of this file's own `warnOnMixedVocabulary`, - * shared by `ObjectForm.tsx`'s `SimpleObjectForm` and `flatFields.ts`'s - * `buildFlatFields`, neither of which is a sectioned variant. It lives here - * rather than in a third module because the vocabulary it warns about IS the - * one shape (2) documents, and the two warnings should not drift in voice. + * Also hosts the two top-level counterparts of this file's own + * `warnOnMixedVocabulary`, both about the SAME vocabulary shape (2) documents, + * and all three kept here so they do not drift in voice: + * + * - `warnUnresolvedTopLevelField` (objectui#8738 route 1) — a top-level + * `fields` member that resolves to no field name, shared by + * `ObjectForm.tsx`'s `SimpleObjectForm` and `flatFields.ts`'s + * `buildFlatFields`, neither of which is a sectioned variant. + * - `warnSectionMemberExcludedByFields` (objectui#9884) — a `sections[].fields` + * member the object really declares, dropped because the form's top-level + * `fields` does not list it. The two keys INTERSECT; this is the warning + * that makes the loser audible. */ import type { FormField } from '@object-ui/types'; @@ -144,6 +150,57 @@ export function warnUnresolvedTopLevelField(entry: unknown, objectName: string): ); } +/** + * The THIRD member of this file's warning family (objectui#9884), and the one + * about an interaction rather than about a single member's shape. + * + * Top-level `fields` and `sections` are NOT layers — they INTERSECT. + * `SimpleObjectForm` builds the parent field pool from `schema.fields` first + * (`fieldsToShow` in `ObjectForm.tsx`), and each section then resolves its own + * members against THAT pool. So a member a section names, that the object + * really declares, is dropped for the single reason that the form's top-level + * `fields` does not list it — and when it is that section's last surviving + * member the section is dropped whole, heading and all. + * + * ⛔ This function only WARNS, for the same reason `warnUnresolvedTopLevelField` + * above only warns: resolving the member here — widening the pool to the union + * of `fields` and every section's members — would change what a landed schema + * renders, submits and prefills, because that pool is also what feeds create + * defaults, the `initialValues` merge and the submitted value set. The + * intersection is the behaviour three of this package's four `fields` + * registrations declare and `objectFormFieldsMembers-8071` pins; what was + * wrong was `object-master-detail-form`'s registration claiming `fields` is + * "Ignored when `sections` is given", and that sentence is what objectui#9884 + * corrected. The remaining defect was the SILENCE, and this is it. + * + * ⚠️ Deliberately NOT fired for a member the object never declares at all — + * a typo, or a detail-collection column borrowed into the parent's vocabulary. + * That member resolves to nothing whether or not `fields` is authored, so it + * is a different silence with a different remedy; it is pinned as behaviour by + * `objectFormSectionMembers-8071` row 2 and `masterDetailSectionMembers-8071` + * row 2, and widening this warning to cover it would make those two rows fire + * it on every run while saying nothing this card measured. + */ +const warnedSectionMemberExcluded = new Set(); +export function warnSectionMemberExcludedByFields( + memberName: string, + objectName: string, + sectionLabel?: string, +): void { + const where = sectionLabel ? `section '${sectionLabel}'` : 'an untitled section'; + const key = `${objectName}:${where}:${memberName}`; + if (warnedSectionMemberExcluded.has(key)) return; + warnedSectionMemberExcluded.add(key); + console.warn( + `[object-ui] ${where} names '${memberName}', which object '${objectName}' declares, but the ` + + `form's top-level \`fields\` does not list it — so it was dropped from the rendered form. ` + + `Top-level \`fields\` and \`sections\` INTERSECT (the parent field pool is built from ` + + `\`fields\` first, and every section resolves its members against that pool); they are ` + + `alternatives, not layers. Author one or the other, or list every section member in ` + + `\`fields\` too. A section that loses EVERY member this way disappears with its heading.`, + ); +} + /** * Build a runtime FormField from object-schema metadata for `fieldName`. *