Skip to content

Commit a00e16d

Browse files
os-zhuangclaude
andauthored
feat: CEL disabled on action buttons + record-page Undo wiring (#1787)
- components/layout (page header): evaluate CEL `disabled` against the record (was boolean-only), mirroring the existing `visible` eval — actions can grey out conditionally instead of only hiding. - plugin-grid RowActionMenu: evaluate `disabled` (boolean | CEL) per row item. - components action-button: forward `undoable`/`recordIdField` on execute. - app-shell RecordDetailView: mount useGlobalUndo + wire the success toast's "Undo" for undoable actions (prior values from the loaded record). - plugin-detail record:quick_actions: CEL disabled + running spinner per button. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f7f325d commit a00e16d

6 files changed

Lines changed: 156 additions & 49 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@object-ui/components": minor
3+
"@object-ui/plugin-grid": minor
4+
"@object-ui/plugin-detail": minor
5+
"@object-ui/app-shell": minor
6+
---
7+
8+
feat: evaluate CEL `disabled` on action buttons + record-page Undo wiring
9+
10+
- **components (page header)**: the `record_header` action toolbar now evaluates
11+
a CEL `disabled` predicate against the record (boolean was the only honoured
12+
form before), mirroring its existing `visible` evaluation. An action can now
13+
grey out conditionally (e.g. "Reassign" on a converted lead) instead of only
14+
hiding via `visible`.
15+
- **plugin-grid (row menu)**: `RowActionMenu` items likewise evaluate `disabled`
16+
(boolean or CEL against the row), and skip the click when disabled.
17+
- **components (action-button)**: forward `undoable` / `recordIdField` when
18+
executing, so undoable update actions keep their Undo affordance through the
19+
`action:button` path.
20+
- **app-shell (RecordDetailView)**: mount `useGlobalUndo` and wire the record
21+
action runtime's success toast to offer "Undo" for `undoable` actions
22+
(capturing the changed fields' prior values from the loaded record).
23+
- **plugin-detail (record:quick_actions)**: the widget's buttons now evaluate a
24+
CEL `disabled` and show a spinner + disable while running.

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
1111
import { DetailView, RecordChatterPanel, buildDefaultPageSchema, extractMentions } from '@object-ui/plugin-detail';
1212
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
1313
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
14-
import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider } from '@object-ui/react';
14+
import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, useGlobalUndo } from '@object-ui/react';
1515
import { buildExpandFields } from '@object-ui/core';
1616
import { toast } from 'sonner';
1717
import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration';
@@ -347,10 +347,24 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
347347
});
348348
}, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel]);
349349

350-
const toastHandler = useCallback((message: string, options?: { type?: string }) => {
351-
if (options?.type === 'error') toast.error(message);
352-
else toast.success(message);
353-
}, []);
350+
// Global undo/redo (Ctrl+Z), backed by the dataSource — the success toast's
351+
// "Undo" button (for `undoable` actions) restores the record's prior values.
352+
const undoCtl = useGlobalUndo({
353+
dataSource,
354+
onUndo: () => { setActionRefreshKey(k => k + 1); toast.success('Change undone'); },
355+
});
356+
357+
const toastHandler = useCallback((message: string, options?: { type?: string; duration?: number; undo?: { label?: string } }) => {
358+
if (options?.type === 'error') { toast.error(message); return; }
359+
if (options?.undo) {
360+
toast.success(message, {
361+
duration: options.duration,
362+
action: { label: options.undo.label || 'Undo', onClick: () => { void undoCtl.undo(); } },
363+
});
364+
return;
365+
}
366+
toast.success(message, { duration: options?.duration });
367+
}, [undoCtl]);
354368

355369
const navigateHandler = useCallback((url: string, options?: { external?: boolean; newTab?: boolean }) => {
356370
if (options?.external || options?.newTab) {
@@ -364,8 +378,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
364378
const apiHandler = useCallback(async (action: ActionDef) => {
365379
try {
366380
const target = action.target || action.name;
367-
const params = action.params || {};
381+
const params: Record<string, any> = { ...(action.params || {}) };
382+
delete params._rowRecord;
368383

384+
let undo: any;
369385
switch (target) {
370386
case 'opportunity_change_stage':
371387
await dataSource.update(objectName!, pureRecordId!, { stage: params.new_stage });
@@ -379,6 +395,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
379395
default:
380396
// Generic: update record with collected params
381397
if (Object.keys(params).length > 0) {
398+
// Undoable single-record update: capture the changed fields' prior
399+
// values from the loaded record so the success toast can offer Undo.
400+
if (action.undoable && pageRecord && objectName && pureRecordId) {
401+
const undoData: Record<string, unknown> = {};
402+
for (const k of Object.keys(params)) undoData[k] = (pageRecord as any)[k] ?? null;
403+
undo = {
404+
id: `undo-${objectName}-${pureRecordId}-${Date.now()}`,
405+
type: 'update',
406+
objectName,
407+
recordId: String(pureRecordId),
408+
timestamp: Date.now(),
409+
description: action.label || `Undo ${objectName}`,
410+
undoData,
411+
redoData: { ...params },
412+
};
413+
}
382414
await dataSource.update(objectName!, pureRecordId!, params);
383415
}
384416
break;
@@ -387,12 +419,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
387419
const shouldRefresh = action.refreshAfter === true;
388420
if (shouldRefresh) {
389421
setActionRefreshKey(k => k + 1);
422+
} else if (undo) {
423+
// Even when refreshAfter isn't set, reflect the change so the user sees
424+
// it (and the subsequent Undo) on the open record.
425+
setActionRefreshKey(k => k + 1);
390426
}
391-
return { success: true, reload: shouldRefresh };
427+
return { success: true, reload: shouldRefresh, undo };
392428
} catch (error) {
393429
return { success: false, error: (error as Error).message };
394430
}
395-
}, [dataSource, objectName, pureRecordId]);
431+
}, [dataSource, objectName, pureRecordId, pageRecord]);
396432

397433
// Client-side modal transport: `type:'modal'` actions open here (Dialog /
398434
// Sheet / Drawer by `placement`) and render arbitrary SchemaNode content.

packages/components/src/renderers/action/action-button.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
9898
successMessage: schema.successMessage,
9999
errorMessage: schema.errorMessage,
100100
refreshAfter: schema.refreshAfter,
101+
// Forward `undoable` (and the row id field) so update actions can
102+
// offer an Undo affordance — without this the flag is dropped and the
103+
// handler never builds the undo operation.
104+
undoable: (schema as any).undoable,
105+
recordIdField: (schema as any).recordIdField,
101106
toast: schema.toast,
102107
// One-shot reveal dialog for actions whose response is shown
103108
// exactly once (2FA setup, OAuth client_secret, regenerated

packages/components/src/renderers/layout/containers.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,11 +804,30 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
804804
const useOverflow = headerActions.length > INLINE_MAX + 1;
805805
const inlineActions = useOverflow ? headerActions.slice(0, INLINE_MAX) : headerActions;
806806
const overflowActions = useOverflow ? headerActions.slice(INLINE_MAX) : [];
807+
// Resolve a `disabled` predicate against the record. Mirrors the `visible`
808+
// evaluation above — a boolean OR a CEL expression (`'record.status == …'`
809+
// or the `{ dialect, source }` envelope). Without this a CEL `disabled`
810+
// silently did nothing (only boolean was honoured).
811+
const dRecord: any = ctx?.data;
812+
const dEvaluator = new ExpressionEvaluator({
813+
...(dRecord && typeof dRecord === 'object' ? dRecord : {}),
814+
record: dRecord,
815+
data: dRecord,
816+
});
817+
const resolveDisabled = (d: any): boolean => {
818+
if (d === undefined || d === null) return false;
819+
if (typeof d === 'boolean') return d;
820+
const src = typeof d === 'string'
821+
? d
822+
: (d && typeof d === 'object' && typeof (d as any).source === 'string' ? (d as any).source : undefined);
823+
if (!src) return false;
824+
try { return !!dEvaluator.evaluateExpression(src); } catch { return false; }
825+
};
807826
const renderButton = (action: any, idx: number) => {
808827
const label = resolveLabel(action, idx);
809828
const variant = action.variant || 'default';
810829
const size = action.size || 'sm';
811-
const disabled = typeof action.disabled === 'boolean' ? action.disabled : undefined;
830+
const disabled = resolveDisabled(action.disabled);
812831
const icon = typeof action.icon === 'string' ? action.icon : null;
813832
return (
814833
<Button
@@ -852,7 +871,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
852871
<DropdownMenuContent align="end" className="w-44">
853872
{overflowActions.map((action, idx) => {
854873
const label = resolveLabel(action, idx + INLINE_MAX);
855-
const disabled = typeof action.disabled === 'boolean' ? action.disabled : undefined;
874+
const disabled = resolveDisabled(action.disabled);
856875
const icon = typeof action.icon === 'string' ? action.icon : null;
857876
const isDestructive =
858877
action.variant === 'destructive' || action.name === 'sys_delete';

packages/plugin-detail/src/renderers/record-quick-actions.tsx

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import React from 'react';
19-
import { useRecordContext, useActionEngine, useMetadataItem } from '@object-ui/react';
19+
import { useRecordContext, useActionEngine, useMetadataItem, useCondition, toPredicateInput } from '@object-ui/react';
2020
import { usePermissions } from '@object-ui/permissions';
2121
import { Button, cn } from '@object-ui/components';
2222
import { Loader2 } from 'lucide-react';
@@ -110,11 +110,6 @@ export const RecordQuickActionsRenderer: React.FC<RecordQuickActionsRendererProp
110110
// (see packages/react/src/hooks/useActionEngine.ts) so executeAction
111111
// automatically picks up confirm/param/modal/result-dialog/toast handlers
112112
// — no need to thread a separate globalExecute.
113-
// Tracks the action currently executing so its button shows a spinner and
114-
// disables — a visible progress state for record-header actions (e.g. a
115-
// `flow` action opening its wizard, or a slow server action).
116-
const [runningName, setRunningName] = React.useState<string | null>(null);
117-
118113
const { getActionsForLocation, executeAction } = useActionEngine({
119114
actions,
120115
context: {
@@ -158,40 +153,62 @@ export const RecordQuickActionsRenderer: React.FC<RecordQuickActionsRendererProp
158153
aria-label={schema.aria?.label || 'Quick actions'}
159154
{...designer}
160155
>
161-
{visibleActions.map((action, idx) => {
162-
const label = action.label || action.name || `Action ${idx + 1}`;
163-
const variant = (action as any).variant || schema.variant || 'default';
164-
const size = (action as any).size || schema.size || 'sm';
165-
const disabled =
166-
typeof action.disabled === 'boolean' ? action.disabled : undefined;
167-
const isRunning = runningName === (action.name || `qa-${idx}`);
168-
return (
169-
<Button
170-
key={action.name || `qa-${idx}`}
171-
variant={variant}
172-
size={size}
173-
disabled={disabled || isRunning}
174-
onClick={async () => {
175-
const key = action.name || `qa-${idx}`;
176-
setRunningName(key);
177-
try {
178-
if (typeof action.onClick === 'function') {
179-
await action.onClick();
180-
} else if (action.name) {
181-
await executeAction(action.name);
182-
}
183-
} finally {
184-
setRunningName((c) => (c === key ? null : c));
185-
}
186-
}}
187-
>
188-
{isRunning && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
189-
{label}
190-
</Button>
191-
);
192-
})}
156+
{visibleActions.map((action, idx) => (
157+
<QuickActionButton
158+
key={action.name || `qa-${idx}`}
159+
action={action}
160+
record={ctx?.data}
161+
variant={(action as any).variant || schema.variant || 'default'}
162+
size={(action as any).size || schema.size || 'sm'}
163+
label={action.label || action.name || `Action ${idx + 1}`}
164+
onRun={async () => {
165+
if (typeof action.onClick === 'function') await action.onClick();
166+
else if (action.name) await executeAction(action.name);
167+
}}
168+
/>
169+
))}
193170
</div>
194171
);
195172
};
196173

174+
/**
175+
* A single quick-action button. Owns its own running/loading state (a spinner +
176+
* disable while the action executes — a visible progress state for slow / flow
177+
* actions) and evaluates a CEL `disabled` predicate against the record (so an
178+
* action can grey out conditionally, not just hide via `visible`).
179+
*/
180+
function QuickActionButton({
181+
action, record, variant, size, label, onRun,
182+
}: {
183+
action: ActionDef;
184+
record: Record<string, unknown> | undefined;
185+
variant: any;
186+
size: any;
187+
label: string;
188+
onRun: () => Promise<void> | void;
189+
}) {
190+
const [running, setRunning] = React.useState(false);
191+
const recordCtx = React.useMemo(() => ({ record: record || {} }), [record]);
192+
// `disabled` may be a boolean or a CEL predicate. Evaluate the predicate form
193+
// against the record; useCondition must be called unconditionally (hook).
194+
const disabledPred = toPredicateInput((action as any).disabled);
195+
const condDisabled = useCondition(typeof disabledPred === 'string' ? disabledPred : undefined, recordCtx);
196+
const isDisabled =
197+
(typeof disabledPred === 'string' ? condDisabled : disabledPred === true) || running;
198+
return (
199+
<Button
200+
variant={variant}
201+
size={size}
202+
disabled={isDisabled}
203+
onClick={async () => {
204+
setRunning(true);
205+
try { await onRun(); } finally { setRunning(false); }
206+
}}
207+
>
208+
{running && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
209+
{label}
210+
</Button>
211+
);
212+
}
213+
197214
export default RecordQuickActionsRenderer;

packages/plugin-grid/src/components/RowActionMenu.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,16 @@ const RowActionMenuItem: React.FC<{
9191
onActionDef?: (def: RowActionDef, row: any) => void;
9292
}> = ({ def, row, onActionDef }) => {
9393
const isVisible = useCondition(toPredicateInput(def.visible), row);
94+
// `disabled` may be a boolean or a CEL predicate evaluated against the row
95+
// (e.g. grey out "Reassign" once a lead is converted) — previously ignored.
96+
const disabledPred = toPredicateInput((def as any).disabled);
97+
const evalDisabled = useCondition(typeof disabledPred === 'string' ? disabledPred : undefined, row);
98+
const isDisabled = typeof disabledPred === 'string' ? evalDisabled : disabledPred === true;
9499
if (def.visible && !isVisible) return null;
95100
return (
96101
<DropdownMenuItem
97-
onClick={() => onActionDef?.(def, row)}
102+
disabled={isDisabled}
103+
onClick={() => { if (!isDisabled) onActionDef?.(def, row); }}
98104
data-testid={`row-action-${def.name}`}
99105
className={def.variant === 'danger' ? 'text-destructive focus:text-destructive' : undefined}
100106
>

0 commit comments

Comments
 (0)