Repository navigation
Unify list-view conditional features (conditional formatting / row-action visibility) onto the CEL engine #1584
Description
Context
B2 (ADR-0036) standardized field-level conditional rules on CEL — @objectstack/formula's ExpressionEngine, evaluated identically client + server via evalFieldPredicate / resolveFieldRuleState (packages/core/src/evaluator/fieldRules.ts). ADR-0089 then unified the key name (visibleWhen) across layers.
The list-view conditional tier never moved: it still evaluates on the legacy ExpressionEvaluator (@object-ui/core's recursive-descent JS-dialect parser), even where code comments already claim "CEL predicate".
This is a spec-compliance gap, not just a cleanup. Per @objectstack/spec, these surfaces are already CEL by contract:
view.zod.tsconditionalFormatting: [{ condition: ExpressionInputSchema /* "Predicate (CEL)" */, style }]action.zod.tsvisible: ExpressionInputSchema /* "Visibility predicate (CEL)" */ExpressionInputSchemanormalizes a bare string to{ dialect: 'cel', source }at parse — yet ObjectUI'stoPredicateInput(packages/react/src/hooks/useExpression.ts) unwraps that envelope and feeds the source to the legacy engine. A predicate explicitly declareddialect: 'cel'is evaluated in the wrong dialect today.
ADR-0058 frames the platform contract: one authoring language (CEL), one interpret backend (formula's cel-engine). The list tier is the notable client surface still off it.
Affected sites (all on the legacy engine today)
| Surface | Site | Notes |
|---|---|---|
| Conditional formatting (list) | packages/plugin-list/src/ListView.tsx evaluateConditionalFormatting |
3 rule shapes: {field, operator, value}, spec {condition, style}, {expression: '${…}'} |
| Conditional formatting (grid) | packages/plugin-grid/src/ObjectGrid.tsx getRowStyle |
inline copy of the ListView logic, not shared |
| Conditional formatting (kanban) | plugin-kanban KanbanEnhanced.tsx / KanbanImpl.tsx |
field/operator/value only; plugin-view/ObjectView.tsx's kanban branch drops top-level conditionalFormatting entirely (bug) |
| Row-action visibility | plugin-grid/src/components/RowActionMenu.tsx, components/src/renderers/complex/data-table.tsx (RowActionItem) |
visible/disabled from objectDef.actions (locations: ['list_item']) — server-authored CEL, client-parsed as JS via useCondition |
| Action engine | core/src/actions/ActionEngine.ts getActionsForLocation |
legacy evaluator, fail-closed + warn |
| Legacy form condition | components/src/renderers/form/form.tsx condition: {field, equals/notEquals/in} |
hand-rolled, bypasses any engine; only live authors are 2 sites in plugin-designer/FieldDesigner.tsx |
The dialect fork is already observable
- Legacy parser has no
inoperator → the canonical CEL idiom'admin' in user.roles/record.status in ['sent','paid']fails to parse on a row action today and fails open (item stays visible). - Legacy-only syntax (
===,?.,.includes(),${}interpolation) is not CEL — so the samevisibleWhen-style string means different things depending on which tier evaluates it. - Missing map key: CEL faults (needs null-seeding, cf. ADR-0036's gotcha); legacy returns
undefined. - Failure postures are inconsistent:
useConditionfail-open,ActionEnginefail-closed+warn,evaluatePlainConditionfail-closed.
Back-compat blast radius: near zero (surveyed)
Full-tree survey (apps/, examples/, e2e/, content/, docs/, skills/): zero real authored usages of list conditionalFormatting, row-action string predicates, or the legacy form condition. Only 3 package-test fixtures use {condition: '${data.x === "y"}', style}, and most action-predicate fixtures are already CEL-compatible (record.id == ctx.user.id, !record.two_factor_enabled). Transparent translation + dialect routing is cheap.
Plan
- Core helpers (
@object-ui/core, zero-React, next tofieldRules.ts):evalRowPredicate(pred, row, opts)— wrapsevalFieldPredicate; bindsrecord+ bare row fields (viascope) + the global predicate scope (features/user/current_userfromPredicateScopeContext— required for scope parity with existing predicates); null-seeds row fields.resolveConditionalFormatting(record, rules)— the single implementation for all three rule shapes:{field, operator, value}mechanically translated to CEL (equals→==,in→in,contains→.contains()); string/envelope routed by dialect.- Dialect routing:
{dialect:'cel'}envelope → CEL engine, always (fixes the active protocol violation). Bare string → CEL per spec contract, unless it carries legacy markers (${,===,?., method calls) → legacy engine + one-time deprecation warning.
- Converge consumers:
ListView.evaluateConditionalFormattingbecomes a thin wrapper (export kept); deleteObjectGrid.getRowStyle's inline copy; kanban consumes the same helper andplugin-viewforwards top-levelconditionalFormattingto the kanban branch;RowActionMenu/ data-tableRowActionItemswitch to the CEL-first path. - Failure posture (per ADR-0058's fail-policy matrix — fail-soft-but-logged for non-security): formatting failure = no style + console warn; row-action
visiblefailure = hidden + warn (aligns withActionEngine); nothing silent. - Retire the legacy form
condition: translate{field, equals/notEquals/in}→ CEL (record.f == v/record.f in […]) intoevalFieldPredicateat the form.tsx entry; migrate the 2FieldDesignerauthoring sites tovisibleWhen. - Docs/skill/tests:
skills/objectui/guides/schema-expressions.mdgains a "list conditional tier = CEL (data-model tier)" section; unit tests for the 3 shapes + dialect routing + missing-key; live e2e on a showcase list (formatting + row-actionvisible).
Ship order: ① core helpers → ② formatting convergence (incl. kanban pass-through fix) → ③ row-action predicates → ④ form condition retirement → ⑤ docs/e2e. Each step is independently releasable with a changeset.
Naming (settled by ADR-0089 — do NOT rename)
- Actions keep
visible— ADR-0089 renamed only the field/view/page visibility keys;action.zod.tskeepsvisibleand it is already CEL by contract. - Formatting keeps spec's
{condition, style}as canonical; ObjectUI-local shapes become back-compat translations (normalize once at the spec-bridge boundary, mirroring ADR-0089 D2). useCondition/useExpressionremain the schema/widget-tier hooks (${}templates, dashboards, pages) — do not swap their engine globally; only row/record predicate call-sites move.
Risks / open questions
- Perf:
formula'scelEngine.evaluatere-parses the source per call (no program cache; cel-js can't re-execute a parsed AST). Lists are rows × rules × re-renders. Likely acceptable at page-size row counts, but measure; consider upstream caching in@objectstack/formulaif hot. - Rows are server-serialized: benefits from cel-engine's built-in numeric/date string overload-retry — a correctness gain over the legacy engine, but worth covering in tests.
- Behavior change (intended): previously-unparseable row-action predicates that silently stayed visible will now hide + warn.
Acceptance
- A list view expresses conditional formatting + row-action visibility as CEL predicates over the row record, evaluated by
@objectstack/formula— same verdict as the server for the same record. - A
{dialect:'cel'}envelope is never evaluated by the legacy engine. - Legacy JSON
{field, operator, value}and legacy-dialect strings keep working (translated / routed), with deprecation warnings. - Top-level
conditionalFormattingreaches kanban. - Unit tests + a live e2e on a showcase list; docs/skill note updated.
Priority & references
- B2 follow-up: parent-scoped conditional rules in inline grids ("paid invoice → lock lines") #1581 (parent-scoped grid lock) ✅ done; Studio: CEL editor (validate + autocomplete) for field conditional rules (visibleWhen/readonlyWhen/requiredWhen) #1582 (Studio CEL editor) still open — not a blocker, and its authoring UI can reuse
CelPredicateField/celAuthoring.tsfrom feat(app-shell): CEL authoring safety for RLS policies — lint, field autocomplete, test-run #2533 for formatting/action predicates later. - ADR-0036 (field conditional rules), framework ADR-0058 (expression & predicate surface), framework ADR-0089 (
visibleWhenunification), objectui#2490 / feat(components): page:tabs honors item-level visibleWhen — conditional tabs (framework#2606) #2516 (ADR-0089 renderer reads).
Activity
feat(list): unify conditional formatting + row-action visibility onto…
feat(kanban): accept CEL { condition, style } conditional-formatting …
feat(app-shell): Studio CEL editor for list-view conditional formatti…
fix(components): 记录页头的动作谓词改走 CEL,与行菜单/批量栏同一入口 (objectstack-ai#3521) (o…