diff --git a/.changeset/adr-0089-visibility-lint.md b/.changeset/adr-0089-visibility-lint.md new file mode 100644 index 0000000000..d2c8f41527 --- /dev/null +++ b/.changeset/adr-0089-visibility-lint.md @@ -0,0 +1,13 @@ +--- +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +ADR-0089 D3b: add the `validateVisibilityPredicates` lint rule for conditional-visibility keys, wired into `os validate` and `os compile` as advisory warnings. + +Two rules, both `warning` (never fail the build): + +- `visibility-alias-deprecated` — a `visibleOn` (view form section/field) or `visibility` (page component) key in authored source. It still works — the schema normalizes it to `visibleWhen` at parse — but the canonical key is `visibleWhen`. Fix: rename the key (same CEL value). +- `visibility-root-mislayered` — a runtime view/page visibility predicate rooted at `data.` (the metadata-editing-form root). Runtime record surfaces bind `record` + `current_user` (pages also expose `page.`), so a `data.`-rooted predicate here never matches and the element renders unconditionally. Fix: use `record.`/`page.`. + +The rule runs on the **pre-parse** stack (like `validate-list-view-mode`) so it can see the deprecated alias the author actually wrote before the schema folds it into `visibleWhen`. diff --git a/.changeset/adr-0089-visible-when.md b/.changeset/adr-0089-visible-when.md new file mode 100644 index 0000000000..bf2c39e906 --- /dev/null +++ b/.changeset/adr-0089-visible-when.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +--- + +ADR-0089: unify the conditional-visibility predicate under one canonical key, `visibleWhen`, across every layer (data field, view form section/field, page component). This aligns visibility with the existing `readonlyWhen` / `requiredWhen` family and the `conditionalRequired → requiredWhen` precedent. + +**Canonical key:** `visibleWhen` — a CEL predicate; the element is shown only when it is TRUE. The binding *root* is still set by the layer: runtime record forms and pages bind `record` + `current_user` (pages also expose `page.`); metadata-editing forms (`*.form.ts`) bind `data`. + +**Deprecated aliases (still accepted):** the view key `visibleOn` and the page key `visibility` are now `@deprecated`. Both are folded into `visibleWhen` **once, at the schema boundary** (a zod `.transform()`), so consumers only ever read `visibleWhen`. When both a canonical and an alias key are present, the canonical wins. + +Migration (L1 — no consumer action required; existing metadata keeps working): + +- View form section/field: `visibleOn: ""` → `visibleWhen: ""` +- Page component: `visibility: ""` → `visibleWhen: ""` +- Data field / field option: already `visibleWhen` — unchanged. + +Out of scope (unchanged): the boolean `visible` (Tab on/off), field `hidden`, gallery `visibleFields`, and unrelated `visibility` *enums* (feed / package / environment / agent). Aliases remain for the standard deprecation window and are removed in a future major. diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index 37e3e9bdc9..d769a84e56 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -11,7 +11,7 @@ piece of metadata needs to compute a value or evaluate a condition: - **Formula fields** (`type: 'formula'`) - **Predicates** — validation `condition`, sharing `condition`, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), - view section/column visibility (`visibleOn`), action `disabled`, + view section/column visibility (`visibleWhen`), action `disabled`, hook `condition`, flow decisions - **Dynamic seed values** — fixtures whose value depends on the install-time clock or identity context diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index ec5f73a90e..69a9066090 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -752,7 +752,7 @@ interface FormSection { collapsible?: boolean; // default false collapsed?: boolean; // default false columns?: 1 | 2 | 3 | 4; // default 1 - visibleOn?: string; // CEL visibility predicate; hides section when false + visibleWhen?: string; // CEL visibility predicate; hides section when false fields: (string | FormField)[]; } ``` @@ -772,7 +772,7 @@ interface FormField { readonly?: boolean; hidden?: boolean; widget?: string; // custom widget override - visibleOn?: string; // CEL visibility predicate + visibleWhen?: string; // CEL visibility predicate } ``` @@ -794,18 +794,30 @@ interface ResponsiveColumns { ### Visibility Rule Visibility is a single **CEL expression** string, not a structured rule object. -Page components expose it as `visibility` (`packages/spec/src/ui/page.zod.ts`); -form sections and fields expose it as `visibleOn` (`packages/spec/src/ui/view.zod.ts`). -The component is shown only when the expression evaluates truthy. +Since [ADR-0089](/docs/adr) the one canonical key is **`visibleWhen`** across every +layer — data fields, view form sections/fields, and page components — aligning with +the `readonlyWhen` / `requiredWhen` family. The element is shown only when the +expression evaluates truthy. ```typescript // e.g. on a PageComponent: -visibility: "record.account_type == 'premium'" +visibleWhen: "record.account_type == 'premium'" // e.g. on a FormSection / FormField: -visibleOn: "record.status != 'closed' && user.hasRole('admin')" +visibleWhen: "record.status != 'closed' && user.hasRole('admin')" ``` +The predicate's **binding root** is set by the layer, not the key: + +| Layer | Predicate binds | +|---|---| +| Runtime record forms & pages (`*.view.ts`, `*.page.ts`) | `record` + `current_user` (pages also expose `page.`) | +| Metadata-editing forms (`*.form.ts`) | `data` — the row under edit | + +The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated` +aliases: still accepted and folded into `visibleWhen` at parse time, so existing +metadata keeps working. Author new metadata with `visibleWhen`. + Breakpoint-based show/hide is handled separately via the component's `responsive.hiddenOn` array (e.g. `hiddenOn: ['xs', 'sm']`), see `packages/spec/src/ui/responsive.zod.ts`. diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 0e65c54c59..2f2ae43eb2 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -233,7 +233,7 @@ fields: [ required: true, // Override required colSpan: 2, // Span 2 grid columns widget: 'custom-editor', // Custom widget component - visibleOn: "status != 'cancelled'", + visibleWhen: "status != 'cancelled'", }, ] ``` @@ -250,7 +250,7 @@ fields: [ | `colSpan` | `1-4` | Column span in grid layout | | `widget` | `string` | Custom widget/component name | | `dependsOn` | `string` | Parent field for cascading | -| `visibleOn` | `string` | Visibility condition expression | +| `visibleWhen` | `string` | Visibility condition expression (was `visibleOn`, ADR-0089) | ### Default Sort for Related Lists diff --git a/docs/adr/0089-unify-visibility-predicate-naming.md b/docs/adr/0089-unify-visibility-predicate-naming.md index 9891507aba..dee2e5c2d3 100644 --- a/docs/adr/0089-unify-visibility-predicate-naming.md +++ b/docs/adr/0089-unify-visibility-predicate-naming.md @@ -1,6 +1,6 @@ # ADR-0089: Unify the conditional-visibility predicate under one name (`visibleWhen`), alias the rest -**Status**: Proposed (2026-07-05) +**Status**: Accepted (2026-07-14) — D1 + D2 + codemod + docs implemented in #2642. D3a (`.strict()` flip) and D3b (lint rule) deferred to follow-up per the ADR's staged rollout (sweep first, then flip). **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared-but-unchecked visibility key is exactly this class), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata — a mis-layered visibility key that zod strips is inert by accident), [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) (the enforce-or-remove pass that already deleted a consumer-less `visibleOn` from `fieldGroups`), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the AI-authoring population this ADR optimizes for), [ADR-0058](./0058-expression-and-predicate-surface.md) (the CEL predicate surface these keys all belong to), [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (conversion-over-notification; this rename ships as an L1 invisible break via the alias mechanism) **Consumers**: `@objectstack/spec` (the field / view / page zod schemas + the alias normalization), `@objectstack/objectql` (`rule-validator` — the server-side enforcer), `@objectstack/lint` (the new mis-layer / wrong-root rule), ObjectUI form + page renderers, and every AI author of `*.object.ts` / `*.view.ts` / `*.page.ts` diff --git a/examples/app-showcase/src/ui/pages/contact-form.page.ts b/examples/app-showcase/src/ui/pages/contact-form.page.ts index 2add661a6e..4ea4d294f5 100644 --- a/examples/app-showcase/src/ui/pages/contact-form.page.ts +++ b/examples/app-showcase/src/ui/pages/contact-form.page.ts @@ -94,7 +94,7 @@ export const ContactFormPage = definePage({ { type: 'element:text', id: 'ready_hint', - visibility: "page.inquiryEmail != ''", + visibleWhen: "page.inquiryEmail != ''", properties: { content: '✓ Looks good — hit Submit to send your inquiry.', variant: 'caption', diff --git a/examples/app-showcase/src/ui/pages/page-variables.page.ts b/examples/app-showcase/src/ui/pages/page-variables.page.ts index d2f852beae..60ed40928a 100644 --- a/examples/app-showcase/src/ui/pages/page-variables.page.ts +++ b/examples/app-showcase/src/ui/pages/page-variables.page.ts @@ -10,7 +10,7 @@ import { definePage } from '@objectstack/spec/ui'; * is `project_picker` (PageVariableSchema.source = that component id). * 2. `element:record_picker` (id: project_picker) writes the chosen project's * id into that variable on selection. - * 3. Sibling components gate on `page.selectedProjectId` via `visibility`: + * 3. Sibling components gate on `page.selectedProjectId` via `visibleWhen`: * the empty-state hint shows while nothing is picked; the detail panel * appears the moment a project is chosen — re-evaluated live, no reload. * @@ -69,7 +69,7 @@ export const PageVariablesPage = definePage({ { type: 'element:text', id: 'empty_hint', - visibility: "page.selectedProjectId == ''", + visibleWhen: "page.selectedProjectId == ''", properties: { content: '↑ Select a project above to reveal its detail panel.', variant: 'caption', @@ -79,12 +79,12 @@ export const PageVariablesPage = definePage({ { type: 'element:divider', id: 'detail_divider', - visibility: "page.selectedProjectId != ''", + visibleWhen: "page.selectedProjectId != ''", }, { type: 'element:text', id: 'detail_heading', - visibility: "page.selectedProjectId != ''", + visibleWhen: "page.selectedProjectId != ''", properties: { content: '✓ Project selected', variant: 'subheading', @@ -93,7 +93,7 @@ export const PageVariablesPage = definePage({ { type: 'element:text', id: 'detail_body', - visibility: "page.selectedProjectId != ''", + visibleWhen: "page.selectedProjectId != ''", properties: { content: 'This panel is gated on `page.selectedProjectId != ""`. It became visible the instant the picker wrote the variable — the same page-local state any other component (or its data filter) can read.', diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 43b66171cc..0b47354d8e 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -239,13 +239,13 @@ export const TaskViews = defineView({ { field: 'status', required: true }, { field: 'priority' }, { field: 'due_date' }, - // View-level conditional visibility (FormField.visibleOn, CEL): + // View-level conditional visibility (FormField.visibleWhen, CEL): // the notes box only appears while the task is Urgent. Data-level // counterpart is `visibleWhen` on invoice.paid_on. // Width via the semantic `span` (#2578): 'full' = whole row at any // derived column count — the primary primitive; absolute colSpan // is legacy and lint-discouraged. - { field: 'notes', visibleOn: P`record.priority == 'urgent'`, span: 'full' }, + { field: 'notes', visibleWhen: P`record.priority == 'urgent'`, span: 'full' }, ], }, ], diff --git a/examples/app-showcase/test/page-variables.test.ts b/examples/app-showcase/test/page-variables.test.ts index 53f3dc0165..10d3f50b6c 100644 --- a/examples/app-showcase/test/page-variables.test.ts +++ b/examples/app-showcase/test/page-variables.test.ts @@ -21,7 +21,7 @@ import { ShowcaseApp } from '../src/ui/apps/index.js'; type AnyComponent = { type: string; id?: string; - visibility?: unknown; + visibleWhen?: unknown; [k: string]: unknown; }; @@ -76,7 +76,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => { it('gates its detail panel on the variable — hidden until a project is picked, shown after', () => { const comps = allComponents(PageVariablesPage); - const gated = comps.filter((c) => c.visibility !== undefined); + const gated = comps.filter((c) => c.visibleWhen !== undefined); // Empty-hint + divider + heading + body — every gated node references the variable. expect(gated.length).toBeGreaterThanOrEqual(2); @@ -86,7 +86,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => { let shownWhenEmpty = 0; let shownWhenPicked = 0; for (const c of gated) { - const src = predicateSource(c.visibility); + const src = predicateSource(c.visibleWhen); expect(src, `gated component ${c.id} must carry a predicate`).toBeTruthy(); // Every gating predicate is about the page variable. expect(src).toContain('page.selectedProjectId'); @@ -102,7 +102,7 @@ describe('Page Variables showcase — page-local state (ADR-0049)', () => { // The empty-state predicate and the detail predicates are mutually exclusive: // no gated node is visible in BOTH states. for (const c of gated) { - const src = predicateSource(c.visibility)!; + const src = predicateSource(c.visibleWhen)!; const inEmpty = evalPredicate(src, empty.page); const inPicked = evalPredicate(src, picked.page); expect(inEmpty && inPicked, `component ${c.id} should not be visible in both states`).toBe(false); diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 38a663eaad..24336c66c8 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -9,6 +9,7 @@ import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/s import { loadConfig } from '../utils/config.js'; import { lowerCallables } from '../utils/lower-callables.js'; import { validateStackExpressions } from '@objectstack/lint'; +import { validateVisibilityPredicates } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; @@ -168,6 +169,20 @@ export default class Compile extends Command { } } + // 3b-bis. ADR-0089 D3b — deprecated visibility aliases + mis-layered + // binding root. Checked on `normalized` (PRE-parse): the schema folds + // `visibleOn`/`visibility` into `visibleWhen` at parse, so `result.data` + // no longer carries the alias the author wrote. Advisory, never fatal. + const visibilityFindings = validateVisibilityPredicates(normalized as Record); + if (visibilityFindings.length > 0 && !flags.json) { + printWarning(`Visibility warnings (${visibilityFindings.length}) — ADR-0089`); + for (const f of visibilityFindings.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(` ${f.hint}`); + console.log(` rule: ${f.rule} at ${f.path}`); + } + } + // 3c. Widget-binding diagnostics (issues #1719/#1721) — semantic checks // that need the widget's `dataset` reference resolved to its dataset // and `dimensions`/`values` resolved to declared names. Errors are diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 951b375327..d00db4495d 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -14,6 +14,7 @@ import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; import { validateCapabilityReferences } from '@objectstack/lint'; +import { validateVisibilityPredicates } from '@objectstack/lint'; import { validateSecurityPosture } from '@objectstack/lint'; import { printHeader, @@ -392,6 +393,15 @@ export default class Validate extends Command { // 5. Warnings (non-blocking) const warnings: string[] = []; + // ADR-0089 D3b — deprecated visibility aliases + mis-layered binding root. + // Checked on `normalized` (PRE-parse): the schema folds `visibleOn`/ + // `visibility` into `visibleWhen` during parse, so `result.data` no longer + // carries the alias the author actually wrote. + const visibilityFindings = validateVisibilityPredicates(normalized as Record); + for (const f of visibilityFindings) { + warnings.push(`${f.where}: ${f.message} — ${f.hint}`); + } + // ADR-0087 D2 conversion notices: the source used a deprecated shape that // was auto-converted at load. No action is required to keep loading, but // the notice steers the author to the canonical key before it retires. diff --git a/packages/dogfood/test/expression-conformance.ledger.ts b/packages/dogfood/test/expression-conformance.ledger.ts index f1afb5bc76..9832bf765a 100644 --- a/packages/dogfood/test/expression-conformance.ledger.ts +++ b/packages/dogfood/test/expression-conformance.ledger.ts @@ -114,8 +114,15 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ // WITH an enforcement path. 'ui/action.zod.ts:visible', 'ui/app.zod.ts:visible', + // ADR-0089: `visibleWhen` is the canonical conditional-visibility key on + // page components and view form sections/fields; `visibility` (page) and + // `visibleOn` (view) stay accepted as deprecated aliases, normalized to + // `visibleWhen` at parse. Both spellings are live CEL surfaces until the + // aliases are removed in a future major, so both carry a ledger row. + 'ui/page.zod.ts:visibleWhen', 'ui/page.zod.ts:visibility', 'ui/view.zod.ts:condition', + 'ui/view.zod.ts:visibleWhen', 'ui/view.zod.ts:visibleOn', 'ui/component.zod.ts:onSubmit', 'system/settings-manifest.zod.ts:visible', diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 6fe2042d71..33a127d517 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -69,6 +69,13 @@ export { } from './validate-form-layout.js'; export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js'; +export { + validateVisibilityPredicates, + VISIBILITY_ALIAS_DEPRECATED, + VISIBILITY_ROOT_MISLAYERED, +} from './validate-visibility-predicates.js'; +export type { VisibilityFinding, VisibilitySeverity } from './validate-visibility-predicates.js'; + export { validateCapabilityReferences, CAPABILITY_REFERENCE_UNKNOWN, diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts new file mode 100644 index 0000000000..b21dd54dc1 --- /dev/null +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateVisibilityPredicates, + VISIBILITY_ALIAS_DEPRECATED, + VISIBILITY_ROOT_MISLAYERED, +} from './validate-visibility-predicates'; + +describe('validateVisibilityPredicates (ADR-0089 D3b)', () => { + it('is clean for canonical `visibleWhen` with a runtime binding root', () => { + const stack = { + views: [ + { + name: 'task_form', + sections: [ + { + label: 'Details', + visibleWhen: "record.type == 'urgent'", + fields: [{ field: 'notes', visibleWhen: "record.priority == 'high'" }], + }, + ], + }, + ], + pages: [ + { + name: 'detail_page', + regions: [ + { components: [{ type: 'element:text', visibleWhen: "page.selectedId != ''" }] }, + ], + }, + ], + }; + expect(validateVisibilityPredicates(stack)).toEqual([]); + }); + + it('flags a deprecated `visibleOn` alias on a form section (→ visibleWhen)', () => { + const stack = { + views: [ + { name: 'task_form', sections: [{ label: 'S', visibleOn: "record.a == 1", fields: [] }] }, + ], + }; + const findings = validateVisibilityPredicates(stack); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(VISIBILITY_ALIAS_DEPRECATED); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('views[0].sections[0].visibleOn'); + }); + + it('flags a deprecated `visibleOn` alias on a form field', () => { + const stack = { + views: [ + { name: 'task_form', sections: [{ fields: [{ field: 'notes', visibleOn: "record.a == 1" }] }] }, + ], + }; + const findings = validateVisibilityPredicates(stack); + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ALIAS_DEPRECATED]); + expect(findings[0].path).toBe('views[0].sections[0].fields[0].visibleOn'); + }); + + it('flags a deprecated `visibility` alias on a page component', () => { + const stack = { + pages: [ + { name: 'p', regions: [{ components: [{ type: 'element:text', visibility: "page.x != ''" }] }] }, + ], + }; + const findings = validateVisibilityPredicates(stack); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(VISIBILITY_ALIAS_DEPRECATED); + expect(findings[0].path).toBe('pages[0].regions[0].components[0].visibility'); + }); + + it('flags a `data.`-rooted predicate in a runtime view as mis-layered', () => { + const stack = { + views: [ + { name: 'task_form', sections: [{ fields: [{ field: 'notes', visibleWhen: "data.type == 'grid'" }] }] }, + ], + }; + const findings = validateVisibilityPredicates(stack); + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]); + expect(findings[0].severity).toBe('warning'); + }); + + it('reports BOTH alias + mis-layer when a `visibleOn` predicate is `data.`-rooted', () => { + const stack = { + views: [ + { name: 'task_form', sections: [{ visibleOn: "data.status == 'x'", fields: [] }] }, + ], + }; + const rules = validateVisibilityPredicates(stack).map((f) => f.rule).sort(); + expect(rules).toEqual([VISIBILITY_ALIAS_DEPRECATED, VISIBILITY_ROOT_MISLAYERED].sort()); + }); + + it('does not confuse a field literally named `data` (e.g. `record.data`) for a data root', () => { + const stack = { + views: [ + { name: 'f', sections: [{ fields: [{ field: 'x', visibleWhen: "record.data == 1" }] }] }, + ], + }; + expect(validateVisibilityPredicates(stack)).toEqual([]); + }); + + it('resolves predicates stored as `{ dialect, source }` envelopes', () => { + const stack = { + pages: [ + { + name: 'p', + regions: [{ components: [{ type: 'element:text', visibleWhen: { dialect: 'cel', source: "data.x == 1" } }] }], + }, + ], + }; + const findings = validateVisibilityPredicates(stack); + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]); + }); + + it('walks legacy `groups` (alias of sections) too', () => { + const stack = { + views: [ + { name: 'f', groups: [{ visibleOn: "record.a == 1", fields: [] }] }, + ], + }; + expect(validateVisibilityPredicates(stack).map((f) => f.rule)).toEqual([VISIBILITY_ALIAS_DEPRECATED]); + }); + + it('is clean on an empty / model-less stack', () => { + expect(validateVisibilityPredicates({})).toEqual([]); + expect(validateVisibilityPredicates({ views: [], pages: [] })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts new file mode 100644 index 0000000000..f7ec62c784 --- /dev/null +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Build-time conditional-visibility diagnostics (ADR-0089 D3b). + * + * ADR-0089 unifies the conditional-visibility predicate under the single + * canonical key **`visibleWhen`** across data fields, view form sections/fields, + * and page components. The deprecated spellings — `visibleOn` (view form) and + * `visibility` (page component) — stay accepted and are folded into `visibleWhen` + * at the schema boundary (a zod `.transform()`). Because that fold happens during + * `parse()`, the aliases are gone from the *parsed* stack — so this rule runs on + * the **pre-parse** (normalized) stack, exactly like `validate-list-view-mode`, + * to see what the author actually wrote. + * + * Two advisory rules (both `warning` — nothing is broken, the alias still works + * and a mis-rooted predicate just never matches): + * + * - `visibility-alias-deprecated` — a `visibleOn` / `visibility` key in authored + * source. Autofix intent: rename the key to `visibleWhen` (same value). + * - `visibility-root-mislayered` — a runtime view/page visibility predicate + * rooted at `data.` (the metadata-editing-form root, ADR-0089 §Context). Runtime + * record surfaces bind `record` + `current_user` (pages also expose `page.`), + * so a `data.`-rooted predicate here is almost always a wrong-layer paste that + * silently never matches. + * + * Scope: `views` (form `sections` / legacy `groups`, and their `fields`) and + * `pages` (`regions[].components[]`). Data-field `visibleWhen` is already covered + * by `validate-expressions` and is not re-checked here. + */ + +export const VISIBILITY_ALIAS_DEPRECATED = 'visibility-alias-deprecated'; +export const VISIBILITY_ROOT_MISLAYERED = 'visibility-root-mislayered'; + +export type VisibilitySeverity = 'error' | 'warning'; + +export interface VisibilityFinding { + /** Always `warning` today — both rules are advisory (see module note). */ + severity: VisibilitySeverity; + /** Diagnostic rule id, e.g. `visibility-alias-deprecated`. */ + rule: string; + /** Human-readable location, e.g. `view "contact_form"`. */ + where: string; + /** Config path, e.g. `views[2].sections[0].fields[3]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** The canonical key and its two deprecated aliases (ADR-0089). */ +const CANONICAL = 'visibleWhen'; +const ALIASES = ['visibleOn', 'visibility'] as const; + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** Extract the CEL source from a predicate value (string, or `{ source }` envelope). */ +function predicateSource(v: unknown): string | undefined { + if (typeof v === 'string') return v; + if (v && typeof v === 'object' && typeof (v as AnyRec).source === 'string') { + return (v as AnyRec).source as string; + } + return undefined; +} + +/** Does the predicate reference `data.` as a binding root? */ +function usesDataRoot(source: string): boolean { + // `data` as a leading identifier followed by a property access — the + // metadata-editing-form root. Excludes `foo.data` (a field named data). + return /(^|[^.\w$])data\.\w/.test(source); +} + +/** + * Inspect one element carrying a visibility predicate. Emits the alias-deprecated + * finding (when an alias key is present) and the mis-layered-root finding (when + * the effective predicate is rooted at `data.`). + */ +function checkElement( + el: AnyRec, + where: string, + path: string, + findings: VisibilityFinding[], +): void { + // (1) deprecated alias key present → steer to `visibleWhen`. + for (const alias of ALIASES) { + if (el[alias] !== undefined) { + findings.push({ + severity: 'warning', + rule: VISIBILITY_ALIAS_DEPRECATED, + where, + path: `${path}.${alias}`, + message: + `\`${alias}\` is the deprecated spelling of the conditional-visibility ` + + `predicate (ADR-0089). It still works — it is normalized to \`visibleWhen\` ` + + `at parse — but the canonical key is \`visibleWhen\`.`, + hint: `Rename the key \`${alias}\` → \`visibleWhen\` (same CEL value).`, + }); + } + } + + // (2) mis-layered binding root — check the effective predicate (canonical wins). + const raw = el[CANONICAL] ?? el.visibleOn ?? el.visibility; + const source = predicateSource(raw); + if (source && usesDataRoot(source)) { + findings.push({ + severity: 'warning', + rule: VISIBILITY_ROOT_MISLAYERED, + where, + path, + message: + `visibility predicate is rooted at \`data.\` — that is the ` + + `metadata-editing-form root (a \`*.form.ts\` row under edit), not a runtime ` + + `surface. A runtime view/page predicate that binds \`data.\` never matches ` + + `and the element renders unconditionally (ADR-0089).`, + hint: + `Runtime record surfaces bind \`record\` + \`current_user\` (pages also ` + + `expose \`page.\`). Use e.g. \`record.status == 'open'\` instead of ` + + `\`data.status == 'open'\`.`, + }); + } +} + +/** A section field entry is either a bare field name or `{ field, visibleWhen, … }`. */ +function isFieldObject(entry: unknown): entry is AnyRec { + return !!entry && typeof entry === 'object' && !Array.isArray(entry); +} + +/** + * Validate conditional-visibility keys across authored views and pages. + * + * Runs on the **pre-parse** (normalized) stack so it can see the deprecated + * `visibleOn` / `visibility` aliases before the schema folds them into + * `visibleWhen`. Returns findings (empty = clean); all advisory (`warning`) — + * the caller must never fail the build on these alone. + */ +export function validateVisibilityPredicates(stack: AnyRec): VisibilityFinding[] { + const findings: VisibilityFinding[] = []; + + // ── Views: form sections / legacy groups, and their fields ────────── + const views = asArray(stack.views); + for (let i = 0; i < views.length; i++) { + const view = views[i]; + if (!view || typeof view !== 'object') continue; + const viewName = typeof view.name === 'string' ? view.name : `(view ${i})`; + const where = `view "${viewName}"`; + + // `sections` (canonical) and `groups` (legacy alias → sections) both hold + // FormSection objects with an optional visibility predicate + `fields`. + for (const bucket of ['sections', 'groups'] as const) { + const sections = Array.isArray(view[bucket]) ? (view[bucket] as unknown[]) : []; + for (let s = 0; s < sections.length; s++) { + const sec = sections[s]; + if (!sec || typeof sec !== 'object') continue; + const secPath = `views[${i}].${bucket}[${s}]`; + checkElement(sec as AnyRec, where, secPath, findings); + + const secFields = Array.isArray((sec as AnyRec).fields) ? ((sec as AnyRec).fields as unknown[]) : []; + for (let f = 0; f < secFields.length; f++) { + const entry = secFields[f]; + if (isFieldObject(entry)) { + checkElement(entry, where, `${secPath}.fields[${f}]`, findings); + } + } + } + } + } + + // ── Pages: regions[].components[] ─────────────────────────────────── + const pages = asArray(stack.pages); + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + if (!page || typeof page !== 'object') continue; + const pageName = typeof page.name === 'string' ? page.name : `(page ${i})`; + const where = `page "${pageName}"`; + const regions = Array.isArray(page.regions) ? (page.regions as unknown[]) : []; + for (let r = 0; r < regions.length; r++) { + const region = regions[r]; + const components = region && typeof region === 'object' && Array.isArray((region as AnyRec).components) + ? ((region as AnyRec).components as unknown[]) + : []; + for (let c = 0; c < components.length; c++) { + const comp = components[c]; + if (comp && typeof comp === 'object') { + checkElement(comp as AnyRec, where, `pages[${i}].regions[${r}].components[${c}]`, findings); + } + } + } + } + + return findings; +} diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index ba9586fadc..4210b695c4 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -4421,6 +4421,7 @@ "TemplateExpressionInputSchema (const)", "TransformType (type)", "TransformTypeSchema (const)", + "VISIBILITY_ALIAS_KEYS (const)", "ViewName (type)", "ViewNameSchema (const)", "applyProtection (function)", @@ -4436,6 +4437,7 @@ "normalizeMetadataCollection (function)", "normalizePluginMetadata (function)", "normalizeStackInput (function)", + "normalizeVisibleWhen (function)", "objectStackErrorMap (const)", "pluralToSingular (function)", "renderDiffMessage (function)", diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 9be4ff6ba5..27dd5c96d8 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -34,19 +34,19 @@ export const fieldForm = defineForm({ fields: [ { field: 'defaultValue', helpText: 'Default value for new records' }, // Text field options - { field: 'minLength', visibleOn: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Minimum character length' }, - { field: 'maxLength', visibleOn: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Maximum character length' }, + { field: 'minLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Minimum character length' }, + { field: 'maxLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Maximum character length' }, // Number field options - { field: 'min', visibleOn: "data.type == 'number' || data.type == 'currency'", helpText: 'Minimum value' }, - { field: 'max', visibleOn: "data.type == 'number' || data.type == 'currency'", helpText: 'Maximum value' }, - { field: 'precision', visibleOn: "data.type == 'currency' || data.type == 'number'", helpText: 'Decimal places (e.g., 2 for $10.50)' }, - { field: 'scale', visibleOn: "data.type == 'number'", helpText: 'Number of decimal digits' }, + { field: 'min', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Minimum value' }, + { field: 'max', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Maximum value' }, + { field: 'precision', visibleWhen: "data.type == 'currency' || data.type == 'number'", helpText: 'Decimal places (e.g., 2 for $10.50)' }, + { field: 'scale', visibleWhen: "data.type == 'number'", helpText: 'Number of decimal digits' }, // Select field options - { field: 'options', type: 'repeater', visibleOn: "data.type == 'select' || data.type == 'multiselect'", helpText: 'Available options (label/value pairs)' }, + { field: 'options', type: 'repeater', visibleWhen: "data.type == 'select' || data.type == 'multiselect'", helpText: 'Available options (label/value pairs)' }, // Reference field options - { field: 'reference', widget: 'ref:object', visibleOn: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'Referenced object name' }, - { field: 'referenceFilters', widget: 'string-tags', visibleOn: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'Filter expressions (e.g., "active = true")' }, - { field: 'deleteBehavior', visibleOn: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted' }, + { field: 'reference', widget: 'ref:object', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'Referenced object name' }, + { field: 'referenceFilters', widget: 'string-tags', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'Filter expressions (e.g., "active = true")' }, + { field: 'deleteBehavior', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted' }, ], }, { diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 966c0108b5..3cdd436455 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -115,21 +115,21 @@ export const objectForm = defineForm({ { field: 'placeholder', type: 'text', helpText: 'Placeholder hint' }, // Text constraints - { field: 'maxLength', type: 'number', helpText: 'Max characters', visibleOn: "type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, - { field: 'minLength', type: 'number', helpText: 'Min characters', visibleOn: "type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, + { field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, + { field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, // Numeric constraints - { field: 'min', type: 'number', helpText: 'Minimum value', visibleOn: "type in ['number','currency','percent','rating','slider','progress']" }, - { field: 'max', type: 'number', helpText: 'Maximum value', visibleOn: "type in ['number','currency','percent','rating','slider','progress']" }, - { field: 'precision', type: 'number', helpText: 'Total digits', visibleOn: "type in ['number','currency','percent']" }, - { field: 'scale', type: 'number', helpText: 'Decimal places', visibleOn: "type in ['number','currency','percent']" }, + { field: 'min', type: 'number', helpText: 'Minimum value', visibleWhen: "type in ['number','currency','percent','rating','slider','progress']" }, + { field: 'max', type: 'number', helpText: 'Maximum value', visibleWhen: "type in ['number','currency','percent','rating','slider','progress']" }, + { field: 'precision', type: 'number', helpText: 'Total digits', visibleWhen: "type in ['number','currency','percent']" }, + { field: 'scale', type: 'number', helpText: 'Decimal places', visibleWhen: "type in ['number','currency','percent']" }, // Selection options { field: 'options', type: 'repeater', helpText: 'Available choices', - visibleOn: "type in ['select','multiselect','radio','checkboxes']", + visibleWhen: "type in ['select','multiselect','radio','checkboxes']", fields: [ { field: 'label', type: 'text', required: true }, { field: 'value', type: 'text', required: true }, @@ -140,29 +140,29 @@ export const objectForm = defineForm({ }, // Relational - { field: 'reference', type: 'text', helpText: 'Target object name', visibleOn: "type in ['lookup','master_detail','tree']" }, - { field: 'referenceFilter', type: 'code', language: 'expression', helpText: 'CEL filter applied to the picker', visibleOn: "type in ['lookup','master_detail']" }, - { field: 'cascadeDelete', type: 'boolean', helpText: 'Delete children when parent is deleted', visibleOn: "type == 'master_detail'" }, - { field: 'multiple', type: 'boolean', helpText: 'Allow selecting multiple records', visibleOn: "type in ['lookup']" }, + { field: 'reference', type: 'text', helpText: 'Target object name', visibleWhen: "type in ['lookup','master_detail','tree']" }, + { field: 'referenceFilter', type: 'code', language: 'expression', helpText: 'CEL filter applied to the picker', visibleWhen: "type in ['lookup','master_detail']" }, + { field: 'cascadeDelete', type: 'boolean', helpText: 'Delete children when parent is deleted', visibleWhen: "type == 'master_detail'" }, + { field: 'multiple', type: 'boolean', helpText: 'Allow selecting multiple records', visibleWhen: "type in ['lookup']" }, // Formula / summary - { field: 'formula', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleOn: "type == 'formula'" }, - { field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleOn: "type == 'formula'", options: [ + { field: 'formula', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "type == 'formula'" }, + { field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleWhen: "type == 'formula'", options: [ { label: 'Text', value: 'text' }, { label: 'Number', value: 'number' }, { label: 'Boolean', value: 'boolean' }, { label: 'Date', value: 'date' }, { label: 'Datetime', value: 'datetime' }, { label: 'Currency', value: 'currency' }, ] }, - { field: 'summaryType', type: 'select', helpText: 'Aggregation', visibleOn: "type == 'summary'", options: [ + { field: 'summaryType', type: 'select', helpText: 'Aggregation', visibleWhen: "type == 'summary'", options: [ { label: 'Count', value: 'count' }, { label: 'Sum', value: 'sum' }, { label: 'Avg', value: 'avg' }, { label: 'Min', value: 'min' }, { label: 'Max', value: 'max' }, ] }, - { field: 'summaryField', type: 'text', helpText: 'Field on child object to aggregate', visibleOn: "type == 'summary'" }, + { field: 'summaryField', type: 'text', helpText: 'Field on child object to aggregate', visibleWhen: "type == 'summary'" }, // Autonumber - { field: 'displayFormat', type: 'text', helpText: 'e.g. "INV-{0000}"', visibleOn: "type == 'autonumber'" }, - { field: 'startingNumber', type: 'number', helpText: 'Starting sequence value', visibleOn: "type == 'autonumber'" }, + { field: 'displayFormat', type: 'text', helpText: 'e.g. "INV-{0000}"', visibleWhen: "type == 'autonumber'" }, + { field: 'startingNumber', type: 'number', helpText: 'Starting sequence value', visibleWhen: "type == 'autonumber'" }, // Code language - { field: 'language', type: 'text', helpText: 'Editor language (e.g. sql, javascript)', visibleOn: "type == 'code'" }, + { field: 'language', type: 'text', helpText: 'Editor language (e.g. sql, javascript)', visibleWhen: "type == 'code'" }, // Validation / governance { field: 'validation', type: 'code', language: 'expression', helpText: 'CEL predicate — must evaluate true' }, diff --git a/packages/spec/src/shared/index.ts b/packages/spec/src/shared/index.ts index 0a517239a6..3cc597b5f7 100644 --- a/packages/spec/src/shared/index.ts +++ b/packages/spec/src/shared/index.ts @@ -17,5 +17,6 @@ export * from './external-errors'; export * from './metadata-collection.zod'; export * from './lazy-schema'; export * from './expression.zod'; +export * from './visibility'; export * from './protection.zod'; export * from './resilient-fetch'; diff --git a/packages/spec/src/shared/visibility.ts b/packages/spec/src/shared/visibility.ts new file mode 100644 index 0000000000..1ee90f4c8a --- /dev/null +++ b/packages/spec/src/shared/visibility.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Conditional-visibility predicate normalization (ADR-0089) + * + * One concept — *"show this only when the CEL predicate is TRUE"* — used to be + * spelled three different ways depending on the layer: + * + * | Layer | Legacy key | Canonical | + * |:------------------------------|:-------------|:-------------| + * | Data field / field option | `visibleWhen`| `visibleWhen`| + * | View form section / field | `visibleOn` | `visibleWhen`| + * | Page component | `visibility` | `visibleWhen`| + * + * ADR-0089 makes **`visibleWhen`** the single canonical key across all layers + * (aligning with the `readonlyWhen` / `requiredWhen` family and the resolved + * `conditionalRequired → requiredWhen` precedent). `visibleOn` and `visibility` + * stay accepted as `@deprecated` aliases and are folded into `visibleWhen` + * **once, at the schema boundary** (a zod `.transform()`), so no renderer or + * validator has to re-implement the fallback. + * + * The *binding root* is still determined by the layer — runtime record surfaces + * bind `record` + `current_user`; metadata-editing forms bind `data` — this + * unifies the *name*, not the environment. + */ + +/** Deprecated alias keys folded into the canonical `visibleWhen`. */ +export const VISIBILITY_ALIAS_KEYS = ['visibleOn', 'visibility'] as const; + +/** Object carrying the canonical key and/or its deprecated aliases. */ +type WithVisibilityAliases = { + visibleWhen?: unknown; + visibleOn?: unknown; + visibility?: unknown; +}; + +/** + * Fold the deprecated `visibleOn` / `visibility` aliases into the canonical + * `visibleWhen` and drop the aliases from the output (ADR-0089 D2). The + * canonical key wins when present; otherwise the first defined alias is used. + * + * Designed to be used as a zod `.transform()` on any object schema that carries + * a conditional-visibility predicate: + * + * ```ts + * z.object({ ..., visibleWhen: Expr.optional(), visibleOn: Expr.optional() }) + * .transform(normalizeVisibleWhen) + * ``` + */ +export function normalizeVisibleWhen( + input: T, +): Omit { + const { visibleOn, visibility, ...rest } = input; + const canonical = + rest.visibleWhen !== undefined + ? rest.visibleWhen + : visibleOn !== undefined + ? visibleOn + : visibility; + + if (canonical === undefined) { + // Nothing to fold — strip the (absent) aliases and return as-is. + return rest as Omit; + } + return { ...rest, visibleWhen: canonical } as Omit; +} diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 38bdbe6845..c930a1dfef 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -28,12 +28,12 @@ export const actionForm = defineForm({ label: 'Behavior', description: 'Configure what happens when the action is triggered.', fields: [ - { field: 'target', visibleOn: "data.type != 'script'", helpText: 'URL, flow name, or API endpoint to call' }, - { field: 'method', visibleOn: "data.type == 'api'", helpText: 'HTTP method (GET, POST, PUT, DELETE)' }, + { field: 'target', visibleWhen: "data.type != 'script'", helpText: 'URL, flow name, or API endpoint to call' }, + { field: 'method', visibleWhen: "data.type == 'api'", helpText: 'HTTP method (GET, POST, PUT, DELETE)' }, { field: 'body', type: 'composite', - visibleOn: "data.type == 'script'", + visibleWhen: "data.type == 'script'", helpText: 'Either an L1 expression or an L2 sandboxed JS body', // Mirrors hook.form.ts: `body` is a discriminated union // (HookBodySchema) on `language`, not a bare string. A flat @@ -78,9 +78,9 @@ export const actionForm = defineForm({ fields: [ { field: 'bulkEnabled', colSpan: 1, helpText: 'Allow applying to multiple selected records' }, { field: 'ai', colSpan: 2, helpText: 'AI exposure (opt-in): set ai.exposed=true and write ai.description (≥40 chars) to make this callable by agents.' }, - { field: 'recordIdParam', visibleOn: "data.type == 'api'", colSpan: 1, helpText: 'Body parameter name for record ID' }, - { field: 'recordIdField', visibleOn: "data.type == 'api' && data.recordIdParam", colSpan: 1, helpText: 'Field to use as record ID (default: "id")' }, - { field: 'bodyShape', visibleOn: "data.type == 'api'", colSpan: 2, helpText: 'Request body structure (flat or nested)' }, + { field: 'recordIdParam', visibleWhen: "data.type == 'api'", colSpan: 1, helpText: 'Body parameter name for record ID' }, + { field: 'recordIdField', visibleWhen: "data.type == 'api' && data.recordIdParam", colSpan: 1, helpText: 'Field to use as record ID (default: "id")' }, + { field: 'bodyShape', visibleWhen: "data.type == 'api'", colSpan: 2, helpText: 'Request body structure (flat or nested)' }, ], }, ], diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index a103e6f908..f6d88c3bcc 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -39,7 +39,7 @@ export const pageForm = defineForm({ }, // A list/interface page renders via InterfaceListPage and ignores the // region template, so hide it there (same rationale as Data Context / Layout). - { field: 'template', colSpan: 2, visibleOn: "data.type != 'list'", helpText: 'Layout template (e.g., "header-sidebar-main")' }, + { field: 'template', colSpan: 2, visibleWhen: "data.type != 'list'", helpText: 'Layout template (e.g., "header-sidebar-main")' }, { field: 'description', widget: 'textarea', colSpan: 2, helpText: 'Page description for navigation' }, ], }, @@ -49,7 +49,7 @@ export const pageForm = defineForm({ // Interface/list pages bind their data via the Interface section // (source/sourceView), not a page-level object — hide this to keep // the panel lean (the region/record machinery doesn't apply). - visibleOn: "data.type != 'list'", + visibleWhen: "data.type != 'list'", fields: [ { field: 'object', widget: 'ref:object', helpText: 'Bound object (for Record pages)' }, { field: 'variables', type: 'repeater', helpText: 'Local page state variables' }, @@ -60,7 +60,7 @@ export const pageForm = defineForm({ description: 'Page regions and components placed within them.', // List pages render a curated list surface, not free-form regions — // the region designer is irrelevant here, so hide it for list pages. - visibleOn: "data.type != 'list'", + visibleWhen: "data.type != 'list'", fields: [ // Not required: an empty full page falls back to the synthesized default // layout, slotted pages compose via `slots`, and list pages ignore regions @@ -76,7 +76,7 @@ export const pageForm = defineForm({ collapsible: true, // Primary content for a list page — open by default (still collapsible). collapsed: false, - visibleOn: "data.type == 'list'", + visibleWhen: "data.type == 'list'", fields: [ { field: 'interfaceConfig', diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index fcb0ea81c9..acd18dbdae 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -35,12 +35,12 @@ describe('PageComponentSchema', () => { filterField: 'account_id', columns: ['name', 'email', 'phone'], }, - visibility: 'record.type == "Customer"', + visibleWhen: 'record.type == "Customer"', }); expect(component.id).toBe('related_contacts'); expect(component.label).toBe('Related Contacts'); - expect(component.visibility).toBeDefined(); + expect(component.visibleWhen).toBeDefined(); }); it('should accept component with complex properties', () => { @@ -992,3 +992,27 @@ describe('PageSchema - no cross-field requirements', () => { })).not.toThrow(); }); }); + +describe('ADR-0089 — visibleWhen unification (page component)', () => { + it('normalizes a deprecated `visibility` alias to `visibleWhen`', () => { + const parsed = PageComponentSchema.parse({ + type: 'element:text', + visibility: "page.selectedId != ''", + }); + expect(parsed.visibleWhen).toBeDefined(); + expect((parsed as Record).visibility).toBeUndefined(); + }); + + it('keeps the canonical `visibleWhen` when both are present (canonical wins)', () => { + const parsed = PageComponentSchema.parse({ + type: 'element:text', + visibleWhen: "page.a == 1", + visibility: "page.b == 2", + }); + const src = typeof parsed.visibleWhen === 'string' + ? parsed.visibleWhen + : (parsed.visibleWhen as { source?: string }).source; + expect(src).toBe('page.a == 1'); + expect((parsed as Record).visibility).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 9768daa532..379c201d8e 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; +import { normalizeVisibleWhen } from '../shared/visibility'; import { SortItemSchema } from '../shared/enums.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -105,8 +106,14 @@ export const PageComponentSchema = lazySchema(() => z.object({ responsiveStyles: ResponsiveStylesSchema.optional() .describe('Per-breakpoint scoped style maps (ADR-0065)'), - /** Visibility Rule */ - visibility: ExpressionInputSchema.optional().describe('Visibility predicate (CEL).'), + /** + * Conditional-visibility predicate (CEL) — the component is rendered only when + * TRUE (ADR-0089, canonical `*When` name). Page predicates bind the live page + * surface: `record` + `current_user` plus page state as `page.`. + */ + visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Binds `record`, `current_user`, `page.`. e.g. \"page.selectedProjectId != ''\""), + /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ + visibility: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), /** Per-element data binding, overrides page-level object context */ dataSource: ElementDataSourceSchema.optional().describe('Per-element data binding for multi-object pages'), @@ -116,7 +123,7 @@ export const PageComponentSchema = lazySchema(() => z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), -})); +}).transform(normalizeVisibleWhen)); /** * Page Variable Schema diff --git a/packages/spec/src/ui/report.form.ts b/packages/spec/src/ui/report.form.ts index 0c681507ba..3113104a25 100644 --- a/packages/spec/src/ui/report.form.ts +++ b/packages/spec/src/ui/report.form.ts @@ -34,20 +34,20 @@ export const reportForm = defineForm({ label: 'Dataset binding', description: 'The semantic-layer dataset this report renders. Values are the dataset’s measures; rows are its dimensions.', // A `joined` report carries its data on `blocks` instead. - visibleOn: "data.type != 'joined'", + visibleWhen: "data.type != 'joined'", fields: [ { field: 'dataset', widget: 'ref:dataset', helpText: 'Dataset to bind (measures/dimensions come from its semantic layer)' }, { field: 'values', widget: 'string-tags', helpText: 'Measure names (from the dataset) to display' }, { field: 'rows', widget: 'string-tags', helpText: 'Dimension names (from the dataset) to group rows by' }, // CEL visibility — only Matrix reports pivot across a second dimension. - { field: 'columns', widget: 'string-tags', visibleOn: "data.type == 'matrix'", helpText: 'Dimension names across (matrix only)' }, + { field: 'columns', widget: 'string-tags', visibleWhen: "data.type == 'matrix'", helpText: 'Dimension names across (matrix only)' }, { field: 'drilldown', helpText: 'Click an aggregated row/cell to open the underlying records' }, ], }, { label: 'Joined blocks', description: 'Additional dataset-bound blocks stacked into a single report (joined reports only).', - visibleOn: "data.type == 'joined'", + visibleWhen: "data.type == 'joined'", fields: [ { field: 'blocks', type: 'repeater', helpText: 'Dataset-bound sub-reports (joined report only)' }, ], diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index 2a8592a9dc..04087bca4e 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -47,7 +47,7 @@ export const viewForm = defineForm({ name: 'table_options', label: 'Table options', description: 'Grid-only display options.', - visibleOn: "data.type == 'grid' || data.type == null", + visibleWhen: "data.type == 'grid' || data.type == null", collapsible: true, collapsed: true, columns: 2, @@ -65,42 +65,42 @@ export const viewForm = defineForm({ name: 'kanban', label: 'Kanban', description: 'Kanban-specific board configuration.', - visibleOn: "data.type == 'kanban'", + visibleWhen: "data.type == 'kanban'", fields: [{ field: 'kanban', type: 'composite' }], }, { name: 'calendar', label: 'Calendar', description: 'Calendar-specific configuration.', - visibleOn: "data.type == 'calendar'", + visibleWhen: "data.type == 'calendar'", fields: [{ field: 'calendar', type: 'composite' }], }, { name: 'gantt', label: 'Gantt', description: 'Gantt-specific configuration.', - visibleOn: "data.type == 'gantt'", + visibleWhen: "data.type == 'gantt'", fields: [{ field: 'gantt', type: 'composite' }], }, { name: 'gallery', label: 'Gallery', description: 'Gallery-specific configuration.', - visibleOn: "data.type == 'gallery'", + visibleWhen: "data.type == 'gallery'", fields: [{ field: 'gallery', type: 'composite' }], }, { name: 'timeline', label: 'Timeline', description: 'Timeline-specific configuration.', - visibleOn: "data.type == 'timeline'", + visibleWhen: "data.type == 'timeline'", fields: [{ field: 'timeline', type: 'composite' }], }, { name: 'chart', label: 'Chart', description: 'Chart-specific configuration.', - visibleOn: "data.type == 'chart'", + visibleWhen: "data.type == 'chart'", fields: [{ field: 'chart', type: 'composite' }], }, { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index ad149dd7b7..65288e1e32 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -1103,7 +1103,7 @@ describe('FormFieldSchema', () => { const field: FormField = { field: 'state', dependsOn: 'country', - visibleOn: 'country === "USA"', + visibleWhen: 'country === "USA"', }; expect(() => FormFieldSchema.parse(field)).not.toThrow(); @@ -1217,12 +1217,12 @@ describe('Enhanced FormSectionSchema', () => { { field: 'state', dependsOn: 'country', - visibleOn: 'country === "USA"', + visibleWhen: 'country === "USA"', }, { field: 'province', dependsOn: 'country', - visibleOn: 'country === "Canada"', + visibleWhen: 'country === "Canada"', }, 'city', 'postal_code', @@ -1268,7 +1268,7 @@ describe('Enhanced FormViewSchema with Complex Fields', () => { { field: 'state', dependsOn: 'country', - visibleOn: 'country === "USA"', + visibleWhen: 'country === "USA"', widget: 'state-select', }, ], @@ -1350,7 +1350,7 @@ describe('Real-World Enhanced View Examples', () => { { field: 'billing_state', dependsOn: 'billing_country', - visibleOn: 'billing_country === "USA"', + visibleWhen: 'billing_country === "USA"', }, ], }, @@ -2505,3 +2505,34 @@ describe('HttpMethodSchema/HttpRequestSchema backward compat', () => { expect(result.method).toBe('GET'); }); }); + +describe('ADR-0089 — visibleWhen unification (view form)', () => { + it('normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form field', () => { + const parsed = FormFieldSchema.parse({ field: 'state', visibleOn: "record.country == 'US'" }); + expect(parsed.visibleWhen).toBeDefined(); + expect(parsed.visibleOn).toBeUndefined(); + }); + + it('normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form section', () => { + const parsed = FormSectionSchema.parse({ + label: 'Shipping', + visibleOn: "record.needs_shipping == true", + fields: ['address'], + }); + expect(parsed.visibleWhen).toBeDefined(); + expect((parsed as Record).visibleOn).toBeUndefined(); + }); + + it('keeps the canonical `visibleWhen` when both are present (canonical wins)', () => { + const parsed = FormFieldSchema.parse({ + field: 'state', + visibleWhen: "record.a == 1", + visibleOn: "record.b == 2", + }); + const src = typeof parsed.visibleWhen === 'string' + ? parsed.visibleWhen + : (parsed.visibleWhen as { source?: string }).source; + expect(src).toBe('record.a == 1'); + expect(parsed.visibleOn).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index e18ea8ae83..eedfbdf327 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5,6 +5,7 @@ import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; +import { normalizeVisibleWhen } from '../shared/visibility'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { SharingConfigSchema } from './sharing.zod'; import { ResponsiveConfigSchema, PerformanceConfigSchema } from './responsive.zod'; @@ -759,9 +760,17 @@ export const FormFieldSchema: z.ZodType = lazySchema(() => z.object({ }).optional().describe('Key column config for record-typed fields'), dependsOn: z.string().optional().describe('Parent field name for cascading'), - visibleOn: ExpressionInputSchema.optional().describe('Visibility predicate (CEL).'), + /** + * Conditional-visibility predicate (CEL) — the field is shown only when TRUE + * (ADR-0089, canonical `*When` name). Binding root depends on the surface: + * runtime record forms bind `record` + `current_user`; metadata-editing forms + * (`*.form.ts`) bind the row under edit as `data`. + */ + visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). e.g. P`record.priority == 'urgent'`"), + /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ + visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), -})); +}).transform(normalizeVisibleWhen)); /** * Form Layout Section @@ -778,7 +787,15 @@ export const FormSectionSchema = lazySchema(() => z.object({ description: z.string().optional().describe('Optional description rendered under the section header.'), collapsible: z.boolean().default(false), collapsed: z.boolean().default(false), - visibleOn: ExpressionInputSchema.optional().describe('Visibility predicate (CEL). Hides the whole section when false.'), + /** + * Conditional-visibility predicate (CEL) — the whole section is shown only + * when TRUE (ADR-0089, canonical `*When` name). Same per-layer binding root as + * {@link FormFieldSchema.visibleWhen}: `record`+`current_user` in runtime + * forms, `data` in metadata-editing forms. + */ + visibleWhen: ExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms).'), + /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ + visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse.'), columns: z.union([ z.enum(['1', '2', '3', '4']), z.literal(1), @@ -790,7 +807,7 @@ export const FormSectionSchema = lazySchema(() => z.object({ z.string(), // Legacy: simple field name FormFieldSchema, // Enhanced: detailed field config ])), -})); +}).transform(normalizeVisibleWhen)); /** * Form View Schema diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 51b2aa55a6..df75cfe18d 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -301,10 +301,10 @@ to the envelope. | `Field` | `formula` (when `type: 'formula'`) | cel | | `Field` | `visibleWhen` / `readonlyWhen` / `requiredWhen` | cel | | `Field` | `conditionalRequired` (deprecated alias of `requiredWhen`) | cel | -| `Field` | `visibleOn` | cel | +| `View` / `Page` | `visibleWhen` (form section/field, page component) | cel | | `Field` | `defaultValue` (M9.9b) | cel | | `ConditionalValidation` | `when` | cel | -| `View` | `visibleOn` | cel | +| `View` / `Page` | `visibleOn` / `visibility` (deprecated aliases of `visibleWhen`, ADR-0089) | cel | | `View.criteria` | filter expression | cel | | `Action` | `disabled` | cel (or boolean) | | `Hook` | `condition` | cel |