diff --git a/.changeset/cel-renderer-predicates.md b/.changeset/cel-renderer-predicates.md new file mode 100644 index 000000000..e6820b068 --- /dev/null +++ b/.changeset/cel-renderer-predicates.md @@ -0,0 +1,40 @@ +--- +"@object-ui/core": minor +"@object-ui/react": minor +--- + +feat(evaluator): route `{ dialect: 'cel' }` component/action predicates to the canonical CEL engine (#2661) + +Component and action `visible` / `disabled` / `hidden` predicates were evaluated +by the home-grown JS `ExpressionEvaluator`, while field rules +(`visibleWhen`/`readonlyWhen`/`requiredWhen`, via `fieldRules.ts`) and row/list +conditionals (via `evalRowPredicate`) already delegate to the canonical +`@objectstack/formula` engine. That split meant a `{ dialect: 'cel' }` predicate +in a renderer/action surface was executed as **JavaScript** — CEL-only forms +(`x in list`, `has()`, typed `==`, the `today()`/`daysFromNow()` catalog) behaved +differently from, or faulted against, the server's enforcement. + +This converges the remaining tier onto the same engine: + +- **`@object-ui/core`** — `ExpressionEvaluator.evaluateCondition` now detects a + `{ dialect: 'cel', source }` envelope and evaluates it on `@objectstack/formula` + (via `evalFieldPredicate`), binding the `record` namespace plus the whole + context bag as top-level scope (`record.*`, `features.*`, `user.*`, `app.*`). + Fail-soft to visible/enabled to match the legacy default; `throwOnError` + callers still fail closed on a *faulting* predicate (a genuine `false` never + throws). This fixes every `SchemaRenderer` visibility/disabled read at once. +- **`@object-ui/react`** — `toPredicateInput` preserves a CEL envelope instead of + collapsing it to a `${source}` string, and `useCondition` accepts and forwards + the envelope (keyed on a stable `(dialect, source)` so it doesn't re-evaluate + each render). Action buttons (`action-icon`/`group`/`bar`/`button`) therefore + evaluate CEL `visible`/`enabled`/`disabled` on the canonical engine. + +**Back-compat:** bare strings and `${…}` templates stay on the legacy JS path +(deprecation window); only an explicit `{ dialect: 'cel' }` envelope is rerouted. +`{ dialect: 'template' }` is unaffected. + +Together with the `^15.1.1` alignment (#2662), a renderer CEL predicate now +reaches the identical verdict as the server — including the framework's +`dateField == today()` equality fix (objectstack-ai/framework#3205) once it +lands in a published 15.x. The broader home-grown-vs-canonical divergence +motivation is #2661. diff --git a/packages/core/src/evaluator/ExpressionEvaluator.ts b/packages/core/src/evaluator/ExpressionEvaluator.ts index c64565be8..98eacc41e 100644 --- a/packages/core/src/evaluator/ExpressionEvaluator.ts +++ b/packages/core/src/evaluator/ExpressionEvaluator.ts @@ -19,6 +19,7 @@ import { ExpressionContext } from './ExpressionContext.js'; import { ExpressionCache } from './ExpressionCache.js'; import { FormulaFunctions } from './FormulaFunctions.js'; +import { evalFieldPredicate } from './fieldRules.js'; /** * Options for expression evaluation @@ -208,6 +209,21 @@ export class ExpressionEvaluator { return condition; } + // #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula` + // engine (the one `fieldRules` / list conditionals already use), NOT the legacy + // JS evaluator below. This makes a component / action `visible` / `disabled` + // predicate reach the SAME verdict as server enforcement — including CEL-only + // behavior like `record.due_date == today()` (framework#3205). Bare strings and + // `${…}` templates stay on the legacy path (back-compat deprecation window); + // only an explicit `{ dialect: 'cel' }` envelope is rerouted. + if ( + condition && typeof condition === 'object' + && (condition as { dialect?: string }).dialect === 'cel' + && typeof (condition as { source?: string }).source === 'string' + ) { + return this.evaluateCelCondition((condition as { source: string }).source, options); + } + // Unwrap Expression envelope (see `evaluate` for rationale). if (condition && typeof condition === 'object' && typeof (condition as any).source === 'string') { condition = (condition as any).source as string; @@ -250,6 +266,37 @@ export class ExpressionEvaluator { } } + /** + * Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula` + * engine (via `evalFieldPredicate`), binding this evaluator's context: the + * `record` key as the `record` namespace and the whole context bag as top-level + * scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to + * `true` (visible/enabled — the legacy default) unless the caller opted into + * `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL + * syntax) throws; a genuine `false` never throws. + */ + private evaluateCelCondition(source: string, options: EvaluationOptions): boolean { + if (!source.trim()) return true; // no predicate → visible/enabled + const bag = this.context.toObject(); + const rec = bag.record; + const record = (rec && typeof rec === 'object' && !Array.isArray(rec)) + ? (rec as Record) + : (bag as Record); + if (!options.throwOnError) { + // Fast path: one evaluation, fail-soft to visible/enabled (legacy parity). + return evalFieldPredicate(source, record, true, undefined, bag); + } + // Fail-closed callers need to tell a genuine `false` from a fault. The + // canonical helper fails soft to the fallback, so a value that tracks the + // fallback in BOTH runs means the predicate faulted — then we throw. + const asTrue = evalFieldPredicate(source, record, true, undefined, bag); + const asFalse = evalFieldPredicate(source, record, false, undefined, bag); + if (asTrue !== asFalse) { + throw new Error(`CEL predicate failed to evaluate: ${source}`); + } + return asTrue; + } + /** * Update the context with new data */ diff --git a/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts b/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts index 255db5fb6..92f176670 100644 --- a/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts +++ b/packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts @@ -94,11 +94,73 @@ describe('ExpressionEvaluator', () => { it('should handle boolean values directly', () => { const evaluator = new ExpressionEvaluator({}); - + expect(evaluator.evaluateCondition(true)).toBe(true); expect(evaluator.evaluateCondition(false)).toBe(false); }); }); + + // #2661 — a `{ dialect: 'cel' }` predicate must route to the canonical + // @objectstack/formula engine (like fieldRules / list conditionals), NOT the + // legacy JS evaluator, so component/action `visible`/`disabled` verdicts match + // the server. Bare strings and `${…}` templates stay on the legacy path. + describe('evaluateCondition — CEL dialect routing (#2661)', () => { + const cel = (source: string) => ({ dialect: 'cel' as const, source }); + + it('evaluates a CEL envelope on the canonical engine (CEL `in`, not JS `in`)', () => { + // CEL: `x in list` is list membership → true. Legacy JS `in` checks object + // keys/array indices → false. The divergence proves the canonical route. + const evaluator = new ExpressionEvaluator({ record: { roles: ['admin'] } }); + expect(evaluator.evaluateCondition(cel("'admin' in record.roles"))).toBe(true); + expect(evaluator.evaluateCondition(cel("'editor' in record.roles"))).toBe(false); + }); + + it('binds record.* and the host scope (features.*) for a CEL envelope', () => { + const evaluator = new ExpressionEvaluator({ + record: { status: 'open' }, + features: { beta: true }, + }); + expect(evaluator.evaluateCondition(cel('record.status == "open" && features.beta'))).toBe(true); + expect(evaluator.evaluateCondition(cel('record.status == "closed"'))).toBe(false); + }); + + it('resolves an ISO date field against today() with an ordering comparison', () => { + // today() is a canonical stdlib fn absent from the legacy evaluator; the + // string date field hydrates on the CEL ordering path (framework #1530). + const iso = new Date().toISOString().slice(0, 10); + const evaluator = new ExpressionEvaluator({ record: { due: iso } }); + expect(evaluator.evaluateCondition(cel('record.due <= today()'))).toBe(true); + expect(evaluator.evaluateCondition(cel('record.due >= today()'))).toBe(true); + }); + + it('fails soft to visible/enabled on a faulting CEL predicate (legacy parity)', () => { + const evaluator = new ExpressionEvaluator({ record: {} }); + // `record.nope.deep` faults; default (no throwOnError) → true. + expect(evaluator.evaluateCondition(cel('record.nope.deep == 1'))).toBe(true); + }); + + it('throws on a faulting CEL predicate when throwOnError is set (fail-closed)', () => { + const evaluator = new ExpressionEvaluator({ record: { x: 2 } }); + // Missing-key deep access faults in CEL → throws under throwOnError. + expect(() => evaluator.evaluateCondition(cel('record.a.b.c == 1'), { throwOnError: true })).toThrow(); + // A genuine `false` (field present, predicate simply false) must NOT throw. + expect(evaluator.evaluateCondition(cel('record.x == 1'), { throwOnError: true })).toBe(false); + }); + + it('an empty CEL source is "no predicate" → visible/enabled', () => { + const evaluator = new ExpressionEvaluator({ record: { x: 1 } }); + expect(evaluator.evaluateCondition(cel(' '))).toBe(true); + }); + + it('leaves bare strings and `${…}` templates on the legacy JS path (back-compat)', () => { + const evaluator = new ExpressionEvaluator({ data: { age: 25 }, record: { roles: ['admin'] } }); + // `${…}` template — legacy JS. + expect(evaluator.evaluateCondition('${data.age >= 18}')).toBe(true); + // A `{ dialect: 'template' }` envelope is NOT rerouted — unwrapped to source + // and run on the legacy path (here a bare non-`${}` string → its own value). + expect(evaluator.evaluateCondition({ dialect: 'template', source: '${data.age < 18}' })).toBe(false); + }); + }); }); describe('evaluatePlainCondition', () => { diff --git a/packages/react/src/hooks/__tests__/useExpression.test.ts b/packages/react/src/hooks/__tests__/useExpression.test.ts index cc02194e7..6a8044e08 100644 --- a/packages/react/src/hooks/__tests__/useExpression.test.ts +++ b/packages/react/src/hooks/__tests__/useExpression.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest'; import { renderHook } from '@testing-library/react'; import { createElement } from 'react'; -import { useExpression, useCondition, useRowPredicate, PredicateScopeProvider } from '../useExpression'; +import { useExpression, useCondition, useRowPredicate, toPredicateInput, PredicateScopeProvider } from '../useExpression'; describe('useExpression', () => { it('returns string value directly for non-expression strings', () => { @@ -177,3 +177,44 @@ describe('useRowPredicate (canonical CEL row predicate — issue #1584)', () => warn.mockRestore(); }); }); + +// #2661 — a CEL-dialect action/component predicate must reach the canonical +// engine through `toPredicateInput` → `useCondition`, not collapse to a legacy +// `${…}` string. +describe('toPredicateInput — CEL envelope preservation (#2661)', () => { + it('preserves a { dialect: "cel" } envelope (does not wrap as ${…})', () => { + expect(toPredicateInput({ dialect: 'cel', source: 'record.x == 1' })) + .toEqual({ dialect: 'cel', source: 'record.x == 1' }); + }); + + it('still wraps bare strings and non-cel envelopes as legacy ${…}', () => { + expect(toPredicateInput('data.age >= 18')).toBe('${data.age >= 18}'); + expect(toPredicateInput({ dialect: 'template', source: 'data.age >= 18' })).toBe('${data.age >= 18}'); + }); + + it('passes booleans / empties through', () => { + expect(toPredicateInput(true)).toBe(true); + expect(toPredicateInput('')).toBeUndefined(); + expect(toPredicateInput({ dialect: 'cel', source: '' })).toBeUndefined(); + }); +}); + +describe('useCondition — CEL envelope routes to the canonical engine (#2661)', () => { + it('evaluates a cel envelope from toPredicateInput on the CEL engine (CEL `in`)', () => { + const { result } = renderHook(() => + useCondition(toPredicateInput({ dialect: 'cel', source: "'admin' in record.roles" }), { + record: { roles: ['admin'] }, + }), + ); + expect(result.current).toBe(true); + }); + + it('a cel envelope predicate that is false hides/disables (not defaulted true)', () => { + const { result } = renderHook(() => + useCondition(toPredicateInput({ dialect: 'cel', source: 'record.status == "open"' }), { + record: { status: 'closed' }, + }), + ); + expect(result.current).toBe(false); + }); +}); diff --git a/packages/react/src/hooks/useExpression.ts b/packages/react/src/hooks/useExpression.ts index 2394bfa2b..35c09f308 100644 --- a/packages/react/src/hooks/useExpression.ts +++ b/packages/react/src/hooks/useExpression.ts @@ -67,13 +67,19 @@ export function usePredicateScope(): Record { */ export function toPredicateInput( value: unknown, -): string | boolean | undefined { +): string | boolean | { dialect: 'cel'; source: string } | undefined { if (value === null || value === undefined || value === '') return undefined; if (typeof value === 'boolean') return value; if (typeof value === 'string') return `\${${value}}`; if (typeof value === 'object' && typeof (value as any).source === 'string') { const src = (value as any).source as string; - return src ? `\${${src}}` : undefined; + if (!src) return undefined; + // #2661 — preserve a CEL-dialect envelope so `useCondition` routes it to the + // canonical `@objectstack/formula` engine (identical verdict to the server), + // instead of collapsing it to a `${source}` string on the legacy JS path. + // Every other dialect (template / unset) keeps the legacy `${…}` behavior. + if ((value as any).dialect === 'cel') return { dialect: 'cel', source: src }; + return `\${${src}}`; } return undefined; } @@ -117,7 +123,7 @@ export function useExpression( const _warnedConditions = new Set(); export function useCondition( - condition: string | boolean | undefined, + condition: string | boolean | undefined | { dialect?: string; source?: string }, context: Record = {}, options?: { throwOnError?: boolean; label?: string } ): boolean {