diff --git a/.changeset/base-schema-visible-predicate.md b/.changeset/base-schema-visible-predicate.md new file mode 100644 index 000000000..ee0ca3c43 --- /dev/null +++ b/.changeset/base-schema-visible-predicate.md @@ -0,0 +1,23 @@ +--- +'@object-ui/types': minor +--- + +`BaseSchema.visible` accepts the predicate string the renderer evaluates + +`visible` was declared `boolean`, but the renderer never read it as one: it +evaluates the key — `SchemaRenderer.tsx:382` calls +`evaluator.evaluateCondition(schema.visible)`, and `evaluateCondition` is +declared `(condition: string | boolean | undefined, context?) => boolean`. The +sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for that +same reason; `visible` simply under-reported a capability it already had, and +fixtures exercising it had to cast past the declaration. + +Now `boolean | string` — exactly what the evaluator accepts, no wider. + +Graded **minor** by position analysis of the published `.d.ts`: the only diff is +`visible?: boolean` becoming `visible?: boolean | string` on an +authored-input-dominant property, with no union member removed and no other +declaration touched — the same shape as #4586/#4591. Authors gain a spelling; +nothing that previously type-checked stops doing so. Code that READS +`schema.visible` was already coping with `any` through `BaseSchema`'s index +signature. diff --git a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx index f6e1e1859..1e8b7517a 100644 --- a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx @@ -11,11 +11,15 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; -// `@object-ui/types` declares `BaseSchema.visible` / `.disabled` as `boolean`, -// but BOTH accept a predicate STRING here — that is the capability these cases -// exercise, and the renderer evaluates it (`evaluateCondition`). The declaration -// is the narrow one; until it is widened these fixtures state their real shape -// through `BaseSchema`'s index signature (objectui#4548 measured the gap). +// `BaseSchema.visible` now declares `boolean | string` (objectui#4581), so the +// two visibility cases below state their predicate strings directly — no cast. +// +// `.disabled` is the SAME gap and is NOT yet widened: the renderer evaluates it +// through the same `evaluateCondition` (`SchemaRenderer.tsx:466`) and the +// `disabledOn?: string` sibling exists for the same reason, but #4581 named only +// `visible` and `ariaLabel`, so widening `disabled` was left to its own card +// rather than taken unruled. The two `disabled` casts below are what remains of +// the gap — drop them when that lands. import type { BaseSchema } from '@object-ui/types'; import { SchemaRendererContext } from '../context/SchemaRendererContext'; @@ -49,7 +53,7 @@ describe('SchemaRenderer Expression Integration', () => { it('evaluates visible expression string', () => { render( - + ); expect(screen.getByTestId('test-component')).toBeInTheDocument(); @@ -58,7 +62,7 @@ describe('SchemaRenderer Expression Integration', () => { it('hides when visible expression evaluates to false', () => { const { container } = render( - + ); expect(container.innerHTML).toBe(''); diff --git a/packages/types/src/__tests__/base-schema-visible-predicate.test.ts b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts new file mode 100644 index 000000000..325f20faa --- /dev/null +++ b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `BaseSchema.visible` admits the predicate string the renderer evaluates + * (objectui#4581). + * + * `BaseSchema` declared `visible?: boolean`. The renderer does not treat it as + * a boolean — `packages/react/src/SchemaRenderer.tsx:382` reads: + * + * ```ts + * if (newSchema.visible !== undefined) { + * return !evaluator.evaluateCondition(newSchema.visible); + * } + * ``` + * + * and `evaluateCondition` is declared + * `(condition: string | boolean | undefined, context?) => boolean` + * (`packages/core/dist/evaluator/ExpressionEvaluator.d.ts:143`). So a predicate + * STRING is a supported, evaluated input — the sibling keys `visibleWhen` and + * the deprecated `visibleOn` are both `string` for the same reason — and the + * declared type simply under-reported it. Two fixtures in + * `packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx` exercised + * exactly that capability through `as unknown as BaseSchema`; PR #4578 is what + * made the gap visible, by typing `schema` honestly for the first time. + * + * The widening is `boolean | string` — what `evaluateCondition` accepts, no + * wider. It is authored-input dominant: authors write `visible`, and code that + * READS `schema.visible` already had to cope with `any` through + * `BaseSchema`'s index signature. + * + * ## Predictions, written before the first run (red-first) + * + * Against `origin/main` (`92250d648`), `tsc -p packages/types/tsconfig.test.json` + * must report: + * + * 1. `predicateStringIsAuthorable` — TS2322, `Type 'string' is not + * assignable to type 'boolean | undefined'`. + * 2. `assertionVisible` — `Equal< BaseSchema['visible'], boolean | string | + * undefined >` resolves `false`, failing `Expect`'s constraint (TS2344). + * + * After the fix both compile clean. + * + * `assertionVisible` is INVARIANT on purpose. A `satisfies`-style check, or a + * one-way `extends`, would be vacuous here in both directions: the narrow + * `boolean` is assignable to the wide `boolean | string`, so a widening that + * never happened and a widening that overshot to `any` would both stay green. + * Pinning the exact union is the only assertion that can go red for the right + * reason — and `BaseSchema`'s `[key: string]: any` index signature makes the + * overshoot a live risk rather than a hypothetical one, since deleting the + * declared property altogether would leave `visible` typed `any` and every + * fixture below still compiling. + */ + +import { describe, it, expect } from 'vitest'; +import type { BaseSchema } from '../base'; + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/* ── The declared type is exactly what the evaluator accepts ─────────────── */ + +export type assertionVisible = Expect< + Equal< BaseSchema['visible'], boolean | string | undefined > +>; + +/* ── Authorable fixtures ─────────────────────────────────────────────────── */ + +/** The capability the renderer implements, now declared. */ +export const predicateStringIsAuthorable: BaseSchema = { + type: 'test-component', + visible: 'record.status == "open"', +}; + +/** The template-expression spelling the shipped fixtures use. */ +export const templateExpressionIsAuthorable: BaseSchema = { + type: 'test-component', + visible: '${data.role === "admin"}', +}; + +/** The boolean form is untouched — this is a widening, not a replacement. */ +export const booleanIsStillAuthorable: BaseSchema = { + type: 'test-component', + visible: false, +}; + +/* ── Runtime companion ───────────────────────────────────────────────────── */ + +describe('BaseSchema.visible (objectui#4581)', () => { + it('type-level: visible is boolean | string, pinned invariantly', () => { + // Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained + // from this package's `type-check` script. The runtime case exists so a + // green vitest run is not mistaken for the proof. + expect(predicateStringIsAuthorable.visible).toBe('record.status == "open"'); + expect(booleanIsStillAuthorable.visible).toBe(false); + }); +}); diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index a8471420f..28064c22a 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -102,9 +102,21 @@ export interface BaseSchema { /** * Controls whether the component is visible. * When false, component is not rendered (display: none). + * + * Accepts a PREDICATE STRING as well as a boolean (objectui#4581): the + * renderer does not read this key as a boolean, it evaluates it — + * `SchemaRenderer.tsx:382` calls `evaluator.evaluateCondition(schema.visible)`, + * and `evaluateCondition` is declared + * `(condition: string | boolean | undefined, context?) => boolean`. The + * sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for + * the same reason; this one simply under-reported the capability, and + * fixtures exercising it had to cast past the declaration. + * * @default true + * @example true + * @example "${data.role === 'admin'}" */ - visible?: boolean; + visible?: boolean | string; /** * Canonical conditional-visibility predicate (ADR-0089) — the element is shown