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
47 changes: 47 additions & 0 deletions .changeset/9884-master-detail-fields-intersection.md
Original file line number Diff line number Diff line change
@@ -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 `<ObjectForm>` — 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.
28 changes: 21 additions & 7 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2440,7 +2440,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'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 `<ObjectForm>`, 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 `<ObjectForm>`, 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',
Expand Down Expand Up @@ -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`
Expand Down
46 changes: 45 additions & 1 deletion packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1470,6 +1470,50 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
(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);
Expand Down
Loading
Loading