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
7 changes: 7 additions & 0 deletions .changeset/12654-console-visibleon-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/console': minor
---

The console's form page (`FormPage`, the `/forms/:name` and public `/f/:slug` routes) reads a form view field's and section's visibility predicate from `visibleWhen` only (objectui#12654). `@objectstack/spec` 18 retires both `visibleOn` keys as `retiredKey` tombstones naming `visibleWhen` (objectstack#23543 F4).

What a raw schema loses: a form view handed to this page without passing through the spec's parse, whose field or section carries only `visibleOn`, no longer hides that field or section; write `visibleWhen`. What is unaffected: form views served by an ObjectStack runtime, which already arrive with `visibleWhen` (the spec's parse fold through the 18 pre-releases, and spec 18's stored-row rehydration).
9 changes: 9 additions & 0 deletions .changeset/12654-plugin-form-visibleon-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/plugin-form': minor
---

A form view field's `visibleOn` is no longer read when sections are normalized (`normalizeSectionField`, used by `ObjectForm`, `ModalForm` and the record form page) (objectui#12654). `@objectstack/spec` 18 retires the form view field's `visibleOn` as a `retiredKey` tombstone naming `visibleWhen` (objectstack#23543 F4), so the field's `visibleWhen` is the one view-level predicate carried onto the runtime field.

What a raw schema loses: a form view section entry `{ field: 'x', visibleOn: 'PREDICATE' }` that did not pass through the spec's parse no longer hides the field; write `visibleWhen`. A runtime `FormField` (an inline field object with its own `name`) keeps its `visibleOn` slot: that is the runtime field's view-level gate, a different key, and it is unchanged.

What is unaffected: form views served by an ObjectStack runtime. They already arrive with `visibleWhen`: the spec's parse folds the old key through the 18 pre-releases, and spec 18's stored-row rehydration rewrites a row stored with it.
9 changes: 9 additions & 0 deletions .changeset/12654-react-visibility-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/react': minor
---

`SchemaRenderer` no longer reads a page component's `visibility` key (objectui#12654). `@objectstack/spec` 18 retires it as a `retiredKey` tombstone naming `visibleWhen` (objectstack#23543 F4), so `visibleWhen` is the one conditional-visibility key the renderer reads for it.

What a raw schema loses: a node handed straight to `SchemaRenderer`, without passing through the spec's parse, that carries only `visibility: 'PREDICATE'` is no longer hidden when the predicate is false; it now draws, and the `visibility` value is no longer stripped before the component's props. Write `visibleWhen` instead. The `SchemaRenderer` working-copy type no longer declares `visibility`.

What is unaffected: metadata served by an ObjectStack runtime. A served page row already reaches the renderer with `visibleWhen`: the spec's parse folds the old key through the 18 pre-releases, and spec 18's stored-row rehydration rewrites a row stored with it. `BaseSchema`'s own deprecated `visibleOn` is a different key and is still read.
40 changes: 18 additions & 22 deletions apps/console/src/components/FormPage.conditionalRules.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* objectui#5627 — this renderer honours the OTHER THREE conditional-rule
* surfaces: section-level `visibleWhen`/`visibleOn`, and the object-level field
* surfaces: section-level `visibleWhen`, and the object-level field
* rules `visibleWhen`/`readonlyWhen`/`requiredWhen`.
*
* ## Why a second pin file next to `FormPage.visibleWhen.test.tsx`
Expand Down Expand Up @@ -37,7 +37,7 @@
*/

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { buildSections, FormPage, isSectionVisible, resolveRowState } from './FormPage';
Expand Down Expand Up @@ -229,33 +229,28 @@ describe('objectui#5627 — FormPage evaluates SECTION-level visibility', () =>
expect(screen.getByLabelText('Status')).toBeInTheDocument();
});

it('honours the deprecated ADR-0089 alias `visibleOn` on a section', async () => {
// The spec normaliser rewrites the alias, so a spec-served view never
// carries it — but hand-written layouts and this app's own create schemas
// still do, which is why every sibling reader keeps reading it.
// objectui#12654: spec 18 retires the section's `visibleOn` spelling
// (objectstack#23543 F4). A raw view still carrying it is refused by the
// spec's parse; this reader does not fold it back. The pair below is the
// narrowing: the SAME predicate hides the section under `visibleWhen` and
// gates nothing under the retired key.
it('does not read the retired `visibleOn` on a section — the same predicate under `visibleWhen` does hide it', async () => {
renderInternal([
{ label: 'Basics', fields: ['priority'] },
{ label: 'Escalation', visibleOn: "record.priority == 'urgent'", fields: ['notes'] },
]);
await awaitForm();
expect(screen.queryByText('Escalation')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument();
});
expect(screen.getByText('Escalation')).toBeInTheDocument();
expect(screen.getByLabelText('Notes')).toBeInTheDocument();
cleanup();

it('lets the canonical `visibleWhen` win when a section authors both spellings', async () => {
// The two predicates disagree on purpose: only the canonical one's verdict
// can produce this outcome.
renderInternal([
{ label: 'Basics', fields: ['priority'] },
{
label: 'Escalation',
visibleWhen: "record.priority == 'urgent'", // false -> hidden
visibleOn: "record.priority == 'low'", // true -> would show
fields: ['notes'],
},
{ label: 'Escalation', visibleWhen: "record.priority == 'urgent'", fields: ['notes'] },
]);
await awaitForm();
expect(screen.queryByText('Escalation')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument();
});

it('honours the { dialect, source } wire shape on a section', async () => {
Expand Down Expand Up @@ -563,23 +558,24 @@ describe('objectui#5627 — requiredWhen takes the serverOwnedValue carve-out',
// ─── 4. The row-building step the DOM cases stand on ─────────────────────

describe('objectui#5627 — the keys reach the rows, in their own slots', () => {
it('buildSections carries the SECTION predicate onto the row, canonical first', () => {
it('buildSections carries the SECTION predicate onto the row, from `visibleWhen` only', () => {
const sections = buildSections(
{
sections: [
{ label: 'a', visibleWhen: 'record.x == 1', fields: ['f'] },
{ label: 'b', visibleOn: { dialect: 'cel', source: 'record.x == 2' }, fields: ['f'] },
{ label: 'c', visibleWhen: 'record.x == 3', visibleOn: 'record.x == 4', fields: ['f'] },
{ label: 'b', visibleWhen: { dialect: 'cel', source: 'record.x == 2' }, fields: ['f'] },
{ label: 'c', visibleOn: 'record.x == 4', fields: ['f'] },
{ label: 'd', fields: ['f'] },
],
},
null,
);

// `c`: the retired spelling reaches no row (objectui#12654).
expect(sections.map((s) => s.visibleWhen)).toEqual([
'record.x == 1',
{ dialect: 'cel', source: 'record.x == 2' },
'record.x == 3',
undefined,
undefined,
]);
});
Expand Down
64 changes: 27 additions & 37 deletions apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@
* ## Conditional field visibility (objectui#5594)
*
* A FormView field may carry a CEL predicate saying WHEN it is visible —
* `visibleWhen`, or the deprecated ADR-0089 alias `visibleOn`. This renderer
* read neither, so such a field rendered unconditionally on both routes:
* `visibleWhen` (ADR-0089). This renderer read it not at all before #5594, so such a field rendered unconditionally on both routes:
* fail-open and silent, with no diagnostic for the author.
*
* It is the SECOND form renderer in this repo, which is why the gap survived.
Expand All @@ -74,7 +73,7 @@
* and for the same reason: nothing downstream could read a key this file's
* payload types did not admit.
*
* - SECTION-level `visibleWhen` / `visibleOn` (ADR-0089). The section type is
* - SECTION-level `visibleWhen` (ADR-0089). The section type is
* no longer this file's to widen — objectui#5596 replaced the hand copy with
* the shared `FormSectionSpec`, which has DECLARED these two keys ever since
* ("declaring a key is not honouring it", as that type's own docstring says
Expand Down Expand Up @@ -477,15 +476,15 @@ interface RenderableField {
* this one by {@link resolveRowState} (objectui#5627). Two layers may both
* condition one control, and either one hiding it is enough.
*
* Resolved CANONICAL-FIRST when the row is built: `visibleWhen ?? visibleOn`.
* ADR-0089 renamed the key, and `@objectstack/spec`'s normaliser REWRITES the
* alias rather than keeping both, so a spec-served FormView carries
* `visibleWhen` and no `visibleOn` at all. The deprecated spelling is still
* read because it still has live producers — hand-written layouts, and this
* app's own create schemas, which never pass through that normaliser. Same
* spelling and same precedence as the two sibling readers, deliberately:
* `@object-ui/plugin-form` `sectionFields.ts` (`fd.visibleWhen ?? fd.visibleOn`)
* and app-shell's metadata-admin `readVisibility`.
* Read from `visibleWhen` ONLY (objectui#12654). ADR-0089 renamed the key,
* and `@objectstack/spec` 18 retires the old `visibleOn` spelling as a
* `retiredKey` tombstone (objectstack#23543 F4): every parsing door refuses
* it by name, and a stored row written with it is rehydrated to
* `visibleWhen` before it is served. A raw view still carrying `visibleOn`
* is therefore an authoring mistake the spec reports, and this reader does
* not fold it back — a renderer-side `??` would hide that mistake. The
* runtime form chain reads the same single key
* (`@object-ui/plugin-form` `sectionFields.ts`).
*
* The TYPE is derived from the authoring surface rather than restated:
* re-spelling `string | { dialect?, source }` here would be a third
Expand Down Expand Up @@ -578,18 +577,14 @@ interface RenderableSection {
collapsible: boolean;
collapsed: boolean;
/**
* Conditional visibility for the whole section (ADR-0089), resolved
* CANONICAL-FIRST when the row is built: `visibleWhen ?? visibleOn`
* (objectui#5627).
* Conditional visibility for the whole section (ADR-0089, objectui#5627),
* read from `visibleWhen` ONLY when the row is built (objectui#12654).
*
* The same key, the same precedence and the same reason as the field row one
* type up — the spec's normaliser rewrites the alias, so a spec-served
* FormView carries only `visibleWhen`, while hand-written layouts and this
* app's own create schemas still produce the deprecated spelling. app-shell's
* `SchemaForm` reads its sections through a `readVisibility` helper spelling
* exactly this `??`; that helper is module-private there (it is not part of
* `@object-ui/app-shell`'s published surface), so the shared machinery this
* file can actually import is the ENGINE — `evalFieldPredicate`, via
* The same key and the same reason as the field row one type up: spec 18
* retires the section's `visibleOn` spelling, so a served FormView carries
* only `visibleWhen`, and a raw view still writing the old key is refused
* by the spec rather than folded here. The shared machinery this file
* imports is the ENGINE — `evalFieldPredicate`, via
* {@link isSectionVisible} — which is the half that decides anything.
*
* Type derived from the shared authoring surface (objectui#5596's
Expand Down Expand Up @@ -783,11 +778,10 @@ export function buildSections(
required: override.required ?? def.required ?? false,
readonly: override.readonly ?? false,
hidden: override.hidden ?? false,
// Canonical key wins over the deprecated alias — see
// `RenderableField.visibleWhen`. Carried, not evaluated: the verdict
// depends on the live form values, which change per keystroke, while
// these rows are memoized on the loaded spec.
visibleWhen: override.visibleWhen ?? override.visibleOn,
// The one spelling — see `RenderableField.visibleWhen`. Carried, not
// evaluated: the verdict depends on the live form values, which change
// per keystroke, while these rows are memoized on the loaded spec.
visibleWhen: override.visibleWhen,
// The OBJECT's own rules (ADR-0036), off the object field and NOT
// merged with the view predicate above: two layers, two slots, ANDed
// at render by `resolveRowState` (objectui#5627). A `??` here would be
Expand Down Expand Up @@ -838,9 +832,9 @@ export function buildSections(
columns: cols,
collapsible: !!sec.collapsible,
collapsed: !!sec.collapsed,
// Canonical key wins over the deprecated alias, same precedence as the
// field row — see `RenderableSection.visibleWhen` (objectui#5627).
visibleWhen: sec.visibleWhen ?? sec.visibleOn,
// The one spelling, as on the field row — see
// `RenderableSection.visibleWhen` (objectui#5627, objectui#12654).
visibleWhen: sec.visibleWhen,
fields,
};
});
Expand Down Expand Up @@ -894,11 +888,7 @@ export function isFieldVisible(
): boolean {
if (field.hidden) return false;
return evalFieldPredicate(field.visibleWhen, values, true, previous ?? undefined, predicateScope, {
// Named for the canonical key even when the deprecated alias supplied the
// predicate: ADR-0089 is what this predicate is CALLED, and the warning
// prints the source text verbatim, which is what finds a hand-written
// `visibleOn`. A second stored key whose only job is to spell a deprecated
// name in a warning would be bookkeeping, not an honoured key.
// Named for the one key this row reads (ADR-0089, objectui#12654).
context: `visibleWhen of field '${field.name}'`,
});
}
Expand All @@ -907,7 +897,7 @@ export function isFieldVisible(
* Is this SECTION on screen, given the form's current state? (objectui#5627)
*
* The section-granularity twin of {@link isFieldVisible}, and deliberately the
* same three things: the canonical-first key read (done once, at build time —
* same three things: the one-key read (done once, at build time —
* `RenderableSection.visibleWhen`), the canonical ENGINE
* (`evalFieldPredicate`), and fail-OPEN with a one-time warning. A section that
* cannot be evaluated must not vanish, for the field-level reason multiplied by
Expand Down
46 changes: 21 additions & 25 deletions apps/console/src/components/FormPage.visibleWhen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5594 — this renderer evaluates `visibleWhen` / `visibleOn`.
* objectui#5594 — this renderer evaluates `visibleWhen` (and, since
* objectui#12654, `visibleWhen` only: spec 18 retired `visibleOn`).
*
* ## The class this pins shut
*
Expand Down Expand Up @@ -43,6 +44,10 @@
* - "buildSections carries the predicate onto the row" -> `undefined`
* - "isFieldVisible answers … in one verdict" -> no such export
*
* (A historical reading. The two `visibleOn` rows named above were replaced by
* objectui#12654, which retires that spelling: their successor pins that the
* retired key gates nothing.)
*
* The ONE that is green on both sides is the control that has to be:
*
* - "a field with NO predicate at all still renders" — without it, every
Expand All @@ -67,7 +72,7 @@
*/

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { buildSections, FormPage, isFieldVisible } from './FormPage';
Expand Down Expand Up @@ -247,28 +252,18 @@ describe('objectui#5594 — FormPage evaluates conditional field visibility', ()
expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument();
});

it('honours the deprecated ADR-0089 alias `visibleOn`', async () => {
// The spec normaliser rewrites the alias, so a spec-served view never
// carries it — but hand-written layouts still do, which is why both
// sibling readers keep reading it.
// objectui#12654: spec 18 retires the form field's `visibleOn` spelling
// (objectstack#23543 F4); the spec's parse refuses it by name and a stored
// row is rehydrated to `visibleWhen` before it is served. A raw view still
// carrying it gates nothing here — the narrowing, pinned beside the same
// predicate under the one key that is read.
it('does not read the retired `visibleOn` — the same predicate under `visibleWhen` does hide the field', async () => {
renderInternal(['priority', { field: 'notes', visibleOn: "record.priority == 'urgent'" }]);
await awaitForm();
expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument();
});
expect(screen.getByLabelText('Notes')).toBeInTheDocument();
cleanup();

it('lets the canonical `visibleWhen` win when both spellings are authored', async () => {
// `visibleWhen ?? visibleOn` — the same precedence as
// `@object-ui/plugin-form` `sectionFields.ts` and app-shell's
// `readVisibility`. The two predicates disagree on purpose: only the
// canonical one's verdict can produce this outcome.
renderInternal([
'priority',
{
field: 'notes',
visibleWhen: "record.priority == 'urgent'", // false -> hidden
visibleOn: "record.priority == 'low'", // true -> would show
},
]);
renderInternal(['priority', { field: 'notes', visibleWhen: "record.priority == 'urgent'" }]);
await awaitForm();
expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -362,15 +357,15 @@ describe('objectui#5594 — FormPage evaluates conditional field visibility', ()
});

describe('objectui#5594 — the predicate reaches the row and the verdict', () => {
it('buildSections carries the predicate onto the renderable row, canonical first', () => {
it('buildSections carries the predicate onto the renderable row, from `visibleWhen` only', () => {
const [section] = buildSections(
{
sections: [
{
fields: [
{ field: 'a', visibleWhen: "record.x == 1" },
{ field: 'b', visibleOn: { dialect: 'cel', source: 'record.x == 2' } },
{ field: 'c', visibleWhen: "record.x == 3", visibleOn: "record.x == 4" },
{ field: 'b', visibleWhen: { dialect: 'cel', source: 'record.x == 2' } },
{ field: 'c', visibleOn: "record.x == 4" },
{ field: 'd' },
'e',
],
Expand All @@ -380,10 +375,11 @@ describe('objectui#5594 — the predicate reaches the row and the verdict', () =
null,
);

// `c`: the retired spelling reaches no row (objectui#12654).
expect(section.fields.map((f) => f.visibleWhen)).toEqual([
"record.x == 1",
{ dialect: 'cel', source: 'record.x == 2' },
"record.x == 3",
undefined,
undefined,
undefined,
]);
Expand Down
5 changes: 3 additions & 2 deletions content/docs/guide/metadata-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,9 @@ a blank predicate at parse, as their protocol counterparts do. `visible`,
and is only diagnosed.

The same is now true of a **component node's own gate** — `visibleWhen` on a
page component (and its `visible` / `visibleOn` / `visibility` / `hidden` /
`hiddenOn` siblings), plus a `page:tabs` item's `visibleWhen`. These used to
page component (and its `visible` / `visibleOn` / `hidden` / `hiddenOn`
siblings; the page component's old `visibility` spelling is retired and not
read), plus a `page:tabs` item's `visibleWhen`. These used to
report in a development build only, so a gate that stopped biting in
production left nothing on the console at all. They now warn in **both**
builds, with the node type, the node id, the gate key, the predicate source
Expand Down
Loading
Loading