From f274f153efb4bf89e1bfccd0de7f339a986c7855 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:01:36 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(components,types):=20section=20groupin?= =?UTF-8?q?g=20contract=20=E2=80=94=20a=20claiming=20divider=20gates=20its?= =?UTF-8?q?=20whole=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `section-divider` row may now claim its member fields (`FormField.fields: string[]`, the FormFieldTab/FormFieldPane membership shape) and the form renderer gates the WHOLE group on the divider's visibility verdict: heading and claimed fields hide together, the hidden members skip client-side validation (ruled: a user must never be blocked by an error pointing at a control they cannot see), and their values still submit (visibility decides what is DRAWN and nothing else — the console precedent). A divider without a claim keeps the old heading-only contract. Both ruled semantics ride the mechanism the field-level predicate already uses (return null; react-hook-form keeps the value and skips the unmounted control at submit validation), so field- and section-level visibility cannot drift. Zod mirror + coverage pin updated; new pin file section-grouping-6236.test.tsx; the 6010 parity sectionSurface row now asserts heading and claimed member move together. Maintainer ruling 2026-08-27 (option A on the card's facet block). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .changeset/6236-section-grouping-contract.md | 37 +++ .../predicate-scope-parity-6010.test.tsx | 24 +- .../__tests__/section-grouping-6236.test.tsx | 243 ++++++++++++++++++ .../components/src/renderers/form/form.tsx | 117 ++++++++- .../__tests__/form-field-zod-coverage.test.ts | 2 + packages/types/src/form.ts | 26 ++ packages/types/src/objectql.ts | 17 +- packages/types/src/zod/form.zod.ts | 2 + 8 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 .changeset/6236-section-grouping-contract.md create mode 100644 packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx diff --git a/.changeset/6236-section-grouping-contract.md b/.changeset/6236-section-grouping-contract.md new file mode 100644 index 0000000000..0841b773cb --- /dev/null +++ b/.changeset/6236-section-grouping-contract.md @@ -0,0 +1,37 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +--- + +The section grouping contract (objectui#6236, maintainer ruling 2026-08-27): a +`section-divider` row may now CLAIM its member fields — `FormField.fields: string[]`, the +same membership shape `FormFieldTab.fields` / `FormFieldPane.fields` already model — and +the form renderer then gates the WHOLE group on the divider's own visibility verdict +(`visibleWhen` / `visibleOn` / legacy `condition`). + +Before this, one authored `FormSection.visibleWhen` meant two different things: the +console renderer drops the whole `
` (heading and fields), while the plugin-form +chain's renderer treated `section-divider` as a purely presentational row and hid only +the HEADING, leaving the section's fields rendering (measured in objectui#6111, which +pinned that honestly rather than implying a guarantee it did not deliver). + +Ruled semantics, now pinned in `section-grouping-6236.test.tsx`: + +- **Visibility decides what is DRAWN and nothing else** (console precedent, 2026-08-22 + ruling after #5594) — a hidden section's values still submit. +- **A hidden section's fields skip client-side validation** — a user is never blocked by + an error pointing at a control they cannot see (the objectui#6110 defect shape); the + server-side contract remains the loud floor for genuinely-required data. A section + hiding mid-session also clears its members' stale errors, the way a field's own false + predicate already did. +- **A divider without a claim keeps the old contract** (its predicate gates only the + heading), so existing schemas are untouched. + +Both halves ride the mechanism the field-level predicate already uses (return `null`; +react-hook-form keeps the value and skips the unmounted control), so field-level and +section-level visibility cannot drift apart. The zod mirror (`FormFieldSchema`) declares +the key with the same scope note. Scope note: the plugin-form `section-divider` synthesis +sites do not yet stamp the claim onto the dividers they emit — an authored section +predicate on those chains still gates only the heading until that wiring lands (the +remaining half of objectui#6236). The tabbed arm's predicate slot (objectui#6237) is +designed to reuse this same grouping contract. diff --git a/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx b/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx index 4358e72cb6..364d1d6993 100644 --- a/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx +++ b/packages/components/src/renderers/form/__tests__/predicate-scope-parity-6010.test.tsx @@ -201,19 +201,37 @@ function fieldSurface(predicate: unknown, scope: Record): boole return screen.queryByLabelText(/salary/i) !== null; } -/** Form SECTION `visibleWhen` — the same call site, `type: 'section-divider'`. */ +/** + * Form SECTION `visibleWhen` — the same call site, `type: 'section-divider'`. + * + * Since objectui#6236 the divider carries a membership claim (`fields`) and + * its predicate gates the WHOLE group, so this row asserts the two halves + * cannot disagree — heading and claimed member move together — and returns + * their shared verdict. (Until #6236 it asserted only the heading; the group + * semantics themselves are pinned in section-grouping-6236.test.tsx.) + */ function sectionSurface(predicate: unknown, scope: Record): boolean { renderForm( { fields: [ { name: 'title', label: 'Title', type: 'input' }, - { name: 'pay', label: 'Compensation', type: 'section-divider', visibleWhen: predicate }, + { + name: 'pay', + label: 'Compensation', + type: 'section-divider', + visibleWhen: predicate, + fields: ['salary'], + }, + { name: 'salary', label: 'Salary', type: 'input' }, ], }, scope, ); expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); - return screen.queryByText('Compensation') !== null; + const headingShown = screen.queryByText('Compensation') !== null; + const memberShown = screen.queryByLabelText(/salary/i) !== null; + expect(memberShown).toBe(headingShown); + return headingShown; } /** diff --git a/packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx b/packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx new file mode 100644 index 0000000000..2524a78c40 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx @@ -0,0 +1,243 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6236 — the section grouping contract (maintainer ruling 2026-08-27). + * + * A `section-divider` row that CLAIMS its member fields (`fields: string[]` — + * the membership shape `FormFieldTab.fields` / `FormFieldPane.fields` already + * model) gates its WHOLE group: a false section predicate hides the heading + * AND every claimed field. Ruled semantics, pinned case by case below: + * + * 1. Visibility decides what is DRAWN and nothing else (console precedent, + * 2026-08-22 ruling after #5594) — a hidden section's values still + * submit. + * 2. A hidden section's fields SKIP client-side validation — a user must + * never be blocked by an error pointing at a control they cannot see + * (the objectui#6110 defect shape). The server-side contract remains the + * loud floor for genuinely-required data. + * 3. A divider WITHOUT a claim keeps the pre-#6236 contract — its predicate + * gates only the heading. That is deliberate compatibility (the + * plugin-form synthesis sites do not stamp claims yet), not an oversight. + * + * ## Why the DENIED rows assert the FIELD and not only the heading + * + * The heading half is predicate-scope-parity-6010's `sectionSurface` row and + * has been true since #6111. The deliverable HERE is the other half — the + * claimed fields going with it — which is exactly what the pre-#6236 renderer + * did not do, so every DENIED assertion below is red on the old code in the + * SHOWN direction. + * + * ## Reverse verification (direction predicted BEFORE running) + * + * Revert the `hiddenSectionFieldNames.has(name)` member check in + * `renderFormField` and: the DENIED group rows go red in the SHOWN direction + * (claimed fields come back), and the validation rows go red in the BLOCKED + * direction (the hidden required field starts refusing the submit again). The + * values-still-submit row stays GREEN — value retention comes from + * react-hook-form keeping unmounted values, which the revert does not touch — + * so it is a semantics pin, not a differentiator, and the same holds for the + * claim-less compat row and every ALLOWED/FAULTED control. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { PredicateScopeProvider } from '@object-ui/react'; +// Module-scope import (not `beforeAll`) — objectui#3010/#3021. +import '../../../renderers'; + +/** + * The canonical wire shape — `@objectstack/spec` normalizes an authored + * predicate into a `{ dialect: 'cel' }` envelope at parse (ADR-0089 D2). Same + * spelling as predicate-scope-parity-6010, on purpose: one authored text, one + * verdict, every surface. + */ +const cel = (source: string) => ({ dialect: 'cel', source }); + +/** THE authored section predicate. */ +const GATE = cel("'sales_manager' in current_user.positions"); + +/** A root NOTHING binds — "faulted" must be unambiguous. */ +const UNBOUND_ROOT = cel("'sales_manager' in no_such_root.positions"); + +/** The host scope `ExpressionProvider` mounts, transcribed (see #6010's pin). */ +function hostScope(positions: string[]) { + const user = { id: 'u1', name: 'Kim', positions }; + return { current_user: user, user, ctx: { user }, os: { user }, app: {}, data: {}, features: {} }; +} + +const DENIED = hostScope(['sales']); +const ALLOWED = hostScope(['sales_manager']); + +function renderForm(schema: Record, scope: Record) { + const Form = ComponentRegistry.get('form')!; + return render( + +
+ , + ); +} + +/** + * One un-gated field (the control that proves the form rendered at all), one + * gated section claiming its two members. `'ghost'` in the claim pins + * FormFieldTab parity: unknown claimed names are ignored, never a fault. + */ +const groupedFields = (gate: unknown, salaryExtra: Record = {}) => [ + { name: 'title', label: 'Title', type: 'input' }, + { + name: 'pay', + label: 'Compensation', + type: 'section-divider', + visibleWhen: gate, + fields: ['salary', 'bonus', 'ghost'], + }, + { name: 'salary', label: 'Salary', type: 'input', ...salaryExtra }, + { name: 'bonus', label: 'Bonus', type: 'input' }, +]; + +const heading = () => screen.queryByText('Compensation'); +const member = (re: RegExp) => screen.queryByLabelText(re); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('#6236 — a claiming divider gates its whole group', () => { + it('DENIED: the heading AND every claimed field are hidden (control stays)', () => { + renderForm({ fields: groupedFields(GATE) }, DENIED); + expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); + expect(heading()).toBeNull(); + expect(member(/salary/i)).toBeNull(); + expect(member(/bonus/i)).toBeNull(); + }); + + it('ALLOWED: the SAME predicate text, a user it admits — heading and fields all render', () => { + renderForm({ fields: groupedFields(GATE) }, ALLOWED); + expect(heading()).not.toBeNull(); + expect(member(/salary/i)).not.toBeNull(); + expect(member(/bonus/i)).not.toBeNull(); + }); + + it('FAULTED: a genuinely unbound root fails OPEN — the whole group renders', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + renderForm({ fields: groupedFields(UNBOUND_ROOT) }, DENIED); + expect(heading()).not.toBeNull(); + expect(member(/salary/i)).not.toBeNull(); + expect(member(/bonus/i)).not.toBeNull(); + }); + + it('a hidden section\'s values STILL SUBMIT — visibility decides what is drawn and nothing else', async () => { + const onSubmit = vi.fn(); + renderForm( + { + fields: groupedFields(GATE), + defaultValues: { salary: '120000' }, + showSubmit: true, + submitLabel: 'Save', + onSubmit, + }, + DENIED, + ); + // The claimed field is genuinely not drawn — the value below cannot have + // come from a rendered control. + expect(member(/salary/i)).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect((onSubmit.mock.calls[0][0] as Record).salary).toBe('120000'); + }); + + it('a REQUIRED field in a hidden section does not block the submit (client validation skipped)', async () => { + const onSubmit = vi.fn(); + renderForm( + { + fields: groupedFields(GATE, { required: true }), + showSubmit: true, + submitLabel: 'Save', + onSubmit, + }, + DENIED, + ); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(document.body.textContent).not.toMatch(/required/i); + }); + + it('control: the SAME required field in a VISIBLE section still blocks the submit', async () => { + const onSubmit = vi.fn(); + renderForm( + { + fields: groupedFields(GATE, { required: true }), + showSubmit: true, + submitLabel: 'Save', + onSubmit, + }, + ALLOWED, + ); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(document.body.textContent).toMatch(/required/i)); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('a section hiding mid-session clears its members\' stale errors and unblocks the submit', async () => { + const onSubmit = vi.fn(); + renderForm( + { + fields: [ + { name: 'plan', label: 'Plan', type: 'input' }, + { + name: 'pay', + label: 'Compensation', + type: 'section-divider', + visibleWhen: cel("record.plan == 'standard'"), + fields: ['salary'], + }, + { name: 'salary', label: 'Salary', type: 'input', required: true }, + ], + defaultValues: { plan: 'standard' }, + showSubmit: true, + submitLabel: 'Save', + onSubmit, + }, + ALLOWED, + ); + // Section visible, required member empty → the submit is refused. This + // also registers the member's validator, so the second half below proves + // an already-registered rule is skipped once the section hides — the + // stronger direction (a never-mounted member skips trivially). + expect(member(/salary/i)).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(document.body.textContent).toMatch(/required/i)); + expect(onSubmit).not.toHaveBeenCalled(); + // Flip the gating value → the section (and its member) leave the screen, + // the stale required-error clears, and the submit goes through. + fireEvent.change(screen.getByLabelText(/plan/i), { target: { value: 'exec' } }); + await waitFor(() => expect(member(/salary/i)).toBeNull()); + await waitFor(() => expect(document.body.textContent).not.toMatch(/required/i)); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); + + it('compat: a divider WITHOUT a claim gates only the heading (pre-#6236 contract)', () => { + renderForm( + { + fields: [ + { name: 'title', label: 'Title', type: 'input' }, + { name: 'pay', label: 'Compensation', type: 'section-divider', visibleWhen: GATE }, + { name: 'salary', label: 'Salary', type: 'input' }, + ], + }, + DENIED, + ); + expect(heading()).toBeNull(); + expect(member(/salary/i)).not.toBeNull(); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index b626f3671d..e21b39e69c 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -1238,6 +1238,99 @@ ComponentRegistry.register('form', return locked; }, [fields, ruleRecord, previousRecord, isCreateForm, predicateScope]); + // ── The section grouping contract (objectui#6236, maintainer ruling + // 2026-08-27) ───────────────────────────────────────────────────────────── + // A `section-divider` that CLAIMS its member fields (`fields: string[]` — + // the membership shape `FormFieldTab.fields` / `FormFieldPane.fields` + // already model) gates its WHOLE group: when the divider's own visibility + // verdict is FALSE, `renderFormField` draws neither the heading (the + // divider's own rule check does that half, pinned by + // predicate-scope-parity-6010) nor any claimed field (this set does the + // other half). Hiding a member this way is the SAME mechanism as the + // field's own false predicate — return `null`, so the Controller unmounts, + // react-hook-form skips the unmounted field at submit-time validation and + // keeps its value (`shouldUnregister` stays default-false). That is + // exactly the ruled semantics, inherited from the field-level precedent + // (#5594 / #2212) rather than re-implemented: visibility decides what is + // DRAWN and nothing else — a hidden section's values still submit — and a + // hidden section's fields skip client-side validation, so a user is never + // blocked by an error pointing at a control they cannot see (#6110's + // defect shape). The server-side contract stays the floor for + // genuinely-required data. + // + // The divider's verdict here replicates the FOUR gates `renderFormField` + // applies to the divider row itself — static `hidden`, the legacy + // `condition`, `visibleWhen` (via `resolveFieldRuleState`) and `visibleOn` + // — with the SAME record assembly (`ruleRecord` / `previousRecord` / + // `predicateScope`), the same fail-open fallbacks and the same locator + // strings, so heading and group cannot reach different verdicts + // (`warnPredicateFailure` dedupes by predicate source, so re-evaluating a + // predicate the render path also evaluates cannot double-warn — the + // `readonlyFieldNames` memo above already leans on that). + // + // A divider WITHOUT a claim keeps the pre-#6236 contract (a presentational + // row whose predicate gates only the heading); unknown claimed names are + // ignored (FormFieldTab parity); a field claimed by several dividers hides + // when ANY hidden claimer names it. + const hiddenSectionFieldNames = React.useMemo(() => { + const hiddenNames = new Set(); + for (const f of fields as FormFieldConfig[]) { + const divider = f as FormFieldConfig & { fields?: unknown }; + if (divider?.type !== 'section-divider') continue; + const claimed = divider.fields; + if (!Array.isArray(claimed) || claimed.length === 0) continue; + const name = divider.name; + let dividerHidden = !!divider.hidden; + if (!dividerHidden) { + const legacyConditionCel = legacyConditionToCel(divider.condition); + if ( + legacyConditionCel && + !evalFieldPredicate(legacyConditionCel, ruleRecord, true, undefined, undefined, { + context: `condition of field '${name}'`, + }) + ) { + dividerHidden = true; + } + } + if (!dividerHidden) { + const st = resolveFieldRuleState( + { + visibleWhen: (divider as any).visibleWhen, + readonlyWhen: (divider as any).readonlyWhen, + requiredWhen: (divider as any).requiredWhen, + }, + ruleRecord, + { + required: !!divider.required, + readonly: (divider as any).readonly === true, + serverOwnedValue: isServerOwnedValue(divider, isCreateForm), + }, + previousRecord, + predicateScope, + `field '${name}'`, + ); + if (!st.visible) dividerHidden = true; + } + if (!dividerHidden && (divider as any).visibleOn != null) { + const viewVisible = evalFieldPredicate( + (divider as any).visibleOn, + ruleRecord, + true, + previousRecord, + predicateScope, + { context: `visibleOn of field '${name}'` }, + ); + if (!viewVisible) dividerHidden = true; + } + if (dividerHidden) { + for (const claimedName of claimed) { + if (typeof claimedName === 'string') hiddenNames.add(claimedName); + } + } + } + return hiddenNames; + }, [fields, ruleRecord, previousRecord, isCreateForm, predicateScope]); + // When a field's CEL rule relaxes — it becomes hidden (visibleWhen FALSE) or // no longer required (requiredWhen FALSE) — clear any stale validation error // left from a prior submit attempt. react-hook-form keeps an error until the @@ -1274,16 +1367,29 @@ ComponentRegistry.register('form', }); // A hidden field shows no errors at all; an un-required field clears // only its *required* error (keep legitimate format/min/etc. errors). + // A field hidden by its SECTION's predicate (#6236) clears the same + // way a field hidden by its own rule does — same rendering verdict, + // same stale-error hygiene. const errType = (errs[name] as { type?: string } | undefined)?.type; - if (!st.visible || !viewVisible || (!st.required && errType === 'required')) form.clearErrors(name); + if ( + !st.visible || + !viewVisible || + hiddenSectionFieldNames.has(name) || + (!st.required && errType === 'required') + ) { + form.clearErrors(name); + } } // `predicateScope` joins `ruleRecord` here for the same reason it is passed // above (#6010): a scope change — the host swapping organisations, so // `current_user.positions` changes — can flip a `visibleWhen` to FALSE just // as a keystroke can, and a stale required-error on the field it just hid // must clear on that transition too. + // `hiddenSectionFieldNames` joins them (#6236): a section verdict flip is + // a visibility transition for every claimed field, and the memo can move + // on a `fields` identity change the two record inputs would miss. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ruleRecord, predicateScope]); + }, [ruleRecord, predicateScope, hiddenSectionFieldNames]); // Read DataSource from SchemaRendererContext and propagate it to field // widgets as a prop so they can dynamically load related records. @@ -1937,6 +2043,13 @@ ComponentRegistry.register('form', // Skip hidden fields if (hidden) return null; + // A field claimed by a HIDDEN section is not drawn (#6236) — the same + // `return null` a field's own false predicate takes, so the ruled + // semantics ride the existing mechanism: the value stays in the form + // state and still submits, and react-hook-form skips the unmounted + // control at submit-time validation. See `hiddenSectionFieldNames`. + if (hiddenSectionFieldNames.has(name)) return null; + // Legacy `condition: { field, equals/notEquals/in }` — translated // to CEL and evaluated on the canonical engine over the seeded // live record (issue #1584), so it agrees with `visibleWhen` and diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index 43570d3abc..e055e068cb 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -52,6 +52,8 @@ const DECLARED_KEYS = [ 'requiredWhen', 'colSpan', 'span', + // objectui#6236 — the section grouping claim (section-divider rows only). + 'fields', ]; describe('FormFieldSchema covers the FormField contract', () => { diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 63ca15e49b..2cdf323bcb 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1060,6 +1060,32 @@ export interface FormField { * @objectstack/spec FormField.span. Prefer this over `colSpan`. */ span?: 'auto' | 'full'; + /** + * Section grouping claim (objectui#6236) — `type: 'section-divider'` rows + * only. Names of the fields (as declared in `FormSchema.fields`) that belong + * to the section this divider heads: the same membership-claim shape + * {@link FormFieldTab.fields} and {@link FormFieldPane.fields} already model, + * so tabs, panes and sections share ONE grouping contract (the tabbed arm's + * predicate slot is objectui#6237). + * + * A divider that carries this claim gates its WHOLE group: when the + * divider's own visibility verdict (`visibleWhen` / `visibleOn` / legacy + * `condition`) resolves FALSE, the renderer draws neither the heading nor + * the claimed fields. Ruled semantics (maintainer, 2026-08-27, following the + * console precedent of 2026-08-22 after #5594): visibility decides what is + * DRAWN and nothing else — a hidden section's values still submit — and a + * hidden section's fields SKIP client-side validation, so a user is never + * blocked by an error pointing at a control they cannot see (the + * objectui#6110 defect shape); the server-side contract remains the loud + * floor for genuinely-required data. + * + * Unknown names are ignored (FormFieldTab parity). A field should be claimed + * by at most one divider; when several claim it, any hidden claimer hides + * it. A divider WITHOUT this claim keeps the pre-#6236 contract — a + * presentational row whose predicate gates only the heading. On a + * non-divider row the key has no meaning and is ignored by the renderer. + */ + fields?: string[]; } /** diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 6c82feaf47..e0b1cbb2fe 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1069,12 +1069,17 @@ export interface ObjectFormSection { * `visibleWhen` surface uses, so one authored predicate text means one thing * everywhere (#6010). A broken predicate fails OPEN (the header renders). * - * ⚠️ Scope, measured: this gates the section's `section-divider` HEADER row. - * The renderer treats that row as presentational and holds no association - * between it and the fields that follow, so a false predicate removes the - * heading and leaves its fields rendering (objectui#6111). The console - * renderer drops the whole `
`; reconciling the two is filed - * separately. + * ⚠️ Scope: the renderer-side grouping contract exists since objectui#6236 + * (maintainer ruling 2026-08-27) — a `section-divider` row that carries the + * membership claim (`FormField.fields`, the FormFieldTab shape) gates its + * WHOLE group: heading and claimed fields hide together, the hidden fields + * skip client-side validation, and their values still submit (the console + * precedent, 2026-08-22 after #5594). Measured limit: the plugin-form + * layouts' `section-divider` synthesis sites copy `visibleWhen` onto the + * divider (objectui#6111) but do not yet stamp this claim, so an authored + * section predicate still gates only the HEADING on those chains until that + * wiring lands (the remaining half of objectui#6236). The console renderer + * (`apps/console/src/components/FormPage.tsx`) drops the whole `
`. */ visibleWhen?: string | { dialect?: string; source: string }; diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 6637111b4a..0276c9046e 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -593,6 +593,8 @@ export const FormFieldSchema = z.object({ .describe('Field-level required rule (CEL)'), colSpan: z.number().optional().describe('Column span in grid layout (legacy — prefer span)'), span: z.enum(['auto', 'full']).optional().describe('Relative field width'), + fields: z.array(z.string()).optional() + .describe('Section grouping claim (objectui#6236) — section-divider rows only: names of the fields the section claims (the FormFieldTab.fields membership shape); the divider predicate then gates the whole group'), }).superRefine((field, ctx) => { // objectui#5449 — the namespace rule `@object-ui/core` has enforced since // objectui#5375, stated here so `objectui validate` (which reaches this From 02b73fc9f721195a2a0fa764df150b7a8e55daac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:14:24 +0000 Subject: [PATCH 2/3] docs(plugin-form): update the 6111 honest pin's commentary to the post-6236 state The renderer-side grouping contract now exists (FormField.fields claim + whole-group gate, pinned in section-grouping-6236.test.tsx); what this chain still lacks is the synthesis-site stamp of the membership claim, so the pinned behaviour itself is unchanged and the assertion stays green. The comment now says which half is missing and keeps the red-flip as the landing signal for that remaining wiring. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../sectionVisibleWhen-6111.test.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx index 562ca0cd62..274d971769 100644 --- a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx +++ b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx @@ -279,12 +279,19 @@ describe('#6111 — an authored section `visibleWhen` reaches an evaluator in ev }); it('measured scope: a hidden section still renders its FIELDS (objectui#6111 follow-up)', async () => { - // NOT an endorsement — an honest pin of what this change does and does not - // deliver. `section-divider` is a presentational ROW; the renderer holds no - // association between it and the fields after it, so the heading goes and - // the fields stay. The console renderer drops the whole `
`. - // Reconciling the two needs a renderer-side grouping contract and is filed - // separately; this assertion turning red is the SIGNAL that it landed. + // NOT an endorsement — an honest pin of what this chain does and does not + // deliver. The renderer-side grouping contract EXISTS since objectui#6236 + // (maintainer ruling 2026-08-27): a `section-divider` that claims its + // members (`FormField.fields: string[]`) gates the whole group — heading + // and fields together, hidden members skip client-side validation, values + // still submit (pinned in `packages/components/.../section-grouping-6236. + // test.tsx`). What is still missing is THIS CHAIN's half: the layouts' + // divider synthesis sites (`ObjectForm.tsx` and siblings) copy + // `visibleWhen` onto the pseudo-field but do not yet stamp the membership + // claim, so an authored section predicate still hides only the heading + // here — the fields stay, exactly as below. The console renderer drops + // the whole `
`. This assertion turning red is the SIGNAL that + // the synthesis wiring (the remaining half of objectui#6236) landed. await renderObjectForm(DENIED, { formType: 'simple' }); expect(gatedHeading()).toBeNull(); expect(screen.getByLabelText(/salary/i)).toBeTruthy(); From f9c01fc261c75a2c9697bd7a4e9c9f8fa4528cec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:30:19 +0000 Subject: [PATCH 3/3] feat(plugin-form): stamp the section membership claim at all six divider synthesis sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .changeset/6236-section-grouping-contract.md | 17 +++- packages/plugin-form/src/DrawerForm.tsx | 10 ++- packages/plugin-form/src/ModalForm.tsx | 7 ++ packages/plugin-form/src/ObjectForm.tsx | 7 ++ packages/plugin-form/src/SplitForm.tsx | 3 + .../sectionVisibleWhen-6111.test.tsx | 81 ++++++++++++------- packages/types/src/objectql.ts | 23 +++--- 7 files changed, 104 insertions(+), 44 deletions(-) diff --git a/.changeset/6236-section-grouping-contract.md b/.changeset/6236-section-grouping-contract.md index 0841b773cb..c1fed244a0 100644 --- a/.changeset/6236-section-grouping-contract.md +++ b/.changeset/6236-section-grouping-contract.md @@ -1,6 +1,7 @@ --- '@object-ui/types': minor '@object-ui/components': minor +'@object-ui/plugin-form': minor --- 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`: Both halves ride the mechanism the field-level predicate already uses (return `null`; react-hook-form keeps the value and skips the unmounted control), so field-level and section-level visibility cannot drift apart. The zod mirror (`FormFieldSchema`) declares -the key with the same scope note. Scope note: the plugin-form `section-divider` synthesis -sites do not yet stamp the claim onto the dividers they emit — an authored section -predicate on those chains still gates only the heading until that wiring lands (the -remaining half of objectui#6236). The tabbed arm's predicate slot (objectui#6237) is +the key with the same scope note. + +`@object-ui/plugin-form` wires the producer half: all six `section-divider` synthesis +sites (ObjectForm's stacked simple path, ModalForm's sectioned and derived-fieldGroup +paths, DrawerForm's sectioned and derived-fieldGroup paths, SplitForm's panes) now stamp +the membership claim onto the divider they emit, from the RESOLVED member list — so an +authored `FormSection.visibleWhen` finally hides the whole section on the object-view +chain, matching the console renderer. The #6111 honest pin (`measured scope`) flipped +accordingly: it now pins heading-and-fields hiding together, and every per-layout DENIED +row asserts the claimed member as well as the heading. The derived-fieldGroup sites carry +the claim for uniformity but stay fail-open — the spec `fieldGroups` vocabulary has no +section-predicate slot to author. The tabbed arm's predicate slot (objectui#6237) is designed to reuse this same grouping contract. diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index 030264133d..e6ccb84118 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -586,6 +586,9 @@ export const DrawerForm: React.FC = ({ schema.sections.forEach((section, index) => { const sectionKey = section.name || String(index); const isCollapsed = collapsedSections[sectionKey] ?? (section.collapsed ?? false); + // Resolved before the divider push so the membership claim below can + // name exactly the fields this group contributes (#6236). + const sectionFields = buildSectionFields(section); allFields.push({ name: `__section_${sectionKey}`, @@ -594,6 +597,9 @@ export const DrawerForm: React.FC = ({ // ADR-0089 section predicate (#6111) — the renderer evaluates it on // this pseudo-field with the host predicate scope bound (#6010). visibleWhen: (section as any).visibleWhen, + // The membership claim (#6236): resolved member names, so the + // predicate gates the whole group. + fields: sectionFields.map(f => f.name), colSpan: 4, collapsible: section.collapsible, collapsed: isCollapsed, @@ -603,7 +609,6 @@ export const DrawerForm: React.FC = ({ className: (section as any).className, } as any); - const sectionFields = buildSectionFields(section); if (isCollapsed) { allFields.push(...sectionFields.map(f => ({ ...f, hidden: true }))); } else { @@ -652,6 +657,9 @@ export const DrawerForm: React.FC = ({ type: 'section-divider', // ADR-0089 section predicate (#6111). visibleWhen: (section as any).visibleWhen, + // The membership claim (#6236): resolved member names, so the + // predicate gates the whole group. + fields: body.map(f => f.name), colSpan: 4, collapsible: section.collapsible, collapsed: isCollapsed, diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 8f59da0cd9..79d7a0272d 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -716,6 +716,10 @@ export const ModalForm: React.FC = ({ // ADR-0089 section predicate (#6111) — the renderer evaluates it on // this pseudo-field with the host predicate scope bound (#6010). visibleWhen: g.visibleWhen, + // The membership claim (#6236): resolved member names, so the + // predicate gates the whole group (same spelling as the + // `fieldTabs` claim above). + fields: g.fields.map((f) => f.name), colSpan: 4, className: g.className, } as any); @@ -748,6 +752,9 @@ export const ModalForm: React.FC = ({ type: 'section-divider', // ADR-0089 section predicate (#6111). visibleWhen: (section as any).visibleWhen, + // The membership claim (#6236): resolved (post-FLS) member names, + // so the predicate gates the whole group. + fields: body.map((f) => f.name), } as any); } allFields.push(...(columns > 1 ? applyAutoColSpan(body, columns) : body)); diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index b109875548..1b57df05ab 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -1224,6 +1224,13 @@ const SimpleObjectForm: React.FC = ({ // bound (#6010), so copying it here is what makes the authored // section predicate reach an evaluator at all. visibleWhen: (section as any).visibleWhen, + // The membership claim (#6236): the RESOLVED member names — the same + // strings the renderer's flat list carries — so the section + // predicate gates the whole group, not just this heading. Resolved + // rather than authored on purpose: the authored `section.fields` + // entries can be spec field-defs, and a perms-filtered field is not + // in the form at all. + fields: sectionFields.map(f => f.name), colSpan: 4, collapsible: section.collapsible, collapsed: isCollapsed, diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index 827b6460fc..e357d168ca 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -386,6 +386,9 @@ export const SplitForm: React.FC = ({ // ADR-0089 section predicate (#6111) — the renderer evaluates it on // this pseudo-field with the host predicate scope bound (#6010). visibleWhen: section.visibleWhen, + // The membership claim (#6236): resolved member names, so the + // predicate gates the whole group. + fields: body.map(f => f.name), colSpan: 4, className: section.className, } as any); diff --git a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx index 274d971769..d4b4cf27a9 100644 --- a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx +++ b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx @@ -70,16 +70,26 @@ * everywhere, which is precisely why the ALLOWED rows could never have caught * this. * - * ## Scope, measured — what this does NOT claim + * ## Scope — the whole group, since objectui#6236 * - * The renderer treats `section-divider` as a presentational ROW and holds no - * association between it and the fields that follow it, so a false predicate - * removes the HEADING and leaves the section's fields rendering. The console - * renderer (`apps/console/src/components/FormPage.tsx:1819`) drops the whole - * `
`, fields included. That divergence is real and is filed - * separately — it needs a renderer-side grouping contract, not another line in - * a layout. The `stillRendersItsFields` case below pins the CURRENT behaviour - * honestly rather than letting the file imply a guarantee it does not deliver. + * objectui#6236 (maintainer ruling 2026-08-27) closed the divergence this + * header used to record: the renderer now holds a real divider-to-field + * association — the divider's membership claim (`FormField.fields`, stamped by + * every synthesis site above from the RESOLVED member list) — and a false + * section predicate hides the heading AND the claimed fields, matching the + * console renderer. So every DENIED row below asserts both halves: the heading + * (the #6111 deliverable) and the gated member field (the #6236 wiring). The + * hidden members skip client-side validation and their values still submit; + * those semantics are pinned at the renderer in + * `packages/components/src/renderers/form/__tests__/section-grouping-6236.test.tsx` + * — this file pins that each LAYOUT's synthesis site actually stamps the claim + * (an unstamped site reverts to heading-only, invisible to every other suite). + * + * Two derived-fieldGroup synthesis sites (ModalForm / DrawerForm + * `derivedSections`) also stamp the claim for uniformity, but the spec + * `fieldGroups` vocabulary has no section-predicate slot, so no authoring path + * can turn their gate on today and no DENIED row can discriminate them; they + * stay fail-open until that vocabulary grows a predicate. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -192,6 +202,12 @@ const renderModalFormDirect = async (scope: Record) => { /** The gated section's heading — `null` when the layout hid it. */ const gatedHeading = () => screen.queryByText('Compensation'); +/** + * The gated section's claimed MEMBER field (#6236) — `null` when the layout's + * synthesis site stamped the membership claim and the gate took the group. + */ +const gatedField = () => screen.queryByLabelText(/salary/i); + /** * Every layout, by name, with the mount that reaches its own synthesis site. * Named individually because a fix applied to five of six sites still passes a @@ -221,14 +237,19 @@ const LAYOUTS: { label: string; mount: (scope: Record) => Promi ]; describe('#6111 — an authored section `visibleWhen` reaches an evaluator in every layout', () => { - describe('DENIED — the predicate resolves FALSE, so the section heading is HIDDEN', () => { - // ⚠️ THE deliverable. Green here is the only observation that separates - // "the predicate arrived and was evaluated" from "it never arrived" — - // every other row in this file is green on unfixed code. + describe('DENIED — the predicate resolves FALSE, so the whole section is HIDDEN', () => { + // ⚠️ THE deliverable, in two halves. The heading half (#6111) separates + // "the predicate arrived and was evaluated" from "it never arrived"; the + // member half (#6236) separates "this layout's synthesis site stamped the + // membership claim" from "the divider arrived claim-less and the gate + // stayed heading-only" — an unstamped site fails ONLY here, in the SHOWN + // direction, because a claim-less divider is valid compat behaviour + // everywhere else. for (const layout of LAYOUTS) { it(layout.label, async () => { await layout.mount(DENIED); expect(gatedHeading()).toBeNull(); + expect(gatedField()).toBeNull(); }); } }); @@ -236,10 +257,13 @@ describe('#6111 — an authored section `visibleWhen` reaches an evaluator in ev describe('ALLOWED — the SAME predicate text, a user it admits ⇒ still SHOWN', () => { // The control. Without it, "hidden" is satisfied by a layout that dropped // the heading for an unrelated reason — a worse defect, invisible above. + // The member assertion keeps the same honesty for the group half: a gate + // that hides claimed fields unconditionally would pass every DENIED row. for (const layout of LAYOUTS) { it(layout.label, async () => { await layout.mount(ALLOWED); expect(gatedHeading()).not.toBeNull(); + expect(gatedField()).not.toBeNull(); }); } }); @@ -274,26 +298,27 @@ describe('#6111 — an authored section `visibleWhen` reaches an evaluator in ev ); await waitFor(() => expect(screen.getByText('Always')).toBeTruthy()); expect(gatedHeading()).not.toBeNull(); + expect(gatedField()).not.toBeNull(); }); } }); - it('measured scope: a hidden section still renders its FIELDS (objectui#6111 follow-up)', async () => { - // NOT an endorsement — an honest pin of what this chain does and does not - // deliver. The renderer-side grouping contract EXISTS since objectui#6236 - // (maintainer ruling 2026-08-27): a `section-divider` that claims its - // members (`FormField.fields: string[]`) gates the whole group — heading - // and fields together, hidden members skip client-side validation, values - // still submit (pinned in `packages/components/.../section-grouping-6236. - // test.tsx`). What is still missing is THIS CHAIN's half: the layouts' - // divider synthesis sites (`ObjectForm.tsx` and siblings) copy - // `visibleWhen` onto the pseudo-field but do not yet stamp the membership - // claim, so an authored section predicate still hides only the heading - // here — the fields stay, exactly as below. The console renderer drops - // the whole `
`. This assertion turning red is the SIGNAL that - // the synthesis wiring (the remaining half of objectui#6236) landed. + it('measured scope: a hidden section hides its FIELDS too (objectui#6236 wiring landed)', async () => { + // FLIPPED — deliberately, and exactly once. From #6111 until the #6236 + // wiring landed, this case pinned the honest limitation: the divider was a + // presentational row, so a false section predicate removed the heading and + // LEFT THE FIELDS RENDERING (`expect(getByLabelText(/salary/i)).toBeTruthy()` + // stood here). #6111 wrote it so that its red flip would be the SIGNAL the + // grouping contract landed rather than a broken test — and that is what + // happened: the synthesis sites now stamp the membership claim + // (`fields: [...names]`) onto the divider, the renderer gates the whole + // group, and this case now pins the NEW contract on the same chain, the + // same mount, the same predicate: heading gone AND claimed field gone, + // matching the console renderer at last. Values still submit and hidden + // members skip client-side validation — pinned at the renderer in + // section-grouping-6236.test.tsx. await renderObjectForm(DENIED, { formType: 'simple' }); expect(gatedHeading()).toBeNull(); - expect(screen.getByLabelText(/salary/i)).toBeTruthy(); + expect(gatedField()).toBeNull(); }); }); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index e0b1cbb2fe..43e303f4ca 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1069,17 +1069,18 @@ export interface ObjectFormSection { * `visibleWhen` surface uses, so one authored predicate text means one thing * everywhere (#6010). A broken predicate fails OPEN (the header renders). * - * ⚠️ Scope: the renderer-side grouping contract exists since objectui#6236 - * (maintainer ruling 2026-08-27) — a `section-divider` row that carries the - * membership claim (`FormField.fields`, the FormFieldTab shape) gates its - * WHOLE group: heading and claimed fields hide together, the hidden fields - * skip client-side validation, and their values still submit (the console - * precedent, 2026-08-22 after #5594). Measured limit: the plugin-form - * layouts' `section-divider` synthesis sites copy `visibleWhen` onto the - * divider (objectui#6111) but do not yet stamp this claim, so an authored - * section predicate still gates only the HEADING on those chains until that - * wiring lands (the remaining half of objectui#6236). The console renderer - * (`apps/console/src/components/FormPage.tsx`) drops the whole `
`. + * Scope: this gates the WHOLE section (objectui#6236, maintainer ruling + * 2026-08-27). The plugin-form layouts stamp the membership claim + * (`FormField.fields`, the FormFieldTab shape) onto the `section-divider` + * row they synthesize, and the renderer then hides heading and claimed + * fields together on a FALSE predicate — matching the console renderer + * (`apps/console/src/components/FormPage.tsx`), which drops the whole + * section element. Hidden fields skip client-side validation (a user is + * never blocked by an error pointing at a control they cannot see) and + * their values still submit — visibility decides what is DRAWN and nothing + * else (the console precedent, 2026-08-22 after #5594). Derived + * `fieldGroups` sections carry no predicate slot, so their groups are + * always drawn. */ visibleWhen?: string | { dialect?: string; source: string };