Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/listview-cel-conditional.md
Original file line number Diff line number Diff line change
@@ -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).
35 changes: 35 additions & 0 deletions e2e/live/list-row-action-cel.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
17 changes: 6 additions & 11 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -243,17 +243,12 @@ export const DataTableRowActionItem: React.FC<{
row: any;
onActionDef?: (action: RowActionDef, row: any) => void | Promise<void>;
}> = ({ 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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />,
);
}

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());
});
});
53 changes: 36 additions & 17 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ function stripRendererOnlyProps<T extends Record<string, any>>(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;
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading