diff --git a/.changeset/7530-predicate-envelope-declared.md b/.changeset/7530-predicate-envelope-declared.md new file mode 100644 index 0000000000..2023934eb2 --- /dev/null +++ b/.changeset/7530-predicate-envelope-declared.md @@ -0,0 +1,18 @@ +--- +'@object-ui/types': minor +--- + +**`BaseSchema.visible` / `.hidden` / `.disabled` now declare the CEL envelope object the renderer already evaluates, as one named wire type** (objectui#7530, maintainer ruling 2026-09-04, option A). + +Each of the three keys goes from `boolean | string` to `boolean | ExpressionWire` on both faces, where `ExpressionWire` is `string | { dialect?: string; source: string }` — the exact string-or-envelope union `FormField.visibleWhen` and its `*When` / `*On` siblings already carried, and the exact accept set of `@object-ui/core`'s `toPredicateInput` / `hasDeclaredPredicate`. The Zod mirror's `z.union([z.boolean(), z.string()])` becomes `z.union([z.boolean(), ExpressionWireSchema])` on all three. + +Two names are new on the published surface: + +- `ExpressionWire` (type, main entry) — the TypeScript wire union, in `packages/types/src/expression.ts`. +- `ExpressionWireSchema` (`@object-ui/types/zod`) — its runtime twin, hoisted out of `zod/form.zod.ts` (where it was module-private) into `zod/expression.zod.ts` and imported by both `base.zod.ts` and `form.zod.ts`. One envelope type, reused by reference; no second spelling. + +This is a **widening**, not a replacement, and the renderer's behaviour is untouched: `SchemaRenderer`'s `shouldHide` / `shouldDisable` chains already routed all three keys through core's one definition of "declared" and evaluated the value, and the envelope was already pinned as working on `hidden` and `disabled` — through a `Record` cast, because no key declared it. Measured before this change, `BaseSchema.safeParse({ type, hidden: { dialect: 'cel', source: 'true' } })` returned `success: false` (`invalid_union` at path `hidden`) while the identical envelope on `FormField.visibleWhen` parsed one file over; that is the gap this closes, on all three keys at once. Every boolean and string value keeps parsing and keeps type-checking unchanged. `dialect` is optional and unconstrained on the wire because the runtime reads it that way (only `'cel'` keeps its envelope on the canonical engine; anything else is unwrapped onto the legacy path). + +Not changed: `hasDeclaredPredicate` (no per-key branch — option B was rejected), the `*On` / `visibleWhen` sibling keys, and `ActionSchema.condition`, which already declared the envelope inline. + +Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略"). diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 1be46b4385..d0a817e4ce 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -66,12 +66,12 @@ One row per declared member, in declaration order, so the list can be checked ag | `bind` | `string` | Data-scope path this node draws its rows or value from, resolved by `useDataScope()`. Honoured only by components that call it. | | `body` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component. | | `children` | `SchemaNode \| SchemaNode[]` | Alias for `body`. | -| `visible` | `boolean \| string` | Visibility control. Accepts a boolean **or** a predicate expression string — the renderer evaluates this key rather than reading it as a boolean. | +| `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | | `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. | | `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. | -| `hidden` | `boolean \| string` | Inverse of `visible` — the node is not rendered. Accepts a boolean **or** a predicate expression string, which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. | +| `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. | | `hiddenOn` | `string` | Expression for conditional hiding. | -| `disabled` | `boolean \| string` | Disabled state. Accepts a boolean **or** a predicate expression string, on the same evaluated path as `visible`. | +| `disabled` | `boolean \| string \| { dialect?: string; source: string }` | Disabled state. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), on the same evaluated path as `visible`. | | `disabledOn` | `string` | Expression for conditional disabling. | | `testId` | `string` | Test identifier, rendered as `data-testid`. | | `ariaLabel` | `string \| KeyedI18nLabel` | Accessibility label, rendered as `aria-label`. `KeyedI18nLabel` is the **keyed** form (`{ key, defaultValue?, params? }`), resolved by `resolveKeyedI18nLabel` — **not** the `I18nLabel` that `label` and `description` carry. The two are structurally confusable and each returns nothing useful for the other's input. | diff --git a/content/docs/components/basic/button-group.mdx b/content/docs/components/basic/button-group.mdx index a2fb629d3a..cdfe7f16ca 100644 --- a/content/docs/components/basic/button-group.mdx +++ b/content/docs/components/basic/button-group.mdx @@ -35,7 +35,7 @@ interface ButtonGroupSchema { size?: 'default' | 'sm' | 'lg' | 'icon'; // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Styling className?: string; diff --git a/content/docs/components/basic/div.mdx b/content/docs/components/basic/div.mdx index efe1beaa3e..e1381120d0 100644 --- a/content/docs/components/basic/div.mdx +++ b/content/docs/components/basic/div.mdx @@ -64,7 +64,7 @@ interface DivSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/basic/span.mdx b/content/docs/components/basic/span.mdx index e9d449a328..9cd56086e7 100644 --- a/content/docs/components/basic/span.mdx +++ b/content/docs/components/basic/span.mdx @@ -71,7 +71,7 @@ interface SpanSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/complex/carousel.mdx b/content/docs/components/complex/carousel.mdx index f6be0f86d2..1c27a3a79f 100644 --- a/content/docs/components/complex/carousel.mdx +++ b/content/docs/components/complex/carousel.mdx @@ -38,7 +38,7 @@ interface CarouselSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index 504846a75d..b3baf2c874 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -64,7 +64,7 @@ interface DataTableSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/complex/filter-builder.mdx b/content/docs/components/complex/filter-builder.mdx index 1abbf95027..0384d4e251 100644 --- a/content/docs/components/complex/filter-builder.mdx +++ b/content/docs/components/complex/filter-builder.mdx @@ -69,7 +69,7 @@ interface FilterBuilderSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/complex/resizable.mdx b/content/docs/components/complex/resizable.mdx index 0791198fef..7bc38c9ab7 100644 --- a/content/docs/components/complex/resizable.mdx +++ b/content/docs/components/complex/resizable.mdx @@ -43,7 +43,7 @@ interface ResizableSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/complex/scroll-area.mdx b/content/docs/components/complex/scroll-area.mdx index 15a4e2366d..a6c5ccce5a 100644 --- a/content/docs/components/complex/scroll-area.mdx +++ b/content/docs/components/complex/scroll-area.mdx @@ -44,7 +44,7 @@ interface ScrollAreaSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/data-display/statistic.mdx b/content/docs/components/data-display/statistic.mdx index e90ccc3028..300690e5a4 100644 --- a/content/docs/components/data-display/statistic.mdx +++ b/content/docs/components/data-display/statistic.mdx @@ -43,7 +43,7 @@ interface StatisticSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/data-display/tree-view.mdx b/content/docs/components/data-display/tree-view.mdx index 6d3b17cc91..7dfc470552 100644 --- a/content/docs/components/data-display/tree-view.mdx +++ b/content/docs/components/data-display/tree-view.mdx @@ -50,7 +50,7 @@ interface TreeViewSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/disclosure/toggle-group.mdx b/content/docs/components/disclosure/toggle-group.mdx index b0f117876c..610eb9ff3e 100644 --- a/content/docs/components/disclosure/toggle-group.mdx +++ b/content/docs/components/disclosure/toggle-group.mdx @@ -41,7 +41,7 @@ interface ToggleGroupSchema { onValueChange?: (value: string | string[]) => void; // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Styling className?: string; diff --git a/content/docs/components/feedback/toaster.mdx b/content/docs/components/feedback/toaster.mdx index e4d0b0b298..304334d86e 100644 --- a/content/docs/components/feedback/toaster.mdx +++ b/content/docs/components/feedback/toaster.mdx @@ -27,7 +27,7 @@ interface ToasterSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/form/button.mdx b/content/docs/components/form/button.mdx index 2455e17d33..4bc98693d7 100644 --- a/content/docs/components/form/button.mdx +++ b/content/docs/components/form/button.mdx @@ -57,7 +57,7 @@ interface ButtonSchema { iconPosition?: 'left' | 'right'; // Icon placement // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope loading?: boolean; // Actions diff --git a/content/docs/components/form/calendar.mdx b/content/docs/components/form/calendar.mdx index c0e0213976..53c148932c 100644 --- a/content/docs/components/form/calendar.mdx +++ b/content/docs/components/form/calendar.mdx @@ -36,14 +36,14 @@ interface CalendarSchema { // Constraints minDate?: Date | string; // Minimum selectable date maxDate?: Date | string; // Maximum selectable date - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Styling className?: string; // Tailwind CSS classes // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/form/checkbox.mdx b/content/docs/components/form/checkbox.mdx index f6b874d337..7f4da30ac8 100644 --- a/content/docs/components/form/checkbox.mdx +++ b/content/docs/components/form/checkbox.mdx @@ -18,6 +18,6 @@ interface CheckboxSchema { label?: string; defaultChecked?: boolean; required?: boolean; - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope } ``` diff --git a/content/docs/components/form/combobox.mdx b/content/docs/components/form/combobox.mdx index ac3713b0fb..da2154c0af 100644 --- a/content/docs/components/form/combobox.mdx +++ b/content/docs/components/form/combobox.mdx @@ -39,7 +39,7 @@ interface ComboboxSchema { placeholder?: string; // Button placeholder // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Styling className?: string; diff --git a/content/docs/components/form/date-picker.mdx b/content/docs/components/form/date-picker.mdx index 23aa1c262a..07f6d0a105 100644 --- a/content/docs/components/form/date-picker.mdx +++ b/content/docs/components/form/date-picker.mdx @@ -34,7 +34,7 @@ interface DatePickerSchema { onChange?: (date: Date | undefined) => void; // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Styling className?: string; diff --git a/content/docs/components/form/file-upload.mdx b/content/docs/components/form/file-upload.mdx index d4baecf6c7..c82455fd19 100644 --- a/content/docs/components/form/file-upload.mdx +++ b/content/docs/components/form/file-upload.mdx @@ -43,7 +43,7 @@ interface FileUploadSchema { maxFiles?: number; // Maximum number of files (for multiple) // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Help text description?: string; // Help text or description @@ -54,7 +54,7 @@ interface FileUploadSchema { wrapperClass?: string; // Wrapper container classes // Base properties - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/form/input-otp.mdx b/content/docs/components/form/input-otp.mdx index d68079ad04..c61d6c5de7 100644 --- a/content/docs/components/form/input-otp.mdx +++ b/content/docs/components/form/input-otp.mdx @@ -35,7 +35,7 @@ interface InputOTPSchema { onChange?: (value: string) => void; // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Styling className?: string; diff --git a/content/docs/components/form/input.mdx b/content/docs/components/form/input.mdx index e5de3f2b97..ae46e6342d 100644 --- a/content/docs/components/form/input.mdx +++ b/content/docs/components/form/input.mdx @@ -22,7 +22,7 @@ interface InputSchema { placeholder?: string; defaultValue?: string | number; required?: boolean; - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope readonly?: boolean; } ``` diff --git a/content/docs/components/form/radio-group.mdx b/content/docs/components/form/radio-group.mdx index 2b28b631f7..b7a634bfc6 100644 --- a/content/docs/components/form/radio-group.mdx +++ b/content/docs/components/form/radio-group.mdx @@ -47,7 +47,7 @@ interface RadioGroupSchema { orientation?: 'horizontal' | 'vertical'; // States - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope // Styling className?: string; diff --git a/content/docs/components/form/select.mdx b/content/docs/components/form/select.mdx index 8499dab8cc..b097a2ed54 100644 --- a/content/docs/components/form/select.mdx +++ b/content/docs/components/form/select.mdx @@ -19,6 +19,6 @@ interface SelectSchema { options: { label: string; value: string | number }[]; placeholder?: string; required?: boolean; - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope } ``` diff --git a/content/docs/components/form/switch.mdx b/content/docs/components/form/switch.mdx index 22c2f04df5..742ef1d580 100644 --- a/content/docs/components/form/switch.mdx +++ b/content/docs/components/form/switch.mdx @@ -17,6 +17,6 @@ interface SwitchSchema { name?: string; label?: string; defaultChecked?: boolean; - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope } ``` diff --git a/content/docs/components/form/textarea.mdx b/content/docs/components/form/textarea.mdx index 2479fc44f1..2e5e439392 100644 --- a/content/docs/components/form/textarea.mdx +++ b/content/docs/components/form/textarea.mdx @@ -19,6 +19,6 @@ interface TextareaSchema { placeholder?: string; rows?: number; required?: boolean; - disabled?: boolean | string; // boolean, or a predicate expression + disabled?: boolean | string | { dialect?: string; source: string }; // boolean, predicate expression, or CEL envelope } ``` diff --git a/content/docs/components/layout/box.mdx b/content/docs/components/layout/box.mdx index 5446f51f6d..edea41ae62 100644 --- a/content/docs/components/layout/box.mdx +++ b/content/docs/components/layout/box.mdx @@ -47,7 +47,7 @@ interface BoxSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; } ``` diff --git a/content/docs/components/layout/page.mdx b/content/docs/components/layout/page.mdx index 8047751d5c..7d06ac9101 100644 --- a/content/docs/components/layout/page.mdx +++ b/content/docs/components/layout/page.mdx @@ -33,7 +33,7 @@ interface PageSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/content/docs/components/navigation/header-bar.mdx b/content/docs/components/navigation/header-bar.mdx index 8bf92eb8f4..397663f7f9 100644 --- a/content/docs/components/navigation/header-bar.mdx +++ b/content/docs/components/navigation/header-bar.mdx @@ -48,7 +48,7 @@ interface HeaderBarSchema { // Base properties id?: string; - visible?: boolean | string; + visible?: boolean | string | { dialect?: string; source: string }; testId?: string; } ``` diff --git a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx index a88b5d74d8..d53b7a123b 100644 --- a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx @@ -119,8 +119,8 @@ function renderNode(schema: Record) { * * `renderNode` above spreads a `Record` through `as never` * because most of this file exercises shapes `BaseSchema` does not declare and - * should not: `null`, `0`, `[]`, `{}`, and the CEL envelope object. Those keep - * the cast. + * should not: `null`, `0`, `[]`, `{}`, and the EMPTY envelopes. Those keep the + * cast. * * The STRING form is different since objectui#7455 (ruled 2026-09-03): * `hidden` is declared `boolean | string`, so an expression-valued `hidden` is @@ -130,9 +130,12 @@ function renderNode(schema: Record) { * only thing that can see that; vitest cannot, because the annotation is erased * before a single case runs. * - * The envelope pin below deliberately stays on `renderNode`: the envelope form - * is declared on NONE of `visible` / `hidden` / `disabled`, and objectui#7530 - * rules on all three together. + * The CEL ENVELOPE is declared too since objectui#7530 (ruled 2026-09-04, + * option A, on all three keys at once): `hidden` is `boolean | ExpressionWire`, + * where `ExpressionWire` is the string-or-envelope union `visibleWhen` already + * carried, so the non-empty envelope pin below runs through this helper as + * well. That its verdict is IDENTICAL to the string form's, on all three keys + * and in both polarities, is `SchemaRenderer.predicateEnvelopeDeclared.test.tsx`. */ function renderDeclaredNode(schema: BaseSchema) { return render( @@ -214,11 +217,11 @@ describe('SchemaRenderer `hidden` — an empty predicate is not a declared gate expect(rendered()).toBe(true); }); - it('a non-empty CEL envelope keeps its verdict, both ways', () => { - const { unmount } = renderNode({ hidden: { dialect: 'cel', source: 'true' } }); + it('a non-empty CEL envelope keeps its verdict, both ways -- through the DECLARED path, no cast (objectui#7530)', () => { + const { unmount } = renderDeclaredNode({ type: 'probe-3955', hidden: { dialect: 'cel', source: 'true' } }); expect(rendered()).toBe(false); unmount(); - renderNode({ hidden: { dialect: 'cel', source: 'false' } }); + renderDeclaredNode({ type: 'probe-3955', hidden: { dialect: 'cel', source: 'false' } }); expect(rendered()).toBe(true); }); diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx new file mode 100644 index 0000000000..487e8628c4 --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx @@ -0,0 +1,186 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7530 -- the CEL envelope object is DECLARED on `visible` / `hidden` + * / `disabled` (maintainer ruling 2026-09-04, option A), and it renders + * IDENTICALLY to the string form the same keys already declared. + * + * ## What "identically" means here, and how it is measured + * + * The three keys reach the evaluator by two routes. `visible` is consulted the + * moment it is `!== undefined` and handed to `evaluateVisibilityPredicate`; + * `hidden` and `disabled` first ask core's one definition of "declared" + * (`hasDeclaredPredicate`) and then evaluate. Both routes end in + * `ExpressionEvaluator.evaluateCondition`, which routes a `{ dialect: 'cel' }` + * envelope to the canonical `@objectstack/formula` engine and unwraps any other + * envelope onto the legacy `${...}` path. So ONE predicate written three ways + * must reach ONE verdict on each key: + * + * - the `${...}` template string (the form #4581 / #7455 declared); + * - `{ dialect: 'cel', source }` over the CEL spelling of the same predicate; + * - `{ source }` -- an envelope WITHOUT a dialect, over the template spelling, + * which the normalizer unwraps onto the legacy path (`dialect` is optional + * on the wire, exactly as `ExpressionWire` declares it). + * + * Each key is measured in BOTH polarities, so a faulting CEL read cannot pass + * as identity: the evaluator's fail-soft default is `true`, which on `hidden` + * hides both cases, on `visible` shows both, on `disabled` disables both -- and + * each pair below has one case a constant verdict fails. + * + * ## No casts + * + * Every schema below is typed `BaseSchema`, through a helper that takes + * nothing wider. `tsc -p tsconfig.test.json` (chained from this package's + * `type-check` script) is the checker that sees the declaration; vitest erases + * it before a single case runs. Narrowing any of the three keys back to + * `boolean | string` makes the envelope call sites below TS2322. + * + * ## What each case detects + * + * - a spelling that reaches a DIFFERENT verdict from its siblings -- the + * defect this file exists to keep out (a per-key branch in the shared + * evaluator, option B, would show up here first); + * - the envelope silently falling to "no gate" on one key -- the `holds` + * polarity catches it on `visible` (would render) and `disabled` (would + * forward nothing), the `fails` polarity on `hidden` (would hide); + * - `dialect` becoming required somewhere on the path -- the dialect-less + * rows go red on all three keys at once. + * + * The validate-accepts half of the same ruling lives in + * `packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts`, + * beside the zod mirror it pins. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import type { BaseSchema, ExpressionWire } from '@object-ui/types'; +import { SchemaRenderer } from '../SchemaRenderer'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; + +/** + * Records the `disabled` prop exactly as it arrives, so "the node rendered" and + * "the renderer forwarded `disabled`" are separate observations. + */ +const Probe = (props: { disabled?: unknown }) => ( +
+); + +const DATA = { status: 'draft', published: false }; + +/** The DECLARED path -- `BaseSchema`, nothing wider, no cast. */ +function mount(schema: BaseSchema) { + return render( + + + , + ); +} + +function visibility(): 'rendered' | 'not rendered' { + return screen.queryByTestId('probe') !== null ? 'rendered' : 'not rendered'; +} + +/** The forwarded prop, or `null` when the node did not render at all. */ +function disabledProp(): string | null { + const el = screen.queryByTestId('probe'); + return el ? el.getAttribute('data-disabled-prop') : null; +} + +/** + * One predicate, three spellings. `holds` is TRUE against `DATA` + * (`status === 'draft'`); `fails` is FALSE (`published`). + */ +const SPELLINGS: Array<{ label: string; holds: ExpressionWire; fails: ExpressionWire }> = [ + { + label: '`${...}` template string (the form already declared)', + holds: '${data.status === "draft"}', + fails: '${data.published}', + }, + { + label: "{ dialect: 'cel', source } -- the canonical engine", + holds: { dialect: 'cel', source: 'data.status == "draft"' }, + fails: { dialect: 'cel', source: 'data.published' }, + }, + { + label: '{ source } -- no dialect, unwrapped onto the legacy path', + holds: { source: '${data.status === "draft"}' }, + fails: { source: '${data.published}' }, + }, +]; + +describe('the CEL envelope renders identically to the string form on all three keys (objectui#7530)', () => { + beforeEach(() => { + ComponentRegistry.register('probe-7530', Probe as never); + }); + afterEach(() => { + ComponentRegistry.unregister?.('probe-7530'); + }); + + describe.each(SPELLINGS)('$label', ({ holds, fails }) => { + it('visible: a holding predicate renders, a failing one does not', () => { + const { unmount } = mount({ type: 'probe-7530', visible: holds }); + expect(visibility()).toBe('rendered'); + unmount(); + mount({ type: 'probe-7530', visible: fails }); + expect(visibility()).toBe('not rendered'); + }); + + it('hidden: a holding predicate hides, a failing one renders', () => { + const { unmount } = mount({ type: 'probe-7530', hidden: holds }); + expect(visibility()).toBe('not rendered'); + unmount(); + mount({ type: 'probe-7530', hidden: fails }); + expect(visibility()).toBe('rendered'); + }); + + it('disabled: a holding predicate forwards `disabled`, a failing one forwards nothing', () => { + const { unmount } = mount({ type: 'probe-7530', disabled: holds }); + expect(disabledProp()).toBe('true'); + unmount(); + mount({ type: 'probe-7530', disabled: fails }); + expect(disabledProp()).toBe('absent'); + }); + }); + + it('the three spellings reach ONE verdict per key and polarity -- measured side by side', () => { + const verdicts: Record> = {}; + const record = (bucket: string, verdict: string) => { + (verdicts[bucket] ??= new Set()).add(verdict); + }; + for (const { holds, fails } of SPELLINGS) { + for (const [polarity, predicate] of [['holds', holds], ['fails', fails]] as const) { + let view = mount({ type: 'probe-7530', visible: predicate }); + record(`visible/${polarity}`, visibility()); + view.unmount(); + view = mount({ type: 'probe-7530', hidden: predicate }); + record(`hidden/${polarity}`, visibility()); + view.unmount(); + view = mount({ type: 'probe-7530', disabled: predicate }); + record(`disabled/${polarity}`, String(disabledProp())); + view.unmount(); + } + } + // Six buckets, one verdict each -- and the verdicts differ BETWEEN + // polarities, so this is not three spellings agreeing on a constant. + expect(Object.fromEntries(Object.entries(verdicts).map(([k, v]) => [k, [...v]]))).toEqual({ + 'visible/holds': ['rendered'], + 'visible/fails': ['not rendered'], + 'hidden/holds': ['not rendered'], + 'hidden/fails': ['rendered'], + 'disabled/holds': ['true'], + 'disabled/fails': ['absent'], + }); + }); +}); diff --git a/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts b/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts index e92d2d62fb..673f4f24a4 100644 --- a/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts +++ b/packages/types/src/__tests__/base-schema-hidden-predicate.test.ts @@ -42,7 +42,8 @@ * ## What this file pins, and why in this shape * * 1. Type level — `BaseSchema['hidden']` is EXACTLY - * `boolean | string | undefined`, invariantly. `Equal`, not `extends`: + * `boolean | ExpressionWire | undefined` (`boolean | string | undefined` + * until objectui#7530 declared the envelope), invariantly. `Equal`, not `extends`: * the narrow `boolean` is assignable to the wide union, so a one-way check * stays green on a widening that never happened, and `BaseSchema`'s * `[key: string]: any` index signature means a DELETED member reads `any`, @@ -57,15 +58,17 @@ * `hidden`. The refusal is the anti-overshoot guard: `z.any()` would * satisfy every positive case on its own. * - * ## Deliberately NOT pinned here: the CEL envelope object + * ## The CEL envelope object — declared since objectui#7530 * - * `hasDeclaredPredicate` accepts `{ dialect, source }` on this key, and NO key - * declares it — `visible` and `disabled` are `boolean | string` and under-report - * it too. objectui#7530 rules on all three together (declare on all three, or - * refuse on all three). This file therefore asserts nothing about that shape in - * either direction; pinning the current refusal on `hidden` alone would - * pre-empt that ruling and re-introduce, in the pins, exactly the three-way - * asymmetry the widening just removed. + * This file used to assert nothing about `{ dialect, source }` in either + * direction: `hasDeclaredPredicate` accepted it on this key while NO key + * declared it, and pinning the refusal on `hidden` alone would have pre-empted + * the ruling on all three. objectui#7530 (ruled 2026-09-04, option A) declared + * it on all three keys at once through one shared `ExpressionWire`, so the + * type-level assertion above widened with it; the envelope's own pins — + * validate-accepts on every key, reuse by reference, twin parity — live in + * `base-schema-predicate-envelope-7530.test.ts`, and the string-form pins here + * are unchanged. * * ADR-0089's carve-out ("the boolean `visible` ... is explicitly out of scope") * governs `packages/spec`'s keys, not this surface — `BaseSchema` is objectui's @@ -76,6 +79,7 @@ import { describe, it, expect } from 'vitest'; import type { BaseSchema } from '../base'; import { BaseSchema as Mirror } from '../zod/base.zod'; +import type { ExpressionWire } from '../expression'; /* ── Type-level helpers ──────────────────────────────────────────────────── */ @@ -87,7 +91,7 @@ type Expect< T extends true > = T; /* ── The declared type is exactly what the evaluator accepts ─────────────── */ export type assertionHidden = Expect< - Equal< BaseSchema['hidden'], boolean | string | undefined > + Equal< BaseSchema['hidden'], boolean | ExpressionWire | undefined > >; /** The two siblings, asserted beside it: all three keys carry one type. */ diff --git a/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts b/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts index efce1d3df1..8ac34c3250 100644 --- a/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts +++ b/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts @@ -142,6 +142,7 @@ import type { BaseSchema, KeyedI18nLabel } from '../base'; // The INLINE vocabulary, bound from the spec by reference (never re-declared // locally) — the same binding `packages/types/src/index.ts` re-exports. import type { I18nLabel } from '@objectstack/spec/ui'; +import type { ExpressionWire } from '../expression'; /* ── Type-level helpers ──────────────────────────────────────────────────── */ @@ -167,8 +168,10 @@ export type assertionAriaLabel = Expect< Equal< BaseSchema['ariaLabel'], string | KeyedI18nLabel | undefined > >; +// `boolean | string` until objectui#7530 declared the CEL envelope on all three +// predicate keys through the shared `ExpressionWire`. export type assertionDisabled = Expect< - Equal< BaseSchema['disabled'], boolean | string | undefined > + Equal< BaseSchema['disabled'], boolean | ExpressionWire | undefined > >; /* ── PIN MOVED (objectui#4580 revised Q1) ────────────────────────────────── */ diff --git a/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts b/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts new file mode 100644 index 0000000000..e03d8e7ffd --- /dev/null +++ b/packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `BaseSchema.visible` / `.hidden` / `.disabled` declare the CEL envelope object + * the shared evaluator already accepts, as ONE named wire type (objectui#7530, + * maintainer ruling 2026-09-04: option A -- declare on all three by reusing + * `ExpressionWireSchema`; not B, a per-key branch in `hasDeclaredPredicate`). + * + * ## The measured fact this closes + * + * `hasDeclaredPredicate` (`packages/core/src/evaluator/declaredPredicate.ts`) + * is the one definition of "is a gate declared?" every leg of `shouldHide` / + * `shouldDisable` asks, and it -- with `toPredicateInput` under it -- accepts + * three shapes: a boolean, an expression string, and the envelope + * `{ dialect?, source }`. `SchemaRenderer.hiddenDeclaredGate.test.tsx` and + * `SchemaRenderer.disabledDeclaredGate.test.tsx` had pinned the envelope as + * WORKING on `hidden` and `disabled` through a `Record` cast, because no key + * declared it. Measured before this change on `origin/main` `f96a781`: + * + * - TS -- all three were `boolean | string` (`base.ts:281` / `:359` / `:385`). + * - zod -- all three were `z.union([z.boolean(), z.string()])` + * (`base.zod.ts:158` / `:188` / `:203`), and + * `BaseSchema.safeParse({ type, hidden: { dialect: 'cel', source: 'true' } })` + * returned `success: false` with `invalid_union` at path `hidden` -- the + * same envelope `FormFieldSchema.visibleWhen` parsed one file over. + * + * So a schema authoring the envelope on any of the three keys failed `validate` + * and rendered. Declared != enforced, in the direction that gives an AI author + * no signal. + * + * ## What this file pins, and why in this shape + * + * 1. Type level -- each key is EXACTLY `boolean | ExpressionWire | undefined`, + * invariantly, and the three carry the SAME declared type. `Equal`, not + * `extends`: the old `boolean | string` is assignable to the wide union, + * so a one-way check stays green on a widening that never happened, and + * `BaseSchema`'s `[key: string]: any` index signature means a DELETED + * member reads `any`, which a one-way check also accepts. + * 2. Twin parity -- `ExpressionWire` (TS) and `z.input` of + * `ExpressionWireSchema` (zod) are the same union, so the two faces cannot + * drift apart the way `hidden` did between #4581 and #7455. + * 3. Reuse by REFERENCE -- the object arm each key's zod union carries IS + * `ExpressionWireSchema`, and so is the one `FormFieldSchema.visibleWhen` + * carries after the hoist. A faithful copy passes every value comparison; + * identity is the only check that distinguishes reuse from the second + * envelope type the ruling forbids (the insight `spec-subschema-parity` + * already wrote down for spec re-exports). + * 4. Runtime -- the envelope `safeParse`s GREEN in full on all three keys, + * with and without `dialect`, beside the string and boolean controls; and + * the anti-overshoot guards: `{}` (no `source`), `{ dialect: 'cel' }`, + * `{ source: 123 }`, a bare number and `null` are still refused AT THE + * KEY'S PATH. `z.any()` would satisfy every positive case on its own. + * + * ## Why `dialect` is optional and unconstrained + * + * Because the runtime reads it that way. `toPredicateInput` keeps a `'cel'` + * envelope on the canonical engine and unwraps EVERY other dialect (absent, + * `'template'`, unknown) onto the legacy `${...}` path; an object without a + * string `source` is "no predicate". `{ dialect: 'cel' }` alone would refuse a + * spelling the evaluator answers -- a declaration narrower than the runtime is + * the same defect in the other direction. + * + * ## ADR-0089, read again for this card + * + * D4 -- "the boolean `visible` (Tab on/off) is a different type and concept and + * is explicitly out of scope" -- governs `packages/spec`'s keys, not this + * surface; `BaseSchema` is objectui's own declaration (#7455's triage). The ADR + * contains no statement that a predicate key is string-only: its D1 gives + * `visibleWhen` the spec's `ExpressionInputSchema` value, which IS the envelope. + * + * ## Predictions, written before the first run (red-first) + * + * With `base.ts` / `base.zod.ts` at their `origin/main` `f96a781` blobs and + * this file in place: `tsc -p tsconfig.test.json` reports TS2344 on the three + * key assertions (TS face); the twin-parity and wire-shape assertions stay + * clean (they do not read `BaseSchema`); every `safeParse`-green envelope case + * fails with `invalid_union` at the key's path (zod face); every control case + * and every refusal case stays green; the identity cases fail because the + * object arm of `boolean | string` does not exist. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import type { BaseSchema } from '../base'; +import type { ExpressionWire } from '../expression'; +import { BaseSchema as Mirror } from '../zod/base.zod'; +import { ExpressionWireSchema } from '../zod/expression.zod'; +import { FormFieldSchema, SelectOptionSchema } from '../zod/form.zod'; + +/* -- 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 --------------- */ + +/** The union all three keys carry, pinned here so the checks below cannot drift from it. */ +type BasePredicate = boolean | ExpressionWire | undefined; + +export type assertionVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >; +export type assertionHidden = Expect< Equal< BaseSchema['hidden'], BasePredicate > >; +export type assertionDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >; + +/** The wire union itself, spelled out -- the name must not quietly widen. */ +export type assertionWireShape = Expect< + Equal< ExpressionWire, string | { dialect?: string; source: string } > +>; + +/** The two faces of the wire type are one union. */ +export type assertionTwinParity = Expect< + Equal< ExpressionWire, z.input< typeof ExpressionWireSchema > > +>; + +/** The helper can FAIL -- the old union is not the new one. */ +export type assertionEqualCanFail = Expect< + Equal< Equal< boolean | string | undefined, BasePredicate >, false > +>; + +/* -- Authorable fixtures, no cast ------------------------------------------ */ + +/** The capability the renderer implements, now declared on each key. */ +export const visibleEnvelopeIsAuthorable: BaseSchema = { + type: 'test-component', + visible: { dialect: 'cel', source: 'record.status == "open"' }, +}; +export const hiddenEnvelopeIsAuthorable: BaseSchema = { + type: 'test-component', + hidden: { dialect: 'cel', source: 'record.status == "draft"' }, +}; +export const disabledEnvelopeIsAuthorable: BaseSchema = { + type: 'test-component', + disabled: { dialect: 'cel', source: 'record.status == "locked"' }, +}; + +/** `dialect` is optional on the wire -- the legacy-path envelope is authorable too. */ +export const dialectlessEnvelopeIsAuthorable: BaseSchema = { + type: 'test-component', + visible: { source: '${data.role === "admin"}' }, +}; + +/* -- Runtime companion (the zod mirror) ------------------------------------ */ + +const KEYS = ['visible', 'hidden', 'disabled'] as const; +type PredicateKey = (typeof KEYS)[number]; + +const CEL_ENVELOPE = { dialect: 'cel', source: 'data.status == "draft"' }; +const DIALECTLESS_ENVELOPE = { source: '${data.status === "draft"}' }; +const PREDICATE = '${data.status === "draft"}'; + +const REFUSED: Array<{ label: string; value: unknown }> = [ + { label: '{} (no source)', value: {} }, + { label: "{ dialect: 'cel' } (no source)", value: { dialect: 'cel' } }, + { label: '{ source: 123 } (source is not a string)', value: { source: 123 } }, + { label: '123 (a number)', value: 123 }, + { label: 'null', value: null }, +]; + +/** The object arm of a key's `z.union([z.boolean(), ExpressionWireSchema]).optional()`. */ +function envelopeArmOf(key: PredicateKey): unknown { + const union = Mirror.shape[key].unwrap(); + return union.options[1]; +} + +describe.each(KEYS)('BaseSchema.%s declares the CEL envelope (objectui#7530)', (key) => { + it('zod mirror: the CEL envelope parses in full', () => { + // Full parse, not just "no unrecognized_keys": this is a judgement about + // the VALUE, so nothing short of a green `safeParse` measures it. + expect(Mirror.safeParse({ type: 'test-component', [key]: CEL_ENVELOPE }).success).toBe(true); + }); + + it('zod mirror: an envelope without a dialect parses too -- `dialect` is optional on the wire', () => { + expect(Mirror.safeParse({ type: 'test-component', [key]: DIALECTLESS_ENVELOPE }).success).toBe(true); + }); + + it('zod mirror: the string and boolean forms still parse -- a widening, not a replacement', () => { + expect(Mirror.safeParse({ type: 'test-component', [key]: PREDICATE }).success).toBe(true); + expect(Mirror.safeParse({ type: 'test-component', [key]: true }).success).toBe(true); + expect(Mirror.safeParse({ type: 'test-component', [key]: false }).success).toBe(true); + }); + + it.each(REFUSED)('zod mirror: $label is still refused at the key path -- the anti-overshoot guard', ({ value }) => { + // `BaseSchema` is `.passthrough()`, but the key is DECLARED, so a + // wrong-typed value is an error at its path rather than a passthrough. + const result = Mirror.safeParse({ type: 'test-component', [key]: value }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join('.') === key)).toBe(true); + } + }); +}); + +describe('one envelope type across the package -- reuse pinned by reference (objectui#7530)', () => { + it.each(KEYS)('BaseSchema.shape.%s carries ExpressionWireSchema itself, not a copy', (key) => { + expect(envelopeArmOf(key)).toBe(ExpressionWireSchema); + }); + + it('the hoisted form legs carry the same object', () => { + expect(FormFieldSchema.shape.visibleWhen.unwrap()).toBe(ExpressionWireSchema); + expect(FormFieldSchema.shape.visibleOn.unwrap()).toBe(ExpressionWireSchema); + expect(FormFieldSchema.shape.readonlyWhen.unwrap()).toBe(ExpressionWireSchema); + expect(FormFieldSchema.shape.requiredWhen.unwrap()).toBe(ExpressionWireSchema); + expect(SelectOptionSchema.shape.visibleWhen.unwrap()).toBe(ExpressionWireSchema); + }); + + it('... and the form leg still parses the envelope -- the hoist moved the const, not its verdict', () => { + expect(SelectOptionSchema.safeParse({ label: 'Draft', value: 'draft', visibleWhen: CEL_ENVELOPE }).success).toBe(true); + expect(SelectOptionSchema.safeParse({ label: 'Draft', value: 'draft', visibleWhen: PREDICATE }).success).toBe(true); + expect(SelectOptionSchema.safeParse({ label: 'Draft', value: 'draft', visibleWhen: {} }).success).toBe(false); + }); + + it('the shared const accepts exactly the wire union', () => { + expect(ExpressionWireSchema.safeParse(PREDICATE).success).toBe(true); + expect(ExpressionWireSchema.safeParse(CEL_ENVELOPE).success).toBe(true); + expect(ExpressionWireSchema.safeParse(DIALECTLESS_ENVELOPE).success).toBe(true); + expect(ExpressionWireSchema.safeParse(true).success).toBe(false); + expect(ExpressionWireSchema.safeParse({}).success).toBe(false); + }); + + it('type-level: the three keys and the twin parity are 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(visibleEnvelopeIsAuthorable.visible).toEqual({ dialect: 'cel', source: 'record.status == "open"' }); + expect(hiddenEnvelopeIsAuthorable.hidden).toEqual({ dialect: 'cel', source: 'record.status == "draft"' }); + expect(disabledEnvelopeIsAuthorable.disabled).toEqual({ dialect: 'cel', source: 'record.status == "locked"' }); + expect(dialectlessEnvelopeIsAuthorable.visible).toEqual({ source: '${data.role === "admin"}' }); + }); +}); diff --git a/packages/types/src/__tests__/base-schema-visible-predicate.test.ts b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts index 325f20faae..ab99f591c0 100644 --- a/packages/types/src/__tests__/base-schema-visible-predicate.test.ts +++ b/packages/types/src/__tests__/base-schema-visible-predicate.test.ts @@ -49,10 +49,18 @@ * 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. + * + * Widened again by objectui#7530 (ruled 2026-09-04, option A): the expression + * half is now `ExpressionWire` — the string-or-CEL-envelope union — on all three + * of `visible` / `hidden` / `disabled`, so `assertionVisible` below reads that + * union. The envelope's own pins live in + * `base-schema-predicate-envelope-7530.test.ts`; this file keeps the + * string-form ones. */ import { describe, it, expect } from 'vitest'; import type { BaseSchema } from '../base'; +import type { ExpressionWire } from '../expression'; /* ── Type-level helpers ──────────────────────────────────────────────────── */ @@ -64,7 +72,7 @@ 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 > + Equal< BaseSchema['visible'], boolean | ExpressionWire | undefined > >; /* ── Authorable fixtures ─────────────────────────────────────────────────── */ diff --git a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts index a77366b3b5..8caf19d157 100644 --- a/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts +++ b/packages/types/src/__tests__/button-group-doc-surface-6347.test.ts @@ -155,7 +155,10 @@ function interfaceBody(source: string, name: string): string { /** Member rows of an interface body, keyed by name. */ function documentedMembers(body: string): Map { const members = new Map(); - for (const match of body.matchAll(/^ {2}(\w+)(\?)?:\s*([^;]+);/gm)) { + // The type text runs to the `;` that ENDS the row — an inline object type + // carries its own `;` between members (`{ dialect?: string; source: string }`, + // objectui#7530), so "up to the first `;`" would truncate it. + for (const match of body.matchAll(/^ {2}(\w+)(\?)?:\s*(.+?);(?=\s*(?:\/\/.*)?$)/gm)) { members.set(match[1], { optional: match[2] === '?', typeText: match[3].trim() }); } return members; @@ -189,10 +192,12 @@ const declaredOptional = (shape: ZodShape, key: string): boolean => */ interface WrapperCarrier { readonly options?: readonly unknown[]; + readonly shape?: Record; readonly def?: { readonly type?: string; readonly innerType?: unknown; readonly options?: readonly unknown[]; + readonly shape?: Record; }; readonly _def?: { readonly innerType?: unknown }; } @@ -207,12 +212,34 @@ function unwrapWrappers(node: unknown): WrapperCarrier | undefined { return carrier; } -/** The declared type of a member, spelled the way the page writes it. */ +/** + * The declared type of a member, spelled the way the page writes it. + * + * A union is FLATTENED, because the mirror composes one: since objectui#7530 + * `disabled` is `z.union([z.boolean(), ExpressionWireSchema])`, and + * `ExpressionWireSchema` is itself the union `string | { dialect?: string; + * source: string }` reused by reference (the ruling forbids a second envelope + * spelling), so the page's flat `boolean | string | { dialect?: string; source: + * string }` is the nested mirror read through. An object arm is spelled as an + * inline object type — each member with its own type, `?` on the optional ones + * — because that is the one spelling that is valid TypeScript inside a `ts` + * fence AND a faithful reading of the mirror, so the pages can carry one + * spelling whatever their fence language (`box.mdx` fences its block `ts`, + * and `check:doc-snippets` compiles it). + */ function declaredTypeText(node: unknown): string { const inner = unwrapWrappers(node); if (inner?.def?.type === 'union') { const options = inner.options ?? inner.def.options ?? []; - return options.map((option) => unwrapWrappers(option)?.def?.type ?? 'unknown').join(' | '); + return options.map(declaredTypeText).join(' | '); + } + if (inner?.def?.type === 'object') { + const shape = inner.shape ?? inner.def.shape ?? {}; + const members = Object.entries(shape).map(([key, member]) => { + const optional = (member as WrapperCarrier | undefined)?.def?.type === 'optional'; + return `${key}${optional ? '?' : ''}: ${declaredTypeText(member)}`; + }); + return `{ ${members.join('; ')} }`; } return String(inner?.def?.type ?? 'unknown'); } @@ -302,7 +329,9 @@ describe('button-group.mdx: the `ButtonGroupSchema` block IS the shipped mirror // correctly say `boolean`: those 13 schemas redeclare it. This assertion is // what stops a later `disabled?: boolean` narrowing on ButtonGroupSchema // from leaving the page silently over-stating instead of under-stating. - expect(declaredTypeText(groupShape.disabled)).toBe('boolean | string'); + // `boolean | string` until objectui#7530 declared the CEL envelope on the + // base union; the object arm is the shared `ExpressionWireSchema` read through. + expect(declaredTypeText(groupShape.disabled)).toBe('boolean | string | { dialect?: string; source: string }'); expect(Object.keys(groupShape).includes('disabled')).toBe(true); }); }); diff --git a/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts index 3448d2205b..1b065b8721 100644 --- a/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts +++ b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts @@ -81,6 +81,7 @@ import { ChatbotSchema as ChatbotZod, ComplexSchema as ComplexZod, } from '../zod/complex.zod'; +import type { ExpressionWire } from '../expression'; /* ── Type-level helpers (the `tsc` channel) ──────────────────────────────── */ @@ -204,8 +205,9 @@ export type assertionSurfaceVocabulary = Expect< * that dropped both keys cannot pass vacuously. */ export type assertionDisabledStaysTheBaseUnion = [ - Expect>, - Expect>, + // `boolean | string` until objectui#7530 declared the CEL envelope on the base union. + Expect>, + Expect>, Expect>, Expect>, ]; diff --git a/packages/types/src/__tests__/component-docs-disabled-inherited-7239.test.ts b/packages/types/src/__tests__/component-docs-disabled-inherited-7239.test.ts index f3e3b3e13e..f8b03314aa 100644 --- a/packages/types/src/__tests__/component-docs-disabled-inherited-7239.test.ts +++ b/packages/types/src/__tests__/component-docs-disabled-inherited-7239.test.ts @@ -2,8 +2,13 @@ /** * Every `content/docs/components/**` page that documents a schema INHERITING - * `disabled` spells it `boolean | string` (objectui#7239, the docs half of the - * objectui#7087 ruling of 2026-09-01). + * `disabled` spells it the way `BaseSchema` declares it (objectui#7239, the + * docs half of the objectui#7087 ruling of 2026-09-01) — `boolean | string` + * then, `boolean | string | { dialect?: string; source: string }` since + * objectui#7530 declared the CEL envelope on all three predicate keys (ruled + * 2026-09-04) — the same flat spelling `button-group-doc-surface-6347.test.ts` + * reads off the mirror, and the one that is valid TypeScript inside a `ts` + * fence (`box.mdx`), so every page carries one spelling whatever its fence. * * ## Why a pin rather than "the docs gates went green" * @@ -86,7 +91,9 @@ const DOC_DIR = join(REPO_ROOT, 'content', 'docs', 'components'); const TYPES_DIR = join(REPO_ROOT, 'packages', 'types', 'src'); const DECL_RE = /^\s*(?:export\s+)?(?:interface|type)\s+([A-Za-z0-9_]+)/; -const DISABLED_RE = /^\s*disabled\?:\s*(.+?);/; +// Up to the `;` that ENDS the row: the inline object type carries its own `;` +// between members (`{ dialect?: string; source: string }`, objectui#7530). +const DISABLED_RE = /^\s*disabled\?:\s*(.+?);(?=\s*(?:\/\/.*)?$)/; /** Every `disabled?:` row under `content/docs/components`, with its owner. */ interface DocRow { @@ -185,11 +192,11 @@ const INDEPENDENT = [ { page: 'overlay/menubar.mdx', iface: 'MenubarCommandItem', shippedName: null }, ] as const; -describe('component pages spell the INHERITED `disabled` as `boolean | string` (objectui#7239)', () => { +describe('component pages spell the INHERITED `disabled` as the base union (objectui#7239, widened by objectui#7530)', () => { it.each(INHERITED)('$page documents $iface with the inherited union', ({ page, iface }) => { const row = rowFor(page, iface); expect(row, `no \`disabled?:\` row attributed to ${iface} in ${page}`).toBeDefined(); - expect(`${page} ${iface} -> ${row?.type}`).toBe(`${page} ${iface} -> boolean | string`); + expect(`${page} ${iface} -> ${row?.type}`).toBe(`${page} ${iface} -> boolean | string | { dialect?: string; source: string }`); }); it.each(INHERITED)('$iface still inherits `disabled` in the shipped tree', ({ iface }) => { diff --git a/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts b/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts index 9a00a7da79..5a854781af 100644 --- a/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts +++ b/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts @@ -102,6 +102,7 @@ import { ToggleGroupSchema as ToggleGroupMirror, } from '../zod/disclosure.zod'; import * as FormMirrors from '../zod/form.zod'; +import type { ExpressionWire } from '../expression'; /* ── Type-level helpers ──────────────────────────────────────────────────── */ @@ -110,8 +111,14 @@ 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 union both twins carry on `BaseSchema`, pinned here so the checks below cannot drift from it. */ -type BasePredicate = boolean | string | undefined; +/** + * The union both twins carry on `BaseSchema`, pinned here so the checks below + * cannot drift from it. `boolean | string | undefined` until objectui#7530 + * (ruled 2026-09-04) declared the CEL envelope on all three predicate keys + * through the shared `ExpressionWire`; the 18 formerly-narrowed interfaces + * inherit the wider union exactly as they inherited the narrower one. + */ +type BasePredicate = boolean | ExpressionWire | undefined; export type assertionBaseDisabled = Expect< Equal< BaseSchema['disabled'], BasePredicate > >; export type assertionBaseVisible = Expect< Equal< BaseSchema['visible'], BasePredicate > >; diff --git a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts index 4da024fc5d..8496de6741 100644 --- a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts +++ b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts @@ -71,6 +71,7 @@ import { ObjectGallerySchema as ObjectGalleryMirror, ObjectDataTableSchema as ObjectDataTableMirror, } from '../zod/objectql.zod.js'; +import type { ExpressionWire } from '../expression'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(HERE, '..', '..', '..', '..'); @@ -97,7 +98,8 @@ export type assertionOnRowClickDeclared = Expect>; export type assertionGalleryInheritsBind = Expect>; -export type assertionDataTableInheritsVisible = Expect>; +// `boolean | string` until objectui#7530 declared the CEL envelope on the base union. +export type assertionDataTableInheritsVisible = Expect>; export type assertionDataTableInheritsBind = Expect>; /** The widget-local members keep their measured spellings. */ export type assertionGalleryDataStaysTyped = Expect[] | undefined>>; @@ -114,7 +116,7 @@ describe('ObjectGallerySchema / ObjectDataTableSchema — compile-time pins (obj // the declared reason. Each directive fails the build (TS2578) the moment // the member stops being declared. - // @ts-expect-error — `visible` is `boolean | string | undefined` through `BaseSchema`. + // @ts-expect-error — `visible` is `boolean | ExpressionWire | undefined` through `BaseSchema`. const gallery: ObjectGallerySchema = { type: 'object-gallery', visible: 42 }; // @ts-expect-error — same member, same reason, on the type that used to absorb it. const table: ObjectDataTableSchema = { type: 'object-data-table', visible: 42 }; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 2addbb0382..748a76e662 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -1736,6 +1736,8 @@ const EXCLUSIONS: Readonly> = { "a union OVER the mirrors, not an object of its own — its members are checked individually above", 'index.zod.ts#AnyComponentSchema': "the barrel union OVER the mirrors, not an object of its own — its members are checked individually above", + 'expression.zod.ts#ExpressionWireSchema': + "a union (`string | { dialect?, source }`) with no `.shape` of its own — the predicate WIRE shape `BaseSchema`'s `visible` / `hidden` / `disabled` and the form predicate keys carry (objectui#7530); its TS twin `ExpressionWire` (`../expression.ts`) is a type alias, not a key set, and the two faces are pinned equal in `base-schema-predicate-envelope-7530.test.ts`", 'index.zod.ts#SCHEMA_VERSION': "a version string, not a schema", }; diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index a93b73789f..32f9784fda 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -17,6 +17,7 @@ */ import type { I18nLabel } from '@objectstack/spec/ui'; +import type { ExpressionWire } from './expression.js'; /** * A KEYED i18n label — a reference INTO a translation bundle (objectui#4581). @@ -274,11 +275,21 @@ export interface BaseSchema { * the same reason; this one simply under-reported the capability, and * fixtures exercising it had to cast past the declaration. * + * Accepts the CEL ENVELOPE OBJECT as well (objectui#7530, ruled 2026-09-04, + * option A -- on all three of `visible` / `hidden` / `disabled` at once): + * `evaluateCondition` routes `{ dialect: 'cel', source }` to the canonical + * `@objectstack/formula` engine and unwraps any other envelope onto the + * legacy path, so the envelope was already an evaluated input here. + * `ExpressionWire` (`./expression.ts`) is the string-or-envelope union + * `visibleWhen` on form fields already carried, reused rather than spelled a + * second time; its zod twin is `ExpressionWireSchema`. + * * @default true * @example true * @example "${data.role === 'admin'}" + * @example { dialect: 'cel', source: "record.status == 'open'" } */ - visible?: boolean | string; + visible?: boolean | ExpressionWire; /** * Canonical conditional-visibility predicate (ADR-0089) — the element is shown @@ -346,17 +357,23 @@ export interface BaseSchema { * evidence of intent about the same concept, which is why this widening was * ruled rather than applied mechanically. * - * The CEL ENVELOPE OBJECT form is deliberately NOT declared here. - * `hasDeclaredPredicate` accepts it on this key, and it is declared on NONE - * of the three: `visible` and `disabled` are `boolean | string` and - * under-report it too. objectui#7530 rules on all three together (declare on - * all three, or refuse on all three); do not declare it on this key alone. + * The CEL ENVELOPE OBJECT form is declared too (objectui#7530, ruled + * 2026-09-04, option A -- on all three keys at once, never on one alone). + * `hasDeclaredPredicate` had accepted `{ dialect: 'cel', source }` on this + * key all along, pinned through a `Record` cast in + * `SchemaRenderer.hiddenDeclaredGate.test.tsx`; `ExpressionWire` + * (`./expression.ts`) is the string-or-envelope union `visibleWhen` on form + * fields already carried, reused here rather than spelled a second time. The + * ruling rejected the alternative -- a per-key `stringOnly` branch in the + * shared evaluator -- because it would split the platform into two + * expression vocabularies. * * @default false * @example true * @example "${data.status === 'draft'}" + * @example { dialect: 'cel', source: "record.status == 'draft'" } */ - hidden?: boolean | string; + hidden?: boolean | ExpressionWire; /** * Expression for conditional hiding. @@ -378,11 +395,21 @@ export interface BaseSchema { * with `visible` was accidental rather than deliberate (#4580 ruling Q3-A); * the two fixtures exercising it had been casting past the declaration. * + * Accepts the CEL ENVELOPE OBJECT as well (objectui#7530, ruled 2026-09-04, + * option A -- on all three of `visible` / `hidden` / `disabled` at once). The + * `disabled` leg asks `hasDeclaredPredicate(newSchema.disabled)` and then + * evaluates the value, and both already honoured `{ dialect: 'cel', source }` + * (pinned through a `Record` cast in + * `SchemaRenderer.disabledDeclaredGate.test.tsx`). `ExpressionWire` + * (`./expression.ts`) is the one string-or-envelope union, shared with + * `visible`, `hidden` and the form predicate keys. + * * @default false * @example false * @example "${data.status === 'locked'}" + * @example { dialect: 'cel', source: "record.status == 'locked'" } */ - disabled?: boolean | string; + disabled?: boolean | ExpressionWire; /** * Expression for conditional disabling. diff --git a/packages/types/src/expression.ts b/packages/types/src/expression.ts new file mode 100644 index 0000000000..698fce4147 --- /dev/null +++ b/packages/types/src/expression.ts @@ -0,0 +1,67 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - Expression wire type + * + * The ONE TypeScript spelling of a predicate as it travels on the wire, shared + * by every key that carries one. Its runtime twin is `ExpressionWireSchema` + * in `./zod/expression.zod.ts`. + * + * @module expression + * @packageDocumentation + */ + +/** + * The wire shape of a predicate expression (objectui#2212): a bare string, or + * the envelope object `{ dialect?, source }` that `objectstack build` emits for + * every authored predicate (a bare authored string compiles to + * `{ dialect: 'cel', source }`, so the envelope is the LIKELIEST spelling in + * real metadata, not an exotic one). + * + * ## Why this is a NAMED type (objectui#7530) + * + * `BaseSchema.visible` / `.hidden` / `.disabled` declare `boolean | ExpressionWire` + * (maintainer ruling 2026-09-04, option A). The renderer evaluates all three + * through the one path that already honoured the envelope, and declaring the + * shape key by key would have produced three spellings of one contract. Before + * this name existed `FormField.visibleWhen` and its `*When` / `*On` siblings + * carried the same union inline, and the zod twin was a module-private const in + * `zod/form.zod.ts`; `BaseSchema` and the form keys now read ONE definition per + * face, so the wire contract widens or narrows in exactly one place. + * + * ## What it is, measured against the runtime (objectui#7530) + * + * Exactly the accept set of `@object-ui/core`'s `toPredicateInput` (the + * normalizer under `hasDeclaredPredicate`) and `ExpressionEvaluator + * .evaluateCondition` -- no wider, no narrower: + * + * - `dialect` is OPTIONAL and UNCONSTRAINED. The runtime reads it the same + * way: a `'cel'` envelope keeps its envelope and is evaluated on the + * canonical `@objectstack/formula` engine (the server's verdict); any + * other dialect -- absent, `'template'`, or a string the runtime has never + * heard of -- is unwrapped to its `source` and evaluated on the legacy + * `${...}` path. Declaring `dialect: 'cel'` alone would refuse a spelling + * the evaluator answers. + * - `source` is REQUIRED and a string. The runtime treats an object without + * a string `source` as "no predicate at all" (fail-open junk), so admitting + * it here would declare a value that never reaches a verdict. + * + * It is NOT `@object-ui/core`'s `EvaluatorPredicateInput`: that is the OUTPUT + * of normalization (a boolean, a `${...}` template, or a `cel`-only envelope + * with a non-empty `source`); this is what an AUTHOR writes. And it is + * deliberately not the spec's `ExpressionInput` pipe, which canonicalizes a + * string into an envelope at parse time and would change the parsed shape of + * every schema that adopted it -- objectui keeps the wire un-normalized and + * lets core normalize once, at evaluation. + * + * `crud.ts` (`ActionSchema.condition`), `select-option.ts` (`visibleWhen`) and + * the `objectql.ts` predicate keys still spell this union inline; they are + * structurally identical to it and predate the name. + */ +export type ExpressionWire = string | { dialect?: string; source: string }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2e2d256dd0..6e5bbfbf92 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -112,6 +112,11 @@ export type { EventHandlers, StyleProps, } from './base.js'; +// The predicate WIRE shape `BaseSchema.visible` / `.hidden` / `.disabled` and +// the form predicate keys share (objectui#7530): a bare string or the CEL +// envelope `{ dialect?, source }`. Its zod twin is `ExpressionWireSchema` on +// the `./zod` entry. +export type { ExpressionWire } from './expression.js'; // ============================================================================ // Layout Components - Structure & Organization diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index df31ace4ab..9a58c0d7d2 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -19,6 +19,7 @@ import { z } from 'zod'; import { I18nLabelSchema } from '@objectstack/spec/ui'; import { retirementTombstone } from './tombstone.zod.js'; +import { ExpressionWireSchema } from './expression.zod.js'; /** * A KEYED i18n label — the runtime mirror of `KeyedI18nLabel` in `../base.ts`. @@ -154,8 +155,16 @@ const BaseSchemaCore = z.object({ * `(condition: string | boolean | undefined, …) => boolean` — so the string * form is an implemented, evaluated capability, and this validator was the * one surface still refusing it. + * + * Widened again by objectui#7530 (ruled 2026-09-04, option A, all three + * predicate keys at once) to the CEL envelope object: the expression half is + * now `ExpressionWireSchema` (`./expression.zod.ts`), the one string-or- + * envelope union `visibleWhen` on form fields already used, imported rather + * than spelled a second time. Measured before: the envelope this validator + * refused at path `visible` (`invalid_union`) parsed on `FormField.visibleWhen` + * one file over, while the renderer evaluated it on both. */ - visible: z.union([z.boolean(), z.string()]).optional().describe('Visibility control (boolean or predicate expression)'), + visible: z.union([z.boolean(), ExpressionWireSchema]).optional().describe('Visibility control (boolean, predicate expression string, or CEL envelope object)'), /** * Canonical conditional-visibility predicate (ADR-0089) — shown when truthy. @@ -182,10 +191,13 @@ const BaseSchemaCore = z.object({ * string) while the identical string on `visible` parsed -- this validator * was the one surface still refusing a shipped, pinned capability. * - * The CEL envelope object form is NOT declared here, and is declared on none - * of the three keys; objectui#7530 rules on all three together. + * The CEL envelope object form is declared too, since objectui#7530 (ruled + * 2026-09-04, option A, all three keys at once): the expression half is + * `ExpressionWireSchema` (`./expression.zod.ts`), shared with `visible`, + * `disabled` and the form predicate keys. `hasDeclaredPredicate` had accepted + * `{ dialect: 'cel', source }` on this key all along. */ - hidden: z.union([z.boolean(), z.string()]).optional().describe('Hidden control (boolean or predicate expression)'), + hidden: z.union([z.boolean(), ExpressionWireSchema]).optional().describe('Hidden control (boolean, predicate expression string, or CEL envelope object)'), /** * Conditional hidden expression @@ -199,8 +211,12 @@ const BaseSchemaCore = z.object({ * #4581 under #4580's Q3-A ruling: the renderer reads this key through the * same `evaluateCondition` as `visible`, and the asymmetry between the two * was accidental rather than deliberate. + * + * Widened again by objectui#7530 (ruled 2026-09-04, option A, all three + * predicate keys at once) to the CEL envelope object, through the shared + * `ExpressionWireSchema` (`./expression.zod.ts`). */ - disabled: z.union([z.boolean(), z.string()]).optional().describe('Disabled state (boolean or predicate expression)'), + disabled: z.union([z.boolean(), ExpressionWireSchema]).optional().describe('Disabled state (boolean, predicate expression string, or CEL envelope object)'), /** * Conditional disabled expression diff --git a/packages/types/src/zod/expression.zod.ts b/packages/types/src/zod/expression.zod.ts new file mode 100644 index 0000000000..937d107831 --- /dev/null +++ b/packages/types/src/zod/expression.zod.ts @@ -0,0 +1,46 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types/zod - Expression wire validator + * + * The ONE Zod spelling of a predicate as it travels on the wire -- the runtime + * twin of `ExpressionWire` (`../expression.ts`). Read by `BaseSchema`'s + * `visible` / `hidden` / `disabled` (`./base.zod.ts`) and by the form predicate + * keys (`./form.zod.ts`, where it lived as a module-private const until + * objectui#7530 hoisted it here). + * + * @module zod/expression + * @packageDocumentation + */ + +import { z } from 'zod'; + +/** + * The wire shape of a CEL predicate (objectui#2212): a bare string or the + * Expression envelope `{ dialect?, source }`. Deliberately NOT the spec's + * ExpressionInput pipe, which canonicalizes strings into an envelope at parse + * time and would change the output shape of every module that adopted it. + * + * `dialect` is optional and unconstrained and `source` is a required string + * because that is exactly what `@object-ui/core`'s `toPredicateInput` accepts: + * a `'cel'` envelope stays on the canonical engine, every other dialect is + * unwrapped onto the legacy `${...}` path, and an object without a string + * `source` is "no predicate". The reasoning is written once, on the TS twin. + * + * Reuse is pinned by REFERENCE, not by shape: + * `__tests__/base-schema-predicate-envelope-7530.test.ts` asserts that the + * object arm `BaseSchema.shape.visible` carries IS this const, and so is the + * one `FormFieldSchema.shape.visibleWhen` carries. A faithful copy passes every + * value comparison and is still the second envelope type objectui#7530's + * ruling forbids. + */ +export const ExpressionWireSchema = z.union([ + z.string(), + z.object({ dialect: z.string().optional(), source: z.string() }), +]); diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 21ee145425..cf2822298d 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -20,17 +20,12 @@ import { z } from 'zod'; import { handlerKeyRefusal } from './tombstone.zod.js'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; - -/** - * The wire shape of a CEL predicate (#2212): a bare string or the spec - * Expression object `{ dialect?, source }`. Deliberately NOT the spec's - * ExpressionInput pipe, which canonicalizes strings into an envelope at parse - * time and would change this module's output shape. - */ -const ExpressionWireSchema = z.union([ - z.string(), - z.object({ dialect: z.string().optional(), source: z.string() }), -]); +// The predicate wire shape (`string | { dialect?, source }`, #2212) was a +// module-private const here until objectui#7530 hoisted it into +// `./expression.zod.js`, so `BaseSchema`'s `visible` / `hidden` / `disabled` +// and the form predicate keys below read ONE definition. Its docblock and +// rationale moved with it. +import { ExpressionWireSchema } from './expression.zod.js'; /** * Select Option Schema — derived from `@objectstack/spec/data` diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index d7d6a83065..2b5a8db76d 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -61,6 +61,11 @@ export { ClassNameStylePropsSchema, } from './base.zod.js'; +// ============================================================================ +// Expression wire shape - shared by the predicate keys (objectui#7530) +// ============================================================================ +export { ExpressionWireSchema } from './expression.zod.js'; + // ============================================================================ // Layout Components - Structure & Organization // ============================================================================