diff --git a/.changeset/12696-designer-visiblewhen.md b/.changeset/12696-designer-visiblewhen.md new file mode 100644 index 0000000000..92674d4083 --- /dev/null +++ b/.changeset/12696-designer-visiblewhen.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': minor +--- + +The metadata-admin designer reads and writes the form-view visibility predicate as `visibleWhen` only (objectui#12696). **BREAKING** for a host that still spells the retired `visibleOn` on a form layout it hands to `SchemaForm`. + +`@objectstack/spec` 18 retires the form view's `visibleOn` (ADR-0089), and the console form page and `@object-ui/plugin-form` already stopped reading it (objectui#12654). The designer was the last reader and writer: + +- **`SchemaForm` no longer folds `visibleOn` into `visibleWhen`.** A FormView section, a section field, a record-editor sub-field or a raw JSONSchema property that carries only `visibleOn` is now shown unconditionally, as it is in the runtime form. `visibleWhen` hides and shows exactly as before. +- **`FormFieldSpec` and `FormSectionSpec` drop their `visibleOn` member.** Both types are exported from the `@object-ui/app-shell` entry. A typed object literal that sets `visibleOn` on either one no longer compiles. +- **The view create form writes `visibleWhen`.** Its `kind` and `formType` layout pickers still show only for their own `viewKind`. +- **A property named `visibleOn` no longer gets the condition builder by name.** No form the bundled spec ships declares one. A `*When` property still does. + +Clause-②: no (narrowing). One member is removed from each of two exported types, and `SchemaForm` honours one fewer input key. Nothing is added and no accepted input widens. + +The fix for an affected host is one rename: write `visibleWhen` where it wrote `visibleOn`. The predicate value keeps the same shape. diff --git a/apps/console/src/components/FormPage.conditionalRules.test.tsx b/apps/console/src/components/FormPage.conditionalRules.test.tsx index 0ab6673b2c..ea0110de25 100644 --- a/apps/console/src/components/FormPage.conditionalRules.test.tsx +++ b/apps/console/src/components/FormPage.conditionalRules.test.tsx @@ -564,6 +564,7 @@ describe('objectui#5627 — the keys reach the rows, in their own slots', () => sections: [ { label: 'a', visibleWhen: 'record.x == 1', fields: ['f'] }, { label: 'b', visibleWhen: { dialect: 'cel', source: 'record.x == 2' }, fields: ['f'] }, + // @ts-expect-error objectui#12696 — `FormSectionSpec` refuses the retired key; this raw section carries it on purpose { label: 'c', visibleOn: 'record.x == 4', fields: ['f'] }, { label: 'd', fields: ['f'] }, ], diff --git a/apps/console/src/components/FormPage.fieldSpec.test.ts b/apps/console/src/components/FormPage.fieldSpec.test.ts index c41dc4f103..779f226f1f 100644 --- a/apps/console/src/components/FormPage.fieldSpec.test.ts +++ b/apps/console/src/components/FormPage.fieldSpec.test.ts @@ -108,7 +108,8 @@ export function formFieldSpecContractPins(): void { // ── PIN C — the measurement, made executable. Every key below was // `TS2353: … does not exist in type 'FormFieldSpec'` in this app before // #5542, while the runtime accepted it and metadata-admin authored it. They - // are the difference the key-by-key comparison found. + // are the difference the key-by-key comparison found. (`visibleOn` was one of + // them until objectui#12696 dropped that retired spelling from the type.) const wasUndeclarableHere: ConsoleFieldSpec = { field: 'stage', type: 'select', @@ -126,7 +127,6 @@ export function formFieldSpecContractPins(): void { disclosure: 'popover', language: 'sql', visibleWhen: '${data.kind == "deal"}', - visibleOn: { dialect: 'cel', source: 'data.kind == "deal"' }, fields: ['nested', { field: 'deeper' }], }; void wasUndeclarableHere; diff --git a/apps/console/src/components/FormPage.viewSpec.test.ts b/apps/console/src/components/FormPage.viewSpec.test.ts index aa1b2eab94..788ed20f7d 100644 --- a/apps/console/src/components/FormPage.viewSpec.test.ts +++ b/apps/console/src/components/FormPage.viewSpec.test.ts @@ -99,13 +99,13 @@ export function formContainerContractPins(): void { // ── PIN D — the measurement, made executable. Every key below was // `TS2353: … does not exist in type 'FormSectionSpec'` in this app before // #5596, while `@objectstack/spec`'s `FormSectionSchema` accepted all of them - // and app-shell's copy declared three of them. + // and app-shell's copy declared three of them. (`visibleOn` was one of them + // until objectui#12696 dropped that retired spelling from the type.) const wasUndeclarableHere: ConsoleSectionSpec = { name: 'advanced_options', description: 'Rarely-used options', pane: 'secondary', visibleWhen: '${record.kind == "deal"}', - visibleOn: { dialect: 'cel', source: 'record.kind == "deal"' }, columns: '3', fields: ['name'], }; diff --git a/apps/console/src/components/FormPage.visibleWhen.test.tsx b/apps/console/src/components/FormPage.visibleWhen.test.tsx index 84f8da75e5..782bb4926e 100644 --- a/apps/console/src/components/FormPage.visibleWhen.test.tsx +++ b/apps/console/src/components/FormPage.visibleWhen.test.tsx @@ -365,6 +365,7 @@ describe('objectui#5594 — the predicate reaches the row and the verdict', () = fields: [ { field: 'a', visibleWhen: "record.x == 1" }, { field: 'b', visibleWhen: { dialect: 'cel', source: 'record.x == 2' } }, + // @ts-expect-error objectui#12696 — `FormFieldSpec` refuses the retired key; this raw field carries it on purpose { field: 'c', visibleOn: "record.x == 4" }, { field: 'd' }, 'e', diff --git a/packages/app-shell/src/views/metadata-admin/ConditionWidget.conditionScope.test.tsx b/packages/app-shell/src/views/metadata-admin/ConditionWidget.conditionScope.test.tsx index e1c8d433e9..423417115d 100644 --- a/packages/app-shell/src/views/metadata-admin/ConditionWidget.conditionScope.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ConditionWidget.conditionScope.test.tsx @@ -8,8 +8,8 @@ * ## The defect these cases reproduce * * `SchemaForm` routes a field to `ConditionWidget` by NAME CONVENTION - * (`visible` / `hidden` / `disabled` / `visibleOn` / `condition` / `predicate` / - * `*When`), so ONE widget serves every metadata type. It claimed no scope, so + * (`visible` / `hidden` / `disabled` / `condition` / `predicate` / `*When`), + * so ONE widget serves every metadata type. It claimed no scope, so * every one of those fields fell through to `celAuthoring`'s own * `hint.scope ?? 'flattened'` default and a bare `status == 'done'` linted * CLEAN — including on the action and hook tiers, where the runtime binds the diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.collapsible.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.collapsible.test.tsx index 48256c63e3..58167c7201 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.collapsible.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.collapsible.test.tsx @@ -52,14 +52,14 @@ describe('SchemaForm — collapsible sections', () => { expect(screen.getByText('Open Field')).toBeInTheDocument(); }); - it('hides a section whose visibleOn predicate is false (object/CEL form)', () => { + it('hides a section whose visibleWhen predicate is false (object/CEL form)', () => { const condForm = { type: 'simple' as const, sections: [ { label: 'Basics', fields: [{ field: 'openField' }] }, { label: 'Layout', - visibleOn: { dialect: 'cel', source: "data.type != 'list'" }, + visibleWhen: { dialect: 'cel', source: "data.type != 'list'" }, fields: [{ field: 'advancedField' }], }, ], diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.flatVisibility.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.flatVisibility.test.tsx index de7e059a30..5f957ac0ed 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.flatVisibility.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.flatVisibility.test.tsx @@ -8,8 +8,9 @@ afterEach(cleanup); /** * Flat (non-sectioned) schema-driven rendering must honor per-property - * `visibleOn`, so a create form driven by a bare JSONSchema (no FormView - * layout) can gate a field on a sibling value. This is what lets the View + * `visibleWhen` (the retired `visibleOn` spelling is not read, objectui#12696), + * so a create form driven by a bare JSONSchema (no FormView layout) can gate a + * field on a sibling value. This is what lets the View * create form show the list layout picker only for `viewKind: 'list'` and the * form layout picker only for `viewKind: 'form'` (objectui#2323). */ @@ -23,18 +24,18 @@ const schema = { type: 'string', title: 'List layout', enum: ['grid', 'kanban'], - visibleOn: "data.viewKind == 'list'", + visibleWhen: "data.viewKind == 'list'", }, formType: { type: 'string', title: 'Form layout', enum: ['simple', 'tabbed'], - visibleOn: "data.viewKind == 'form'", + visibleWhen: "data.viewKind == 'form'", }, }, }; -describe('SchemaForm flat path — per-property visibleOn (objectui#2323)', () => { +describe('SchemaForm flat path — per-property visibleWhen (objectui#2323)', () => { it('shows the list layout picker and hides the form one for a list draft', () => { render( {}} />); expect(screen.getByText('View family')).toBeInTheDocument(); @@ -49,7 +50,7 @@ describe('SchemaForm flat path — per-property visibleOn (objectui#2323)', () = expect(screen.queryByText('List layout')).not.toBeInTheDocument(); }); - it('fails open — a field with no visibleOn always renders', () => { + it('fails open — a field with no visibleWhen always renders', () => { render( {}} />); // `viewKind` has no predicate, so it shows even before a family is picked. expect(screen.getByText('View family')).toBeInTheDocument(); diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.pageVisibility.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.pageVisibility.test.tsx index 944a84e4ce..2829454ec6 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.pageVisibility.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.pageVisibility.test.tsx @@ -10,7 +10,8 @@ afterEach(cleanup); * ADR-0047 follow-up — page inspector section visibility for `list` pages. * * Framework PR #1817 marks the page form's "Data Context" (object/variables) - * and "Layout" (regions) sections with `visibleOn: "data.type != 'list'"`, + * and "Layout" (regions) sections with `visibleOn: "data.type != 'list'"` (now + * `visibleWhen`, see the note below), * and the "Interface (list pages)" section with `data.type == 'list'`. For a * `type: 'list'` page the data-view sections must hide and the Interface * section must show. @@ -21,13 +22,11 @@ afterEach(cleanup); * report's "still shows Data Context + Layout" symptom turned out to be a * stale backend serving the pre-#1817 form.) * - * NOTE (objectstack#6331): the `visibleOn` spelling below is the DEPRECATED - * ADR-0089 alias, so this file pins the legacy limb, not the shape a current - * backend serves — the spec normaliser rewrites the alias and ships - * `visibleWhen`. Because these fixtures spell the alias they stayed green while - * the reader was alias-only and every spec-served predicate was dead. The - * canonical key (and the bundled `pageForm` itself) is pinned in - * `SchemaForm.visibleWhen.test.tsx`. + * NOTE (objectstack#6331, objectui#12696): PR #1817 spelled these predicates + * `visibleOn`, the ADR-0089 alias that `@objectstack/spec` 18 retires and that + * `SchemaForm` no longer reads. The fixtures below spell the canonical + * `visibleWhen`, the shape a current backend serves. The bundled `pageForm` + * itself is pinned in `SchemaForm.visibleWhen.test.tsx`. */ // Minimal JSONSchema mirroring the page item fields the form references. @@ -50,12 +49,12 @@ const pageForm = { { label: 'Basics', fields: [{ field: 'name' }, { field: 'type' }] }, { label: 'Data Context', - visibleOn: "data.type != 'list'", + visibleWhen: "data.type != 'list'", fields: [{ field: 'object' }, { field: 'variables' }], }, { label: 'Layout', - visibleOn: "data.type != 'list'", + visibleWhen: "data.type != 'list'", fields: [{ field: 'regions' }], }, { @@ -64,7 +63,7 @@ const pageForm = { // exercise the same predicate path the backend serves. collapsible: true, collapsed: false, - visibleOn: { dialect: 'cel', source: "data.type == 'list'" }, + visibleWhen: { dialect: 'cel', source: "data.type == 'list'" }, fields: [{ field: 'interfaceConfig' }], }, ], diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 2dfa3d3f0d..ba3f695fb4 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -953,10 +953,10 @@ export function faceLabelling(face: FieldFace): WidgetLabelling { } } -const CONDITION_FIELD_NAMES = new Set(['visible', 'hidden', 'disabled', 'visibleOn', 'condition', 'predicate']); +const CONDITION_FIELD_NAMES = new Set(['visible', 'hidden', 'disabled', 'condition', 'predicate']); /** * Detect a CEL predicate field by NAME CONVENTION (`visible` / `hidden` / - * `disabled` / `visibleOn` / `condition` / `*When`) so it renders the no-code + * `disabled` / `condition` / `*When`) so it renders the no-code * condition builder instead of a raw expression text box. No enum, and the * schema must admit a string — {@link admitsString} holds that veto and * documents why it looks through nested unions, why an empty schema is not @@ -1052,28 +1052,27 @@ function resolveFieldWidget({ } /** - * Read a visibility predicate off a spec node — **canonical key first**. - * - * ADR-0089 renamed the FormView predicate `visibleOn` → `visibleWhen`, and the - * spec's normaliser REWRITES the alias instead of keeping both: a parsed - * `FormView` carries `visibleWhen` and no `visibleOn` at all - * (`@objectstack/spec` `shared/visibility.ts`). Reading only `visibleOn` — what - * this admin engine did until `7a197e7c5` — therefore found `undefined` on - * every spec-served form and short-circuited each predicate to "always - * visible", so conditional fields/sections rendered unconditionally. - * - * Spelling and precedence mirror the runtime record-form adapter - * (`@object-ui/plugin-form` `sectionFields.ts`: `fd.visibleWhen ?? fd.visibleOn`) - * so metadata-admin and the runtime form speak ONE dialect. The deprecated - * alias stays honoured because it still has live producers: hand-written - * layouts, and this app's own create schemas, which set `visibleOn` directly on - * raw JSONSchema properties (`view-create-body.ts`) — those never pass through - * the spec normaliser. + * Read a visibility predicate off a spec node: `visibleWhen`, and only that. + * + * ADR-0089 renamed the FormView predicate `visibleOn` → `visibleWhen`, and + * `@objectstack/spec` 18 retires the old spelling. A parsed `FormView` carries + * `visibleWhen` and no `visibleOn` at all (`@objectstack/spec` + * `shared/visibility.ts`). Reading only `visibleOn`, which this admin engine did + * until `7a197e7c5`, found `undefined` on every spec-served form and + * short-circuited each predicate to "always visible". + * + * The retired key is NOT read as a fallback (objectui#12696). Its last in-app + * producer, the view anchor's create schema (`anchors.ts`), writes `visibleWhen` + * now, and every authoring form the bundled spec ships carries `visibleWhen` + * only. A layout that still spells `visibleOn` is shown unconditionally here, + * exactly as the runtime form (`@object-ui/plugin-form` `sectionFields.ts`) and + * the console form page treat it, so the designer preview cannot pass a + * predicate that nothing else honours. Rename the key to `visibleWhen`. */ function readVisibility( - node: { visibleWhen?: VisibilityPredicate; visibleOn?: VisibilityPredicate } | undefined | null, + node: { visibleWhen?: VisibilityPredicate } | undefined | null, ): VisibilityPredicate | undefined { - return node?.visibleWhen ?? node?.visibleOn; + return node?.visibleWhen; } export interface SchemaFormIssue { @@ -1099,7 +1098,7 @@ export interface SchemaFormProps { */ idPath?: string; /** - * Optional FormView layout (sections, tabs, widget hints, visibleOn) + * Optional FormView layout (sections, tabs, widget hints, visibleWhen) * shipped by the framework alongside `schema`. When present, fields * are grouped into sections and visibility predicates are honoured. */ diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.visibleWhen.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.visibleWhen.test.tsx index 99a12f4195..98dcbbf93d 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.visibleWhen.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.visibleWhen.test.tsx @@ -26,11 +26,10 @@ afterEach(cleanup); * to "visible" — conditional fields and sections rendered unconditionally. * * These tests pin each of the five sites on the CANONICAL key, in both - * directions (true → shown, false → hidden), plus the deprecated alias (still - * honoured for hand-written layouts and this app's own create schemas) and the - * no-predicate default. Precedence mirrors `@object-ui/plugin-form` - * `sectionFields.ts` (`fd.visibleWhen ?? fd.visibleOn`) — one dialect, canonical - * wins. + * directions (true → shown, false → hidden), plus the no-predicate default. + * The retired `visibleOn` spelling is not honoured at all (objectui#12696): a + * node carrying only `visibleOn` renders unconditionally, as it does in the + * runtime form (`@object-ui/plugin-form` `sectionFields.ts`) — one dialect. */ const schema = { @@ -241,30 +240,54 @@ describe('SchemaForm record rows — sub-field visibleWhen (objectstack#6331)', }); }); -/* ── the deprecated alias, and precedence when both are authored ─────────── */ +/* ── the retired `visibleOn` spelling is not honoured (objectui#12696) ────── */ -describe('SchemaForm — deprecated `visibleOn` alias (objectstack#6331)', () => { - it('a legacy hand-written layout spelling only `visibleOn` still works', () => { - const legacyForm = { +/** + * `readVisibility` used to fall back to `visibleOn` when `visibleWhen` was + * absent. That fold let a layout an AI wrote with the retired key preview + * correctly here while every other reader ignores it. Each case below carries a + * `visibleOn` that would HIDE something if it were read, and asserts that the + * thing is still shown; the paired `visibleWhen` case proves the same predicate + * does hide it under the canonical key, so a green run is not a dead read site. + */ +describe('SchemaForm — retired `visibleOn` is not read (objectui#12696)', () => { + it('a FormView section and field carrying only `visibleOn` are not hidden by it', () => { + const retiredForm = { type: 'simple' as const, sections: [ { label: 'Basics', fields: [{ field: 'type' }] }, - { label: 'Legacy', visibleOn: "data.type == 'list'", fields: [{ field: 'listOnly' }] }, + { label: 'Retired', visibleOn: "data.type == 'record'", fields: [{ field: 'listOnly' }] }, { - label: 'LegacyField', + label: 'RetiredField', fields: [{ field: 'recordOnly', visibleOn: "data.type == 'record'" }], }, ], }; - render( {}} />); - expect(screen.getByText('Legacy')).toBeInTheDocument(); + render( {}} />); + expect(screen.getByText('Retired')).toBeInTheDocument(); expect(screen.getByText('List Only')).toBeInTheDocument(); + expect(screen.getByText('Record Only')).toBeInTheDocument(); + }); + + it('the same predicates under `visibleWhen` do hide that section and field', () => { + const canonicalForm = { + type: 'simple' as const, + sections: [ + { label: 'Basics', fields: [{ field: 'type' }] }, + { label: 'Canonical', visibleWhen: "data.type == 'record'", fields: [{ field: 'listOnly' }] }, + { + label: 'CanonicalField', + fields: [{ field: 'recordOnly', visibleWhen: "data.type == 'record'" }], + }, + ], + }; + render( {}} />); + expect(screen.queryByText('Canonical')).not.toBeInTheDocument(); + expect(screen.queryByText('List Only')).not.toBeInTheDocument(); expect(screen.queryByText('Record Only')).not.toBeInTheDocument(); }); - it('a legacy create schema spelling `visibleOn` on a raw JSONSchema property still works', () => { - // `view-create-body.ts` / `anchors.ts` author this shape directly; it never - // passes through the spec normaliser, so the alias is the live spelling. + it('a raw JSONSchema create property carrying only `visibleOn` is not hidden by it', () => { const createSchema = { type: 'object', properties: { @@ -272,24 +295,17 @@ describe('SchemaForm — deprecated `visibleOn` alias (objectstack#6331)', () => kind: { type: 'string', title: 'List layout', visibleOn: "data.viewKind == 'list'" }, }, }; - render( {}} />); - expect(screen.getByText('List layout')).toBeInTheDocument(); - cleanup(); render( {}} />); - expect(screen.queryByText('List layout')).not.toBeInTheDocument(); + expect(screen.getByText('List layout')).toBeInTheDocument(); }); - }); /* ── precedence: canonical over alias ────────────────────────────────────── */ /** - * Deliberately its own block, not part of the alias suite above: reverse - * verification showed this test tracks the CANONICAL limb, not the alias one. - * Reverting the section read site to `visibleOn`-only turns it red along with - * the section tests, because the stale alias then decides the verdict. The - * alias tests above, by contrast, stay green under that revert — which is - * exactly why alias-spelled fixtures could never have caught this bug. + * A node carrying both keys: `visibleWhen` decides. Reverting a read site to + * `visibleOn` (alone, or as the first leg of a fold) turns this red, because + * the stale key then decides the verdict. */ describe('SchemaForm — visibleWhen wins over visibleOn (objectstack#6331)', () => { it('canonical wins when a node carries both keys with conflicting predicates', () => { @@ -298,8 +314,8 @@ describe('SchemaForm — visibleWhen wins over visibleOn (objectstack#6331)', () sections: [ { label: 'Both', - // Canonical says show for a list draft; the stale alias says the - // opposite. `visibleWhen ?? visibleOn` must follow the canonical key. + // Canonical says show for a list draft; the retired key says the + // opposite. Only `visibleWhen` is read. visibleWhen: "data.type == 'list'", visibleOn: "data.type == 'record'", fields: [{ field: 'listOnly' }], @@ -330,7 +346,7 @@ describe('SchemaForm — bundled spec `pageForm` predicates are reachable (objec const dataContext = form?.sections?.find((s) => s.label === 'Data Context'); expect(dataContext).toBeDefined(); expect(dataContext?.visibleWhen).toBeDefined(); - expect(dataContext?.visibleOn).toBeUndefined(); + expect(dataContext).not.toHaveProperty('visibleOn'); }); it('a `list` page hides the Data Context section; a record page shows it', () => { diff --git a/packages/app-shell/src/views/metadata-admin/anchors.ts b/packages/app-shell/src/views/metadata-admin/anchors.ts index 0c86f8d9a1..9b19c15497 100644 --- a/packages/app-shell/src/views/metadata-admin/anchors.ts +++ b/packages/app-shell/src/views/metadata-admin/anchors.ts @@ -279,7 +279,7 @@ export function registerBuiltinAnchors(): void { enum: ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'chart'], default: 'grid', description: 'Pick a starter layout. Switch later in the designer.', - visibleOn: "data.viewKind == 'list'", + visibleWhen: "data.viewKind == 'list'", }, // Form-family layout (FormViewSchema.type). Shown only when `viewKind` // is 'form'; its value becomes the form `config.type`. @@ -289,7 +289,7 @@ export function registerBuiltinAnchors(): void { enum: ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'], default: 'simple', description: 'Pick a starter form layout. Switch later in the designer.', - visibleOn: "data.viewKind == 'form'", + visibleWhen: "data.viewKind == 'form'", }, }, }, diff --git a/packages/app-shell/src/views/metadata-admin/conditionScope.ts b/packages/app-shell/src/views/metadata-admin/conditionScope.ts index a7bd4d7c3f..797d0cc64d 100644 --- a/packages/app-shell/src/views/metadata-admin/conditionScope.ts +++ b/packages/app-shell/src/views/metadata-admin/conditionScope.ts @@ -12,8 +12,8 @@ import { * ## The defect * * `SchemaForm` routes a field to the CEL condition builder by NAME CONVENTION - * (`visible` / `hidden` / `disabled` / `visibleOn` / `condition` / `predicate` - * / `*When`), so one widget serves every metadata type. It claimed no scope, so + * (`visible` / `hidden` / `disabled` / `condition` / `predicate` / `*When`), + * so one widget serves every metadata type. It claimed no scope, so * every such field fell through to `celAuthoring`'s own `hint.scope ?? * 'flattened'` default — and a bare `status == 'done'` typed into an action's * **Visible when** linted CLEAN while the runtime binds the row as the `record` diff --git a/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx b/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx index b0ef7c0790..a13308fa39 100644 --- a/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx @@ -60,7 +60,10 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? ? true : false; -/** The positions {@link FormSectionSpec} deliberately overrides. */ +/** + * The positions {@link FormSectionSpec} deliberately overrides. `visibleOn` is + * overridden by ABSENCE (objectui#12696): PIN J pins that it is refused. + */ type SectionNarrowed = 'fields' | 'label' | 'description' | 'visibleWhen' | 'visibleOn'; /** The positions {@link FormViewSpec} deliberately overrides. */ type ViewNarrowed = 'sections' | 'groups'; @@ -146,7 +149,6 @@ export function formContainerContractPins(): void { columns: '3', pane: 'secondary', visibleWhen: '${data.kind == "deal"}', - visibleOn: { dialect: 'cel', source: 'data.kind == "deal"' }, fields: ['name', { field: 'amount', colSpan: 2 }], }; void wasUndeclarableOnOneSide; @@ -209,6 +211,26 @@ export function formContainerContractPins(): void { }; void narrowedPredicate; + // ── PIN J — the retired `visibleOn` spelling is REFUSED on both layers + // (objectui#12696). `SchemaForm` reads `visibleWhen` only, so declaring the + // old key would let an author write a predicate the designer ignores. The + // spec's input `FormSection` still carries it, which is why it stays in the + // section's `Omit` list: dropping it there re-admits the spec's own type and + // turns the first directive below into TS2578. + const retiredSectionKey: FormSectionSpec = { + // @ts-expect-error objectui#12696 — write `visibleWhen`; `visibleOn` is retired + visibleOn: "data.kind == 'deal'", + fields: [], + }; + void retiredSectionKey; + + const retiredFieldKey: FormFieldSpec = { + field: 'amount', + // @ts-expect-error objectui#12696 — write `visibleWhen`; `visibleOn` is retired + visibleOn: "data.kind == 'deal'", + }; + void retiredFieldKey; + // ── PIN H — `label` is NOT a form-config key, measured. `FormViewSchema` // answers `unrecognized_keys` for it (the form config says `title`), so the // console's removed copy was describing view IDENTITY as form configuration. diff --git a/packages/app-shell/src/views/metadata-admin/form-spec.ts b/packages/app-shell/src/views/metadata-admin/form-spec.ts index 5f05696d11..1aa563a9c3 100644 --- a/packages/app-shell/src/views/metadata-admin/form-spec.ts +++ b/packages/app-shell/src/views/metadata-admin/form-spec.ts @@ -46,9 +46,10 @@ * `z.enum(['1','2','3','4'])` with the four numeric literals, folded to a number * by its own transform), so app-shell's numbers-only declaration was rejecting * metadata the platform accepts -- objectui#5040's symptom, not a deliberate - * narrowing. `description`, `visibleWhen`, `visibleOn`, and also `name` and - * `pane`, are likewise spec keys that simply went undeclared on one side or the - * other. + * narrowing. `description`, `visibleWhen`, and also `name` and `pane`, are + * likewise spec keys that simply went undeclared on one side or the other. + * (`visibleOn` was a third; it is the retired spelling of `visibleWhen` and is + * dropped from both types, see below.) * * ## Why DERIVED-WITH-NAMED-NARROWINGS and not a bare re-export * @@ -63,11 +64,16 @@ * straight into a text slot and neither resolves a locale map. A section * admitting `{ en: 'x' }` above a field refusing it is the same defect class * this file exists to close. - * - `FormSection.visibleWhen` / `.visibleOn` are the spec's `ExpressionInput` - * (`dialect` REQUIRED and enum-typed, `source` optional, plus `ast`/`meta`). - * The predicate that actually reaches an evaluator here is + * - `FormSection.visibleWhen` is the spec's `ExpressionInput` (`dialect` + * REQUIRED and enum-typed, `source` optional, plus `ast`/`meta`). The + * predicate that actually reaches an evaluator here is * {@link VisibilityPredicate} -- `dialect` optional, `source` required -- * which is the shape `@object-ui/core`'s `evalFieldPredicate` takes. + * - `FormSection.visibleOn` is DROPPED, not re-pointed (objectui#12696). It is + * the ADR-0089 spelling of `visibleWhen` that `@objectstack/spec` 18 + * retires, and `SchemaForm` no longer reads it, so declaring it would + * advertise a key this renderer does not honour. The field leaf drops it + * for the same reason. * - `FormSection.fields` elements are the spec's `FormField` (29 keys), not * the 26-key {@link FormFieldSpec} objectui#5542 converged and pinned. * Re-pointing that position is what would silently re-open #5542. @@ -128,7 +134,7 @@ export interface FormFieldSpec { * that actually reaches an evaluator here is {@link VisibilityPredicate} * (`dialect` optional, `source` required) — the same re-pointing, for the * same reason, that `FormSectionSpec` already makes for its own - * `visibleWhen` / `visibleOn`. One vocabulary per document. + * `visibleWhen`. One vocabulary per document. * - `default` stays DROPPED, and now says so. Nothing on this surface reads * it: none of the three controls that consume `options` seeds a value * from it, so declaring it here would advertise an authoring key this @@ -213,10 +219,11 @@ export interface FormFieldSpec { disclosure?: 'inline' | 'popover'; /** For `type: 'code'` — syntax highlighting language (e.g. 'javascript', 'sql', 'json'). */ language?: string; - /** Canonical field visibility predicate (ADR-0089). */ + /** + * Field visibility predicate (ADR-0089). The retired `visibleOn` spelling is + * not declared and not read (objectui#12696): write `visibleWhen`. + */ visibleWhen?: VisibilityPredicate; - /** @deprecated ADR-0089 alias of `visibleWhen`; still read for legacy layouts. */ - visibleOn?: VisibilityPredicate; /** Sub-fields for `composite` (single embedded object) and `repeater` * (array of embedded objects) types. Recursive. */ fields?: Array; @@ -228,8 +235,8 @@ export interface FormFieldSpec { * Derived from `@objectstack/spec`'s own `FormSection`, so `name`, `collapsible`, * `collapsed`, `columns` and `pane` are the contract's keys with the contract's * types -- including the `columns` string arm, which app-shell's hand copy used - * to refuse. Four positions are narrowed, each for a reason recorded in the file - * header: + * to refuse. Four positions are narrowed and one is dropped, each for a reason + * recorded in the file header: * * fields the element type is {@link FormFieldSpec}, the leaf * objectui#5542 converged and pinned -- NOT the spec's 29-key @@ -239,7 +246,10 @@ export interface FormFieldSpec { * locale-map arm, and `FormFieldSpec.label` is already `string`. * description same, for the same reason. * visibleWhen {@link VisibilityPredicate}, the shape the evaluators take, not - * visibleOn the spec's `ExpressionInput`. + * the spec's `ExpressionInput`. + * visibleOn DROPPED: the retired spelling of `visibleWhen`, which nothing + * here reads (objectui#12696). It stays in the `Omit` list so the + * spec's input type cannot bring it back. * * This type describes what an AUTHOR WROTE, so it stays as wide as the document * -- `pane` is declared here even though no renderer in this repo reads it yet. @@ -265,10 +275,11 @@ export type FormSectionSpec = label?: string; /** Optional description under the section header. Narrowed to `string`. */ description?: string; - /** Canonical section visibility predicate (ADR-0089). */ + /** + * Section visibility predicate (ADR-0089). The retired `visibleOn` + * spelling is not declared and not read (objectui#12696). + */ visibleWhen?: VisibilityPredicate; - /** @deprecated ADR-0089 alias of `visibleWhen`; still read for legacy layouts. */ - visibleOn?: VisibilityPredicate; /** * The authored field list. Element type is the converged leaf (objectui#5542). * @@ -303,8 +314,8 @@ export type FormSectionSpec = * * `groups` is kept even though `@objectstack/spec` folds it onto `sections` at * parse: the fold happens in the NORMALISER, and both renderers also receive - * documents that never pass through one (hand-written layouts, and this package's - * own create schemas -- the same reason `visibleOn` is still read). + * documents that never pass through one (hand-written layouts, and this + * package's own create schemas). * * ## `label` is NOT a key of this type, and that is a measurement * diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageDefaultInspector.tsx index 04a1d15091..44968468c4 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageDefaultInspector.tsx @@ -15,7 +15,7 @@ * * SPEC-DRIVEN (mirrors {@link ReportDefaultInspector}): fields come from the * spec's canonical `pageForm` + Page JSONSchema fed into {@link SchemaForm}; - * the form's type-conditional `visibleOn` sections surface the right fields per + * the form's type-conditional `visibleWhen` sections surface the right fields per * page type, and server-only fields are grafted via {@link mergeServerFields} * so a newer server stays editable even when the bundled spec lags. */ diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx index a31835f8c7..106a89971f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx @@ -9,7 +9,7 @@ * SPEC-DRIVEN: the per-report-type config fields are NOT hardcoded. They are * rendered by feeding the spec's canonical authoring form (`reportForm`) and * the spec-derived Report JSONSchema into the generic {@link SchemaForm}. The - * form's type-conditional `visibleOn` section (joined blocks) automatically + * form's type-conditional `visibleWhen` section (joined blocks) automatically * surfaces the right fields — adding a new report type or prop to * `@objectstack/spec` flows through with zero code changes here. * diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx index b3df49370b..183ca3fabe 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx @@ -11,7 +11,7 @@ * SPEC-DRIVEN: the per-view-type config fields are NOT hardcoded. They are * rendered by feeding the spec's canonical authoring form (`viewForm`) and * the spec-derived View JSONSchema into the generic {@link SchemaForm}. The - * form's type-conditional `visibleOn` sections automatically surface the + * form's type-conditional `visibleWhen` sections automatically surface the * right fields for grid / kanban / calendar / gallery / gantt / timeline / * chart — adding a new view type or prop to `@objectstack/spec` flows * through with zero code changes here. diff --git a/packages/app-shell/src/views/metadata-admin/mergeServerFields.ts b/packages/app-shell/src/views/metadata-admin/mergeServerFields.ts index 027964613c..720139ce8f 100644 --- a/packages/app-shell/src/views/metadata-admin/mergeServerFields.ts +++ b/packages/app-shell/src/views/metadata-admin/mergeServerFields.ts @@ -20,7 +20,7 @@ * * It is purely ADDITIVE and lag-tolerant: * - Only fields the server has and the bundle lacks are added; existing - * bundled fields, ordering, sections and `visibleOn` predicates are + * bundled fields, ordering, sections and `visibleWhen` predicates are * untouched. * - When the bundle catches up (the field appears in `bundledSchema`), the * merge becomes a no-op for that field automatically — no shim to remove. diff --git a/packages/app-shell/src/views/metadata-admin/page-schema.ts b/packages/app-shell/src/views/metadata-admin/page-schema.ts index 8c797061f0..89da29282d 100644 --- a/packages/app-shell/src/views/metadata-admin/page-schema.ts +++ b/packages/app-shell/src/views/metadata-admin/page-schema.ts @@ -14,7 +14,7 @@ * describes all of this: * * • `pageForm` — the canonical authoring FormView: sections with - * type-conditional `visibleOn` predicates. + * type-conditional `visibleWhen` predicates. * • `PageSchema` — the zod schema for the whole Page document. * * We convert the zod schema to JSONSchema once (memoised) via zod 4's native diff --git a/packages/app-shell/src/views/metadata-admin/predicate.test.ts b/packages/app-shell/src/views/metadata-admin/predicate.test.ts index 3fb4e99437..ad6774a052 100644 --- a/packages/app-shell/src/views/metadata-admin/predicate.test.ts +++ b/packages/app-shell/src/views/metadata-admin/predicate.test.ts @@ -260,7 +260,7 @@ describe('bundled spec `objectForm`: bare predicates no longer hide their fields return; } const n = node as Node; - const pred = n.visibleWhen ?? n.visibleOn; + const pred = n.visibleWhen; if (pred) { const source = typeof pred === 'string' ? pred : (pred as { source?: string }).source; if (source) out.push({ field: String(n.field ?? '(section)'), source }); diff --git a/packages/app-shell/src/views/metadata-admin/predicate.ts b/packages/app-shell/src/views/metadata-admin/predicate.ts index a5970c81b4..ed292bdcdb 100644 --- a/packages/app-shell/src/views/metadata-admin/predicate.ts +++ b/packages/app-shell/src/views/metadata-admin/predicate.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Tiny predicate evaluator for FormView `visibleOn` expressions. + * Tiny predicate evaluator for FormView `visibleWhen` expressions. * * This is an interim stand-in for the full CEL runtime * (`@objectstack/formula`) — we keep it intentionally small so the diff --git a/packages/app-shell/src/views/metadata-admin/report-schema.ts b/packages/app-shell/src/views/metadata-admin/report-schema.ts index 8cb25ba94d..6422915143 100644 --- a/packages/app-shell/src/views/metadata-admin/report-schema.ts +++ b/packages/app-shell/src/views/metadata-admin/report-schema.ts @@ -12,7 +12,7 @@ * • `reportForm` — the canonical authoring FormView: sections for basics, * columns, groupings, joined blocks, filter & chart, and * advanced. One section (joined blocks) is type- - * conditional via a `visibleOn` CEL predicate. + * conditional via a `visibleWhen` CEL predicate. * • `ReportSchema` — the zod schema for the whole (flat) Report document. * * We convert the zod schema to JSONSchema once (memoised) via zod 4's native diff --git a/packages/app-shell/src/views/metadata-admin/view-create-body.test.ts b/packages/app-shell/src/views/metadata-admin/view-create-body.test.ts index cc193485b8..29e41c99fa 100644 --- a/packages/app-shell/src/views/metadata-admin/view-create-body.test.ts +++ b/packages/app-shell/src/views/metadata-admin/view-create-body.test.ts @@ -144,8 +144,12 @@ describe('view create form contract exposes both families (objectui#2323)', () = expect(props.kind?.enum).toEqual(['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'chart']); expect(props.formType?.enum).toEqual(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']); // Each layout picker is gated to its family so the form shows only the - // relevant one (rendered via SchemaForm's `visibleOn` support). - expect(props.kind?.visibleOn).toBe("data.viewKind == 'list'"); - expect(props.formType?.visibleOn).toBe("data.viewKind == 'form'"); + // relevant one, through SchemaForm's per-property `visibleWhen`. The + // retired `visibleOn` spelling is not written (objectui#12696): + // `view-create-body.visibility.test.tsx` renders the gate. + expect(props.kind?.visibleWhen).toBe("data.viewKind == 'list'"); + expect(props.formType?.visibleWhen).toBe("data.viewKind == 'form'"); + expect(props.kind).not.toHaveProperty('visibleOn'); + expect(props.formType).not.toHaveProperty('visibleOn'); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/view-create-body.visibility.test.tsx b/packages/app-shell/src/views/metadata-admin/view-create-body.visibility.test.tsx new file mode 100644 index 0000000000..c2d97a57e4 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/view-create-body.visibility.test.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The view anchor's create form gates its two layout pickers on `viewKind` + * through `visibleWhen` (objectui#12696). + * + * `anchors.ts` used to write the retired `visibleOn` spelling on the create + * schema's `kind` and `formType` properties, and `SchemaForm` folded it back in + * with `visibleWhen ?? visibleOn`. Both halves now speak `visibleWhen` only, so + * this renders the REAL registered create schema (not a hand-written mirror) + * and flips `viewKind`: if the anchor regresses to `visibleOn`, both pickers + * show for every family and the hidden-picker assertions go red. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { SchemaForm } from './SchemaForm'; +import { registerBuiltinAnchors } from './anchors'; +import { getMetadataResource } from './registry'; + +registerBuiltinAnchors(); +afterEach(cleanup); + +function viewCreateSchema(): Record { + const schema = getMetadataResource('view')?.createSchema; + if (!schema) throw new Error('view resource must define createSchema'); + return schema as Record; +} + +describe('view create form — layout pickers follow viewKind via visibleWhen (objectui#12696)', () => { + it('a list draft shows the list layout picker and hides the form one', () => { + render( {}} />); + expect(screen.getByText('List layout')).toBeInTheDocument(); + expect(screen.queryByText('Form layout')).not.toBeInTheDocument(); + }); + + it('a form draft shows the form layout picker and hides the list one', () => { + render( {}} />); + expect(screen.getByText('Form layout')).toBeInTheDocument(); + expect(screen.queryByText('List layout')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/view-schema.ts b/packages/app-shell/src/views/metadata-admin/view-schema.ts index 110d923b0e..df81623624 100644 --- a/packages/app-shell/src/views/metadata-admin/view-schema.ts +++ b/packages/app-shell/src/views/metadata-admin/view-schema.ts @@ -10,7 +10,7 @@ * without hardcoding them. The spec already describes this: * * • `viewForm` — the canonical authoring FormView: sections with - * type-conditional `visibleOn` CEL predicates + * type-conditional `visibleWhen` CEL predicates * (`data.type == 'kanban'`, …). One section per view * type, plus shared basics / filters / navigation. * • `ViewSchema` — the zod schema for the whole (nested) View document.