Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion content/docs/plugins/plugin-form.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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`

Expand All@@ -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.
Expand Down
68 changes: 67 additions & 1 deletion packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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`

Expand Down