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
35 changes: 35 additions & 0 deletions .changeset/6237-wizard-step-config-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/plugin-form': patch
---

Split `WizardStepConfig` off `FormSectionConfig`, and correct the section-predicate
support table (objectui#6237, maintainer ruling 2026-08-30).

`WizardForm` typed its steps as `Omit<FormSectionConfig, 'visibleWhen'>` — a
subtraction from the TabbedForm section type, which is the predicate-CARRYING
type. That defended the one key it named and left the mechanism open: every key
added to `FormSectionConfig` reached a wizard step by default, so the next
predicate in the same family (`readonlyWhen` / `requiredWhen`, already this
package's field-level vocabulary) would have handed the wizard a silent slot its
renderer does not read — the declared-but-unenforced shape the ruling split the
types to stop.

`WizardStepConfig` is now declared independently in `WizardForm.tsx`, which is
simply what `SplitFormSectionConfig`, `ModalFormSectionConfig` and
`DrawerFormSectionConfig` already do: each layout owns its group shape, documents
`className` / `gridClassName` in its own terms, and declares `visibleWhen` only
where its renderer honours it. The derivation flips from subtractive to additive
— a key is authorable on a wizard step only if someone writes it there.

No behaviour change and no key added or removed: `WizardStepConfig` exports the
same key set it already had, and `visibleWhen` on a wizard step literal was, and
remains, a compile error. What is new is that it stays one for the whole
predicate family, pinned by a type-level assertion that fails the build if any
`*When` key ever appears on the step type.

Documentation repair in the same stroke: the support table in the README and in
`content/docs/plugins/plugin-form.mdx` still said `formType: 'tabbed'` sections
drop the predicate. That stopped being true when the tabbed arm landed — the row
now reads **Yes**, the surrounding prose no longer claims two inert arms or a
diagnostic that fires for `tabbed`, and the wizard row stays **No**, which is
still exactly true.
44 changes: 26 additions & 18 deletions content/docs/plugins/plugin-form.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -184,9 +184,9 @@ 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. Of the
two, only `ModalForm` forwards a section's `visibleWhen` onto its tab — see the
support table at the end of the next section.
`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` build on this, and both
forward a section's `visibleWhen` onto the tab they synthesise — see the support
table at the end of the next section.

#### Conditional tabs (`FormFieldTab.visibleWhen`)

Expand DownExpand Up@@ -243,28 +243,36 @@ and only where the renderer actually evaluates it:
| --- | --- |
| `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 |
| `TabbedForm` / `formType: 'tabbed'` sections | **Yes** — the section's `visibleWhen` is copied onto the tab `TabbedForm` synthesises; a single-section form, which draws no tab strip, gates the group through the flat layout's own section-divider mechanism instead |
| `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
The remaining **No** row is deliberate, not an oversight: 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.

Both **No** rows now **report themselves** rather than failing silently. Authoring
a section `visibleWhen` on `formType: 'tabbed'` or `formType: 'wizard'` logs a
console warning naming the layout and the sections whose predicate is being
dropped:
so the key stops at the boundary until each surface enforces it.

⛔ **A wizard step has no predicate slot at all, and that is enforced by the
type.** A step predicate is not the tab predicate under another name — steps are
mounted one at a time and keyed by index, so a step predicate would be
step-boundary reactive where a tab's is live-record reactive, and it would need
navigation, indicator, `isLastStep`, final-gate and re-selection semantics that
none of the tab machinery supplies. So `WizardStepConfig` is declared
independently of `FormSectionConfig` (maintainer ruling 2026-08-30,
objectui#6237) and simply does not carry the key: `visibleWhen` on a wizard step
literal is a **compile error**, not a silently ignored key. Track objectui#6237
for the step contract.

That row also **reports itself** rather than failing silently — untyped JSON
reaches the renderer too, so the compile-time half cannot be the only one.
Authoring a section `visibleWhen` on `formType: 'wizard'` logs a console warning
naming the layout and the sections whose predicate is being dropped:

> `[ObjectForm] Section \`visibleWhen\` is not yet supported on this layout: the
> \`tabbed\` layout's tabs drop the predicate, so section(s) pay render
> \`wizard\` layout's steps drop the predicate, so section(s) pay render
> unconditionally. …`

This is an interim diagnostic, ruled 2026-08-29 alongside the decision to design
the real repair as **one** section/group predicate contract shared by every layout
arm instead of patching them one at a time. It changes no behaviour — the
predicate is still dropped on those two arms — it only stops the drop from being
invisible. Nothing warns on the four arms that honour the key.
It changes no behaviour — the predicate is still dropped on that one arm — it
only stops the drop from being invisible. Nothing warns on the five arms that
honour the key.

### Wizard steps and `allowSkip`

Expand Down
38 changes: 23 additions & 15 deletions packages/plugin-form/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,9 +406,9 @@ 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. Of
the two, only `ModalForm` forwards a section's `visibleWhen` onto its tab — see
the support table below.
`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this, and
both forward a section's `visibleWhen` onto the tab they synthesise — see the
support table below.

#### Conditional tabs (`FormFieldTab.visibleWhen`)

Expand DownExpand Up@@ -465,21 +465,29 @@ and only where the renderer actually evaluates it:
| --- | --- |
| `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 |
| `TabbedForm` / `formType: 'tabbed'` sections | **Yes** — the section's `visibleWhen` is copied onto the tab `TabbedForm` synthesises; a single-section form, which draws no tab strip, gates the group through the flat layout's own section-divider mechanism instead |
| `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
The remaining **No** row is deliberate, not an oversight: 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.

Both **No** rows now **report themselves** rather than failing silently. Authoring
a section `visibleWhen` on `formType: 'tabbed'` or `formType: 'wizard'` logs a
console warning naming the layout and the sections whose predicate is being
dropped. This is an interim diagnostic (ruled 2026-08-29, alongside the decision
to design the real repair as **one** section/group predicate contract shared by
every layout arm): it changes no behaviour, it only stops the drop from being
invisible. Nothing warns on the four arms that honour the key.
key stops at the boundary until each surface enforces it.

⛔ **A wizard step has no predicate slot at all, and that is enforced by the
type.** A step predicate is not the tab predicate under another name — steps are
mounted one at a time and keyed by index, so a step predicate would be
step-boundary reactive where a tab's is live-record reactive, and it would need
navigation, indicator, `isLastStep`, final-gate and re-selection semantics that
none of the tab machinery supplies. So `WizardStepConfig` is declared
independently of `FormSectionConfig` (maintainer ruling 2026-08-30, #6237) and
simply does not carry the key: `visibleWhen` on a wizard step literal is a
**compile error**, not a silently ignored key. Track #6237 for the step
contract.

That row also **reports itself** rather than failing silently. Authoring a
section `visibleWhen` on `formType: 'wizard'` logs a console warning naming the
layout and the sections whose predicate is being dropped — untyped JSON reaches
the renderer too, so the compile-time half cannot be the only one. Nothing warns
on the five arms that honour the key.

### Wizard steps and `allowSkip`

Expand Down
15 changes: 10 additions & 5 deletions packages/plugin-form/src/TabbedForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,11 +70,16 @@ export interface FormSectionConfig {
* 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.
* ⛔ Deliberately NOT on the wizard's step type, and that boundary is now
* structural rather than subtractive (maintainer ruling 2026-08-30,
* objectui#6237). `WizardForm` used to type its steps as
* `Omit<FormSectionConfig, 'visibleWhen'>` — a derivation that defended the
* one key it named while every FUTURE key added here still reached a wizard
* step by default. `WizardStepConfig` is now declared independently, exactly
* as `SplitFormSectionConfig` / `ModalFormSectionConfig` /
* `DrawerFormSectionConfig` already are, so this type and the wizard's share
* nothing: a key is authorable on a wizard step only if someone writes it
* there, on the type whose renderer has to honour it.
*/
visibleWhen?: string | { dialect?: string; source: string };

Expand Down
102 changes: 85 additions & 17 deletions packages/plugin-form/src/WizardForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,15 +33,37 @@ import {
} from './submitRedirectNavigation';
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).
* A wizard STEP — the wizard's OWN authored group shape (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:
* ## Why this is declared here rather than derived from the tabbed layout
*
* It used to be `Omit<FormSectionConfig, 'visibleWhen'>`, which subtracted the
* predicate slot from `TabbedForm`'s section type. That closed the hole for the
* ONE key it named and left the mechanism wide open: `FormSectionConfig` is the
* predicate-CARRYING type, so every key added to it lands here by default and
* the author has to remember to widen the `Omit`. The defect the 2026-08-30
* ruling closes is the SILENCE — "在共享 `FormSectionConfig` 上声明 `visibleWhen`
* 会**静默**给 WizardForm 的 step 也发一个谓词槽而那侧无实现" — and a subtractive
* derivation reproduces that silence for the next key in the same family
* (`readonlyWhen` / `requiredWhen`, both already this repo's field-level
* predicate vocabulary). Declaring the step shape independently flips the
* derivation from subtractive to additive: a key reaches a wizard step only
* because someone wrote it here, on the type whose renderer has to honour it.
*
* This is also simply the house pattern. `SplitFormSectionConfig`,
* `ModalFormSectionConfig` and `DrawerFormSectionConfig` each declare their own
* group shape, each documents `className` / `gridClassName` in ITS layout's
* terms, and each declares `visibleWhen` only because its layout honours it
* (`SplitFormSectionConfig` even carries a key — `pane` — that exists nowhere
* else). The wizard borrowing the tabbed layout's interface was the exception,
* and it cost real accuracy: a wizard author reading the shared type was told
* `name` is "used as tab value", `label` is "used as tab trigger text", and
* `className` is "Unused in the tabbed layout" — while this renderer uses all
* three, `className` at the step container (`FormSectionContainer` below).
*
* ## ⛔ Why there is no predicate slot, and why that is not an oversight
*
* 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
Expand All@@ -55,19 +77,65 @@ import type { FormSectionConfig } from './TabbedForm';
* 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.
* Declaring the key on a type this renderer ignores would make it WRITABLE on a
* step — precisely the declared-but-unenforced shape this card family exists to
* close. Its absence 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. Both halves
* are pinned in `__tests__/tabbedFormSectionPredicate-6237.test.tsx`, including
* a FAMILY-level pin that fails the build if any `*When` key ever appears here,
* not just the one this card was about.
*
* ⚠️ 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.
* ⚠️ This changes nothing that used to work. The published surface is exactly
* the key set `WizardStepConfig` already had — the derivation changed, not the
* type — and `visibleWhen` was already a type error on a wizard step literal.
*/
export type WizardStepConfig = Omit<FormSectionConfig, 'visibleWhen'>;
export interface WizardStepConfig {
/**
* Step identifier. Used as the step's test handle
* (`data-testid="wizard-step:<name>"`) and, with `label`, as what the
* indicator names; falls back to the step INDEX when absent.
*/
name?: string;

/**
* Step label — the text the step indicator shows.
* Falls back to `Step <n>`.
*/
label?: string;

/**
* Step description, rendered under the step heading.
*/
description?: string;

/**
* How densely this step fills the grid (1–4). Outranked by the form view's
* own `columns` (spec `FormView.columns`), which sets the grid WIDTH.
* @default 1
*/
columns?: 1 | 2 | 3 | 4;

/**
* Field names or configurations in this step.
*/
fields: (string | FormField)[];

/**
* Custom CSS class for this step's container.
*
* Honoured, unlike its tabbed counterpart: a wizard mounts ONE step at a
* time inside its own `FormSectionContainer`, so the step has a container to
* carry it.
*/
className?: string;

/**
* Custom CSS class for this step's field grid (overrides the column classes).
*/
gridClassName?: string;
}

/**
* What the submitter is told when a DECLARED `navigateOnSuccess` produced no
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,4 +343,52 @@ describe('#6237 — the wizard boundary is enforced by the TYPE, not by a commen
};
expect(step.name).toBe('pay');
});

it('⛔ NO predicate-family key may appear on a wizard step — the family pin', () => {
// The pin above names ONE key. That was the whole weakness of the shape
// this replaced: `WizardStepConfig` was `Omit<FormSectionConfig,
// 'visibleWhen'>`, so it defended `visibleWhen` and let every FUTURE key
// added to the tabbed section type reach a wizard step by default — the
// same SILENT slot the 2026-08-30 ruling split the types to stop, one key
// later. `readonlyWhen` and `requiredWhen` are already this repo's
// field-level predicate vocabulary (see the plugin-form README's rule
// table), so the next key in the family is a named possibility, not a
// hypothetical.
//
// These are TYPE assertions: they are erased at runtime, so vitest proves
// nothing about them and `tsc -p tsconfig.test.json` is the only thing that
// can. `Expect` fails its own constraint when the argument is not `true`.
type Expect<T extends true> = T;
// `-?` strips optionality, or a mapped type over optional keys yields
// `K | undefined` and the `never` comparison below could never be true.
type PredicateKeysOf<T> = {
[K in keyof T]-?: K extends `${string}When` ? K : never;
}[keyof T];

// ⭐ The CONTROL, and it is not decoration: a `PredicateKeysOf` that
// silently resolved to `never` for everything would make the wizard row
// below pass vacuously — a phantom check that can never fail. This row
// proves the machinery detects a predicate key when one is really there,
// on the sibling type that really has one.
//
// ⚠️ The DIRECTION is the whole control. Written the other way round
// (`PredicateKeysOf<FormSectionConfig> extends 'visibleWhen'`) it would be
// vacuous for exactly the failure it is meant to catch: `never` is
// assignable to everything, so a broken helper returning `never` would
// satisfy it too — and then BOTH rows would pass while measuring nothing.
// Asking whether `'visibleWhen'` is assignable TO the helper's result is
// the assertion a `never` result fails.
type _TabbedSectionHasThePredicateKey = Expect<
'visibleWhen' extends PredicateKeysOf<FormSectionConfig> ? true : false
>;

// The pin itself: the wizard's step type carries no `*When` key at all.
type _WizardStepHasNoPredicateKey = Expect<
PredicateKeysOf<WizardStepConfig> extends never ? true : false
>;

// Keep both aliases used, so `noUnusedLocals` cannot delete the pin.
const pinned: [_TabbedSectionHasThePredicateKey, _WizardStepHasNoPredicateKey] = [true, true];
expect(pinned).toEqual([true, true]);
});
});
Loading