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
32 changes: 32 additions & 0 deletions .changeset/quick-pots-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -2326,7 +2326,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'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',
Expand Down
22 changes: 22 additions & 0 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1413,6 +1413,28 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 `<p>` 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({
Expand Down Expand Up @@ -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']);
});
});
Loading