diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index f734fad386..476bc74de8 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -170,6 +170,7 @@ inactive tab unmount along with its values). fieldTabs: [ { key: 'basics', label: 'Basics', fields: ['subject', 'status'] }, { key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] }, + { key: 'billing', label: 'Billing', fields: ['vat_id'], visibleWhen: 'status == "won"' }, ], defaultFieldTab?: 'basics', // defaults to the first tab fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right' @@ -183,7 +184,72 @@ inactive tab unmount along with its values). - Fields no tab claims render above the tab strip rather than disappearing. - Needs at least two tabs; ignored when the form uses `children`. -`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` build on this. +`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` build on this. Of the +two, only `ModalForm` forwards a section's `visibleWhen` onto its tab — see the +support table at the end of the next section. + +#### Conditional tabs (`FormFieldTab.visibleWhen`) + +A tab may carry a `visibleWhen` predicate — the same slot, vocabulary and +engine as the field-level rule (`string | { dialect?, source }`, a CEL +predicate over the live record, evaluated by `@objectstack/formula` with the +host predicate scope bound, so it can read `current_user` / `app` / `data` / +`features` exactly as a field rule can). Like every conditional rule in this +system it **fails open**: a predicate that cannot be evaluated leaves the tab +visible rather than hiding data behind a broken expression. + +When the predicate resolves FALSE the renderer draws **neither the tab's +trigger nor its panel** — a hidden tab disappears from the tab strip entirely, +it is not merely an empty panel. + +**Submit semantics are deliberately counter-intuitive — visibility gates +drawing, and nothing else:** + +- **A hidden tab's values still submit.** Hiding a tab does not remove its + fields' values from the record: whatever they hold (loaded from the record, + seeded by defaults, or typed before the tab hid) is carried in the payload. + A visibility rule is not a data filter — dropping the values would turn a + cosmetic predicate into a silent data-loss door. +- **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 flip side: + a *required* field on a hidden tab sails past the client — **the + server-side contract is the loud floor** for genuinely-required data, and a + submit missing it fails there, visibly. +- **Stale errors clear.** A tab hiding mid-session (its predicate flipping on + a keystroke or a scope change) clears its fields' leftover validation + errors, the same hygiene a field's own `visibleWhen` applies. + +These are not tab-specific rules: they are the ruled hidden-group semantics +every section `visibleWhen` follows, inherited through the same mechanism a +field's own FALSE predicate uses (the fields simply stop being drawn; the form +keeps their values and skips unmounted controls at validation). + +Two mechanics worth knowing: + +- **Selection is derived over the visible tabs.** If the predicate hides the + ACTIVE tab, the renderer re-selects deterministically — the user's own pick + if still visible, else `defaultFieldTab`, else the first visible tab — so + the form never shows an empty panel. The pick itself is kept: the tab the + user chose becomes active again the moment its predicate re-admits it. +- **Whether the tabbed layout engages at all is judged on the DECLARED tabs** + (the "needs at least two tabs" rule above counts declarations, not + verdicts). A predicate hiding all but one tab filters what is drawn; it + never collapses the strip into the flat layout mid-interaction. + +**Where `visibleWhen` on a tab works today** — this key landed renderer-first, +and only where the renderer actually evaluates it: + +| Authoring surface | Carries the predicate? | +| --- | --- | +| `type: 'form'` with `fieldTabs[].visibleWhen` (as above) | **Yes** — evaluated by the form renderer | +| `ModalForm` / `formType: 'modal'` with `contentLayout: 'tabbed'` | **Yes** — the section's `visibleWhen` is copied onto its tab | +| `TabbedForm` / `formType: 'tabbed'` sections | **No** — a section `visibleWhen` is dropped before the renderer sees it; the tab always renders | +| `WizardForm` / `formType: 'wizard'` steps | **No** — steps are not tabs; nothing evaluates a step-level predicate | + +The two **No** rows are deliberate, not oversights: declaring the key on a +surface whose renderer ignores it would make the metadata lie (objectui#6111), +so the key stops at the boundary until each surface enforces it. Track +objectui#6237 for both. ### Wizard steps and `allowSkip` @@ -195,6 +261,10 @@ that step's indicator (`data-error="true"`), and names the fields in a toast — nothing is sent. Conditional rules are respected (`visibleWhen` / `requiredWhen` are evaluated on the same canonical engine as the renderer and the server). +Those conditional rules are the **fields'** rules. A wizard *step* carries no +`visibleWhen` of its own today — see the support table under +[Conditional tabs](#conditional-tabs-formfieldtabvisiblewhen). + This matters because react-hook-form only validates the fields currently mounted, and a wizard mounts one step at a time — so a required field on a step nobody opened used to be absent from the payload with nothing on screen saying so. diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index d217eecafb..88d72f5ba2 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -367,6 +367,7 @@ the renderer distribute the fields: fieldTabs: [ { key: 'basics', label: 'Basics', fields: ['subject', 'status'] }, { key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] }, + { key: 'billing', label: 'Billing', fields: ['vat_id'], visibleWhen: 'status == "won"' }, ], defaultFieldTab?: 'basics', // defaults to the first tab fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right' @@ -382,7 +383,72 @@ the renderer distribute the fields: - Fields no tab claims render above the tab strip rather than disappearing. - Needs at least two tabs, and is ignored when the form uses `children`. -`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this. +`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this. Of +the two, only `ModalForm` forwards a section's `visibleWhen` onto its tab — see +the support table below. + +#### Conditional tabs (`FormFieldTab.visibleWhen`) + +A tab may carry a `visibleWhen` predicate — the same slot, vocabulary and +engine as the field-level rule (`string | { dialect?, source }`, a CEL +predicate over the live record, evaluated by `@objectstack/formula` with the +host predicate scope bound, so it can read `current_user` / `app` / `data` / +`features` exactly as a field rule can). Like every conditional rule in this +system it **fails open**: a predicate that cannot be evaluated leaves the tab +visible rather than hiding data behind a broken expression. + +When the predicate resolves FALSE the renderer draws **neither the tab's +trigger nor its panel** — a hidden tab disappears from the tab strip entirely, +it is not merely an empty panel. + +**Submit semantics are deliberately counter-intuitive — visibility gates +drawing, and nothing else:** + +- **A hidden tab's values still submit.** Hiding a tab does not remove its + fields' values from the record: whatever they hold (loaded from the record, + seeded by defaults, or typed before the tab hid) is carried in the payload. + A visibility rule is not a data filter — dropping the values would turn a + cosmetic predicate into a silent data-loss door. +- **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 flip side: + a *required* field on a hidden tab sails past the client — **the + server-side contract is the loud floor** for genuinely-required data, and a + submit missing it fails there, visibly. +- **Stale errors clear.** A tab hiding mid-session (its predicate flipping on + a keystroke or a scope change) clears its fields' leftover validation + errors, the same hygiene a field's own `visibleWhen` applies. + +These are not tab-specific rules: they are the ruled hidden-group semantics +every section `visibleWhen` follows, inherited through the same mechanism a +field's own FALSE predicate uses (the fields simply stop being drawn; the form +keeps their values and skips unmounted controls at validation). + +Two mechanics worth knowing: + +- **Selection is derived over the visible tabs.** If the predicate hides the + ACTIVE tab, the renderer re-selects deterministically — the user's own pick + if still visible, else `defaultFieldTab`, else the first visible tab — so + the form never shows an empty panel. The pick itself is kept: the tab the + user chose becomes active again the moment its predicate re-admits it. +- **Whether the tabbed layout engages at all is judged on the DECLARED tabs** + (the "needs at least two tabs" rule above counts declarations, not + verdicts). A predicate hiding all but one tab filters what is drawn; it + never collapses the strip into the flat layout mid-interaction. + +**Where `visibleWhen` on a tab works today** — this key landed renderer-first, +and only where the renderer actually evaluates it: + +| Authoring surface | Carries the predicate? | +| --- | --- | +| `type: 'form'` with `fieldTabs[].visibleWhen` (as above) | **Yes** — evaluated by the form renderer | +| `ModalForm` / `formType: 'modal'` with `contentLayout: 'tabbed'` | **Yes** — the section's `visibleWhen` is copied onto its tab | +| `TabbedForm` / `formType: 'tabbed'` sections | **No** — a section `visibleWhen` is dropped before the renderer sees it; the tab always renders | +| `WizardForm` / `formType: 'wizard'` steps | **No** — steps are not tabs; nothing evaluates a step-level predicate | + +The two **No** rows are deliberate, not oversights: declaring the key on a +surface whose renderer ignores it would make the metadata lie (#6111), so the +key stops at the boundary until each surface enforces it. Track #6237 for +both. ### Wizard steps and `allowSkip`