Skip to content

Unify list-view conditional features (conditional formatting / row-action visibility) onto the CEL engine #1584

Description

@xuyushun441-sys

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.ts conditionalFormatting: [{ condition: ExpressionInputSchema /* "Predicate (CEL)" */, style }]
  • action.zod.ts visible: ExpressionInputSchema /* "Visibility predicate (CEL)" */
  • ExpressionInputSchema normalizes a bare string to { dialect: 'cel', source } at parse — yet ObjectUI's toPredicateInput (packages/react/src/hooks/useExpression.ts) unwraps that envelope and feeds the source to the legacy engine. A predicate explicitly declared dialect: '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 in operator → 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 same visibleWhen-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: useCondition fail-open, ActionEngine fail-closed+warn, evaluatePlainCondition fail-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

  1. Core helpers (@object-ui/core, zero-React, next to fieldRules.ts):
    • evalRowPredicate(pred, row, opts) — wraps evalFieldPredicate; binds record + bare row fields (via scope) + the global predicate scope (features/user/current_user from PredicateScopeContext — 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==, inin, 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.
  2. Converge consumers: ListView.evaluateConditionalFormatting becomes a thin wrapper (export kept); delete ObjectGrid.getRowStyle's inline copy; kanban consumes the same helper and plugin-view forwards top-level conditionalFormatting to the kanban branch; RowActionMenu / data-table RowActionItem switch to the CEL-first path.
  3. Failure posture (per ADR-0058's fail-policy matrix — fail-soft-but-logged for non-security): formatting failure = no style + console warn; row-action visible failure = hidden + warn (aligns with ActionEngine); nothing silent.
  4. Retire the legacy form condition: translate {field, equals/notEquals/in} → CEL (record.f == v / record.f in […]) into evalFieldPredicate at the form.tsx entry; migrate the 2 FieldDesigner authoring sites to visibleWhen.
  5. Docs/skill/tests: skills/objectui/guides/schema-expressions.md gains 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-action visible).

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.ts keeps visible and 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/useExpression remain 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's celEngine.evaluate re-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/formula if 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 conditionalFormatting reaches kanban.
  • Unit tests + a live e2e on a showcase list; docs/skill note updated.

Priority & references

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions