From 753124e6d5303acbe505f0b3d35ba98c0678a0a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 08:12:19 +0000 Subject: [PATCH] fix(plugin-form): hand a section's `description` to the default layout's divider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SimpleObjectForm`'s grouped branch — the layout a section-carrying `object-form` gets when it declares no `formType` — rebuilds each section key by key into a virtual `section-divider` row. The rebuild copied `label`, the ADR-0089 predicate, the membership claim and the collapse pair, and not `description`, so the key was dropped on the path an author reaches by default while `SectionDivider` (the component that row renders as) has always drawn a blurb and the `tabbed` / `wizard` / `split` / `modal` rebuilds copied one. The sibling `label` on the same member arrived, which is what made the miss invisible. The `if (label)` gate is deliberately not widened with it: that gate also decides the predicate row and the membership claim, so a member carrying a `description` and neither `name` nor `label` still draws no divider. Pinned as behaviour beside the fix. `objectFormSectionMembers-8071` row 6 pinned the drop; it is rewritten onto the new behaviour in this same change, reading the blurb off the divider itself rather than off the form's `textContent`, and a new row pins the boundary above. The member-pin ledger entry that described the old row in prose is corrected with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- .changeset/quick-pots-repeat.md | 32 ++++++++ .../registry-inputs-spec-parity.test.ts | 2 +- packages/plugin-form/src/ObjectForm.tsx | 22 ++++++ .../objectFormSectionMembers-8071.test.tsx | 75 ++++++++++++++++--- 4 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 .changeset/quick-pots-repeat.md diff --git a/.changeset/quick-pots-repeat.md b/.changeset/quick-pots-repeat.md new file mode 100644 index 0000000000..cf95174e03 --- /dev/null +++ b/.changeset/quick-pots-repeat.md @@ -0,0 +1,32 @@ +--- +'@object-ui/plugin-form': patch +--- + +fix(plugin-form): `object-form`'s default layout hands a section's `description` to the divider it already draws + +An `object-form` that declares `sections` and no `formType` renders through +`SimpleObjectForm`'s grouped branch, which rebuilds each section key by key into +a virtual `section-divider` row. That rebuild copied `label`, the ADR-0089 +`visibleWhen`, the objectui#6236 membership claim and the collapse pair — and +not `description`. The key was therefore dropped on the layout an author reaches +by default, while `SectionDivider` (the very component the row renders as) has +always drawn a blurb and the `tabbed` / `wizard` / `split` / `modal` rebuilds all +copied one. Its sibling `label` on the same member arrived, so a titled section +with a blurb rendered the title and silently ate the blurb. + +Measured on this branch, arm by arm, through the real renderer: `tabbed`, +`wizard`, `split` and `modal` render it; the default layout and `drawer` did +not. Only the default layout is changed here — the `drawer` miss is a separate +defect and is handed back as a finding rather than fixed under this card. + +The boundary that did NOT move: the divider row exists only for a member that +yields a heading (a `name` or a `label`), so a member carrying a `description` +and neither of those still draws no divider and still drops its blurb. That gate +also decides the section predicate and the membership claim, so widening it is a +ruling about other keys, not this one. + +`objectFormSectionMembers-8071`'s sixth row pinned the drop as behaviour; it is +rewritten onto the new behaviour in this same change, plus a row pinning the +boundary above. `registry-inputs-spec-parity`'s member-pin ledger entry for +`object-form.sections` described the old row in prose and is corrected with it — +no published behaviour of `@object-ui/console` changes. 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 029537acac..4768f0c7dd 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2326,7 +2326,7 @@ const MEMBER_PINS: Record = { }, 'object-form.sections': { file: 'packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx', - pins: 'Members are section OBJECTS, pinned in the DEFAULT layout (the one a section-carrying form gets with no `formType`) on the six keys `SimpleObjectForm`\'s grouped branch reads. The row a plausible improvement breaks: `fields` is read as a SET, not an order — the resolution is `sourceFields.filter((f) => sectionFieldNames.includes(f.name))`, so the OBJECT\'s order wins and the authored member order is discarded, which is the OPPOSITE of the sibling key `object-form.fields` on the same block, pinned next door on authored order being PRESERVED. The silent row: a section whose members resolve to no field is dropped WHOLE, heading included, so one mistyped member name costs a heading with nothing visibly wrong. `name` alone titles the section and a member carrying neither `name` nor `label` draws no divider at all; `collapsed` takes the members out of the DOM while `collapsible` is the SEPARATE member that makes the heading a control, so a section declared `collapsed` and not `collapsible` renders permanently closed with no affordance — each arm carrying its own control in the same call. A no-sections row is the non-vacuity control. LIMIT, pinned as behaviour and handed back as a finding: this layout DROPS a member\'s `description`, which `SectionDivider` renders and the five rebuild arms copy, with the same section\'s `label` in the same call as the live control. `visibleWhen`, the `{ group }` reference form and the legacy `groups` spelling are owned by their own neighbouring files and deliberately not re-pinned here. Both declared sides are unconstrained (bare `type: \'array\'`; `z.array(z.unknown())`), so the read site is the whole member contract. New file (objectui#8071 slice 11).', + pins: 'Members are section OBJECTS, pinned in the DEFAULT layout (the one a section-carrying form gets with no `formType`) on the six keys `SimpleObjectForm`\'s grouped branch reads. The row a plausible improvement breaks: `fields` is read as a SET, not an order — the resolution is `sourceFields.filter((f) => sectionFieldNames.includes(f.name))`, so the OBJECT\'s order wins and the authored member order is discarded, which is the OPPOSITE of the sibling key `object-form.fields` on the same block, pinned next door on authored order being PRESERVED. The silent row: a section whose members resolve to no field is dropped WHOLE, heading included, so one mistyped member name costs a heading with nothing visibly wrong. `name` alone titles the section and a member carrying neither `name` nor `label` draws no divider at all; `collapsed` takes the members out of the DOM while `collapsible` is the SEPARATE member that makes the heading a control, so a section declared `collapsed` and not `collapsible` renders permanently closed with no affordance — each arm carrying its own control in the same call. A no-sections row is the non-vacuity control. The seventh key changed hands: this layout used to DROP a member\'s `description` — pinned as behaviour and handed back as a finding — and objectui#9779 made it copy the key onto the divider row, so the same row now pins the ARRIVAL, read off the divider itself with the same section\'s `label` in the same call as the live control. The gate that decides whether a member gets a divider row at all was NOT widened with it, so a member carrying a `description` and neither `name` nor `label` still draws no divider and still drops its blurb; that boundary is pinned beside it and handed back in turn. `visibleWhen`, the `{ group }` reference form and the legacy `groups` spelling are owned by their own neighbouring files and deliberately not re-pinned here. Both declared sides are unconstrained (bare `type: \'array\'`; `z.array(z.unknown())`), so the read site is the whole member contract. New file (objectui#8071 slice 11).', }, 'object-form.submitBehavior': { file: 'packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx', diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 42b7a9b085..83a8fbd13c 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -1413,6 +1413,28 @@ const SimpleObjectForm: React.FC = ({ name: `__section_${sectionKey}`, label, type: 'section-divider', + // The section's authored blurb (spec `FormSection.description`, + // objectui#9779). This map is a key-by-key rebuild, so a key it does + // not copy is dropped before any renderer can see it — and this key + // was dropped HERE, on the layout a section-carrying form gets when + // it declares no `formType`, while the `tabbed` / `wizard` / `split` + // / `modal` maps above copied it and `SectionDivider` (the very + // component this row renders as) has always drawn one. Its sibling + // `label` on the same member arrived, so the miss was invisible to + // the author: a titled section with a blurb rendered the title and + // silently ate the blurb. + // + // ⚠️ The `if (label)` gate above is NOT widened with it, deliberately. + // That gate decides whether this member gets a divider row at all, + // and the row carries two other contracts — the ADR-0089 predicate + // and the #6236 membership claim that gates the group — plus the + // collapse pair below, whose "an untitled bucket is never + // collapsible" rule the gate is what implements. So a member + // carrying a `description` and NEITHER `name` nor `label` still + // draws no divider and still drops its blurb; that shape is pinned + // as behaviour beside the row below it and handed back as a finding, + // ⛔ not decided here. + description: section.description, // ADR-0089 `FormSection.visibleWhen` (#6111). The renderer evaluates // a `visibleWhen` on this pseudo-field with the host predicate scope // bound (#6010), so copying it here is what makes the authored diff --git a/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx b/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx index 07c0375f15..8e9ef43c43 100644 --- a/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx +++ b/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx @@ -67,12 +67,23 @@ * fields render, with no divider anywhere — so rows 1-4 cannot be passing on a * form that draws nothing. * - * ⚠️ LIMIT, recorded rather than asserted as a second layout: a section's - * `description` IS a member the five rebuild arms copy and `SectionDivider` - * renders, and the default layout above does NOT copy it. Row 6 pins that as - * behaviour, with the section's own `label` in the same call as the live - * control; it is handed back as a finding on objectui#8071 rather than fixed, - * because fixing it is a renderer change and this card writes pins only. + * Row 6 is the SEVENTH key, and it changed hands. It was written here as a + * LIMIT — `description` is a member `SectionDivider` renders and the rebuild + * arms copy, and this layout did NOT copy it, so the row pinned the DROP as + * behaviour and handed it back as a finding, because objectui#8071 wrote pins + * only. objectui#9779 took that finding and made the default layout copy the + * key, so the row now pins the ARRIVAL: same fixture, same live control + * (`label` on the same member, in the same call), opposite verdict. ⛔ The + * row's job is unchanged and it is still the only thing watching this key — + * it must go red if the blurb stops reaching the divider. + * + * ⚠️ The BOUNDARY objectui#9779 did not move, pinned in the same row rather + * than left to be rediscovered: the divider row exists only for a member that + * yields a heading (row 3's `name`-or-`label` gate), so a member carrying a + * `description` and NEITHER of those still draws no divider and still drops + * its blurb. That gate also decides the ADR-0089 predicate row and the #6236 + * membership claim, so widening it is a ruling about other keys — handed back + * as a finding, ⛔ not taken by that card. */ import { describe, it, expect, vi } from 'vitest'; @@ -122,6 +133,17 @@ const drawnFields = (c: HTMLElement): string[] => const headings = (c: HTMLElement): string[] => [...c.querySelectorAll('.border-b span')].map((el) => el.textContent ?? ''); +/** + * The section BLURBS actually drawn, in DOM order — read off the divider row + * itself (`SectionDivider` renders the description as the `

` inside the same + * `.border-b` block that carries the heading), not off the form's whole + * `textContent`. A `textContent` read would be satisfied by the string + * appearing anywhere at all — a field's own help text, a toast, a label — which + * is fine for asserting ABSENCE and useless for asserting ARRIVAL. + */ +const blurbs = (c: HTMLElement): string[] => + [...c.querySelectorAll('.border-b p')].map((el) => el.textContent ?? ''); + describe('`object-form` — the member shape of `sections`', () => { it('1. a member’s `fields` are field NAMES read as a SET — the OBJECT’s order wins, ⛔ not the authored one', async () => { const c = await mount({ @@ -187,17 +209,46 @@ describe('`object-form` — the member shape of `sections`', () => { expect(drawnFields(c)).toEqual(['customer', 'note', 'amount']); }); - it('6. a member’s `description` is DROPPED by the default layout, while its `label` is not', async () => { + it('6. a member’s `description` REACHES the divider, alongside its `label` (objectui#9779)', async () => { + // ⚠️ This row previously pinned the OPPOSITE — `.not.toContain(...)`, the + // drop recorded as behaviour by objectui#8071 slice 11 and handed back as a + // finding. objectui#9779 fixed the default layout, so the row was rewritten + // onto the new behaviour in the SAME change. Its job did not change: this + // is still the only assertion watching whether the blurb reaches the + // divider, and it must fail if it stops. const c = await mount({ sections: [{ label: 'Money', description: 'Totals as invoiced', fields: ['amount'] }], }); - expect(headings(c), 'the live control: the sibling member on the SAME section does reach the divider').toEqual([ + expect(headings(c), 'the live control: the sibling member on the SAME section reaches the divider').toEqual([ 'Money', ]); expect( - c.textContent, - '`SectionDivider` renders a `description` and the five rebuild arms copy one; this ' + - 'layout does not — recorded as behaviour, handed back as a finding', - ).not.toContain('Totals as invoiced'); + blurbs(c), + '`SectionDivider` renders a `description`; the default layout now hands it one, so the ' + + 'blurb is read off the divider itself — not off `textContent`, which a stray render ' + + 'of the same string anywhere in the form would satisfy', + ).toEqual(['Totals as invoiced']); + expect(drawnFields(c), 'and the section still draws its member').toEqual(['amount']); + }); + + it('7. the boundary: a member with a `description` but NEITHER `name` nor `label` still draws no divider', async () => { + // The gate row 3 pins decides whether a member gets a divider ROW at all, + // and that row carries two further contracts (the ADR-0089 predicate and + // the #6236 membership claim) plus the collapse pair — so objectui#9779 + // copied the key onto the row and deliberately did NOT widen the gate. + // Recorded here as behaviour, exactly as row 6 recorded the drop, and + // handed back as a finding: a member CAN author a blurb with no heading, + // and four of the other arms (`split` / `modal` / `wizard` / `tabbed`) + // render one for it. + const c = await mount({ + sections: [{ description: 'Totals as invoiced', fields: ['amount'] }], + }); + expect(headings(c), 'no heading is authored, so no divider is drawn').toEqual([]); + expect(blurbs(c), '…and with no divider there is nothing to carry the blurb').toEqual([]); + expect( + drawnFields(c), + 'the liveness control: the member itself still renders, so the two negatives above are ' + + 'about a form that drew something', + ).toEqual(['amount']); }); });