diff --git a/.changeset/6237-tabbed-section-predicate.md b/.changeset/6237-tabbed-section-predicate.md new file mode 100644 index 000000000..35b97ac32 --- /dev/null +++ b/.changeset/6237-tabbed-section-predicate.md @@ -0,0 +1,36 @@ +--- +'@object-ui/plugin-form': minor +--- + +`formType: 'tabbed'` now honours an authored section `visibleWhen` (objectui#6237). + +The tabbed arm of the one grouping contract ruled 2026-08-29 (option A). Before +this, an authored `FormSection.visibleWhen` was dropped on the tabbed route +while `split` / `drawer` / `modal` and the flat layout all honoured it — the key +never reached a renderer at all, so it did nothing. + +`TabbedForm` already synthesised the renderer's `fieldTabs`, which is the same +machinery the `modal` + `contentLayout: 'tabbed'` arm runs on. The predicate was +simply dropped at three points on the way there, and all three now carry it: +`ObjectForm`'s tabbed section map, `FormSectionConfig` (which declared no such +key), and `TabbedForm`'s `fieldTabs` synthesis. + +Because the arm reaches the existing evaluator, the three ruled semantics are +inherited rather than re-implemented beside it: a hidden tab's values still +submit, its fields skip client-side validation (so a required field on a hidden +tab cannot block a submit invisibly — objectui#2959's defect through a new +door), a predicate hiding the ACTIVE tab re-selects deterministically instead of +drawing an empty panel, and arm engagement stays structural on the DECLARED +tabs so a predicate cannot collapse the strip mid-interaction. + +Two boundaries are deliberate: + +- A single-section tabbed form never engages the tab arm, so it degrades to the + untabbed layout's own predicate mechanism — a chrome-less `section-divider` + claiming its members by name. Existing single-section forms are unchanged; the + gate is emitted only where a predicate was actually authored. +- Wizard STEPS still do not take a predicate, and now say so in the type: + `WizardStepConfig` omits the key, because a step predicate is a different + contract (step-boundary reactive against the ruled live-record reactivity, and + needing navigation and final-gate semantics none of this machinery supplies). + `ObjectForm` continues to report that gap at runtime for untyped JSON. diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index de30e3812..c819da2d6 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -203,19 +203,23 @@ export const ObjectForm: React.FC = ({ const routesToMasterDetail = !!(schema as any).subforms?.length && schema.mode !== 'view' && schema.formType !== 'drawer' && schema.formType !== 'modal'; - // ── objectui#6237 interim diagnostic (maintainer ruling, 2026-08-29) ──────── - // `tabbed` and `wizard` are the two routes below that drop an authored section - // `visibleWhen` (see `sectionPredicateUnsupportedWarning`). Report the gap - // instead of dropping it in silence. This declares no key and hides nothing — - // making these arms honour the predicate is the ruled design task's job. + // ── objectui#6237 diagnostic, now scoped to the ONE arm still inert ──────── + // The interim diagnostic ruled on 2026-08-29 covered `tabbed` AND `wizard`, + // the two routes that dropped an authored section `visibleWhen`. The tabbed + // arm now HONOURS it (the map below copies the key, `TabbedForm` puts it on + // the tab it synthesises, and the renderer evaluates it), so warning about it + // would be a false alarm about a working feature — the same boundary the four + // control rows of the diagnostic's pin defend. `wizard` stays inert by + // DESIGN, not by omission: a step predicate is a different contract, not a + // port (see `WizardStepConfig`), so its gap is still reported rather than + // silently dropped. // // Deliberately NOT reported for the master-detail branch: that branch re-enters // `ObjectForm` through `MasterDetailForm`'s parent schema, which is where the // real layout is decided (a master-detail `wizard` parent renders `simple`, // which DOES honour the predicate). Reporting here as well would double-report // the tabbed parent and false-report the wizard one. - const inertPredicateLayout = !routesToMasterDetail - && (schema.formType === 'tabbed' || schema.formType === 'wizard') + const inertPredicateLayout = !routesToMasterDetail && schema.formType === 'wizard' ? schema.formType : null; // Joined to a string on purpose: the effect's deps must be primitives, or a @@ -274,6 +278,18 @@ export const ObjectForm: React.FC = ({ description: s.description, columns: s.columns, fields: s.fields, + // ADR-0089 section predicate (objectui#6237) — key-by-key rebuild, + // so an uncopied key is silently dropped before TabbedForm ever + // sees it, exactly as it was on this route until this card. The + // split/drawer/modal maps below have carried it since #6111; this + // is the tabbed arm joining them. + // + // Read WITHOUT an `as any` cast on purpose, unlike those three: + // `ObjectFormSection.visibleWhen` is declared, so the compiler is + // able to catch a rename here. Through a cast it would keep + // compiling and silently copy `undefined` — the exact silent-drop + // failure this line exists to fix. + visibleWhen: s.visibleWhen, className: (s as any).className, gridClassName: (s as any).gridClassName, })), diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 900b4e111..3628c3cf2 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -51,6 +51,33 @@ export interface FormSectionConfig { */ fields: (string | FormField)[]; + /** + * ADR-0089 `FormSection.visibleWhen` — the TABBED arm of the one grouping + * contract ruled 2026-08-29 (objectui#6237, option A). Spelled exactly as the + * sibling `ModalFormSectionConfig.visibleWhen`, because it IS the same + * authored key: `ObjectForm` copies a section's predicate here, this layout + * copies it onto the tab it synthesises (`FormFieldTab.visibleWhen`), and the + * form renderer evaluates it on the canonical engine with the live record and + * the host predicate scope bound (#6010) — the same path a field's own + * `visibleWhen` takes. A broken predicate fails OPEN (the tab stays visible). + * + * Ruled semantics (maintainer 2026-08-27, the same ruling for tabs as for + * sections), inherited from the renderer rather than re-implemented here: + * 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 stays the loud floor for genuinely-required data; + * see the boundary note on `WizardStepConfig` and objectui#6237 for the + * measured reason the server cannot read this predicate. + * + * ⛔ Deliberately NOT on the wizard's step type. `WizardForm` used to borrow + * this very interface for its steps, and declaring the key on a type the + * wizard renderer never reads would manufacture the declared-but-unenforced + * shape this card family exists to close. `WizardStepConfig` omits it, so the + * key is writable exactly where it is honoured. + */ + visibleWhen?: string | { dialect?: string; source: string }; + /** * Custom CSS class for the section's Card wrapper. * @@ -435,13 +462,64 @@ export const TabbedForm: React.FC = ({ label: section.label || `Tab ${index + 1}`, description: section.description, containerClass: section.gridClassName, + // The authored section predicate (objectui#6237). Carried on the group so + // BOTH synthesis paths below can read it — the tab arm and the sub-two-tab + // degradation — instead of each re-deriving it from `schema.sections`. + visibleWhen: section.visibleWhen, fields: formColumns > 1 ? applyAutoColSpan(body, formColumns, clampCol(section.columns)) : body, }; }); - const allFields: FormField[] = tabGroups.flatMap((g) => g.fields); + // ── "Collapse below two tabs", the ruling's third binding semantic ───────── + // Two different situations wear that name, and only one of them was answered + // by the renderer: + // + // (a) A PREDICATE hides one of two tabs. Answered upstream and inherited: the + // renderer judges whether the tab arm engages on the DECLARED tabs, so a + // predicate can only filter what is drawn — it never collapses the strip + // mid-interaction (which would remount every surviving field, destroying + // focus and in-progress edits, and would draw the hidden tab's fields + // flat, breaking the ruled semantics). Nothing to do here. + // + // (b) The form DECLARES fewer than two tabs. The renderer's tab arm needs + // more than one usable tab to engage, so a single-section `tabbed` form + // is already rendered as the untabbed layout — there is no tab to carry a + // predicate, and the key would be silently inert exactly as it was before + // this card. That is the case this block answers, and answering it is not + // optional: leaving it out would let `ObjectForm` stop reporting the gap + // (the arm now "supports" the key) while one shape of the gap survived. + // + // The defined degradation is the untabbed layout's OWN predicate mechanism + // (#6236): a `section-divider` row that CLAIMS its member fields by name, so + // the verdict gates the whole group through the identical unmount path and the + // ruled semantics stay byte-for-byte the same across the two shapes. + // + // Deliberately chrome-less — no `label`, no `description`. A single-section + // tabbed form draws no tab strip today, so its section heading is already + // absent; synthesising a visible header here would change the layout of every + // such form rather than just honouring the key. `SectionDivider` renders + // `null` without a label or description, so the row costs nothing visually and + // exists only to carry the claim. Emitted ONLY for sections that actually + // authored a predicate: a form with no predicate is byte-identical to before. + const rendersAsTabs = tabGroups.length > 1; + const degradedSectionGates: FormField[] = rendersAsTabs + ? [] + : tabGroups + .filter((g) => g.visibleWhen != null) + .map((g) => ({ + name: `__section_gate_${g.key}`, + type: 'section-divider', + visibleWhen: g.visibleWhen, + fields: g.fields.map((f) => f.name), + colSpan: 4, + } as unknown as FormField)); + + const allFields: FormField[] = [ + ...degradedSectionGates, + ...tabGroups.flatMap((g) => g.fields), + ]; return (
@@ -468,6 +546,15 @@ export const TabbedForm: React.FC = ({ description: g.description, fields: g.fields.map((f) => f.name), containerClass: g.containerClass, + // The tab's predicate slot (objectui#6237) — the same authored + // `FormSection.visibleWhen` the modal arm copies onto its tab and + // the flat arm copies onto its divider. The renderer evaluates it + // and hides trigger, panel and fields together under the ruled + // hidden-group semantics; re-selection when the ACTIVE tab hides is + // the renderer's too (`activeFieldTab` derives over the VISIBLE + // tabs), so this layout inherits all three ruled semantics instead + // of re-implementing any of them. + visibleWhen: g.visibleWhen, })), defaultFieldTab: initialTab, fieldTabsPosition: schema.tabPosition || 'top', diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 8a4ec4f8f..1d853d2d9 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -35,6 +35,40 @@ import { useOccSave } from './occSave'; import { hasInlineFieldSource, noSubmitTargetError } from './submitTarget'; import type { FormSectionConfig } from './TabbedForm'; +/** + * A wizard STEP — the tabbed layout's section config minus its predicate slot + * (objectui#6237). + * + * The wizard borrows the tabbed arm's section shape because the two describe the + * same authored thing: a named group of fields. It must NOT borrow the predicate + * slot, and the omission is the enforcement, not a comment: + * + * A step predicate is not a port of the tab predicate, it is a different + * contract. Steps are wizard component state keyed by section INDEX, only the + * current step is mounted (unmounted-ness is every other step's NORMAL state, + * which is why the final gate re-checks the whole declared field set), and the + * predicate would read `formData` — which merges only at step boundaries. So a + * step predicate is structurally STEP-BOUNDARY reactive where a tab's is ruled + * LIVE-RECORD reactive: one keyword, two different "when". It also needs + * machinery none of which is inherited — navigation policy for skipping hidden + * steps, the indicator, `isLastStep`, the "Step X of Y" denominator, index + * stability while hiding is live, re-selection when the CURRENT step hides, and + * a final-gate exclusion for a hidden step's required fields. + * + * Declaring the key on the shared type would make it WRITABLE on a step while + * this renderer ignores it — precisely the declared-but-unenforced shape this + * card family exists to close. Omitting it means TypeScript rejects the key on a + * wizard step literal, which is where an author (or an agent authoring metadata) + * finds out. `ObjectForm` additionally reports the gap at runtime for a section + * predicate arriving on the wizard route, since untyped JSON reaches it too. + * + * ⚠️ This omission changes nothing that used to work: `FormSectionConfig` did + * not declare `visibleWhen` before objectui#6237 either, so a wizard step + * literal carrying it was already a type error. The wizard's surface is exactly + * what it was; only the tabbed arm widened. + */ +export type WizardStepConfig = Omit; + /** * What the submitter is told when a DECLARED `navigateOnSuccess` produced no * destination — objectui#5034 point 2. @@ -108,7 +142,7 @@ export interface WizardFormSchema { /** * Wizard step sections */ - sections: FormSectionConfig[]; + sections: WizardStepConfig[]; /** * Allow navigation to any step (not just sequential). @@ -399,7 +433,7 @@ export const WizardForm: React.FC = ({ // Build section fields from object schema const buildSectionFields = useCallback( - (section: FormSectionConfig): FormField[] => + (section: WizardStepConfig): FormField[] => buildSectionFieldsShared(section as any, { objectSchema, objectName: schema.objectName, diff --git a/packages/plugin-form/src/__tests__/sectionPredicateLayoutDiagnostic-6237.test.tsx b/packages/plugin-form/src/__tests__/sectionPredicateLayoutDiagnostic-6237.test.tsx index 1349ba6f4..8c24a3c66 100644 --- a/packages/plugin-form/src/__tests__/sectionPredicateLayoutDiagnostic-6237.test.tsx +++ b/packages/plugin-form/src/__tests__/sectionPredicateLayoutDiagnostic-6237.test.tsx @@ -35,11 +35,19 @@ * feature. * - flat / `simple` — carries the predicate on the `section-divider` * pseudo-field. Also works. - * - `tabbed` (`TabbedForm`) and `wizard` (`WizardForm`) — the same key-by-key - * rebuild, copying no predicate. These are the inert arms. + * - `tabbed` (`TabbedForm`) — WAS inert, and is no longer: the tabbed map now + * copies the predicate, `TabbedForm` puts it on the tab it synthesises, and + * the renderer evaluates it. It has MOVED to the honouring set below, which + * is the single most important edit this file has taken: a diagnostic that + * keeps warning about a feature that started working is a false alarm, and + * false alarms are how a real one stops being read. + * - `wizard` (`WizardForm`) — the same key-by-key rebuild, copying no + * predicate. The last inert arm, and inert BY DESIGN: a step predicate is a + * different contract, not a port of the tab one (see `WizardStepConfig`). * - * The four negative rows below therefore pin the boundary, not politeness: they - * are what stops a later edit from turning this into a blanket warning. + * The negative rows below therefore pin the boundary, not politeness: they are + * what stops a later edit from turning this into a blanket warning, and what + * would catch the tabbed arm regressing back into silence. * * ⚠️ Note the ModalForm `contentLayout: 'tabbed'` arm is NOT an inert arm and is * deliberately absent from the warned set — it gained a real predicate slot in @@ -136,35 +144,37 @@ const diagnosticCalls = () => .map((c: unknown[]) => String(c[0])) .filter((m: string) => m.includes('Section `visibleWhen` is not yet supported on this layout')); -describe('#6237 — the inert arms REPORT instead of dropping the predicate in silence', () => { - it('formType `tabbed`: reports, naming the ruled phrase, the surface and the section', async () => { - await renderObjectForm(DENIED, { formType: 'tabbed' }); +describe('#6237 — the ONE still-inert arm REPORTS instead of dropping the predicate in silence', () => { + it('formType `wizard`: reports, naming the ruled phrase, the surface and the section', async () => { + await renderObjectForm(DENIED, { formType: 'wizard' }); await waitFor(() => expect(diagnosticCalls().length).toBe(1)); const message = diagnosticCalls()[0]; expect(message).toContain('not yet supported on this layout'); - expect(message).toContain("the `tabbed` layout's tabs"); + expect(message).toContain("the `wizard` layout's steps"); // The section is named, so an author with ten sections knows which one. expect(message).toContain('pay'); - // The remedy names an arm that genuinely works today. expect(message).toContain('objectui#6237'); }); - it('formType `wizard`: reports too — steps are the second silently-inert arm', async () => { + it("the remedy now names `tabbed` as a working arm — it stopped being the problem", async () => { + // The remedy line is the half an author actually acts on. When an arm moves + // from inert to honouring, a remedy that still omits it sends the author to + // a layout they may not want for a reason that no longer exists. await renderObjectForm(DENIED, { formType: 'wizard' }); await waitFor(() => expect(diagnosticCalls().length).toBe(1)); - expect(diagnosticCalls()[0]).toContain("the `wizard` layout's steps"); + expect(diagnosticCalls()[0]).toContain("`formType: 'tabbed' | 'modal' | 'drawer' | 'split'`"); }); it('the report does not depend on the VERDICT — an admitting predicate is just as inert', async () => { - // Nothing evaluates the key on these arms, so a TRUE predicate is dropped + // Nothing evaluates the key on this arm, so a TRUE predicate is dropped // exactly as a FALSE one is. A diagnostic that only fired on the denied // scope would leave the author of an allow-rule believing it worked. - await renderObjectForm(ALLOWED, { formType: 'tabbed' }); + await renderObjectForm(ALLOWED, { formType: 'wizard' }); await waitFor(() => expect(diagnosticCalls().length).toBe(1)); }); it('every section carrying a predicate is named, not just the first', async () => { - await renderObjectForm(DENIED, { formType: 'tabbed' }, [ + await renderObjectForm(DENIED, { formType: 'wizard' }, [ { name: 'always', label: 'Always', fields: ['subject'] }, { name: 'pay', label: 'Compensation', visibleWhen: GATE, fields: ['salary'] }, { name: 'extra', label: 'Extra', visibleWhen: GATE, fields: ['subject'] }, @@ -185,6 +195,12 @@ describe('#6237 — the boundary: arms that HONOUR the predicate stay silent', ( { formType: 'modal', open: true, contentLayout: 'tabbed' }], ['drawer (ObjectForm drawer map copies it)', { formType: 'drawer', open: true }], ['split (ObjectForm split map copies it)', { formType: 'split' }], + // The row this card moved. `tabbed` honours the key through the very same + // renderer machinery the `modal + contentLayout: tabbed` row above uses — + // `ObjectForm`'s tabbed map copies the predicate, `TabbedForm` copies it + // onto the synthesised `FormFieldTab.visibleWhen`. Its BEHAVIOUR (not just + // its silence) is pinned in tabbedFormSectionPredicate-6237.test.tsx. + ['tabbed (TabbedForm, via FormFieldTab.visibleWhen)', { formType: 'tabbed' }], ]; for (const [label, extra] of honouring) { @@ -195,7 +211,7 @@ describe('#6237 — the boundary: arms that HONOUR the predicate stay silent', ( } it('an inert arm with NO authored predicate is silent — the gap, not the layout, is reported', async () => { - await renderObjectForm(DENIED, { formType: 'tabbed' }, [ + await renderObjectForm(DENIED, { formType: 'wizard' }, [ { name: 'always', label: 'Always', fields: ['subject'] }, { name: 'pay', label: 'Compensation', fields: ['salary'] }, ]); @@ -205,7 +221,7 @@ describe('#6237 — the boundary: arms that HONOUR the predicate stay silent', ( describe('#6237 — the report is once per mount, not once per render', () => { it('re-rendering the same schema does not re-report', async () => { - const view = await renderObjectForm(DENIED, { formType: 'tabbed' }); + const view = await renderObjectForm(DENIED, { formType: 'wizard' }); await waitFor(() => expect(diagnosticCalls().length).toBe(1)); // A fresh element with the same authored content: the effect's deps are // primitives (layout + joined names), so identity churn must not re-fire it. @@ -219,7 +235,7 @@ describe('#6237 — the report is once per mount, not once per render', () => { objectName: 'crm_case', mode: 'create', sections: sections(), - formType: 'tabbed', + formType: 'wizard', } as any} dataSource={dataSource} /> @@ -244,21 +260,36 @@ describe('#6237 — the master-detail branch reports through its INNER pass, exa expect(diagnosticCalls()).toEqual([]); }); - it('master-detail `tabbed`: reported ONCE, not once per ObjectForm pass', async () => { + it('master-detail `tabbed`: silent — the parent re-enters the honouring tabbed arm', async () => { + // This row USED to be the "reported exactly once" case. It is silent now, + // and the path is worth naming: `MasterDetailForm` passes the authored + // sections through untouched and re-enters `ObjectForm` with + // `formType: 'tabbed'`, so the predicate rides the same map every other + // tabbed form uses. Master-detail needed no work of its own. await renderObjectForm(DENIED, { formType: 'tabbed', subforms }); - await waitFor(() => expect(diagnosticCalls().length).toBe(1)); - expect(diagnosticCalls()[0]).toContain("the `tabbed` layout's tabs"); + expect(diagnosticCalls()).toEqual([]); }); }); -describe('#6237 — the message is single-sourced', () => { - it('both arms speak through one builder, so the two cannot drift apart', () => { - expect(sectionPredicateUnsupportedWarning('tabbed', 'pay')) - .toContain('not yet supported on this layout'); - expect(sectionPredicateUnsupportedWarning('wizard', 'pay')) - .toContain('not yet supported on this layout'); - // The one thing that differs is the surface noun. - expect(sectionPredicateUnsupportedWarning('tabbed', 'pay')) - .not.toEqual(sectionPredicateUnsupportedWarning('wizard', 'pay')); +describe('#6237 — the message is single-sourced, and its remedy stays true', () => { + it('the builder names the wizard surface and the sections it was given', () => { + const message = sectionPredicateUnsupportedWarning('wizard', 'pay'); + expect(message).toContain('not yet supported on this layout'); + expect(message).toContain("the `wizard` layout's steps"); + expect(message).toContain('pay'); + }); + + it('⛔ the remedy must never point at an arm that does not honour the key', () => { + // The failure this pins is asymmetric and quiet: the message is prose, so a + // layout that regressed (or one added later without a predicate slot) can + // sit in the remedy list for months while every author it advises is sent + // somewhere the key does nothing. The honouring rows above test the arms; + // this tests the SENTENCE that recommends them. + const message = sectionPredicateUnsupportedWarning('wizard', 'pay'); + for (const honoured of ['tabbed', 'modal', 'drawer', 'split']) { + expect(message).toContain(honoured); + } + // ...and never recommends the arm it is complaining about. + expect(message).not.toContain("'wizard' |"); }); }); diff --git a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx index 537ae7f12..c2888e61d 100644 --- a/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx +++ b/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx @@ -101,9 +101,24 @@ * 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. + * the tab. + * + * ## `TabbedForm` — the eighth synthesis site (objectui#6237, this card) + * + * `formType: 'tabbed'` reaches `TabbedForm`, which already synthesised + * `fieldTabs` — the very machinery the modal tabbed arm above runs on — but + * dropped the predicate at three points: `ObjectForm`'s tabbed map, the section + * config (which declared no such key), and the `fieldTabs` synthesis. All three + * now carry it, so this arm reaches the SAME evaluator by the SAME route and + * gets a row in the matrix below rather than a mechanism of its own. + * + * ⛔ `WizardForm` is deliberately still absent, and not by oversight: its step + * type OMITS the predicate (`WizardStepConfig`) because a step predicate is a + * different contract — step-boundary reactive against the ruled live-record + * reactivity, needing navigation and final-gate semantics none of this + * machinery supplies. `ObjectForm` reports that gap at runtime instead + * (sectionPredicateLayoutDiagnostic-6237.test.tsx). Adding a wizard row here + * would be pinning an unruled contract into existence. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -267,6 +282,17 @@ const LAYOUTS: { mount: (scope, gate) => renderObjectForm(scope, { formType: 'modal', open: true, contentLayout: 'tabbed' }, gate), }, + { + // The `formType: 'tabbed'` arm (#6237, this card). Same shape as the two + // modal-tabbed rows above — sections become tab panels, so the stamp is the + // tab's own `FormFieldTab.visibleWhen` and `gatedHeading()` reads the tab + // TRIGGER text — but a DIFFERENT chain: `ObjectForm`'s tabbed map into + // `TabbedForm`'s own `fieldTabs` synthesis. Both hops must copy the key, and + // a row per chain is the point of this matrix: the modal rows stayed green + // through the entire period this arm was inert. + label: "TabbedForm — formType 'tabbed', via ObjectForm delegation (tabbed map + fieldTabs synthesis, #6237)", + mount: (scope, gate) => renderObjectForm(scope, { formType: 'tabbed' }, gate), + }, { label: 'DrawerForm — via ObjectForm delegation (key-by-key remap + explicit-sections divider)', mount: (scope, gate) => renderObjectForm(scope, { formType: 'drawer', open: true }, gate), diff --git a/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx b/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx new file mode 100644 index 000000000..d18e663b4 --- /dev/null +++ b/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx @@ -0,0 +1,346 @@ +/** + * 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 `formType: 'tabbed'` arm of the ONE grouping contract, + * and the three semantics the 2026-08-29 ruling made binding. + * + * ## What this file is for, and what it deliberately is not + * + * `sectionVisibleWhen-6111.test.tsx` pins that this arm's synthesis chain + * COPIES the predicate (its DENIED/ALLOWED/FAULTED triad). This file pins the + * three ruled SEMANTICS as they are observable from the authored surface — the + * questions the ruling said the design must answer before implementation: + * + * 1. Hidden tab x required fields — neither silent submit-blocking (which + * re-opens the objectui#2959 regression) nor a bypass the server then 400s. + * 2. What happens when the ACTIVE tab hides itself. + * 3. Collapse below two tabs. + * + * ⭐ Semantics 1 to 3 were SETTLED BEFORE this card's implementation, and the + * rows below are inheritance receipts, not new decisions: + * + * - The behaviour was ruled on 2026-08-27 (visibility gates DRAWING only; + * hidden-group values still submit; hidden-group fields skip CLIENT-side + * validation with the server as the loud floor) and implemented in the form + * renderer for `fieldTabs` by PR #6619. + * - `TabbedForm` already synthesised `fieldTabs`. So this arm inherits all + * three through the identical code path the modal tabbed arm uses; nothing + * was re-implemented beside it. + * + * That is exactly why these rows are worth their cost. Inheritance is a claim + * about a code path, and a later edit that gives this layout its own predicate + * handling — a plausible "cleanup" — would satisfy every row in the #6111 + * matrix (the key still arrives) while quietly diverging on all three + * semantics. These rows fail when the arms stop agreeing. + * + * ## The one semantic this card actually DECIDED: sub-two-tab degradation + * + * Semantic 3 wears one name over two situations. The renderer answered the one + * everybody meant — a predicate hiding one of two tabs does not collapse the + * strip, because engagement is judged on DECLARED tabs. It does not answer the + * other: a form declaring a SINGLE section never engages the tab arm at all + * (the renderer needs more than one usable tab), so before this card there was + * no tab to carry the predicate and the key was inert in that one shape. + * + * Left alone, this card would have made things WORSE there: `ObjectForm` stops + * reporting the tabbed gap (the arm supports the key now), so the single-section + * case would have gone from loudly-inert to silently-inert. The defined + * degradation is the untabbed layout's OWN mechanism (#6236) — a chrome-less + * `section-divider` claiming its members by name, which reaches the same + * unmount path and therefore the same ruled semantics. + * + * ## The boundary that is enforced by TYPE, not by prose + * + * The wizard borrows this layout's section shape but cannot honour a predicate + * (see `WizardStepConfig`). The last block pins that the key is a compile error + * on a wizard step — the enforcement half of "declared equals enforced". + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { PredicateScopeProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; +import type { FormSectionConfig } from '../TabbedForm'; +import type { WizardStepConfig } from '../WizardForm'; + +registerAllFields(); + +/** Canonical wire shape — same spelling as the #6111 / #6010 pins. */ +const cel = (source: string) => ({ dialect: 'cel', source }); +const GATE = cel("'sales_manager' in current_user.positions"); + +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']); + +/** + * `salary` is REQUIRED on the object — the whole point of semantic 1. A form + * that blocks on it while its tab is hidden re-opens #2959 through the new door. + */ +const objectSchema = { + name: 'crm_case', + fields: { + subject: { type: 'text', label: 'Subject' }, + salary: { type: 'text', label: 'Salary', required: true }, + notes: { type: 'text', label: 'Notes' }, + }, +}; + +let dataSource: any; + +beforeEach(() => { + dataSource = { + getObjectSchema: vi.fn().mockResolvedValue(objectSchema), + findOne: vi.fn(), + create: vi.fn().mockResolvedValue({ id: 'case-1' }), + update: vi.fn(), + }; +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const twoSections = (gate: unknown = GATE) => [ + { name: 'basics', label: 'Basics', fields: ['subject'] }, + { name: 'pay', label: 'Compensation', visibleWhen: gate, fields: ['salary'] }, +]; + +const renderTabbed = async ( + scope: Record, + sections: unknown, + extra: Record = {}, +) => { + const view = render( + + + , + ); + // The un-gated sibling is the readiness signal AND the proof the form mounted, + // so a missing gated tab is a VERDICT rather than an inability. + await waitFor(() => expect(screen.getAllByText('Basics').length).toBeGreaterThan(0)); + return view; +}; + +const trigger = (key: string) => screen.queryByTestId(`form-tab:${key}`); +const panel = (key: string) => screen.queryByTestId(`form-tab-panel:${key}`); +const salaryInput = () => screen.queryByLabelText(/salary/i); +const submit = () => screen.getByRole('button', { name: /create/i }); + +describe('#6237 semantic 1 — a hidden tab x required fields', () => { + it('DENIED: the required field on the hidden tab does NOT block the submit', async () => { + // The ruled resolution, and the reason it is not a bypass invented here: + // the panel is not drawn, so its Controller unmounts and react-hook-form + // skips an unmounted field's rules. Same mechanism a field's own false + // predicate uses — inherited, not re-implemented. + await renderTabbed(DENIED, twoSections()); + expect(trigger('pay')).toBeNull(); + expect(salaryInput()).toBeNull(); + fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'S1' } }); + fireEvent.click(submit()); + await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1)); + }); + + it('CONTROL: the SAME required field on a VISIBLE (merely inactive) tab still blocks', async () => { + // Without this row, "does not block" is satisfied by a form that stopped + // validating altogether — which is the far worse defect and is invisible + // above. #2959's contract is that an inactive tab keeps BOTH its values and + // its validation; only a PREDICATE-hidden tab sheds the rules. + await renderTabbed(ALLOWED, twoSections()); + expect(trigger('pay')).not.toBeNull(); + fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'S1' } }); + fireEvent.click(submit()); + await waitFor(() => expect(screen.getAllByText(/required/i).length).toBeGreaterThan(0)); + expect(dataSource.create).not.toHaveBeenCalled(); + }); + + it('DENIED: the hidden tab\'s VALUE still submits — visibility gates drawing and nothing else', async () => { + // The other half of the ruled semantics, and the half that makes the + // skipped validation coherent rather than a data hole: a value seeded from + // the record (or a default) is kept by react-hook-form and reaches the + // payload even though nothing drew it. + await renderTabbed(DENIED, twoSections(), { initialValues: { salary: '120000' } }); + expect(salaryInput()).toBeNull(); + fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'S1' } }); + fireEvent.click(submit()); + await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1)); + const payload = dataSource.create.mock.calls[0].find( + (a: unknown) => a && typeof a === 'object' && 'salary' in (a as object), + ) as Record | undefined; + expect(payload?.salary).toBe('120000'); + }); +}); + +describe('#6237 semantic 2 — the ACTIVE tab hides itself', () => { + it('a form whose FIRST tab is hidden activates a visible one — never an empty panel', async () => { + // `activeFieldTab` derives over the VISIBLE tabs, so the tab that would + // have been picked by default simply stops winning. The failure this + // forecloses is a selection pointing at a tab that is no longer drawn, + // which renders a form with a strip and nothing under it. + await renderTabbed(DENIED, [ + { name: 'pay', label: 'Compensation', visibleWhen: GATE, fields: ['salary'] }, + { name: 'basics', label: 'Basics', fields: ['subject'] }, + { name: 'more', label: 'More', fields: ['notes'] }, + ]); + expect(trigger('pay')).toBeNull(); + await waitFor(() => + expect(panel('basics')).toHaveAttribute('data-state', 'active'), + ); + expect(salaryInput()).toBeNull(); + }); + + it('the DECLARED default is honoured when it survives, and skipped when it does not', async () => { + // Re-selection is an ordered fallback (pick, then declared default, then + // first visible), not "always the first tab". A default pointing at a + // hidden tab must degrade rather than win and blank the form. + await renderTabbed(DENIED, [ + { name: 'basics', label: 'Basics', fields: ['subject'] }, + { name: 'pay', label: 'Compensation', visibleWhen: GATE, fields: ['salary'] }, + { name: 'more', label: 'More', fields: ['notes'] }, + ], { defaultTab: 'pay' }); + expect(trigger('pay')).toBeNull(); + await waitFor(() => + expect(panel('basics')).toHaveAttribute('data-state', 'active'), + ); + }); +}); + +describe('#6237 semantic 3a — a predicate does NOT collapse the arm', () => { + it('one of two tabs hidden: the strip survives with a single trigger', async () => { + // Engagement is structural, judged on DECLARED tabs. A collapse into the + // untabbed layout mid-interaction would remount every surviving field — + // destroying focus and in-progress edits — and would draw the hidden tab's + // fields flat, breaking the ruled semantics in the same stroke. + await renderTabbed(DENIED, twoSections()); + expect(trigger('basics')).not.toBeNull(); + expect(trigger('pay')).toBeNull(); + expect(panel('basics')).not.toBeNull(); + }); + + it('ALLOWED: the same two declared tabs, both drawn', async () => { + await renderTabbed(ALLOWED, twoSections()); + expect(trigger('basics')).not.toBeNull(); + expect(trigger('pay')).not.toBeNull(); + }); +}); + +describe('#6237 semantic 3b — degradation BELOW two declared tabs', () => { + // The situation the renderer does not answer, and the only one this card + // decided rather than inherited. A single-section `tabbed` form is already + // rendered as the untabbed layout, so the defined degradation is the untabbed + // layout's own predicate mechanism. + const oneSection = (gate: unknown = GATE) => [ + { name: 'pay', label: 'Compensation', visibleWhen: gate, fields: ['salary'] }, + ]; + + it('DENIED: a single-section tabbed form still honours the predicate', async () => { + render( + + + , + ); + // No un-gated sibling exists here, so readiness is the submit control. + await waitFor(() => expect(screen.getByRole('button', { name: /create/i })).toBeTruthy()); + expect(salaryInput()).toBeNull(); + }); + + it('ALLOWED: the same single section renders — the gate is a verdict, not a swallow', async () => { + render( + + + , + ); + await waitFor(() => expect(salaryInput()).not.toBeNull()); + }); + + it('a single section with NO predicate is untouched — no heading appears', async () => { + // The degradation gate is chrome-less and emitted only for a section that + // authored a predicate, so an existing single-section tabbed form renders + // exactly as it did. A visible heading here would be a layout regression + // shipped to every such form under the banner of a predicate fix. + render( + + + , + ); + await waitFor(() => expect(salaryInput()).not.toBeNull()); + expect(screen.queryByText('Compensation')).toBeNull(); + }); +}); + +describe('#6237 — the wizard boundary is enforced by the TYPE, not by a comment', () => { + it('the tabbed arm\'s section config ACCEPTS the predicate', () => { + const section: FormSectionConfig = { + name: 'pay', + label: 'Compensation', + fields: ['salary'], + visibleWhen: GATE, + }; + expect(section.visibleWhen).toBe(GATE); + }); + + it('⛔ a wizard STEP rejects it at compile time — the enforcement half', () => { + // This is the pin, and it is a TYPE assertion: `tsc -p tsconfig.test.json` + // runs over this file, so the `@ts-expect-error` below FAILS THE BUILD if + // the key ever becomes writable on a wizard step. Declaring a key the + // wizard renderer does not read is precisely the declared-but-unenforced + // shape this card family exists to close, and prose cannot prevent it. + const step: WizardStepConfig = { + name: 'pay', + label: 'Compensation', + fields: ['salary'], + // @ts-expect-error a wizard step has no predicate slot (objectui#6237) + visibleWhen: GATE, + }; + expect(step.name).toBe('pay'); + }); +}); diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index a8da5196b..bd3477814 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -46,7 +46,7 @@ export { export { TabbedForm } from './TabbedForm'; export type { TabbedFormProps, TabbedFormSchema, FormSectionConfig } from './TabbedForm'; export { WizardForm } from './WizardForm'; -export type { WizardFormProps, WizardFormSchema } from './WizardForm'; +export type { WizardFormProps, WizardFormSchema, WizardStepConfig } from './WizardForm'; export { SplitForm } from './SplitForm'; export type { SplitFormProps, SplitFormSchema } from './SplitForm'; export { DrawerForm } from './DrawerForm'; diff --git a/packages/plugin-form/src/sectionPredicateDiagnostic.ts b/packages/plugin-form/src/sectionPredicateDiagnostic.ts index 6d789465c..8865071a1 100644 --- a/packages/plugin-form/src/sectionPredicateDiagnostic.ts +++ b/packages/plugin-form/src/sectionPredicateDiagnostic.ts @@ -7,38 +7,39 @@ */ /** - * The layout arms that DROP an authored `FormSection.visibleWhen` (objectui#6237). + * The one layout arm that still DROPS an authored `FormSection.visibleWhen` + * (objectui#6237). * - * Of the five layout routes in `ObjectForm`, three rebuild each section key by key - * and DO copy the predicate (`split` / `drawer` / `modal`), and the flat arm carries - * it on the `section-divider` pseudo-field — those four honour it. `tabbed` - * (`TabbedForm`) and `wizard` (`WizardForm`) rebuild the section the same way but - * copy no predicate, so an authored key never reaches a renderer at all. + * Of the six layout routes in `ObjectForm`, five honour the predicate: + * `split` / `drawer` / `modal` rebuild each section key by key and copy it + * (#6111), the flat arm carries it on the `section-divider` pseudo-field + * (#6236), and `tabbed` joined them by copying it onto the tab `TabbedForm` + * synthesises, where the renderer evaluates it (#6237). `wizard` is the + * remainder. * - * Making those two arms actually honour it is a DESIGN task, ruled 2026-08-29 - * (option A): ONE renderer-side section/group contract with a predicate slot, - * designed once for every layout arm rather than patched arm by arm. Ruled as part - * of that option, this diagnostic lands FIRST so the gap stops being silent — an - * author who writes the key on one of these arms is told it is not yet supported - * here instead of watching it do nothing. + * ⛔ The wizard's gap is a DESIGN boundary, not an oversight, and it is not one + * copy line away. A step predicate would be step-boundary reactive against the + * ruled live-record reactivity, and it needs navigation, indicator, final-gate + * and re-selection semantics that the tab arm's machinery does not supply — + * `WizardStepConfig` carries the full measurement. Until that contract is ruled, + * an author who writes the key on a wizard is told so rather than watching it do + * nothing. * - * Single-sourced so both inert arms report the gap in one voice, and so a test can - * pin the wording without restating it. + * Single-sourced so the runtime report and the pin that holds its wording cannot + * drift apart. */ export function sectionPredicateUnsupportedWarning( - layout: 'tabbed' | 'wizard', + layout: 'wizard', sectionNames: string, ): string { - const surface = layout === 'tabbed' - ? "the `tabbed` layout's tabs" - : "the `wizard` layout's steps"; return '[ObjectForm] Section `visibleWhen` is not yet supported on this layout: ' - + `${surface} drop the predicate, so section(s) ${sectionNames} render ` - + 'unconditionally. Support is being designed as ONE grouping contract across ' - + 'every layout arm (objectui#6237); until it lands, use ' - + "`formType: 'modal' | 'drawer' | 'split'` or the flat layout — each honours a " - + 'section `visibleWhen` — or move the predicate onto the individual fields, ' - + 'whose own `visibleWhen` is evaluated on every layout.'; + + `the \`${layout}\` layout's steps drop the predicate, so section(s) ` + + `${sectionNames} render unconditionally. A wizard STEP predicate is a ` + + 'separate contract still being designed (objectui#6237) — it is not the ' + + 'tab predicate with a different name. Today, use ' + + "`formType: 'tabbed' | 'modal' | 'drawer' | 'split'` or the flat layout — " + + 'each honours a section `visibleWhen` — or move the predicate onto the ' + + 'individual fields, whose own `visibleWhen` is evaluated on every layout.'; } /*