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
23 changes: 23 additions & 0 deletions .changeset/condition-builder-cel-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@object-ui/app-shell": minor
---

Studio: CEL lint + field autocomplete for condition predicates (#1582).

`ConditionBuilder`'s raw-expression escape hatch — a bare `<textarea>` — is
replaced by `CelPredicateField`, so every surface that authors a condition
through it gains inline syntax/semantic validation and field-name autocomplete
on the canonical `@objectstack/formula` engine:

- field-level `visibleWhen` / `readonlyWhen` / `requiredWhen` (SchemaForm's
`condition` widget auto-maps `/When$/` properties),
- action `visible` / `disabled` (ActionDefaultInspector),
- every other `condition`-widget property (`visibleOn`, `predicate`, …).

The no-code [subject][op][value] builder path is unchanged; only the "Expression"
mode is upgraded. An invalid predicate now surfaces a readable inline error
instead of failing silently at runtime. English + Chinese labels.

This completes the objectui side of #1582 — the CEL assists it asked for now
cover the field `*When` inputs (and, since the previous change, view
`conditionalFormatting` conditions).
6 changes: 6 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.view.cf.remove': 'Remove rule',
'engine.inspector.view.cf.moveUp': 'Move up',
'engine.inspector.view.cf.moveDown': 'Move down',
// ConditionBuilder raw-expression mode (CEL editor, #1582)
'engine.condition.celLabel': 'CEL expression',
'engine.condition.advancedHint': 'Advanced expression — Builder only supports simple AND/OR conditions.',
'engine.inspector.view.type.grid': 'Table / List',
'engine.inspector.view.type.kanban': 'Kanban',
'engine.inspector.view.type.calendar': 'Calendar',
Expand Down Expand Up @@ -1768,6 +1771,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.view.cf.remove': '删除规则',
'engine.inspector.view.cf.moveUp': '上移',
'engine.inspector.view.cf.moveDown': '下移',
// ConditionBuilder 裸表达式模式(CEL 编辑器,#1582)
'engine.condition.celLabel': 'CEL 表达式',
'engine.condition.advancedHint': '高级表达式 —— 搭建器仅支持简单的 AND/OR 条件。',
'engine.inspector.view.type.grid': '表格 / 列表',
'engine.inspector.view.type.kanban': '看板',
'engine.inspector.view.type.calendar': '日历',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ConditionBuilder raw-expression mode = CEL editor (#1582).
*
* The bare <textarea> escape hatch is replaced by CelPredicateField, so the
* surfaces that route conditions through this builder — field-level
* `visibleWhen`/`readonlyWhen`/`requiredWhen` (via SchemaForm's condition
* widget) and action `visible`/`disabled` (ActionDefaultInspector) — get
* inline lint + field autocomplete on the canonical engine.
*/

import * as React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConditionBuilder } from './ConditionBuilder';
import { __setCelFormulaLoader } from '../celAuthoring';

afterEach(() => {
cleanup();
__setCelFormulaLoader(undefined);
});

const FIELDS = [
{ name: 'status', label: 'Status' },
{ name: 'amount', label: 'Amount' },
];

/** Controlled harness: `record.status in [...]` can't round-trip through the
* no-code builder, so the component opens in RAW mode. */
function Harness({ initial }: { initial: string }) {
const [v, setV] = React.useState(initial);
return (
<div>
<ConditionBuilder label="Visible when" value={v} onCommit={setV} objectName="invoice" fields={FIELDS} />
<pre data-testid="committed">{v}</pre>
</div>
);
}

const RAW_INIT = "record.status in ['sent', 'paid']";

describe('ConditionBuilder raw mode — CEL editor (#1582)', () => {
it('renders the CEL editor (not a bare textarea) and commits edits', () => {
render(<Harness initial={RAW_INIT} />);
// CelPredicateField's textarea is a combobox (autocomplete host).
const ta = screen.getByRole('combobox') as HTMLTextAreaElement;
expect(ta.value).toBe(RAW_INIT);

fireEvent.change(ta, { target: { value: "record.amount > 100" } });
expect(screen.getByTestId('committed').textContent).toBe('record.amount > 100');
});

it('surfaces a lint error inline for an invalid predicate', async () => {
__setCelFormulaLoader(() =>
Promise.resolve({
validateExpression: () => ({
ok: false,
errors: [{ message: 'Unknown variable: statuss' }],
warnings: [],
}),
}),
);
// `in [...]` can't round-trip the no-code builder → opens in raw mode.
render(<Harness initial={"statuss in ['open']"} />);
await waitFor(
() => expect(screen.getByText(/Unknown variable: statuss/i)).toBeInTheDocument(),
{ timeout: 3000 },
);
});

it("autocompletes the object's field names as you type", async () => {
__setCelFormulaLoader(() =>
Promise.resolve({
validateExpression: () => ({ ok: true, errors: [], warnings: [] }),
introspectScope: () => ({
fields: ['status', 'amount'],
roots: ['record', 'current_user'],
functions: ['has'],
}),
}),
);
const user = userEvent.setup();
render(<Harness initial={RAW_INIT} />);
const ta = screen.getByRole('combobox') as HTMLTextAreaElement;
await user.clear(ta);
await user.type(ta, 'sta');
const option = await screen.findByRole('option', { name: /status/i }, { timeout: 3000 });
expect(option).toBeTruthy();
});

it('keeps the no-code builder path for simple conditions (unchanged)', () => {
const { container } = render(<Harness initial={"record.status == 'open'"} />);
// Round-trippable → builder mode: no CEL textarea, the compiled CEL chip is
// shown instead. (Builder-mode Selects are also role=combobox, so assert on
// the textarea element specifically.)
expect(container.querySelector('textarea')).toBeNull();
// Compiled-CEL chip + the harness <pre> both echo the value.
expect(screen.getAllByText("record.status == 'open'").length).toBeGreaterThanOrEqual(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from '@object-ui/components';
import { Plus, X, Code2, ListFilter } from 'lucide-react';
import { useObjectFields } from '../previews/useObjectFields';
import { CelPredicateField } from '../CelPredicateField';
import { t, useMetadataLocale } from '../i18n';

type Op = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'truthy' | 'falsy';

Expand Down Expand Up @@ -125,6 +127,14 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
.map((f) => ({ value: `record.${f.name}`, label: `record.${f.name}` }));
return [...fieldOpts, ...CONTEXT_SUBJECTS];
}, [fields]);
// The raw-expression editor's CEL assists (#1582): field-existence lint +
// autocomplete need the bare field-name catalog and a locale-bound `t`.
const locale = useMetadataLocale();
const tLocal = React.useCallback((k: string) => t(k, locale), [locale]);
const fieldNames = React.useMemo(
() => fields.filter((f) => !f.hidden).map((f) => f.name),
[fields],
);

const init = React.useMemo(() => initFrom(value), []); // first mount only
const [rows, setRowsState] = React.useState<Row[]>(init.rows);
Expand Down Expand Up @@ -168,17 +178,21 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
<ListFilter className="h-3 w-3" /> Builder
</button>
</div>
<textarea
{/* CEL editor with inline lint + field autocomplete (#1582) — the same
author-time assists the RLS policy editor gets, on the canonical
@objectstack/formula engine. Replaces the bare <textarea>. */}
<CelPredicateField
label={tLocal('engine.condition.celLabel')}
value={value}
onChange={(e) => { lastEmitted.current = e.target.value; onCommit(e.target.value); }}
onChange={(v) => { lastEmitted.current = v; onCommit(v); }}
disabled={disabled}
spellCheck={false}
rows={2}
placeholder="CEL expression, e.g. record.status != 'done' && user.isAdmin"
className="w-full rounded border border-input bg-background px-2 py-1.5 text-xs font-mono outline-none focus:ring-1 focus:ring-primary resize-y disabled:opacity-60"
placeholder="record.status != 'done' && user.isAdmin"
objectName={objectName}
fieldNames={fieldNames}
t={tLocal}
/>
{value && !parse(value) && (
<div className="text-[10px] text-muted-foreground/70">Advanced expression — Builder only supports simple AND/OR conditions.</div>
<div className="text-[10px] text-muted-foreground/70">{tLocal('engine.condition.advancedHint')}</div>
)}
</div>
);
Expand Down
Loading