From 35c5233144188f25c3badef43e83a44217143b7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:53:34 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(types,components,plugin-form):=20the?= =?UTF-8?q?=20tabbed=20arm's=20predicate=20slot=20=E2=80=94=20FormFieldTab?= =?UTF-8?q?.visibleWhen=20(#6237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tabbed modal arm (`ModalForm` `contentLayout: 'tabbed'`) synthesises no section-divider, so the #6236 grouping contract had no slot to carry an authored `FormSection.visibleWhen` — the predicate was dropped one hop before evaluation. `FormFieldTab` now declares the predicate slot the 2026-08-27 maintainer ruling named, and the form renderer evaluates it with the same record assembly and fail-open fallback the field-level rules use. A FALSE verdict draws neither trigger nor panel; not drawing the panel unmounts the claimed fields through the mechanism a field's own false predicate uses, so the ruled semantics are inherited: hidden-tab values still submit, hidden-tab fields skip client-side validation (server is the loud floor), and a mid-session flip clears the members' stale errors. Selection re-derives over visible tabs only (pick → default → first visible; a hidden pick is restored on re-admission), and the arm decision stays structural on the DECLARED tabs, so a predicate hiding one of two tabs filters the strip instead of collapsing the layout mid-interaction. ModalForm's tabbed synthesis site copies the section predicate onto the tab it emits; the #6111 layout matrix gains both tabbed-modal rows and its FAULTED block now genuinely mounts each row's layout (it used to mount the same simple form under every label). New renderer pin file fieldtab-visiblewhen-6237.test.tsx covers DENIED/ALLOWED/FAULTED, both ruled semantics, re-selection, non-collapse, and compat. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .changeset/6237-tabbed-arm-predicate-slot.md | 43 +++ .../fieldtab-visiblewhen-6237.test.tsx | 345 ++++++++++++++++++ .../components/src/renderers/form/form.tsx | 154 ++++++-- packages/plugin-form/src/ModalForm.tsx | 5 + .../sectionVisibleWhen-6111.test.tsx | 87 +++-- packages/types/src/form.ts | 24 ++ 6 files changed, 606 insertions(+), 52 deletions(-) create mode 100644 .changeset/6237-tabbed-arm-predicate-slot.md create mode 100644 packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx diff --git a/.changeset/6237-tabbed-arm-predicate-slot.md b/.changeset/6237-tabbed-arm-predicate-slot.md new file mode 100644 index 0000000000..05eea44304 --- /dev/null +++ b/.changeset/6237-tabbed-arm-predicate-slot.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +'@object-ui/plugin-form': minor +--- + +The tabbed arm of the grouping contract (objectui#6237, same maintainer ruling as +objectui#6236): `FormFieldTab` gains the predicate slot the ruling named — +`visibleWhen?: string | { dialect?: string; source: string }` — so a section rendered as +a TAB PANEL (`ModalForm` `contentLayout: 'tabbed'`) can finally carry an authored +`FormSection.visibleWhen`. The tabbed layout synthesises no `section-divider` at all, so +the #6236 membership-claim mechanism had nothing to stamp the predicate onto and no slot +to copy it into; the predicate was silently dropped one hop before evaluation (measured +in objectui#6237's card). + +The form renderer evaluates the tab's predicate with the same record assembly the +field-level rules use (`ruleRecord` / `previousRecord` / host predicate scope, #6010), +fail-open, and when FALSE draws neither the tab's trigger nor its panel. Not drawing the +panel unmounts the claimed fields through the exact mechanism a field's own false +predicate uses, so the ruled hidden-group semantics are inherited rather than +re-implemented, and are pinned in `fieldtab-visiblewhen-6237.test.tsx`: + +- **Visibility decides what is DRAWN and nothing else** — a hidden tab's values still + submit. +- **A hidden tab's fields skip client-side validation** — a user is never blocked by an + error pointing at a control they cannot see; the server-side contract remains the loud + floor for genuinely-required data (#2959's trap, answered the same way for tabs as for + sections). A tab hiding mid-session clears its members' stale errors. +- **Deterministic re-selection**: a predicate hiding the ACTIVE tab activates the user's + pick if still visible, else the declared default, else the first visible tab — never an + empty panel — and the user's pick is restored the moment its tab is re-admitted. +- **No mid-interaction collapse**: whether the tabbed arm engages stays judged on the + DECLARED tabs, so a predicate hiding one of two tabs filters the strip (and hides the + tab's fields) instead of collapsing the modal into the stacked layout under the user's + cursor. With every tab hidden the strip is omitted; unclaimed fields still render. +- **A tab without the key keeps the pre-#6237 contract** (always drawn), so existing + schemas are untouched. + +`@object-ui/plugin-form` wires the producer half: `ModalForm`'s tabbed synthesis site now +copies the section's `visibleWhen` onto the tab it emits, and the #6111 layout matrix +gains the tabbed-modal rows (direct and via `ObjectForm` delegation). `TabbedForm` / +`WizardForm` still declare no section predicate in their own section configs — those arms +remain open on objectui#6237. diff --git a/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx b/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx new file mode 100644 index 0000000000..b0cd9c575a --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx @@ -0,0 +1,345 @@ +/** + * 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#6237 — the tabbed arm of the grouping contract (same maintainer + * ruling as objectui#6236, 2026-08-27). + * + * `FormFieldTab.visibleWhen` is the tab's predicate slot: a section rendered + * as a tab panel can carry the same authored `FormSection.visibleWhen` a + * stacked section carries on its `section-divider`. The renderer evaluates it + * with the live record and the host predicate scope (#6010) and, when FALSE, + * draws neither the tab's trigger nor its panel — which unmounts the claimed + * fields through the exact mechanism a field's own false predicate uses. The + * ruled semantics follow, pinned case by case below: + * + * 1. Visibility decides what is DRAWN and nothing else — a hidden tab's + * values still submit. + * 2. A hidden tab's fields SKIP client-side validation — a user must never + * be blocked by an error pointing at a control they cannot see. The + * server-side contract remains the loud floor (#2959's trap, answered by + * the ruling: the 400 for a genuinely-required field on a hidden tab is + * the server's to raise, loudly, not the client's to raise invisibly). + * 3. The ARM decision stays structural: whether the tabbed layout engages is + * judged on the DECLARED tabs, so a predicate hiding one of two tabs + * filters the strip instead of collapsing the layout mid-interaction. + * 4. A predicate hiding the ACTIVE tab re-selects deterministically (pick → + * declared default → first visible) — never an empty panel — and the + * user's pick is restored when its tab is re-admitted. + * + * ## Why the DENIED rows assert trigger AND panel AND member + * + * `visibleWhen` fails OPEN, so a tab that renders is the outcome of three + * worlds (TRUE / never arrived / faulted) and a SHOWN assertion distinguishes + * none of them. The DENIED rows are the deliverable; ALLOWED and FAULTED are + * the controls that keep "hidden" meaning "evaluated and false". + * + * ## Reverse verification (direction predicted BEFORE running) + * + * Neutralise the verdict collection in `hiddenFieldTabKeys` (form.tsx) and: + * the DENIED rows go red in the SHOWN direction (trigger, panel and members + * come back), the validation rows go red in the BLOCKED direction (the hidden + * required field refuses the submit again), and the re-selection rows go red + * with the gated tab still active. 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 every ALLOWED/FAULTED control. + */ + +import { describe, it, expect, vi, beforeEach, 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 section-grouping-6236 / predicate-scope-parity-6010, on purpose: + * one authored text, one verdict, every surface. + */ +const cel = (source: string) => ({ dialect: 'cel', source }); + +/** THE authored tab 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 unclaimed field (`title` — proves the form rendered at all and pins that + * a hidden tab's claims do NOT leak into the leading unclaimed block), one + * plain tab, one gated tab claiming two members. `'ghost'` in the claim pins + * FormFieldTab's existing contract: unknown claimed names are ignored. + */ +const FIELDS = (salaryExtra: Record = {}) => [ + { name: 'title', label: 'Title', type: 'input' }, + { name: 'subject', label: 'Subject', type: 'input' }, + { name: 'salary', label: 'Salary', type: 'input', ...salaryExtra }, + { name: 'bonus', label: 'Bonus', type: 'input' }, +]; + +const TABS = (gate: unknown) => [ + { key: 'basics', label: 'Basics', fields: ['subject'] }, + { key: 'pay', label: 'Compensation', fields: ['salary', 'bonus', 'ghost'], visibleWhen: gate }, +]; + +const trigger = () => screen.queryByRole('tab', { name: /compensation/i }); +const panel = () => screen.queryByTestId('form-tab-panel:pay'); +const member = (re: RegExp) => screen.queryByLabelText(re); +const submitBtn = () => screen.getByRole('button', { name: /save/i }); + +beforeEach(() => { + if (!(Element.prototype as any).scrollIntoView) { + (Element.prototype as any).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('#6237 — a tab predicate gates the whole tab', () => { + it('DENIED: trigger, panel and every claimed field are hidden (control tab stays)', () => { + renderForm({ fields: FIELDS(), fieldTabs: TABS(GATE) }, DENIED); + // The form rendered: unclaimed control field and the un-gated tab. + expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /basics/i })).toBeInTheDocument(); + // The gated tab is gone in all three shapes. + expect(trigger()).toBeNull(); + expect(panel()).toBeNull(); + expect(member(/salary/i)).toBeNull(); + expect(member(/bonus/i)).toBeNull(); + }); + + it('DENIED: the hidden tab\'s claims do not leak into the unclaimed leading block', () => { + renderForm({ fields: FIELDS(), fieldTabs: TABS(GATE) }, DENIED); + // `title` is the only unclaimed field; if the hidden tab's claims were + // treated as unclaimed, salary/bonus would render beside it. + expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); + expect(member(/salary/i)).toBeNull(); + }); + + it('ALLOWED: the SAME predicate text, a user it admits — trigger, panel and fields render', () => { + renderForm({ fields: FIELDS(), fieldTabs: TABS(GATE) }, ALLOWED); + expect(trigger()).not.toBeNull(); + expect(panel()).not.toBeNull(); + expect(member(/salary/i)).not.toBeNull(); + expect(member(/bonus/i)).not.toBeNull(); + }); + + it('FAULTED: a genuinely unbound root fails OPEN — the tab renders', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + renderForm({ fields: FIELDS(), fieldTabs: TABS(UNBOUND_ROOT) }, DENIED); + expect(trigger()).not.toBeNull(); + expect(panel()).not.toBeNull(); + expect(member(/salary/i)).not.toBeNull(); + }); + + it('a hidden tab\'s values STILL SUBMIT — visibility decides what is drawn and nothing else', async () => { + const onSubmit = vi.fn(); + renderForm( + { + fields: FIELDS(), + fieldTabs: TABS(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(submitBtn()); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect((onSubmit.mock.calls[0][0] as Record).salary).toBe('120000'); + }); + + it('a REQUIRED field on a hidden tab does not block the submit (client validation skipped)', async () => { + const onSubmit = vi.fn(); + renderForm( + { + fields: FIELDS({ required: true }), + fieldTabs: TABS(GATE), + showSubmit: true, + submitLabel: 'Save', + onSubmit, + }, + DENIED, + ); + fireEvent.click(submitBtn()); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(document.body.textContent).not.toMatch(/required/i); + }); + + it('control: the SAME required field on a VISIBLE (even inactive) tab still blocks the submit', async () => { + const onSubmit = vi.fn(); + renderForm( + { + fields: FIELDS({ required: true }), + fieldTabs: TABS(GATE), + showSubmit: true, + submitLabel: 'Save', + onSubmit, + }, + ALLOWED, + ); + // `pay` is visible but NOT active — #2959's force-mount is what makes its + // rule count; this is the boundary the hidden-tab row above crosses. + fireEvent.click(submitBtn()); + await waitFor(() => expect(document.body.textContent).toMatch(/required/i)); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('a tab 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: 'salary', label: 'Salary', type: 'input', required: true }, + ], + fieldTabs: [ + { key: 'basics', label: 'Basics', fields: ['plan'] }, + { + key: 'pay', + label: 'Compensation', + fields: ['salary'], + visibleWhen: cel("record.plan == 'standard'"), + }, + ], + defaultValues: { plan: 'standard' }, + showSubmit: true, + submitLabel: 'Save', + onSubmit, + }, + ALLOWED, + ); + // Tab visible, required member empty → the submit is refused. This also + // registers the member's validator, so the second half proves an + // already-registered rule is skipped once the tab hides — the stronger + // direction (a never-mounted member skips trivially). + expect(member(/salary/i)).not.toBeNull(); + fireEvent.click(submitBtn()); + await waitFor(() => expect(document.body.textContent).toMatch(/required/i)); + expect(onSubmit).not.toHaveBeenCalled(); + // Flip the gating value → the tab (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(panel()).toBeNull()); + await waitFor(() => expect(document.body.textContent).not.toMatch(/required/i)); + fireEvent.click(submitBtn()); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); + + it('re-selection: hiding the ACTIVE tab activates the first visible tab — never an empty panel — and the pick is restored on re-admission', async () => { + renderForm( + { + fields: [ + { name: 'plan', label: 'Plan', type: 'input' }, + { name: 'salary', label: 'Salary', type: 'input' }, + ], + fieldTabs: [ + { key: 'basics', label: 'Basics', fields: ['plan'] }, + { + key: 'pay', + label: 'Compensation', + fields: ['salary'], + visibleWhen: cel("record.plan == 'standard'"), + }, + ], + defaultValues: { plan: 'standard' }, + }, + ALLOWED, + ); + // The user picks the gated tab and types into it. (Radix activates a + // trigger on mousedown, so a bare `click` would not switch tabs here.) + fireEvent.mouseDown(screen.getByRole('tab', { name: /compensation/i })); + await waitFor(() => + expect(screen.getByTestId('form-tab-panel:pay')).toHaveAttribute('data-state', 'active'), + ); + fireEvent.change(screen.getByLabelText(/salary/i), { target: { value: '120000' } }); + // A keystroke on the OTHER tab's field flips the predicate: the active tab + // hides and selection falls to the first visible tab. + fireEvent.change(screen.getByLabelText(/plan/i), { target: { value: 'exec' } }); + await waitFor(() => expect(panel()).toBeNull()); + expect(screen.getByTestId('form-tab-panel:basics')).toHaveAttribute('data-state', 'active'); + // Flip back: the tab returns, the USER'S PICK wins again, and the value + // survived the unmount round-trip (visibility never touched the data). + fireEvent.change(screen.getByLabelText(/plan/i), { target: { value: 'standard' } }); + await waitFor(() => expect(panel()).not.toBeNull()); + expect(screen.getByTestId('form-tab-panel:pay')).toHaveAttribute('data-state', 'active'); + expect((screen.getByLabelText(/salary/i) as HTMLInputElement).value).toBe('120000'); + }); + + it('the arm does NOT collapse: one of two tabs hidden keeps the strip (structural decision, declared tabs)', () => { + renderForm({ fields: FIELDS(), fieldTabs: TABS(GATE) }, DENIED); + // Still the tabbed layout: a tablist with the surviving trigger — not the + // flat untabbed rendering (which would draw salary/bonus). + expect(screen.getByRole('tablist')).toBeInTheDocument(); + expect(screen.getAllByRole('tab')).toHaveLength(1); + expect(screen.getByTestId('form-tab-panel:basics')).toBeInTheDocument(); + expect(member(/salary/i)).toBeNull(); + }); + + it('every tab hidden: no strip at all, unclaimed fields still render', () => { + renderForm( + { + fields: FIELDS(), + fieldTabs: [ + { key: 'basics', label: 'Basics', fields: ['subject'], visibleWhen: GATE }, + { key: 'pay', label: 'Compensation', fields: ['salary', 'bonus'], visibleWhen: GATE }, + ], + }, + DENIED, + ); + expect(screen.queryByRole('tablist')).toBeNull(); + expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); + expect(member(/subject/i)).toBeNull(); + expect(member(/salary/i)).toBeNull(); + }); + + it('compat: a tab WITHOUT the predicate slot keeps the pre-#6237 contract (always drawn)', () => { + renderForm( + { + fields: FIELDS(), + fieldTabs: [ + { key: 'basics', label: 'Basics', fields: ['subject'] }, + { key: 'pay', label: 'Compensation', fields: ['salary', 'bonus'] }, + ], + }, + DENIED, + ); + expect(trigger()).not.toBeNull(); + expect(panel()).not.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 e21b39e69c..eae49ff9eb 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -1331,6 +1331,88 @@ ComponentRegistry.register('form', return hiddenNames; }, [fields, ruleRecord, previousRecord, isCreateForm, predicateScope]); + // --- Tabbed field layout (#2959) --------------------------------------- + // `fieldTabs` spreads THIS form's fields across tab panels. Crucially there + // is still exactly ONE / react-hook-form instance: the panels are + // force-mounted and merely CSS-hidden, so a tab the user navigated away from + // keeps BOTH its values and its validation. (Rendering one form per tab lost + // every non-active tab's input — the footer submit button can only be + // associated with a single form — and unmounting a tab's fields makes + // react-hook-form skip their rules, which let a required field on a tab + // nobody opened sail past the client and return as a server 400.) + // + // Declared here, above the stale-error effect, because the tab predicate + // verdicts (#6237, below) join that effect's inputs. + const fieldTabs = React.useMemo(() => { + const declared = schema.fieldTabs; + if (schema.children || !Array.isArray(declared)) return null; + const usable = declared.filter((t) => t && typeof t.key === 'string'); + return usable.length > 1 ? usable : null; + }, [schema.fieldTabs, schema.children]); + + // ── The tabbed arm of the grouping contract (objectui#6237, same + // maintainer ruling as #6236) ───────────────────────────────────────────── + // A tab may carry the section predicate (`FormFieldTab.visibleWhen`) the + // synthesis sites copy from an authored `FormSection.visibleWhen`. The + // verdict is evaluated HERE, with the same record assembly the field-level + // rules use (`ruleRecord` / `previousRecord` / `predicateScope`, #6010) and + // the same fail-open fallback, so a section rendered as a tab and the same + // section rendered as a divider cannot reach different verdicts. + // + // A hidden tab's trigger and panel are simply NOT DRAWN — which unmounts + // its fields, the exact mechanism a field's own false predicate uses + // (`return null`): react-hook-form keeps the values (`shouldUnregister` + // stays default-false), so they still submit, and skips the unmounted + // controls at submit-time validation. Those are the ruled semantics + // (visibility gates drawing only; the server stays the loud floor), + // inherited rather than re-implemented. + // + // Deliberately NOT folded into the `fieldTabs` memo above: whether the + // tabbed arm engages at all (`usable.length > 1`) is judged on the + // DECLARED tabs, so a predicate hiding one of two tabs filters what is + // drawn instead of collapsing the strip into the untabbed layout + // mid-interaction (a collapse would remount every remaining field — + // destroying focus and in-progress edits — and would draw the hidden + // tab's fields flat, breaking the ruled semantics). + const hiddenFieldTabKeys = React.useMemo(() => { + const hidden = new Set(); + for (const tab of fieldTabs ?? []) { + const visibleWhen = tab.visibleWhen; + if (visibleWhen == null) continue; + const visible = evalFieldPredicate( + visibleWhen, + ruleRecord, + true, + previousRecord, + predicateScope, + { context: `visibleWhen of fieldTab '${tab.key}'` }, + ); + if (!visible) hidden.add(tab.key); + } + return hidden; + }, [fieldTabs, ruleRecord, previousRecord, predicateScope]); + + // Field names that are NOT drawn because every tab claiming them is hidden + // (#6237). A name also claimed by a VISIBLE tab still renders in that + // panel, so it stays out of this set — the set mirrors what the panels + // actually draw, and feeds the stale-error hygiene below. (The drawing + // itself needs no per-field gate: a hidden tab's panel is not rendered.) + const hiddenTabFieldNames = React.useMemo(() => { + const hidden = new Set(); + if (!fieldTabs || hiddenFieldTabKeys.size === 0) return hidden; + for (const tab of fieldTabs) { + if (!hiddenFieldTabKeys.has(tab.key)) continue; + for (const name of tab.fields ?? []) { + if (typeof name === 'string') hidden.add(name); + } + } + for (const tab of fieldTabs) { + if (hiddenFieldTabKeys.has(tab.key)) continue; + for (const name of tab.fields ?? []) hidden.delete(name); + } + return hidden; + }, [fieldTabs, hiddenFieldTabKeys]); + // 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 @@ -1375,6 +1457,7 @@ ComponentRegistry.register('form', !st.visible || !viewVisible || hiddenSectionFieldNames.has(name) || + hiddenTabFieldNames.has(name) || (!st.required && errType === 'required') ) { form.clearErrors(name); @@ -1388,8 +1471,11 @@ ComponentRegistry.register('form', // `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. + // `hiddenTabFieldNames` joins for the same reason (#6237): a TAB verdict + // flip hides every field the tab claims, and its stale errors must clear + // on that transition exactly as a section flip clears its members'. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ruleRecord, predicateScope, hiddenSectionFieldNames]); + }, [ruleRecord, predicateScope, hiddenSectionFieldNames, hiddenTabFieldNames]); // Read DataSource from SchemaRendererContext and propagate it to field // widgets as a prop so they can dynamically load related records. @@ -1403,22 +1489,6 @@ ComponentRegistry.register('form', return m; }, [fields]); - // --- Tabbed field layout (#2959) --------------------------------------- - // `fieldTabs` spreads THIS form's fields across tab panels. Crucially there - // is still exactly ONE / react-hook-form instance: the panels are - // force-mounted and merely CSS-hidden, so a tab the user navigated away from - // keeps BOTH its values and its validation. (Rendering one form per tab lost - // every non-active tab's input — the footer submit button can only be - // associated with a single form — and unmounting a tab's fields makes - // react-hook-form skip their rules, which let a required field on a tab - // nobody opened sail past the client and return as a server 400.) - const fieldTabs = React.useMemo(() => { - const declared = schema.fieldTabs; - if (schema.children || !Array.isArray(declared)) return null; - const usable = declared.filter((t) => t && typeof t.key === 'string'); - return usable.length > 1 ? usable : null; - }, [schema.fieldTabs, schema.children]); - /** Field name → the tab that owns it (first claim wins). */ const tabKeyByFieldName = React.useMemo(() => { const m = new Map(); @@ -1428,13 +1498,20 @@ ComponentRegistry.register('form', return m; }, [fieldTabs]); + // Only VISIBLE tabs are selectable (#6237): a predicate hiding the ACTIVE + // tab re-selects deterministically — the user's pick if its tab is (still) + // visible, else the declared default, else the first visible tab — so the + // form never shows an empty panel. The pick itself is kept: derived + // selection means a hidden pick simply stops winning, and the tab the user + // chose becomes active again the moment its predicate re-admits it. const activeFieldTab = React.useMemo(() => { if (!fieldTabs?.length) return undefined; - const keys = fieldTabs.map((t) => t.key); + const keys = fieldTabs.map((t) => t.key).filter((k) => !hiddenFieldTabKeys.has(k)); + if (!keys.length) return undefined; if (pickedFieldTab && keys.includes(pickedFieldTab)) return pickedFieldTab; if (schema.defaultFieldTab && keys.includes(schema.defaultFieldTab)) return schema.defaultFieldTab; return keys[0]; - }, [fieldTabs, pickedFieldTab, schema.defaultFieldTab]); + }, [fieldTabs, hiddenFieldTabKeys, pickedFieldTab, schema.defaultFieldTab]); // Resolve each tab's declared field names against the form's field list. const fieldTabGroups = React.useMemo(() => { @@ -1452,12 +1529,27 @@ ComponentRegistry.register('form', }, [fieldTabs, fields]); // A field no tab claimed must not vanish — it renders above the tab strip, - // visible from every tab. + // visible from every tab. Computed from the FULL group list on purpose: a + // predicate-hidden tab (#6237) still CLAIMS its fields — they are hidden + // with it, not promoted into this leading block. const untabbedFields = React.useMemo( () => (fieldTabGroups ? unclaimedFields(fieldTabGroups, fields as FormFieldConfig[]) : []), [fieldTabGroups, fields], ); + // What the strip and the panels actually draw (#6237): the groups whose + // tab the predicate verdict admits. Not drawing a hidden tab's panel IS + // the ruled-semantics mechanism — see `hiddenFieldTabKeys`. + const visibleFieldTabGroups = React.useMemo( + () => + fieldTabGroups + ? hiddenFieldTabKeys.size === 0 + ? fieldTabGroups + : fieldTabGroups.filter((g) => !hiddenFieldTabKeys.has(g.key)) + : null, + [fieldTabGroups, hiddenFieldTabKeys], + ); + // --- Split field layout (#2153) ---------------------------------------- // `fieldPanes` places this form's fields in resizable panels. The // wraps the whole panel GROUP, so — exactly as with `fieldTabs` — there is @@ -1811,7 +1903,14 @@ ComponentRegistry.register('form', // looking at — naming it is useless if they can't see it. Activate its tab // first, then reveal it once that panel has actually been painted (it is // display:none until then, so scroll/focus would no-op). + // + // Unless the tab is predicate-HIDDEN (#6237): only the SERVER can reject + // a field there (its client rules are skipped with it), and there is no + // panel to activate or control to reveal — the toast above already names + // the field. Recording the activation as a pick anyway would yank the + // view to that tab whenever its predicate later re-admits it. const tabKey = tabKeyByFieldName.get(firstName); + if (tabKey && hiddenFieldTabKeys.has(tabKey)) return; if (tabKey && tabKey !== activeFieldTab) { setPickedFieldTab(tabKey); if (typeof requestAnimationFrame === 'function') { @@ -2744,17 +2843,25 @@ ComponentRegistry.register('form',
{renderChildren(schema.children)}
- ) : fieldTabGroups ? ( + ) : fieldTabGroups && visibleFieldTabGroups ? ( // Tabbed field layout (#2959): one , one react-hook-form // instance, N force-mounted panels. Inactive panels are CSS-hidden // (`data-[state=inactive]:hidden`) rather than unmounted, which is // what keeps their values and their validation alive. + // + // Predicate-hidden tabs (#6237) are a different kind of hidden: the + // strip and the panel list below draw only `visibleFieldTabGroups`, + // so a hidden tab's fields UNMOUNT — the ruled hidden-group + // semantics (values kept and submitted, client validation + // skipped). With every tab hidden nothing remains to draw, so the + // strip is omitted rather than rendered empty. <> {untabbedFields.length > 0 && (
{untabbedFields.map(renderFormField)}
)} + {visibleFieldTabGroups.length > 0 && ( - {fieldTabGroups.map((group) => ( + {visibleFieldTabGroups.map((group) => (
- {fieldTabGroups.map((group) => ( + {visibleFieldTabGroups.map((group) => ( + )} ) : fieldPaneGroups ? ( // Split field layout (#2153): one , one react-hook-form diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 79d7a0272d..bd62e2f68c 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -695,6 +695,11 @@ export const ModalForm: React.FC = ({ description: g.description, fields: g.fields.map((f) => f.name), containerClass: g.gridClassName, + // The tab's predicate slot (#6237) — the same authored + // `FormSection.visibleWhen` the stacked arm copies onto its + // divider; the renderer evaluates it and hides trigger, panel + // and fields together under the ruled hidden-group semantics. + visibleWhen: g.visibleWhen, })), }} /> diff --git a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx index d4b4cf27a9..537ae7f125 100644 --- a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx +++ b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx @@ -90,6 +90,20 @@ * `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. + * + * ## The tabbed modal arm — the seventh synthesis site (objectui#6237) + * + * `ModalForm` with `contentLayout: 'tabbed'` synthesises NO divider at all — + * sections become `fieldTabs` entries — so its stamp is the tab's own + * predicate slot (`FormFieldTab.visibleWhen`, same ruling as #6236) and its + * DENIED row asserts the tab TRIGGER text and the member field are both gone. + * The hidden-tab semantics (values still submit, client validation skipped, + * re-selection, no mid-interaction collapse) are pinned at the renderer in + * `packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx`; + * the rows here pin only that THIS synthesis site copies the predicate onto + * the tab. `TabbedForm` / `WizardForm` still declare no section predicate at + * all (their section configs carry no `visibleWhen` key), so there is nothing + * to copy and no row to write — those arms are separate cards. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -152,15 +166,16 @@ afterEach(() => { * proves the form rendered AT ALL — so a missing `Compensation` heading is a * verdict and not an inability. `Compensation` carries the gate. */ -const sections = () => [ +const sections = (gate: unknown = GATE) => [ { name: 'always', label: 'Always', fields: ['subject'] }, - { name: 'pay', label: 'Compensation', visibleWhen: GATE, fields: ['salary'] }, + { name: 'pay', label: 'Compensation', visibleWhen: gate, fields: ['salary'] }, ]; /** Mount through `ObjectForm` — the entry `RecordFormPage` itself uses. */ const renderObjectForm = async ( scope: Record, extra: Record, + gate?: unknown, ) => { render( @@ -169,7 +184,7 @@ const renderObjectForm = async ( type: 'object-form', objectName: 'crm_case', mode: 'create', - sections: sections(), + sections: sections(gate), ...extra, } as any} dataSource={dataSource} @@ -181,7 +196,11 @@ const renderObjectForm = async ( }; /** Mount `ModalForm` directly — the shape `resolveFormViewLayout` produces. */ -const renderModalFormDirect = async (scope: Record) => { +const renderModalFormDirect = async ( + scope: Record, + extra: Record = {}, + gate?: unknown, +) => { render( ) => { objectName: 'crm_case', mode: 'create', open: true, - sections: sections(), + sections: sections(gate), + ...extra, } as any} dataSource={dataSource} /> @@ -213,26 +233,47 @@ const gatedField = () => screen.queryByLabelText(/salary/i); * Named individually because a fix applied to five of six sites still passes a * suite that exercises five. */ -const LAYOUTS: { label: string; mount: (scope: Record) => Promise }[] = [ +const LAYOUTS: { + label: string; + mount: (scope: Record, gate?: unknown) => Promise; +}[] = [ { label: 'ObjectForm — stacked `simple` sections (ObjectForm.tsx section-divider)', - mount: (scope) => renderObjectForm(scope, { formType: 'simple' }), + mount: (scope, gate) => renderObjectForm(scope, { formType: 'simple' }, gate), }, { label: 'ModalForm — via ObjectForm delegation (key-by-key remap + ModalForm groups map)', - mount: (scope) => renderObjectForm(scope, { formType: 'modal', open: true }), + mount: (scope, gate) => renderObjectForm(scope, { formType: 'modal', open: true }, gate), }, { label: 'ModalForm — mounted directly (the resolveFormViewLayout shape)', - mount: (scope) => renderModalFormDirect(scope), + mount: (scope, gate) => renderModalFormDirect(scope, {}, gate), + }, + { + // The tabbed arm (#6237): sections render as TAB PANELS, so there is no + // divider to stamp — the synthesis site copies the predicate onto the + // tab itself (`FormFieldTab.visibleWhen`) and the renderer hides trigger, + // panel and members together. In the DENIED row `gatedHeading()` is the + // tab TRIGGER text rather than a divider heading — same locator, same + // authored key, seventh synthesis site. + label: "ModalForm — contentLayout 'tabbed', mounted directly (fieldTabs synthesis, #6237)", + mount: (scope, gate) => renderModalFormDirect(scope, { contentLayout: 'tabbed' }, gate), + }, + { + // Same arm via ObjectForm delegation: `contentLayout` rides the spread and + // the key-by-key section remap must have copied `visibleWhen` for the tab + // to receive it — both hops, like the stacked modal row above. + label: "ModalForm — contentLayout 'tabbed', via ObjectForm delegation (#6237)", + mount: (scope, gate) => + renderObjectForm(scope, { formType: 'modal', open: true, contentLayout: 'tabbed' }, gate), }, { label: 'DrawerForm — via ObjectForm delegation (key-by-key remap + explicit-sections divider)', - mount: (scope) => renderObjectForm(scope, { formType: 'drawer', open: true }), + mount: (scope, gate) => renderObjectForm(scope, { formType: 'drawer', open: true }, gate), }, { label: 'SplitForm — via ObjectForm delegation (key-by-key remap + paneFields divider)', - mount: (scope) => renderObjectForm(scope, { formType: 'split' }), + mount: (scope, gate) => renderObjectForm(scope, { formType: 'split' }, gate), }, ]; @@ -274,29 +315,17 @@ describe('#6111 — an authored section `visibleWhen` reaches an evaluator in ev // purpose: the only difference from the DENIED block is the ROOT the // predicate names, so a fail-CLOSED regression cannot hide behind the // membership test. + // + // Mounted through `layout.mount` like the two blocks above (#6237's edit): + // the loop used to render the SAME simple form under every row's label, so + // five FAULTED rows named layouts they never mounted — fail-open was only + // ever measured on the stacked path. for (const layout of LAYOUTS) { it(layout.label, async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); const unbound = cel("'sales_manager' in no_such_root.positions"); - render( - - - , - ); - await waitFor(() => expect(screen.getByText('Always')).toBeTruthy()); + await layout.mount(DENIED, unbound); expect(gatedHeading()).not.toBeNull(); expect(gatedField()).not.toBeNull(); }); diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 2cdf323bcb..01ceb78962 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -847,6 +847,30 @@ export interface FormFieldTab { * `FormSchema.fieldContainerClass`, scoped to this tab). */ containerClass?: string; + /** + * The tab's predicate slot (objectui#6237) — the tabbed arm of the grouping + * contract objectui#6236 landed for `section-divider` rows, so a section + * rendered as a tab can carry the same authored `FormSection.visibleWhen` a + * stacked section can. Evaluated by the form renderer on the canonical + * engine with the live record and the host predicate scope bound (#6010), + * exactly like a field's own `visibleWhen`; a broken predicate fails OPEN + * (the tab stays visible). + * + * When it resolves FALSE the renderer draws neither the tab's trigger nor + * its panel. Ruled semantics (maintainer, 2026-08-27 — the same ruling for + * tabs as for sections): visibility decides what is DRAWN and nothing else — + * a hidden tab's values still submit — and a hidden tab's fields SKIP + * client-side validation, so a user is never blocked by an error pointing at + * a control they cannot see; the server-side contract remains the loud floor + * for genuinely-required data. Both semantics ride the unmount mechanism a + * field's own false predicate already uses. + * + * The layout decision stays structural: whether the tabbed arm engages at + * all is judged on the DECLARED tabs, so a predicate hiding all but one tab + * filters what is drawn without collapsing the strip mid-interaction. A tab + * without this key keeps the pre-#6237 contract (always drawn). + */ + visibleWhen?: string | { dialect?: string; source: string }; } /** From 63a55aa8c6942674617287500e66ae6719f5e61e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:55:04 +0000 Subject: [PATCH 2/2] docs(components): record the observed reverse-verification directions in the 6237 pin header Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../fieldtab-visiblewhen-6237.test.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx b/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx index b0cd9c575a..8e51abede8 100644 --- a/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx +++ b/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx @@ -39,16 +39,18 @@ * none of them. The DENIED rows are the deliverable; ALLOWED and FAULTED are * the controls that keep "hidden" meaning "evaluated and false". * - * ## Reverse verification (direction predicted BEFORE running) + * ## Reverse verification (run 2026-08-27, direction predicted BEFORE running) * - * Neutralise the verdict collection in `hiddenFieldTabKeys` (form.tsx) and: - * the DENIED rows go red in the SHOWN direction (trigger, panel and members - * come back), the validation rows go red in the BLOCKED direction (the hidden - * required field refuses the submit again), and the re-selection rows go red - * with the gated tab still active. 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 every ALLOWED/FAULTED control. + * Neutralising the verdict collection in `hiddenFieldTabKeys` (form.tsx) went + * 8 red / 4 green, exactly the predicted rows: the DENIED rows red in the + * SHOWN direction (trigger, panel and members come back), the validation rows + * red in the BLOCKED direction (the hidden required field refuses the submit + * again), the re-selection / collapse / all-hidden rows red with the gated tab + * still drawn — and the values-still-submit row red through its drawn + * precondition (`member(/salary/i)` is null only while the gate works; the + * submitted VALUE itself comes from react-hook-form keeping unmounted values, + * which the revert does not touch). Every ALLOWED/FAULTED/compat control + * stayed green, which is what lets the DENIED rows mean "evaluated and false". */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';