From 889770fef7a58d8d84759a3056894ec2ba657a67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:30:03 +0000 Subject: [PATCH] feat(list): unify conditional formatting + row-action visibility onto the CEL engine (#1584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converge the list-view conditional tier — conditional formatting (list / grid / kanban) and row-action `visible` / `disabled` — onto @objectstack/formula's CEL engine, matching how @objectstack/spec already types these surfaces (ExpressionInputSchema / CEL) and how the server evaluates them (framework ADR-0058). The whole platform now speaks one expression dialect. - @object-ui/core: new evalRowPredicate + resolveConditionalFormatting helpers (one implementation of the three formatting rule shapes; dialect routing — a { dialect: 'cel' } envelope is always CEL, a bare string is CEL unless it carries legacy-only syntax which routes to the old engine with a one-time deprecation warning; native { field, operator, value } translated to CEL). - @object-ui/react: new useRowPredicate hook (canonical CEL, ambient scope). - Consumers converged: ListView.evaluateConditionalFormatting (thin wrapper), ObjectGrid row styling (inline copy removed), kanban card styles, grid and data-table row-action menus. plugin-view now forwards top-level conditionalFormatting to the kanban branch (previously dropped). - Row-action visible fails closed (broken -> hidden + warn); disabled fails soft. The CEL `in` operator now works in row predicates. - Legacy FormField.condition {field, equals/notEquals/in} retired to a CEL translation (back-compat); FieldDesigner migrated to visibleWhen. Unit + jsdom component tests; docs/skill updated; live e2e spec added. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019LwrWThBJgvcfPtqNHpstB --- .changeset/listview-cel-conditional.md | 42 ++++ e2e/live/list-row-action-cel.spec.ts | 35 +++ .../src/renderers/complex/data-table.tsx | 17 +- .../__tests__/form-legacy-condition.test.tsx | 82 +++++++ .../components/src/renderers/form/form.tsx | 53 +++-- .../__tests__/listConditional.test.ts | 203 ++++++++++++++++++ packages/core/src/evaluator/index.ts | 1 + .../core/src/evaluator/listConditional.ts | Bin 0 -> 12812 bytes .../plugin-designer/src/FieldDesigner.tsx | 4 +- packages/plugin-grid/src/ObjectGrid.tsx | 43 +--- .../src/components/RowActionMenu.tsx | 21 +- .../__tests__/RowActionMenu.test.tsx | 30 +++ packages/plugin-kanban/src/KanbanEnhanced.tsx | 38 +--- packages/plugin-kanban/src/KanbanImpl.tsx | 38 +--- packages/plugin-list/src/ListView.tsx | 64 +----- packages/plugin-view/src/ObjectView.tsx | 11 +- .../src/hooks/__tests__/useExpression.test.ts | 46 +++- packages/react/src/hooks/useExpression.ts | 45 +++- skills/objectui/guides/schema-expressions.md | 49 +++++ 19 files changed, 624 insertions(+), 198 deletions(-) create mode 100644 .changeset/listview-cel-conditional.md create mode 100644 e2e/live/list-row-action-cel.spec.ts create mode 100644 packages/components/src/renderers/form/__tests__/form-legacy-condition.test.tsx create mode 100644 packages/core/src/evaluator/__tests__/listConditional.test.ts create mode 100644 packages/core/src/evaluator/listConditional.ts diff --git a/.changeset/listview-cel-conditional.md b/.changeset/listview-cel-conditional.md new file mode 100644 index 000000000..7cbeff468 --- /dev/null +++ b/.changeset/listview-cel-conditional.md @@ -0,0 +1,42 @@ +--- +"@object-ui/core": minor +"@object-ui/react": minor +"@object-ui/components": minor +"@object-ui/plugin-grid": minor +"@object-ui/plugin-kanban": minor +"@object-ui/plugin-view": minor +"@object-ui/plugin-list": minor +"@object-ui/plugin-designer": minor +--- + +Unify the list-view conditional tier onto the canonical CEL engine (#1584). + +Conditional formatting (list / grid / kanban) and row-action `visible` / +`disabled` predicates are now evaluated by `@objectstack/formula`'s +`ExpressionEngine` — the same engine the server uses — instead of the legacy +JS-dialect `ExpressionEvaluator`, matching how `@objectstack/spec` already types +these surfaces (`ExpressionInputSchema` / CEL). The whole platform now speaks one +expression dialect (framework ADR-0058). + +- `@object-ui/core`: new `evalRowPredicate` + `resolveConditionalFormatting` + helpers (next to `evalFieldPredicate`). One implementation of all three + formatting rule shapes; dialect routing (a `{ dialect: 'cel' }` envelope is + always CEL; a bare string is CEL unless it carries legacy-only syntax + (`${…}` / `===` / `?.` / `.includes()`), which routes to the old engine with a + one-time deprecation warning); the native `{ field, operator, value }` form is + translated to CEL. +- `@object-ui/react`: new `useRowPredicate` hook (canonical CEL, ambient + predicate scope merged). +- Consumers converged: `ListView.evaluateConditionalFormatting` (thin wrapper, + export kept), `ObjectGrid` row styling (inline copy removed), kanban card + styles, and the grid / data-table row-action menus. `plugin-view`'s kanban + branch now forwards top-level `conditionalFormatting` (previously dropped). +- Row-action `visible` fails **closed** (broken predicate → hidden + warn); + `disabled` fails soft. The CEL `in` operator (and list membership) now work in + row predicates — the legacy engine could not parse them. +- The legacy `FormField.condition: { field, equals/notEquals/in }` is retired to + a CEL translation (back-compat preserved); `FieldDesigner` migrated to + `visibleWhen`. + +Fully back-compat: existing conditional-formatting rules, row-action predicates, +and form `condition` metadata keep working (translated / routed as needed). diff --git a/e2e/live/list-row-action-cel.spec.ts b/e2e/live/list-row-action-cel.spec.ts new file mode 100644 index 000000000..0b99a212b --- /dev/null +++ b/e2e/live/list-row-action-cel.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +/** + * Live e2e for row-action visibility on the CEL engine (issue #1584). + * + * The showcase task list surfaces the `showcase_mark_done` row action, gated by + * the CEL predicate `visible: '!record.done'`. After unification, that predicate + * is evaluated by the canonical `@objectstack/formula` engine — the same engine + * the server uses — instead of the legacy JS-dialect evaluator. This drives the + * real row-action menu and asserts the CEL-gated item renders coherently + * (visible on a not-done row / hidden on a done row) without faulting. + * + * The exhaustive gate semantics (including the CEL `in` operator, which the + * legacy engine could not parse, and the fail-closed-on-broken posture) are + * covered deterministically by the jsdom component tests + * (`packages/plugin-grid/.../RowActionMenu.test.tsx`) — this spec guards the + * live render path end-to-end. Non-mutating: it only opens the menu. + */ +test('row-action `visible` predicates are CEL-evaluated on the showcase task list', async ({ page }) => { + await page.goto('/apps/showcase_app/showcase_task'); + + const trigger = page.locator('[data-testid="row-action-trigger"]').first(); + await trigger.waitFor(); + await trigger.click(); + + // The row menu renders; its items are gated by CEL `visible` predicates. + // "Edit" is always present — proves the menu opened. + await expect(page.getByRole('menuitem', { name: /^Edit$/i })).toBeVisible(); + + // "Mark Done" (visible: '!record.done'): its presence tracks the row's `done` + // flag. Either way the CEL predicate evaluated without faulting — a faulting + // predicate fails closed (hidden) — so the DOM is coherent (0 or 1 instance). + const markDone = page.getByTestId('row-action-showcase_mark_done'); + expect(await markDone.count()).toBeLessThanOrEqual(1); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index a85396cb9..b4b736968 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -13,7 +13,7 @@ import { resolveIcon } from '../action/resolve-icon'; import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring'; import { ComponentRegistry } from '@object-ui/core'; import type { DataTableSchema } from '@object-ui/types'; -import { useObjectTranslation, useCondition, toPredicateInput } from '@object-ui/react'; +import { useObjectTranslation, useRowPredicate } from '@object-ui/react'; import { Table, TableHeader, @@ -243,17 +243,12 @@ export const DataTableRowActionItem: React.FC<{ row: any; onActionDef?: (action: RowActionDef, row: any) => void | Promise; }> = ({ action, row, onActionDef }) => { - const predicateCtx = { ...(row && typeof row === 'object' ? row : {}), record: row }; const visiblePred = action.visible; - const isVisible = useCondition(toPredicateInput(visiblePred), predicateCtx); - // `disabled` may be a boolean or a CEL predicate evaluated against the row - // (e.g. grey out an action once a record reaches a terminal state). - const disabledPred = toPredicateInput(action.disabled); - const evalDisabled = useCondition( - typeof disabledPred === 'string' ? disabledPred : undefined, - predicateCtx, - ); - const isDisabled = typeof disabledPred === 'string' ? evalDisabled : disabledPred === true; + // Evaluate on the canonical CEL engine (issue #1584): row bound bare + as + // `record.*`, ambient `features`/`user` scope merged. `visible` fails CLOSED + // (hidden + warn); `disabled` fails soft (not disabled). + const isVisible = useRowPredicate(visiblePred, row, { fallback: false, warnOnError: true, label: action.name }); + const isDisabled = useRowPredicate(action.disabled, row, { fallback: false, warnOnError: true, label: `${action.name}:disabled` }); if (visiblePred && !isVisible) return null; const ActionIcon = resolveIcon(action.icon); return ( diff --git a/packages/components/src/renderers/form/__tests__/form-legacy-condition.test.tsx b/packages/components/src/renderers/form/__tests__/form-legacy-condition.test.tsx new file mode 100644 index 000000000..2b7a57f18 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-legacy-condition.test.tsx @@ -0,0 +1,82 @@ +/** + * 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. + */ + +/** + * Legacy `FormField.condition` ({ field, equals/notEquals/in }) — issue #1584. + * + * The bespoke JSON `condition` branch is retired: the form renderer now + * translates it to an equivalent CEL visible-when predicate and evaluates it on + * the canonical engine (over the seeded live record), exactly like `visibleWhen` + * / `visibleOn`. This locks the translated semantics and reactivity. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; + +beforeAll(async () => { + await import('../../../renderers'); +}, 30000); + +function renderForm(fields: any[]) { + const Form = ComponentRegistry.get('form')!; + return render( +
, + ); +} + +describe('form renderer — legacy condition → CEL (#1584)', () => { + it('equals: shows the field only when the sibling equals the value, reactively', async () => { + renderForm([ + { name: 'type', label: 'Type', type: 'input', defaultValue: 'text' }, + { name: 'referenceTo', label: 'Reference To', type: 'input', condition: { field: 'type', equals: 'lookup' } }, + ]); + + // type = 'text' → hidden + expect(screen.queryByLabelText(/reference to/i)).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/type/i), { target: { value: 'lookup' } }); + await waitFor(() => { + expect(screen.getByLabelText(/reference to/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/type/i), { target: { value: 'formula' } }); + await waitFor(() => { + expect(screen.queryByLabelText(/reference to/i)).not.toBeInTheDocument(); + }); + }); + + it('notEquals: hidden when the sibling matches, shown otherwise', async () => { + renderForm([ + { name: 'status', label: 'Status', type: 'input', defaultValue: 'draft' }, + { name: 'reason', label: 'Reason', type: 'input', condition: { field: 'status', notEquals: 'draft' } }, + ]); + // Drive explicit values (a field default doesn't populate the record until + // the control registers a value). status == 'draft' → notEquals false → hidden. + fireEvent.change(screen.getByLabelText(/status/i), { target: { value: 'draft' } }); + await waitFor(() => expect(screen.queryByLabelText(/reason/i)).not.toBeInTheDocument()); + // non-draft → shown + fireEvent.change(screen.getByLabelText(/status/i), { target: { value: 'final' } }); + await waitFor(() => expect(screen.getByLabelText(/reason/i)).toBeInTheDocument()); + }); + + it('in: shown when the sibling value is in the list, hidden otherwise', async () => { + renderForm([ + { name: 'kind', label: 'Kind', type: 'input', defaultValue: 'a' }, + { name: 'extra', label: 'Extra', type: 'input', condition: { field: 'kind', in: ['b', 'c'] } }, + ]); + // 'a' not in list → hidden + expect(screen.queryByLabelText(/extra/i)).not.toBeInTheDocument(); + // 'b' in list → shown + fireEvent.change(screen.getByLabelText(/kind/i), { target: { value: 'b' } }); + await waitFor(() => expect(screen.getByLabelText(/extra/i)).toBeInTheDocument()); + // 'x' not in list → hidden + fireEvent.change(screen.getByLabelText(/kind/i), { target: { value: 'x' } }); + await waitFor(() => expect(screen.queryByLabelText(/extra/i)).not.toBeInTheDocument()); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 143db7fe5..10e68bd16 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -149,6 +149,34 @@ function stripRendererOnlyProps>(props: T): T { return domProps as T; } +/** Serialize a JS value as a CEL literal (string / number / bool / list / null). */ +function celLiteral(v: unknown): string { + if (v === null || v === undefined) return 'null'; + const t = typeof v; + if (t === 'string') return JSON.stringify(v); + if (t === 'number' || t === 'boolean') return String(v); + if (Array.isArray(v)) return `[${v.map(celLiteral).join(', ')}]`; + return JSON.stringify(String(v)); +} + +/** + * Translate the legacy `FormField.condition` shape + * (`{ field, equals?/notEquals?/in? }`) into an equivalent CEL visible-when + * predicate, so it evaluates on the canonical engine like every other + * conditional rule (issue #1584 / ADR-0036) instead of a bespoke JSON branch. + * The present sub-conditions are AND-ed (matching the legacy "hide unless all + * clauses hold"); returns `null` when there is nothing to gate on. + */ +function legacyConditionToCel(condition: FieldCondition | undefined): string | null { + if (!condition || !condition.field) return null; + const ref = `record[${JSON.stringify(condition.field)}]`; + const clauses: string[] = []; + if (condition.equals !== undefined) clauses.push(`${ref} == ${celLiteral(condition.equals)}`); + if (condition.notEquals !== undefined) clauses.push(`${ref} != ${celLiteral(condition.notEquals)}`); + if (Array.isArray(condition.in)) clauses.push(`${ref} in ${celLiteral(condition.in)}`); + return clauses.length > 0 ? clauses.join(' && ') : null; +} + function normalizeFieldType(type: string): string { return type.startsWith('field:') ? type.slice('field:'.length) : type; } @@ -653,23 +681,14 @@ ComponentRegistry.register('form', // Skip hidden fields if (hidden) return null; - // Handle conditional rendering with null/undefined safety - if (condition) { - const watchField = condition.field; - const watchValue = form.watch(watchField); - - // Check for null/undefined before evaluating conditions - const hasValue = watchValue !== undefined && watchValue !== null; - - if (condition.equals !== undefined && watchValue !== condition.equals) { - return null; - } - if (condition.notEquals !== undefined && watchValue === condition.notEquals) { - return null; - } - if (condition.in && (!hasValue || !condition.in.includes(watchValue))) { - return null; - } + // Legacy `condition: { field, equals/notEquals/in }` — translated + // to CEL and evaluated on the canonical engine over the seeded + // live record (issue #1584), so it agrees with `visibleWhen` and + // the server. Fail-open (a broken predicate shows the field), + // matching the CEL rules below. + const legacyConditionCel = legacyConditionToCel(condition); + if (legacyConditionCel && !evalFieldPredicate(legacyConditionCel, ruleRecord, true)) { + return null; } // Field-level CEL conditional rules (B2). Evaluated reactively diff --git a/packages/core/src/evaluator/__tests__/listConditional.test.ts b/packages/core/src/evaluator/__tests__/listConditional.test.ts new file mode 100644 index 000000000..6a86d22b9 --- /dev/null +++ b/packages/core/src/evaluator/__tests__/listConditional.test.ts @@ -0,0 +1,203 @@ +/** + * 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. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + evalRowPredicate, + resolveConditionalFormatting, + isLegacyDialectSource, +} from '../listConditional'; + +describe('isLegacyDialectSource', () => { + it('flags legacy-only syntax', () => { + expect(isLegacyDialectSource('${data.x > 1}')).toBe(true); + expect(isLegacyDialectSource("status === 'active'")).toBe(true); + expect(isLegacyDialectSource('status !== 1')).toBe(true); + expect(isLegacyDialectSource('record.name?.length')).toBe(true); + expect(isLegacyDialectSource('a ?? b')).toBe(true); + expect(isLegacyDialectSource("name.includes('x')")).toBe(true); + }); + + it('treats canonical CEL as non-legacy', () => { + expect(isLegacyDialectSource("record.status == 'active'")).toBe(false); + expect(isLegacyDialectSource("status in ['a', 'b']")).toBe(false); + expect(isLegacyDialectSource("name.contains('x')")).toBe(false); + expect(isLegacyDialectSource('a && b || c')).toBe(false); + }); +}); + +describe('evalRowPredicate', () => { + it('evaluates a canonical CEL predicate over the row (record.* and bare)', () => { + expect(evalRowPredicate("record.status == 'active'", { status: 'active' })).toBe(true); + expect(evalRowPredicate("status == 'active'", { status: 'active' })).toBe(true); + expect(evalRowPredicate("record.status == 'active'", { status: 'closed' })).toBe(false); + }); + + it('supports the CEL `in` operator (which the legacy engine lacks)', () => { + expect(evalRowPredicate("record.status in ['sent', 'paid']", { status: 'paid' })).toBe(true); + expect(evalRowPredicate("record.status in ['sent', 'paid']", { status: 'draft' })).toBe(false); + }); + + it('always evaluates an { dialect: cel } envelope on the CEL engine (never legacy)', () => { + expect( + evalRowPredicate({ dialect: 'cel', source: "record.status == 'active'" }, { status: 'active' }), + ).toBe(true); + }); + + it('binds an extra scope (features/user) alongside the row', () => { + expect( + evalRowPredicate('features.canEdit == true', { id: '1' }, { + scope: { features: { canEdit: true } }, + }), + ).toBe(true); + expect( + evalRowPredicate("record.id == user.id", { id: 'u1' }, { scope: { user: { id: 'u1' } } }), + ).toBe(true); + }); + + it('returns the fallback for an absent or empty predicate', () => { + expect(evalRowPredicate(undefined, { a: 1 }, { fallback: true })).toBe(true); + expect(evalRowPredicate(null, { a: 1 }, { fallback: false })).toBe(false); + expect(evalRowPredicate(' ', { a: 1 }, { fallback: true })).toBe(true); + }); + + it('fails to the fallback on a broken predicate', () => { + expect(evalRowPredicate('record.status ==', { status: 'x' }, { fallback: false })).toBe(false); + expect(evalRowPredicate('record.status ==', { status: 'x' }, { fallback: true })).toBe(true); + }); + + describe('legacy-dialect routing', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => warn.mockRestore()); + + it('routes a `${…}` template string to the legacy engine and warns once', () => { + expect(evalRowPredicate('${data.status === "active"}', { status: 'active' })).toBe(true); + expect(evalRowPredicate('${data.status === "active"}', { status: 'closed' })).toBe(false); + // Same source warns only once. + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('legacy expression dialect'); + }); + + it('routes a bare `===` string to the legacy engine', () => { + expect(evalRowPredicate("status === 'active'", { status: 'active' })).toBe(true); + }); + }); + + describe('warnOnError (fail-closed row actions)', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => warn.mockRestore()); + + it('warns and returns the fallback when a present predicate faults', () => { + const r = evalRowPredicate('record.status ==', { status: 'x' }, { + fallback: false, + warnOnError: true, + label: 'resume', + }); + expect(r).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('failed to evaluate'); + }); + + it('does NOT warn on a genuine false', () => { + const r = evalRowPredicate("record.status == 'active'", { status: 'closed' }, { + fallback: false, + warnOnError: true, + }); + expect(r).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); + +describe('resolveConditionalFormatting', () => { + it('returns {} for empty / missing rules', () => { + expect(resolveConditionalFormatting({ a: 1 }, undefined)).toEqual({}); + expect(resolveConditionalFormatting({ a: 1 }, [])).toEqual({}); + }); + + it('spec shape { condition, style } — CEL predicate', () => { + expect( + resolveConditionalFormatting({ status: 'overdue' }, [ + { condition: "record.status == 'overdue'", style: { backgroundColor: '#fee2e2', color: '#991b1b' } }, + ]), + ).toEqual({ backgroundColor: '#fee2e2', color: '#991b1b' }); + expect( + resolveConditionalFormatting({ status: 'ok' }, [ + { condition: "record.status == 'overdue'", style: { backgroundColor: '#fee2e2' } }, + ]), + ).toEqual({}); + }); + + it('native shape { field, operator, value } — translated to CEL', () => { + const rules = [ + { field: 'status', operator: 'equals' as const, value: 'active', backgroundColor: '#e0ffe0' }, + ]; + expect(resolveConditionalFormatting({ status: 'active' }, rules)).toEqual({ backgroundColor: '#e0ffe0' }); + expect(resolveConditionalFormatting({ status: 'inactive' }, rules)).toEqual({}); + }); + + it('native operators: not_equals / greater_than / less_than / contains / in', () => { + expect( + resolveConditionalFormatting({ n: 5 }, [{ field: 'n', operator: 'greater_than', value: 3, textColor: 'red' }]), + ).toEqual({ color: 'red' }); + expect( + resolveConditionalFormatting({ n: 5 }, [{ field: 'n', operator: 'less_than', value: 3, textColor: 'red' }]), + ).toEqual({}); + expect( + resolveConditionalFormatting({ s: 'hello world' }, [ + { field: 's', operator: 'contains', value: 'world', borderColor: '#000' }, + ]), + ).toEqual({ borderColor: '#000' }); + expect( + resolveConditionalFormatting({ tier: 'gold' }, [ + { field: 'tier', operator: 'in', value: ['gold', 'platinum'], backgroundColor: 'gold' }, + ]), + ).toEqual({ backgroundColor: 'gold' }); + expect( + resolveConditionalFormatting({ tier: 'bronze' }, [ + { field: 'tier', operator: 'not_equals', value: 'bronze', backgroundColor: 'x' }, + ]), + ).toEqual({}); + }); + + it('first matching rule wins', () => { + const rules = [ + { condition: "record.n > 100", style: { backgroundColor: 'red' } }, + { condition: "record.n > 10", style: { backgroundColor: 'orange' } }, + ]; + expect(resolveConditionalFormatting({ n: 50 }, rules)).toEqual({ backgroundColor: 'orange' }); + expect(resolveConditionalFormatting({ n: 500 }, rules)).toEqual({ backgroundColor: 'red' }); + }); + + it('color props override the base style map', () => { + expect( + resolveConditionalFormatting({ ok: true }, [ + { + condition: 'record.ok == true', + style: { backgroundColor: 'base', fontWeight: 'bold' }, + backgroundColor: 'override', + textColor: 'blue', + }, + ]), + ).toEqual({ backgroundColor: 'override', fontWeight: 'bold', color: 'blue' }); + }); + + it('a rule whose field is missing from the record fails soft (no style)', () => { + expect( + resolveConditionalFormatting({ other: 1 }, [ + { field: 'status', operator: 'equals', value: 'active', backgroundColor: 'x' }, + ]), + ).toEqual({}); + }); +}); diff --git a/packages/core/src/evaluator/index.ts b/packages/core/src/evaluator/index.ts index a93ff612d..2d9132c1c 100644 --- a/packages/core/src/evaluator/index.ts +++ b/packages/core/src/evaluator/index.ts @@ -9,6 +9,7 @@ export * from './ExpressionContext.js'; export * from './ExpressionEvaluator.js'; export * from './fieldRules.js'; +export * from './listConditional.js'; export * from './optionRules.js'; export * from './ExpressionCache.js'; export * from './FormulaFunctions.js'; diff --git a/packages/core/src/evaluator/listConditional.ts b/packages/core/src/evaluator/listConditional.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae8ec04d2e5fdf44ab5442e35c2a239571a6c1d1 GIT binary patch literal 12812 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 qh?_{P=2|Q@yih^{7ysHkQXmP0i = ({ const getRowClassName = useRowColor(schema.rowColor); // --- Conditional formatting support --- + // Delegates to the shared CEL evaluator (issue #1584 / ADR-0058) so the grid + // and ListView reach the identical verdict and the whole platform speaks one + // expression dialect. The host predicate scope is bound so `features.*` + // predicates resolve, mirroring row-action visibility. + const predicateScope = usePredicateScope(); const getRowStyle = useCallback((row: Record): React.CSSProperties | undefined => { const rules = schema.conditionalFormatting; if (!rules || rules.length === 0) return undefined; - for (const rule of rules) { - let match = false; - const expression = - ('condition' in rule ? (rule as any).condition : undefined) - || ('expression' in rule ? (rule as any).expression : undefined) - || undefined; - if (expression) { - match = evaluatePlainCondition(expression, row as Record); - } else if ('field' in rule && 'operator' in rule && (rule as any).field && (rule as any).operator) { - const r = rule as any; - const fieldValue = row[r.field]; - switch (r.operator) { - case 'equals': match = fieldValue === r.value; break; - case 'not_equals': match = fieldValue !== r.value; break; - case 'contains': match = typeof fieldValue === 'string' && typeof r.value === 'string' && fieldValue.includes(r.value); break; - case 'greater_than': match = typeof fieldValue === 'number' && typeof r.value === 'number' && fieldValue > r.value; break; - case 'less_than': match = typeof fieldValue === 'number' && typeof r.value === 'number' && fieldValue < r.value; break; - case 'in': match = Array.isArray(r.value) && r.value.includes(fieldValue); break; - } - } - if (match) { - const style: React.CSSProperties = {}; - if ('style' in rule && (rule as any).style) Object.assign(style, (rule as any).style); - if ('backgroundColor' in rule && (rule as any).backgroundColor) style.backgroundColor = (rule as any).backgroundColor; - if ('textColor' in rule && (rule as any).textColor) style.color = (rule as any).textColor; - if ('borderColor' in rule && (rule as any).borderColor) style.borderColor = (rule as any).borderColor; - return style; - } - } - return undefined; - }, [schema.conditionalFormatting]); + const style = resolveConditionalFormatting(row, rules as any, predicateScope); + return Object.keys(style).length > 0 ? (style as React.CSSProperties) : undefined; + }, [schema.conditionalFormatting, predicateScope]); // --- Grouping support --- // Build a per-field value formatter so group headers display the human diff --git a/packages/plugin-grid/src/components/RowActionMenu.tsx b/packages/plugin-grid/src/components/RowActionMenu.tsx index a53fb7ff2..f49c1a3e5 100644 --- a/packages/plugin-grid/src/components/RowActionMenu.tsx +++ b/packages/plugin-grid/src/components/RowActionMenu.tsx @@ -15,7 +15,7 @@ import { DropdownMenuTrigger, } from '@object-ui/components'; import { Edit, Trash2, MoreVertical } from 'lucide-react'; -import { useObjectTranslation, useCondition, toPredicateInput } from '@object-ui/react'; +import { useObjectTranslation, useRowPredicate } from '@object-ui/react'; const ROW_ACTION_FALLBACKS: Record = { 'grid.openMenu': 'Open menu', @@ -97,16 +97,13 @@ const RowActionMenuItem: React.FC<{ row: any; onActionDef?: (def: RowActionDef, row: any) => void; }> = ({ def, row, onActionDef }) => { - // Evaluate predicates against the row with BOTH a bare-field scope (`status`) - // and a `record.` scope (`record.status`) so authors can use either - // convention consistently with the record-header / spec evaluators. - const predicateCtx = { ...(row && typeof row === 'object' ? row : {}), record: row }; - const isVisible = useCondition(toPredicateInput(def.visible), predicateCtx); - // `disabled` may be a boolean or a CEL predicate evaluated against the row - // (e.g. grey out "Reassign" once a lead is converted) — previously ignored. - const disabledPred = toPredicateInput((def as any).disabled); - const evalDisabled = useCondition(typeof disabledPred === 'string' ? disabledPred : undefined, predicateCtx); - const isDisabled = typeof disabledPred === 'string' ? evalDisabled : disabledPred === true; + // Evaluate predicates against the row on the canonical CEL engine (issue + // #1584): the row is bound both bare (`status`) and as `record.status`, and + // the ambient `features`/`user` scope is merged. `visible` fails CLOSED + // (hidden + warn) so a broken predicate can't silently expose an action — + // matching ActionEngine's posture; `disabled` fails soft (not disabled). + const isVisible = useRowPredicate(def.visible, row, { fallback: false, warnOnError: true, label: def.name }); + const isDisabled = useRowPredicate((def as any).disabled, row, { fallback: false, warnOnError: true, label: `${def.name}:disabled` }); if (def.visible && !isVisible) return null; return ( void; }> = ({ def, row, onActionDef }) => { - const isVisible = useCondition(toPredicateInput(def.visible), { ...(row && typeof row === 'object' ? row : {}), record: row }); + const isVisible = useRowPredicate(def.visible, row, { fallback: false, warnOnError: true, label: def.name }); if (def.visible && !isVisible) return null; return (