Skip to content
Open
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
36 changes: 36 additions & 0 deletions .changeset/6237-tabbed-section-predicate.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 23 additions & 7 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,19 +203,23 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
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
Expand Down Expand Up @@ -274,6 +278,18 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
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,
})),
Expand Down
89 changes: 88 additions & 1 deletion packages/plugin-form/src/TabbedForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -435,13 +462,64 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
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 (
<div className={cn('w-full @container', className, schema.className)}>
Expand All @@ -468,6 +546,15 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
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',
Expand Down
38 changes: 36 additions & 2 deletions packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormSectionConfig, 'visibleWhen'>;

/**
* What the submitter is told when a DECLARED `navigateOnSuccess` produced no
* destination — objectui#5034 point 2.
Expand Down Expand Up @@ -108,7 +142,7 @@ export interface WizardFormSchema {
/**
* Wizard step sections
*/
sections: FormSectionConfig[];
sections: WizardStepConfig[];

/**
* Allow navigation to any step (not just sequential).
Expand Down Expand Up @@ -399,7 +433,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({

// Build section fields from object schema
const buildSectionFields = useCallback(
(section: FormSectionConfig): FormField[] =>
(section: WizardStepConfig): FormField[] =>
buildSectionFieldsShared(section as any, {
objectSchema,
objectName: schema.objectName,
Expand Down
Loading
Loading