Skip to content

Commit dea65f7

Browse files
os-zhuangclaude
andauthored
feat(list): unify conditional formatting + row-action visibility onto the CEL engine (#1584) (#2544)
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. Claude-Session: https://claude.ai/code/session_019LwrWThBJgvcfPtqNHpstB Co-authored-by: Claude <noreply@anthropic.com>
1 parent c04de47 commit dea65f7

19 files changed

Lines changed: 624 additions & 198 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/react": minor
4+
"@object-ui/components": minor
5+
"@object-ui/plugin-grid": minor
6+
"@object-ui/plugin-kanban": minor
7+
"@object-ui/plugin-view": minor
8+
"@object-ui/plugin-list": minor
9+
"@object-ui/plugin-designer": minor
10+
---
11+
12+
Unify the list-view conditional tier onto the canonical CEL engine (#1584).
13+
14+
Conditional formatting (list / grid / kanban) and row-action `visible` /
15+
`disabled` predicates are now evaluated by `@objectstack/formula`'s
16+
`ExpressionEngine` — the same engine the server uses — instead of the legacy
17+
JS-dialect `ExpressionEvaluator`, matching how `@objectstack/spec` already types
18+
these surfaces (`ExpressionInputSchema` / CEL). The whole platform now speaks one
19+
expression dialect (framework ADR-0058).
20+
21+
- `@object-ui/core`: new `evalRowPredicate` + `resolveConditionalFormatting`
22+
helpers (next to `evalFieldPredicate`). One implementation of all three
23+
formatting rule shapes; dialect routing (a `{ dialect: 'cel' }` envelope is
24+
always CEL; a bare string is CEL unless it carries legacy-only syntax
25+
(`${…}` / `===` / `?.` / `.includes()`), which routes to the old engine with a
26+
one-time deprecation warning); the native `{ field, operator, value }` form is
27+
translated to CEL.
28+
- `@object-ui/react`: new `useRowPredicate` hook (canonical CEL, ambient
29+
predicate scope merged).
30+
- Consumers converged: `ListView.evaluateConditionalFormatting` (thin wrapper,
31+
export kept), `ObjectGrid` row styling (inline copy removed), kanban card
32+
styles, and the grid / data-table row-action menus. `plugin-view`'s kanban
33+
branch now forwards top-level `conditionalFormatting` (previously dropped).
34+
- Row-action `visible` fails **closed** (broken predicate → hidden + warn);
35+
`disabled` fails soft. The CEL `in` operator (and list membership) now work in
36+
row predicates — the legacy engine could not parse them.
37+
- The legacy `FormField.condition: { field, equals/notEquals/in }` is retired to
38+
a CEL translation (back-compat preserved); `FieldDesigner` migrated to
39+
`visibleWhen`.
40+
41+
Fully back-compat: existing conditional-formatting rules, row-action predicates,
42+
and form `condition` metadata keep working (translated / routed as needed).
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* Live e2e for row-action visibility on the CEL engine (issue #1584).
5+
*
6+
* The showcase task list surfaces the `showcase_mark_done` row action, gated by
7+
* the CEL predicate `visible: '!record.done'`. After unification, that predicate
8+
* is evaluated by the canonical `@objectstack/formula` engine — the same engine
9+
* the server uses — instead of the legacy JS-dialect evaluator. This drives the
10+
* real row-action menu and asserts the CEL-gated item renders coherently
11+
* (visible on a not-done row / hidden on a done row) without faulting.
12+
*
13+
* The exhaustive gate semantics (including the CEL `in` operator, which the
14+
* legacy engine could not parse, and the fail-closed-on-broken posture) are
15+
* covered deterministically by the jsdom component tests
16+
* (`packages/plugin-grid/.../RowActionMenu.test.tsx`) — this spec guards the
17+
* live render path end-to-end. Non-mutating: it only opens the menu.
18+
*/
19+
test('row-action `visible` predicates are CEL-evaluated on the showcase task list', async ({ page }) => {
20+
await page.goto('/apps/showcase_app/showcase_task');
21+
22+
const trigger = page.locator('[data-testid="row-action-trigger"]').first();
23+
await trigger.waitFor();
24+
await trigger.click();
25+
26+
// The row menu renders; its items are gated by CEL `visible` predicates.
27+
// "Edit" is always present — proves the menu opened.
28+
await expect(page.getByRole('menuitem', { name: /^Edit$/i })).toBeVisible();
29+
30+
// "Mark Done" (visible: '!record.done'): its presence tracks the row's `done`
31+
// flag. Either way the CEL predicate evaluated without faulting — a faulting
32+
// predicate fails closed (hidden) — so the DOM is coherent (0 or 1 instance).
33+
const markDone = page.getByTestId('row-action-showcase_mark_done');
34+
expect(await markDone.count()).toBeLessThanOrEqual(1);
35+
});

packages/components/src/renderers/complex/data-table.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { resolveIcon } from '../action/resolve-icon';
1313
import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring';
1414
import { ComponentRegistry } from '@object-ui/core';
1515
import type { DataTableSchema } from '@object-ui/types';
16-
import { useObjectTranslation, useCondition, toPredicateInput } from '@object-ui/react';
16+
import { useObjectTranslation, useRowPredicate } from '@object-ui/react';
1717
import {
1818
Table,
1919
TableHeader,
@@ -243,17 +243,12 @@ export const DataTableRowActionItem: React.FC<{
243243
row: any;
244244
onActionDef?: (action: RowActionDef, row: any) => void | Promise<void>;
245245
}> = ({ action, row, onActionDef }) => {
246-
const predicateCtx = { ...(row && typeof row === 'object' ? row : {}), record: row };
247246
const visiblePred = action.visible;
248-
const isVisible = useCondition(toPredicateInput(visiblePred), predicateCtx);
249-
// `disabled` may be a boolean or a CEL predicate evaluated against the row
250-
// (e.g. grey out an action once a record reaches a terminal state).
251-
const disabledPred = toPredicateInput(action.disabled);
252-
const evalDisabled = useCondition(
253-
typeof disabledPred === 'string' ? disabledPred : undefined,
254-
predicateCtx,
255-
);
256-
const isDisabled = typeof disabledPred === 'string' ? evalDisabled : disabledPred === true;
247+
// Evaluate on the canonical CEL engine (issue #1584): row bound bare + as
248+
// `record.*`, ambient `features`/`user` scope merged. `visible` fails CLOSED
249+
// (hidden + warn); `disabled` fails soft (not disabled).
250+
const isVisible = useRowPredicate(visiblePred, row, { fallback: false, warnOnError: true, label: action.name });
251+
const isDisabled = useRowPredicate(action.disabled, row, { fallback: false, warnOnError: true, label: `${action.name}:disabled` });
257252
if (visiblePred && !isVisible) return null;
258253
const ActionIcon = resolveIcon(action.icon);
259254
return (
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Legacy `FormField.condition` ({ field, equals/notEquals/in }) — issue #1584.
11+
*
12+
* The bespoke JSON `condition` branch is retired: the form renderer now
13+
* translates it to an equivalent CEL visible-when predicate and evaluates it on
14+
* the canonical engine (over the seeded live record), exactly like `visibleWhen`
15+
* / `visibleOn`. This locks the translated semantics and reactivity.
16+
*/
17+
18+
import { describe, it, expect, beforeAll } from 'vitest';
19+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
20+
import { ComponentRegistry } from '@object-ui/core';
21+
22+
beforeAll(async () => {
23+
await import('../../../renderers');
24+
}, 30000);
25+
26+
function renderForm(fields: any[]) {
27+
const Form = ComponentRegistry.get('form')!;
28+
return render(
29+
<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />,
30+
);
31+
}
32+
33+
describe('form renderer — legacy condition → CEL (#1584)', () => {
34+
it('equals: shows the field only when the sibling equals the value, reactively', async () => {
35+
renderForm([
36+
{ name: 'type', label: 'Type', type: 'input', defaultValue: 'text' },
37+
{ name: 'referenceTo', label: 'Reference To', type: 'input', condition: { field: 'type', equals: 'lookup' } },
38+
]);
39+
40+
// type = 'text' → hidden
41+
expect(screen.queryByLabelText(/reference to/i)).not.toBeInTheDocument();
42+
43+
fireEvent.change(screen.getByLabelText(/type/i), { target: { value: 'lookup' } });
44+
await waitFor(() => {
45+
expect(screen.getByLabelText(/reference to/i)).toBeInTheDocument();
46+
});
47+
48+
fireEvent.change(screen.getByLabelText(/type/i), { target: { value: 'formula' } });
49+
await waitFor(() => {
50+
expect(screen.queryByLabelText(/reference to/i)).not.toBeInTheDocument();
51+
});
52+
});
53+
54+
it('notEquals: hidden when the sibling matches, shown otherwise', async () => {
55+
renderForm([
56+
{ name: 'status', label: 'Status', type: 'input', defaultValue: 'draft' },
57+
{ name: 'reason', label: 'Reason', type: 'input', condition: { field: 'status', notEquals: 'draft' } },
58+
]);
59+
// Drive explicit values (a field default doesn't populate the record until
60+
// the control registers a value). status == 'draft' → notEquals false → hidden.
61+
fireEvent.change(screen.getByLabelText(/status/i), { target: { value: 'draft' } });
62+
await waitFor(() => expect(screen.queryByLabelText(/reason/i)).not.toBeInTheDocument());
63+
// non-draft → shown
64+
fireEvent.change(screen.getByLabelText(/status/i), { target: { value: 'final' } });
65+
await waitFor(() => expect(screen.getByLabelText(/reason/i)).toBeInTheDocument());
66+
});
67+
68+
it('in: shown when the sibling value is in the list, hidden otherwise', async () => {
69+
renderForm([
70+
{ name: 'kind', label: 'Kind', type: 'input', defaultValue: 'a' },
71+
{ name: 'extra', label: 'Extra', type: 'input', condition: { field: 'kind', in: ['b', 'c'] } },
72+
]);
73+
// 'a' not in list → hidden
74+
expect(screen.queryByLabelText(/extra/i)).not.toBeInTheDocument();
75+
// 'b' in list → shown
76+
fireEvent.change(screen.getByLabelText(/kind/i), { target: { value: 'b' } });
77+
await waitFor(() => expect(screen.getByLabelText(/extra/i)).toBeInTheDocument());
78+
// 'x' not in list → hidden
79+
fireEvent.change(screen.getByLabelText(/kind/i), { target: { value: 'x' } });
80+
await waitFor(() => expect(screen.queryByLabelText(/extra/i)).not.toBeInTheDocument());
81+
});
82+
});

packages/components/src/renderers/form/form.tsx

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,34 @@ function stripRendererOnlyProps<T extends Record<string, any>>(props: T): T {
149149
return domProps as T;
150150
}
151151

152+
/** Serialize a JS value as a CEL literal (string / number / bool / list / null). */
153+
function celLiteral(v: unknown): string {
154+
if (v === null || v === undefined) return 'null';
155+
const t = typeof v;
156+
if (t === 'string') return JSON.stringify(v);
157+
if (t === 'number' || t === 'boolean') return String(v);
158+
if (Array.isArray(v)) return `[${v.map(celLiteral).join(', ')}]`;
159+
return JSON.stringify(String(v));
160+
}
161+
162+
/**
163+
* Translate the legacy `FormField.condition` shape
164+
* (`{ field, equals?/notEquals?/in? }`) into an equivalent CEL visible-when
165+
* predicate, so it evaluates on the canonical engine like every other
166+
* conditional rule (issue #1584 / ADR-0036) instead of a bespoke JSON branch.
167+
* The present sub-conditions are AND-ed (matching the legacy "hide unless all
168+
* clauses hold"); returns `null` when there is nothing to gate on.
169+
*/
170+
function legacyConditionToCel(condition: FieldCondition | undefined): string | null {
171+
if (!condition || !condition.field) return null;
172+
const ref = `record[${JSON.stringify(condition.field)}]`;
173+
const clauses: string[] = [];
174+
if (condition.equals !== undefined) clauses.push(`${ref} == ${celLiteral(condition.equals)}`);
175+
if (condition.notEquals !== undefined) clauses.push(`${ref} != ${celLiteral(condition.notEquals)}`);
176+
if (Array.isArray(condition.in)) clauses.push(`${ref} in ${celLiteral(condition.in)}`);
177+
return clauses.length > 0 ? clauses.join(' && ') : null;
178+
}
179+
152180
function normalizeFieldType(type: string): string {
153181
return type.startsWith('field:') ? type.slice('field:'.length) : type;
154182
}
@@ -653,23 +681,14 @@ ComponentRegistry.register('form',
653681
// Skip hidden fields
654682
if (hidden) return null;
655683

656-
// Handle conditional rendering with null/undefined safety
657-
if (condition) {
658-
const watchField = condition.field;
659-
const watchValue = form.watch(watchField);
660-
661-
// Check for null/undefined before evaluating conditions
662-
const hasValue = watchValue !== undefined && watchValue !== null;
663-
664-
if (condition.equals !== undefined && watchValue !== condition.equals) {
665-
return null;
666-
}
667-
if (condition.notEquals !== undefined && watchValue === condition.notEquals) {
668-
return null;
669-
}
670-
if (condition.in && (!hasValue || !condition.in.includes(watchValue))) {
671-
return null;
672-
}
684+
// Legacy `condition: { field, equals/notEquals/in }` — translated
685+
// to CEL and evaluated on the canonical engine over the seeded
686+
// live record (issue #1584), so it agrees with `visibleWhen` and
687+
// the server. Fail-open (a broken predicate shows the field),
688+
// matching the CEL rules below.
689+
const legacyConditionCel = legacyConditionToCel(condition);
690+
if (legacyConditionCel && !evalFieldPredicate(legacyConditionCel, ruleRecord, true)) {
691+
return null;
673692
}
674693

675694
// Field-level CEL conditional rules (B2). Evaluated reactively

0 commit comments

Comments
 (0)