Skip to content

Commit f9c01fc

Browse files
committed
feat(plugin-form): stamp the section membership claim at all six divider synthesis sites
Patch round on the same claim (fence widened by the PM after the fork was ruled option A). Every section-divider synthesis site — ObjectForm's stacked simple path, ModalForm's sectioned groups map and derived-fieldGroup path, DrawerForm's sectioned and derived-fieldGroup paths, SplitForm's paneFields — now copies the RESOLVED member names onto the divider it emits (fields: names), which is the exact string list the renderer's hiddenSectionFieldNames gate looks up. An authored FormSection.visibleWhen therefore hides the whole section on the object-view chain, matching the console renderer. The #6111 measured-scope pin FLIPPED as designed: it now asserts heading and claimed field hide together, and every per-layout DENIED/ALLOWED/FAULTED row also asserts the member field. The derived-fieldGroup sites carry the claim for uniformity but stay fail-open — the spec fieldGroups vocabulary has no section-predicate slot to author. Changeset gains @object-ui/plugin-form minor; the types-side scope notes updated to the landed truth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
1 parent 02b73fc commit f9c01fc

7 files changed

Lines changed: 104 additions & 44 deletions

File tree

‎.changeset/6236-section-grouping-contract.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@object-ui/types': minor
33
'@object-ui/components': minor
4+
'@object-ui/plugin-form': minor
45
---
56

67
The section grouping contract (objectui#6236, maintainer ruling 2026-08-27): a
@@ -30,8 +31,16 @@ Ruled semantics, now pinned in `section-grouping-6236.test.tsx`:
3031
Both halves ride the mechanism the field-level predicate already uses (return `null`;
3132
react-hook-form keeps the value and skips the unmounted control), so field-level and
3233
section-level visibility cannot drift apart. The zod mirror (`FormFieldSchema`) declares
33-
the key with the same scope note. Scope note: the plugin-form `section-divider` synthesis
34-
sites do not yet stamp the claim onto the dividers they emit — an authored section
35-
predicate on those chains still gates only the heading until that wiring lands (the
36-
remaining half of objectui#6236). The tabbed arm's predicate slot (objectui#6237) is
34+
the key with the same scope note.
35+
36+
`@object-ui/plugin-form` wires the producer half: all six `section-divider` synthesis
37+
sites (ObjectForm's stacked simple path, ModalForm's sectioned and derived-fieldGroup
38+
paths, DrawerForm's sectioned and derived-fieldGroup paths, SplitForm's panes) now stamp
39+
the membership claim onto the divider they emit, from the RESOLVED member list — so an
40+
authored `FormSection.visibleWhen` finally hides the whole section on the object-view
41+
chain, matching the console renderer. The #6111 honest pin (`measured scope`) flipped
42+
accordingly: it now pins heading-and-fields hiding together, and every per-layout DENIED
43+
row asserts the claimed member as well as the heading. The derived-fieldGroup sites carry
44+
the claim for uniformity but stay fail-open — the spec `fieldGroups` vocabulary has no
45+
section-predicate slot to author. The tabbed arm's predicate slot (objectui#6237) is
3746
designed to reuse this same grouping contract.

‎packages/plugin-form/src/DrawerForm.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,9 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
586586
schema.sections.forEach((section, index) => {
587587
const sectionKey = section.name || String(index);
588588
const isCollapsed = collapsedSections[sectionKey] ?? (section.collapsed ?? false);
589+
// Resolved before the divider push so the membership claim below can
590+
// name exactly the fields this group contributes (#6236).
591+
const sectionFields = buildSectionFields(section);
589592

590593
allFields.push({
591594
name: `__section_${sectionKey}`,
@@ -594,6 +597,9 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
594597
// ADR-0089 section predicate (#6111) — the renderer evaluates it on
595598
// this pseudo-field with the host predicate scope bound (#6010).
596599
visibleWhen: (section as any).visibleWhen,
600+
// The membership claim (#6236): resolved member names, so the
601+
// predicate gates the whole group.
602+
fields: sectionFields.map(f => f.name),
597603
colSpan: 4,
598604
collapsible: section.collapsible,
599605
collapsed: isCollapsed,
@@ -603,7 +609,6 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
603609
className: (section as any).className,
604610
} as any);
605611

606-
const sectionFields = buildSectionFields(section);
607612
if (isCollapsed) {
608613
allFields.push(...sectionFields.map(f => ({ ...f, hidden: true })));
609614
} else {
@@ -652,6 +657,9 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
652657
type: 'section-divider',
653658
// ADR-0089 section predicate (#6111).
654659
visibleWhen: (section as any).visibleWhen,
660+
// The membership claim (#6236): resolved member names, so the
661+
// predicate gates the whole group.
662+
fields: body.map(f => f.name),
655663
colSpan: 4,
656664
collapsible: section.collapsible,
657665
collapsed: isCollapsed,

‎packages/plugin-form/src/ModalForm.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,10 @@ export const ModalForm: React.FC<ModalFormProps> = ({
716716
// ADR-0089 section predicate (#6111) — the renderer evaluates it on
717717
// this pseudo-field with the host predicate scope bound (#6010).
718718
visibleWhen: g.visibleWhen,
719+
// The membership claim (#6236): resolved member names, so the
720+
// predicate gates the whole group (same spelling as the
721+
// `fieldTabs` claim above).
722+
fields: g.fields.map((f) => f.name),
719723
colSpan: 4,
720724
className: g.className,
721725
} as any);
@@ -748,6 +752,9 @@ export const ModalForm: React.FC<ModalFormProps> = ({
748752
type: 'section-divider',
749753
// ADR-0089 section predicate (#6111).
750754
visibleWhen: (section as any).visibleWhen,
755+
// The membership claim (#6236): resolved (post-FLS) member names,
756+
// so the predicate gates the whole group.
757+
fields: body.map((f) => f.name),
751758
} as any);
752759
}
753760
allFields.push(...(columns > 1 ? applyAutoColSpan(body, columns) : body));

‎packages/plugin-form/src/ObjectForm.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,13 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
12241224
// bound (#6010), so copying it here is what makes the authored
12251225
// section predicate reach an evaluator at all.
12261226
visibleWhen: (section as any).visibleWhen,
1227+
// The membership claim (#6236): the RESOLVED member names — the same
1228+
// strings the renderer's flat list carries — so the section
1229+
// predicate gates the whole group, not just this heading. Resolved
1230+
// rather than authored on purpose: the authored `section.fields`
1231+
// entries can be spec field-defs, and a perms-filtered field is not
1232+
// in the form at all.
1233+
fields: sectionFields.map(f => f.name),
12271234
colSpan: 4,
12281235
collapsible: section.collapsible,
12291236
collapsed: isCollapsed,

‎packages/plugin-form/src/SplitForm.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ export const SplitForm: React.FC<SplitFormProps> = ({
386386
// ADR-0089 section predicate (#6111) — the renderer evaluates it on
387387
// this pseudo-field with the host predicate scope bound (#6010).
388388
visibleWhen: section.visibleWhen,
389+
// The membership claim (#6236): resolved member names, so the
390+
// predicate gates the whole group.
391+
fields: body.map(f => f.name),
389392
colSpan: 4,
390393
className: section.className,
391394
} as any);

‎packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx‎

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,26 @@
7070
* everywhere, which is precisely why the ALLOWED rows could never have caught
7171
* this.
7272
*
73-
* ## Scope, measured — what this does NOT claim
73+
* ## Scope — the whole group, since objectui#6236
7474
*
75-
* The renderer treats `section-divider` as a presentational ROW and holds no
76-
* association between it and the fields that follow it, so a false predicate
77-
* removes the HEADING and leaves the section's fields rendering. The console
78-
* renderer (`apps/console/src/components/FormPage.tsx:1819`) drops the whole
79-
* `<section>`, fields included. That divergence is real and is filed
80-
* separately — it needs a renderer-side grouping contract, not another line in
81-
* a layout. The `stillRendersItsFields` case below pins the CURRENT behaviour
82-
* honestly rather than letting the file imply a guarantee it does not deliver.
75+
* objectui#6236 (maintainer ruling 2026-08-27) closed the divergence this
76+
* header used to record: the renderer now holds a real divider-to-field
77+
* association — the divider's membership claim (`FormField.fields`, stamped by
78+
* every synthesis site above from the RESOLVED member list) — and a false
79+
* section predicate hides the heading AND the claimed fields, matching the
80+
* console renderer. So every DENIED row below asserts both halves: the heading
81+
* (the #6111 deliverable) and the gated member field (the #6236 wiring). The
82+
* hidden members skip client-side validation and their values still submit;
83+
* those semantics are pinned at the renderer in
84+
* `packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx`
85+
* — this file pins that each LAYOUT's synthesis site actually stamps the claim
86+
* (an unstamped site reverts to heading-only, invisible to every other suite).
87+
*
88+
* Two derived-fieldGroup synthesis sites (ModalForm / DrawerForm
89+
* `derivedSections`) also stamp the claim for uniformity, but the spec
90+
* `fieldGroups` vocabulary has no section-predicate slot, so no authoring path
91+
* can turn their gate on today and no DENIED row can discriminate them; they
92+
* stay fail-open until that vocabulary grows a predicate.
8393
*/
8494

8595
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -192,6 +202,12 @@ const renderModalFormDirect = async (scope: Record<string, unknown>) => {
192202
/** The gated section's heading — `null` when the layout hid it. */
193203
const gatedHeading = () => screen.queryByText('Compensation');
194204

205+
/**
206+
* The gated section's claimed MEMBER field (#6236) — `null` when the layout's
207+
* synthesis site stamped the membership claim and the gate took the group.
208+
*/
209+
const gatedField = () => screen.queryByLabelText(/salary/i);
210+
195211
/**
196212
* Every layout, by name, with the mount that reaches its own synthesis site.
197213
* Named individually because a fix applied to five of six sites still passes a
@@ -221,25 +237,33 @@ const LAYOUTS: { label: string; mount: (scope: Record<string, unknown>) => Promi
221237
];
222238

223239
describe('#6111 — an authored section `visibleWhen` reaches an evaluator in every layout', () => {
224-
describe('DENIED — the predicate resolves FALSE, so the section heading is HIDDEN', () => {
225-
// ⚠️ THE deliverable. Green here is the only observation that separates
226-
// "the predicate arrived and was evaluated" from "it never arrived" —
227-
// every other row in this file is green on unfixed code.
240+
describe('DENIED — the predicate resolves FALSE, so the whole section is HIDDEN', () => {
241+
// ⚠️ THE deliverable, in two halves. The heading half (#6111) separates
242+
// "the predicate arrived and was evaluated" from "it never arrived"; the
243+
// member half (#6236) separates "this layout's synthesis site stamped the
244+
// membership claim" from "the divider arrived claim-less and the gate
245+
// stayed heading-only" — an unstamped site fails ONLY here, in the SHOWN
246+
// direction, because a claim-less divider is valid compat behaviour
247+
// everywhere else.
228248
for (const layout of LAYOUTS) {
229249
it(layout.label, async () => {
230250
await layout.mount(DENIED);
231251
expect(gatedHeading()).toBeNull();
252+
expect(gatedField()).toBeNull();
232253
});
233254
}
234255
});
235256

236257
describe('ALLOWED — the SAME predicate text, a user it admits ⇒ still SHOWN', () => {
237258
// The control. Without it, "hidden" is satisfied by a layout that dropped
238259
// the heading for an unrelated reason — a worse defect, invisible above.
260+
// The member assertion keeps the same honesty for the group half: a gate
261+
// that hides claimed fields unconditionally would pass every DENIED row.
239262
for (const layout of LAYOUTS) {
240263
it(layout.label, async () => {
241264
await layout.mount(ALLOWED);
242265
expect(gatedHeading()).not.toBeNull();
266+
expect(gatedField()).not.toBeNull();
243267
});
244268
}
245269
});
@@ -274,26 +298,27 @@ describe('#6111 — an authored section `visibleWhen` reaches an evaluator in ev
274298
);
275299
await waitFor(() => expect(screen.getByText('Always')).toBeTruthy());
276300
expect(gatedHeading()).not.toBeNull();
301+
expect(gatedField()).not.toBeNull();
277302
});
278303
}
279304
});
280305

281-
it('measured scope: a hidden section still renders its FIELDS (objectui#6111 follow-up)', async () => {
282-
// NOT an endorsement — an honest pin of what this chain does and does not
283-
// deliver. The renderer-side grouping contract EXISTS since objectui#6236
284-
// (maintainer ruling 2026-08-27): a `section-divider` that claims its
285-
// members (`FormField.fields: string[]`) gates the whole group — heading
286-
// and fields together, hidden members skip client-side validation, values
287-
// still submit (pinned in `packages/components/.../section-grouping-6236.
288-
// test.tsx`). What is still missing is THIS CHAIN's half: the layouts'
289-
// divider synthesis sites (`ObjectForm.tsx` and siblings) copy
290-
// `visibleWhen` onto the pseudo-field but do not yet stamp the membership
291-
// claim, so an authored section predicate still hides only the heading
292-
// here — the fields stay, exactly as below. The console renderer drops
293-
// the whole `<section>`. This assertion turning red is the SIGNAL that
294-
// the synthesis wiring (the remaining half of objectui#6236) landed.
306+
it('measured scope: a hidden section hides its FIELDS too (objectui#6236 wiring landed)', async () => {
307+
// FLIPPED — deliberately, and exactly once. From #6111 until the #6236
308+
// wiring landed, this case pinned the honest limitation: the divider was a
309+
// presentational row, so a false section predicate removed the heading and
310+
// LEFT THE FIELDS RENDERING (`expect(getByLabelText(/salary/i)).toBeTruthy()`
311+
// stood here). #6111 wrote it so that its red flip would be the SIGNAL the
312+
// grouping contract landed rather than a broken test — and that is what
313+
// happened: the synthesis sites now stamp the membership claim
314+
// (`fields: [...names]`) onto the divider, the renderer gates the whole
315+
// group, and this case now pins the NEW contract on the same chain, the
316+
// same mount, the same predicate: heading gone AND claimed field gone,
317+
// matching the console renderer at last. Values still submit and hidden
318+
// members skip client-side validation — pinned at the renderer in
319+
// section-grouping-6236.test.tsx.
295320
await renderObjectForm(DENIED, { formType: 'simple' });
296321
expect(gatedHeading()).toBeNull();
297-
expect(screen.getByLabelText(/salary/i)).toBeTruthy();
322+
expect(gatedField()).toBeNull();
298323
});
299324
});

‎packages/types/src/objectql.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,17 +1069,18 @@ export interface ObjectFormSection {
10691069
* `visibleWhen` surface uses, so one authored predicate text means one thing
10701070
* everywhere (#6010). A broken predicate fails OPEN (the header renders).
10711071
*
1072-
* ⚠️ Scope: the renderer-side grouping contract exists since objectui#6236
1073-
* (maintainer ruling 2026-08-27) — a `section-divider` row that carries the
1074-
* membership claim (`FormField.fields`, the FormFieldTab shape) gates its
1075-
* WHOLE group: heading and claimed fields hide together, the hidden fields
1076-
* skip client-side validation, and their values still submit (the console
1077-
* precedent, 2026-08-22 after #5594). Measured limit: the plugin-form
1078-
* layouts' `section-divider` synthesis sites copy `visibleWhen` onto the
1079-
* divider (objectui#6111) but do not yet stamp this claim, so an authored
1080-
* section predicate still gates only the HEADING on those chains until that
1081-
* wiring lands (the remaining half of objectui#6236). The console renderer
1082-
* (`apps/console/src/components/FormPage.tsx`) drops the whole `<section>`.
1072+
* Scope: this gates the WHOLE section (objectui#6236, maintainer ruling
1073+
* 2026-08-27). The plugin-form layouts stamp the membership claim
1074+
* (`FormField.fields`, the FormFieldTab shape) onto the `section-divider`
1075+
* row they synthesize, and the renderer then hides heading and claimed
1076+
* fields together on a FALSE predicate — matching the console renderer
1077+
* (`apps/console/src/components/FormPage.tsx`), which drops the whole
1078+
* section element. Hidden fields skip client-side validation (a user is
1079+
* never blocked by an error pointing at a control they cannot see) and
1080+
* their values still submit — visibility decides what is DRAWN and nothing
1081+
* else (the console precedent, 2026-08-22 after #5594). Derived
1082+
* `fieldGroups` sections carry no predicate slot, so their groups are
1083+
* always drawn.
10831084
*/
10841085
visibleWhen?: string | { dialect?: string; source: string };
10851086

0 commit comments

Comments
 (0)