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
26 changes: 26 additions & 0 deletions .changeset/action-condition-bare-cel-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@object-ui/core": patch
---

fix(core): evaluate bare CEL predicates in `evaluateCondition`

`ExpressionEvaluator.evaluateCondition` delegated to `evaluate`, which only
processes `${...}` templates and returns any other string verbatim. A bare
predicate such as `record.status == "converted"` (the shape `objectstack build`
emits for `disabled`/`visible`/`condition`) was therefore returned as a
non-empty string and coerced to `true` — so every bare-expression predicate was
silently always-truthy.

The most visible symptom: a param-collecting `api` action invoked from the
record header (e.g. CRM "Reassign Lead") was treated as permanently `disabled`,
so `ActionRunner.execute` bailed before opening the param dialog. The renderer
(`page:header`) was unaffected because it evaluates via `evaluateExpression`
directly.

`evaluateCondition` now treats a non-`${}` condition as a single expression
(via `evaluateExpression`), keeps the `${...}` template path, and preserves the
"empty/undefined ⇒ visible/enabled" and "unparseable ⇒ default visible/enabled"
fallbacks. Also hardens `ActionRunner`'s `disabled` gate to evaluate the
boolean/string/envelope form rather than treating any object as truthy, and
unifies the grid row-action predicate scope so `record.*` and bare-field
predicates resolve identically on every surface.
20 changes: 15 additions & 5 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,11 +385,21 @@ export class ActionRunner {
}
}

if (action.disabled) {
const isDisabled = typeof action.disabled === 'string'
? this.evaluator.evaluateCondition(action.disabled)
: action.disabled;

if (action.disabled != null && action.disabled !== false) {
// `disabled` may be a boolean, a CEL string, or the normalized envelope
// `{ dialect, source }` (what `objectstack build` emits). The previous
// code only evaluated the STRING form and treated any object as truthy,
// so an envelope-disabled action was ALWAYS "disabled" — silently
// blocking every execution (param dialog never opened, handler never
// ran). `evaluateCondition` already handles boolean/string/envelope;
// and the renderers are authoritative for the visual disabled state, so
// any eval failure here defaults to NOT-disabled (don't false-block).
let isDisabled = false;
try {
isDisabled = this.evaluator.evaluateCondition(action.disabled as never);
} catch {
isDisabled = false;
}
if (isDisabled) {
return { success: false, error: 'Action is disabled' };
}
Expand Down
32 changes: 27 additions & 5 deletions packages/core/src/evaluator/ExpressionEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,36 @@ export class ExpressionEvaluator {
condition = (condition as any).source as string;
}

// No condition → default to visible/enabled (undefined, null, '').
if (!condition) {
return true; // Default to visible/enabled if no condition
return true;
}

const result = this.evaluate(condition, options);

// Convert result to boolean
return Boolean(result);
if (typeof condition !== 'string') {
return Boolean(condition);
}

const trimmed = condition.trim();
if (!trimmed) {
return true; // Whitespace-only → treat as "no condition".
}

// A condition is semantically a single boolean expression. When it's a
// `${...}` template, evaluate via the template path. Otherwise treat the
// ENTIRE string as one expression (bare CEL like `record.status == "x"`):
// `evaluate` would short-circuit a non-`${}` string and return it verbatim,
// so `Boolean('record.status == "x"')` was ALWAYS true — silently making
// every bare-expression `disabled`/`condition`/`visible` predicate truthy.
if (trimmed.includes('${')) {
return Boolean(this.evaluate(trimmed, options));
}
try {
return Boolean(this.evaluateExpression(trimmed, { sanitize: options.sanitize !== false }));
} catch {
// Unparseable predicate — preserve the historical "default to
// visible/enabled" behaviour rather than hiding/blocking on a typo.
return true;
}
}

/**
Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-grid/src/components/RowActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,15 @@ const RowActionMenuItem: React.FC<{
row: any;
onActionDef?: (def: RowActionDef, row: any) => void;
}> = ({ def, row, onActionDef }) => {
const isVisible = useCondition(toPredicateInput(def.visible), row);
// Evaluate predicates against the row with BOTH a bare-field scope (`status`)
// and a `record.` scope (`record.status`) so authors can use either
// convention consistently with the record-header / spec evaluators.
const predicateCtx = { ...(row && typeof row === 'object' ? row : {}), record: row };
const isVisible = useCondition(toPredicateInput(def.visible), predicateCtx);
// `disabled` may be a boolean or a CEL predicate evaluated against the row
// (e.g. grey out "Reassign" once a lead is converted) — previously ignored.
const disabledPred = toPredicateInput((def as any).disabled);
const evalDisabled = useCondition(typeof disabledPred === 'string' ? disabledPred : undefined, row);
const evalDisabled = useCondition(typeof disabledPred === 'string' ? disabledPred : undefined, predicateCtx);
const isDisabled = typeof disabledPred === 'string' ? evalDisabled : disabledPred === true;
if (def.visible && !isVisible) return null;
return (
Expand Down Expand Up @@ -132,7 +136,7 @@ const RowActionInlineButton: React.FC<{
row: any;
onActionDef?: (def: RowActionDef, row: any) => void;
}> = ({ def, row, onActionDef }) => {
const isVisible = useCondition(toPredicateInput(def.visible), row);
const isVisible = useCondition(toPredicateInput(def.visible), { ...(row && typeof row === 'object' ? row : {}), record: row });
if (def.visible && !isVisible) return null;
return (
<Button
Expand Down
Loading