diff --git a/.changeset/row-crud-cel-predicates-2614.md b/.changeset/row-crud-cel-predicates-2614.md
new file mode 100644
index 000000000..ae1d1be33
--- /dev/null
+++ b/.changeset/row-crud-cel-predicates-2614.md
@@ -0,0 +1,22 @@
+---
+'@object-ui/plugin-grid': minor
+'@object-ui/components': minor
+'@object-ui/plugin-detail': minor
+'@object-ui/plugin-form': patch
+'@object-ui/app-shell': minor
+'@object-ui/types': minor
+---
+
+feat(grid): built-in row Edit/Delete honor per-record CEL predicates (#2614)
+
+The object's `userActions.edit` / `userActions.delete` now also accept an
+object form `{ enabled?, visibleWhen?, disabledWhen? }`. The predicates are
+evaluated per row on the canonical CEL engine (`useRowPredicate`, the same
+machinery custom row actions use): `visibleWhen` false → the built-in
+Edit/Delete item is not rendered for that row (fail-closed); `disabledWhen`
+true → rendered disabled (fail-soft). Wired through ObjectGrid's
+RowActionMenu and the data-table's row overflow menu (the related-list
+path), with the app-shell `crudAffordances` mirror kept in lockstep.
+Omitting the predicates (or using plain booleans) keeps today's behavior
+bit-for-bit; declared predicates evaluate only when a row's menu opens, so
+grid rendering cost is unchanged.
diff --git a/packages/app-shell/src/utils/crudAffordances.test.ts b/packages/app-shell/src/utils/crudAffordances.test.ts
new file mode 100644
index 000000000..f0e5af55d
--- /dev/null
+++ b/packages/app-shell/src/utils/crudAffordances.test.ts
@@ -0,0 +1,69 @@
+/**
+ * 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.
+ */
+
+/**
+ * UI-side mirror of the framework's `resolveCrudAffordances`. Covers the
+ * managedBy bucket defaults, the boolean overrides, and the objectui#2614
+ * object form of `userActions.edit` / `delete` (per-record CEL predicates).
+ */
+import { describe, it, expect } from 'vitest';
+import { resolveCrudAffordances } from './crudAffordances';
+
+describe('resolveCrudAffordances (app-shell mirror)', () => {
+ it('defaults to the platform bucket when managedBy is unset', () => {
+ expect(resolveCrudAffordances({})).toEqual({
+ create: true, import: true, edit: true, delete: true, exportCsv: true,
+ });
+ });
+
+ it('applies the bucket default matrix (append-only → export only)', () => {
+ expect(resolveCrudAffordances({ managedBy: 'append-only' })).toEqual({
+ create: false, import: false, edit: false, delete: false, exportCsv: true,
+ });
+ });
+
+ it('boolean userActions override the bucket default per flag', () => {
+ const aff = resolveCrudAffordances({ managedBy: 'system', userActions: { edit: true } });
+ expect(aff.edit).toBe(true);
+ expect(aff.delete).toBe(false);
+ });
+
+ describe('#2614 object form (per-record CEL predicates)', () => {
+ it('carries predicates through and resolves enabled from the bucket default', () => {
+ const aff = resolveCrudAffordances({
+ userActions: {
+ edit: { disabledWhen: 'record.frozen == true' },
+ delete: { visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } },
+ },
+ });
+ expect(aff.edit).toBe(true);
+ expect(aff.delete).toBe(true);
+ expect(aff.editPredicates).toEqual({ disabledWhen: 'record.frozen == true' });
+ expect(aff.deletePredicates).toEqual({ visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } });
+ });
+
+ it('object form enabled:false opts out like the bare boolean', () => {
+ const aff = resolveCrudAffordances({
+ userActions: { edit: { enabled: false, disabledWhen: 'record.frozen == true' } },
+ });
+ expect(aff.edit).toBe(false);
+ // Predicates still surface — the caller decides what a disabled
+ // affordance means; the grid path drops them with canEdit=false.
+ expect(aff.editPredicates).toEqual({ disabledWhen: 'record.frozen == true' });
+ });
+
+ it('object form without predicates is byte-identical to the boolean path', () => {
+ const aff = resolveCrudAffordances({ userActions: { edit: { enabled: true }, delete: {} } });
+ expect(aff).toEqual({
+ create: true, import: true, edit: true, delete: true, exportCsv: true,
+ });
+ expect('editPredicates' in aff).toBe(false);
+ expect('deletePredicates' in aff).toBe(false);
+ });
+ });
+});
diff --git a/packages/app-shell/src/utils/crudAffordances.ts b/packages/app-shell/src/utils/crudAffordances.ts
index 13e9ee7a5..9c11e987f 100644
--- a/packages/app-shell/src/utils/crudAffordances.ts
+++ b/packages/app-shell/src/utils/crudAffordances.ts
@@ -33,13 +33,33 @@ export interface CrudAffordances {
delete: boolean;
/** CSV / clipboard export. */
exportCsv: boolean;
+ /**
+ * Per-record CEL predicates for the built-in row Edit/Delete actions,
+ * present only when `userActions.edit` / `delete` used the object form
+ * (objectui#2614). Carried through as authored (bare CEL string or
+ * `{ dialect, source }` envelope) for row renderers to evaluate via
+ * `useRowPredicate`; they never affect the object-level booleans above.
+ */
+ editPredicates?: RowCrudPredicates;
+ deletePredicates?: RowCrudPredicates;
}
+/** Per-record predicates from the #2614 object form of a userActions flag. */
+export interface RowCrudPredicates {
+ visibleWhen?: unknown;
+ disabledWhen?: unknown;
+}
+
+/** `edit`/`delete` accept a bare boolean or the #2614 object form. */
+export type UserActionOverride =
+ | boolean
+ | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown };
+
export interface UserActionsOverride {
create?: boolean;
import?: boolean;
- edit?: boolean;
- delete?: boolean;
+ edit?: UserActionOverride;
+ delete?: UserActionOverride;
exportCsv?: boolean;
}
@@ -56,15 +76,38 @@ export interface SchemaLike {
userActions?: UserActionsOverride | null;
}
+/**
+ * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
+ * the bucket default, surfacing any per-record predicates alongside.
+ */
+function normalizeOverride(
+ v: UserActionOverride | undefined | null,
+ base: boolean,
+): { enabled: boolean; predicates?: RowCrudPredicates } {
+ if (v == null) return { enabled: base };
+ if (typeof v === 'boolean') return { enabled: v };
+ const enabled = v.enabled ?? base;
+ if (v.visibleWhen == null && v.disabledWhen == null) return { enabled };
+ const predicates: RowCrudPredicates = {};
+ if (v.visibleWhen != null) predicates.visibleWhen = v.visibleWhen;
+ if (v.disabledWhen != null) predicates.disabledWhen = v.disabledWhen;
+ return { enabled, predicates };
+}
+
export function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances {
const bucket = (obj?.managedBy as ManagedByBucket | undefined) ?? 'platform';
const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
const o = obj?.userActions ?? {};
- return {
+ const edit = normalizeOverride(o.edit, base.edit);
+ const del = normalizeOverride(o.delete, base.delete);
+ const out: CrudAffordances = {
create: o.create ?? base.create,
import: o.import ?? base.import,
- edit: o.edit ?? base.edit,
- delete: o.delete ?? base.delete,
+ edit: edit.enabled,
+ delete: del.enabled,
exportCsv: o.exportCsv ?? base.exportCsv,
};
+ if (edit.predicates) out.editPredicates = edit.predicates;
+ if (del.predicates) out.deletePredicates = del.predicates;
+ return out;
}
diff --git a/packages/components/src/renderers/complex/__tests__/data-table-builtin-row-action-predicates.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-builtin-row-action-predicates.test.tsx
new file mode 100644
index 000000000..1d9144f46
--- /dev/null
+++ b/packages/components/src/renderers/complex/__tests__/data-table-builtin-row-action-predicates.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#2614 — the data-table's BUILT-IN row Edit/Delete items honor the
+ * per-record `visibleWhen` / `disabledWhen` CEL predicates carried on
+ * `schema.rowEditPredicates` / `rowDeletePredicates` (sourced from the
+ * object's `userActions.edit` / `delete` object form). This is the path a
+ * detail page's related list renders through — the master-detail scenario
+ * from the downstream report (a frozen `task_version_check_item` row must
+ * grey out its Edit button instead of letting the user discover the freeze
+ * on Save).
+ *
+ * The subcomponent is rendered inside a controlled-open dropdown, same as
+ * the `DataTableRowActionItem` tests, because Radix mounts menu content only
+ * when open (which is also why predicate evaluation costs nothing at table
+ * render time).
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import React from 'react';
+import { PredicateScopeProvider } from '@object-ui/react';
+import { Edit } from 'lucide-react';
+import { DataTableBuiltinRowActionItem } from '../data-table';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from '../../../ui/dropdown-menu';
+
+const FROZEN = { id: 'r1', name: 'Check item A', frozen: true };
+const DRAFT = { id: 'r2', name: 'Check item B', frozen: false };
+
+function renderItem(props: { predicates?: any; row: any; onSelect?: (row: any) => void }) {
+ return render(
+
+
+ menu
+
+ }
+ label="Edit"
+ onSelect={props.onSelect ?? (() => {})}
+ predicates={props.predicates}
+ row={props.row}
+ />
+
+
+ ,
+ );
+}
+
+describe('data-table built-in row action — per-record CEL predicates (#2614)', () => {
+ it('renders enabled with no predicates (zero regression)', () => {
+ renderItem({ row: FROZEN });
+ const item = screen.getByTestId('row-action-builtin-edit');
+ expect(item).toBeInTheDocument();
+ expect(item).not.toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('disabledWhen true → rendered but disabled; clicks do not fire', () => {
+ const onSelect = vi.fn();
+ renderItem({ predicates: { disabledWhen: 'record.frozen == true' }, row: FROZEN, onSelect });
+ const item = screen.getByTestId('row-action-builtin-edit');
+ expect(item).toHaveAttribute('aria-disabled', 'true');
+ fireEvent.click(item);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('disabledWhen false → enabled; clicks fire with the row', () => {
+ const onSelect = vi.fn();
+ renderItem({ predicates: { disabledWhen: 'record.frozen == true' }, row: DRAFT, onSelect });
+ fireEvent.click(screen.getByTestId('row-action-builtin-edit'));
+ expect(onSelect).toHaveBeenCalledWith(DRAFT);
+ });
+
+ it('visibleWhen false → the item is not rendered for that row', () => {
+ renderItem({ predicates: { visibleWhen: 'record.frozen != true' }, row: FROZEN });
+ expect(screen.queryByTestId('row-action-builtin-edit')).not.toBeInTheDocument();
+ });
+
+ it('accepts the canonical { dialect, source } envelope', () => {
+ renderItem({
+ predicates: { visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } },
+ row: DRAFT,
+ });
+ expect(screen.getByTestId('row-action-builtin-edit')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx
index 485031271..938ef3b6b 100644
--- a/packages/components/src/renderers/complex/data-table.tsx
+++ b/packages/components/src/renderers/complex/data-table.tsx
@@ -268,9 +268,57 @@ export const DataTableRowActionItem: React.FC<{
);
};
+/**
+ * A built-in Edit/Delete item in the data-table's row overflow menu, gated by
+ * the per-record CEL predicates from the object's `userActions.edit` /
+ * `delete` object form (objectui#2614) — `schema.rowEditPredicates` /
+ * `rowDeletePredicates`. Mirrors `BuiltinRowActionItem` on the ObjectGrid
+ * path so BOTH row-menu renderers honor the predicates identically (the
+ * related-list case is where the master-detail scenario from the issue
+ * actually renders). Same posture: `visibleWhen` fails CLOSED, `disabledWhen`
+ * fails soft. Evaluation only happens when the menu is open (Radix mounts
+ * content lazily), so declared predicates cost nothing at table render time.
+ *
+ * Exported for unit tests — NOT part of the package's public API (the barrel
+ * only side-effect-imports this module; see `DataTableRowActionItem`).
+ */
+export const DataTableBuiltinRowActionItem: React.FC<{
+ name: 'edit' | 'delete';
+ predicates?: { visibleWhen?: unknown; disabledWhen?: unknown };
+ row: any;
+ icon: React.ReactNode;
+ label: string;
+ className?: string;
+ onSelect: (row: any) => void;
+}> = ({ name, predicates, row, icon, label, className, onSelect }) => {
+ const isVisible = useRowPredicate(predicates?.visibleWhen, row, {
+ fallback: false,
+ warnOnError: true,
+ label: `builtin:${name}:visibleWhen`,
+ });
+ const isDisabled = useRowPredicate(predicates?.disabledWhen, row, {
+ fallback: false,
+ warnOnError: true,
+ label: `builtin:${name}:disabledWhen`,
+ });
+ if (predicates?.visibleWhen != null && !isVisible) return null;
+ const disabled = predicates?.disabledWhen != null && isDisabled;
+ return (
+ { if (!disabled) onSelect(row); }}
+ data-testid={`row-action-builtin-${name}`}
+ className={className}
+ >
+ {icon}
+ {label}
+
+ );
+};
+
/**
* Enterprise-level data table component with Airtable-like features.
- *
+ *
* Provides comprehensive table functionality including:
* - Multi-column sorting (ascending/descending/none)
* - Real-time search across all columns
@@ -1796,10 +1844,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
e.stopPropagation()}>
{schema.onRowEdit && (
- schema.onRowEdit?.(row)}>
-
- {t('table.edit')}
-
+ }
+ label={t('table.edit')}
+ onSelect={(r) => schema.onRowEdit?.(r)}
+ />
)}
{/* Child-object custom actions (e.g. a related
list surfacing the child's `list_item`
@@ -1815,13 +1867,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
))}
{schema.onRowDelete && (schema.onRowEdit || customActions.length > 0) && }
{schema.onRowDelete && (
- schema.onRowDelete?.(row)}
+ }
+ label={t('table.delete')}
className="text-destructive focus:text-destructive"
- >
-
- {t('table.delete')}
-
+ onSelect={(r) => schema.onRowDelete?.(r)}
+ />
)}
diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx
index ee0e50905..f790f1a5b 100644
--- a/packages/plugin-detail/src/RelatedList.tsx
+++ b/packages/plugin-detail/src/RelatedList.tsx
@@ -719,6 +719,17 @@ export const RelatedList: React.FC = ({
};
}
+ // Per-record CEL predicates for the built-in row Edit/Delete, from the
+ // CHILD object's `userActions.edit` / `delete` object form (#2614) — the
+ // master-detail case where a child row freezes on a parent state change
+ // renders through this path. Boolean forms yield no predicates.
+ const uaEdit = objectSchema?.userActions?.edit;
+ const uaDelete = objectSchema?.userActions?.delete;
+ const predicatesOf = (v: any) =>
+ v != null && typeof v === 'object' && (v.visibleWhen != null || v.disabledWhen != null)
+ ? { visibleWhen: v.visibleWhen ?? undefined, disabledWhen: v.disabledWhen ?? undefined }
+ : undefined;
+
// Auto-generate schema based on type. We disable the data-table's own
// search/toolbar — RelatedList provides its own filter input above.
switch (type) {
@@ -735,6 +746,8 @@ export const RelatedList: React.FC = ({
rowActions: hasRowActions,
onRowEdit,
onRowDelete: onRowDelete ? handleDeleteRow : undefined,
+ rowEditPredicates: predicatesOf(uaEdit),
+ rowDeletePredicates: predicatesOf(uaDelete),
onRowClick,
// Child-object row actions (locations:['list_item']) rendered in the
// same overflow menu, dispatched with the clicked row as target.
@@ -749,7 +762,7 @@ export const RelatedList: React.FC = ({
default:
return { type: 'div', children: 'No view configured' };
}
- }, [type, paginatedData, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, onRowClick, isMobile, api]);
+ }, [type, paginatedData, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, onRowClick, isMobile, api, objectSchema]);
const headerClassName = collapsible ? 'cursor-pointer select-none' : undefined;
const handleHeaderClick = collapsible ? () => setCollapsed((c) => !c) : undefined;
diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx
index 1853af38d..7fb5cbaaf 100644
--- a/packages/plugin-detail/src/renderers/record-details.tsx
+++ b/packages/plugin-detail/src/renderers/record-details.tsx
@@ -230,7 +230,14 @@ export const RecordDetailsRenderer: React.FC = ({
// Authors can still force-disable with `inlineEdit: false`.
const NON_EDITABLE_BUCKETS = new Set(['system', 'append-only', 'better-auth']);
const managedBy = objSchema?.managedBy as string | undefined;
- const userEditOverride = (objSchema?.userActions as { edit?: boolean } | undefined)?.edit;
+ // `userActions.edit` is a boolean or, since #2614, an object form whose
+ // `enabled` carries the boolean (its per-record predicates gate row action
+ // buttons, not detail inline-edit, so they are ignored here).
+ const rawEditOverride = (objSchema?.userActions as { edit?: boolean | { enabled?: boolean } } | undefined)?.edit;
+ const userEditOverride =
+ typeof rawEditOverride === 'object' && rawEditOverride !== null
+ ? rawEditOverride.enabled
+ : rawEditOverride;
const objectInlineEditable =
userEditOverride ?? !(managedBy != null && NON_EDITABLE_BUCKETS.has(managedBy));
const inlineEditDefault = (schema.inlineEdit ?? true) && objectInlineEditable;
diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx
index e8203f04b..9d7d9f7ff 100644
--- a/packages/plugin-form/src/ObjectForm.tsx
+++ b/packages/plugin-form/src/ObjectForm.tsx
@@ -468,9 +468,14 @@ const SimpleObjectForm: React.FC = ({
// off, so only an explicit `=== true` override unlocks. The server-side
// write guard remains the real boundary; this is UX only.
const managed = !!objectSchema?.managedBy && objectSchema.managedBy !== 'platform';
+ // `userActions.edit` may be the #2614 object form ({ enabled, ...CEL
+ // predicates }); only the explicit enabled boolean unlocks — predicates
+ // gate row action buttons, not the form's blanket lock.
+ const uaEdit = (objectSchema as any)?.userActions?.edit;
+ const editAffordanceOpen = uaEdit === true || (typeof uaEdit === 'object' && uaEdit !== null && uaEdit.enabled === true);
const modeAffordanceOpen =
schema.mode === 'edit'
- ? (objectSchema as any)?.userActions?.edit === true
+ ? editAffordanceOpen
: schema.mode === 'create'
? (objectSchema as any)?.userActions?.create === true
: false;
diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx
index 655facec3..979fdc6b3 100644
--- a/packages/plugin-grid/src/ObjectGrid.tsx
+++ b/packages/plugin-grid/src/ObjectGrid.tsx
@@ -1456,7 +1456,7 @@ export const ObjectGrid: React.FC = ({
// (e.g. sys_environment ships a dedicated Rename + cascade-Delete instead).
// This stops a generic "Delete" from duplicating the object's own Delete
// action, and a generic "Edit" the object turned off from leaking back in.
- const { canEdit, canDelete } = resolveRowCrudAffordances({
+ const { canEdit, canDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({
operationsUpdate: operations?.update,
operationsDelete: operations?.delete,
wantEditAction,
@@ -1491,6 +1491,8 @@ export const ObjectGrid: React.FC = ({
maxInlineActions={(schema as any).maxInlineRowActions ?? 1}
canEdit={canEdit}
canDelete={canDelete}
+ editPredicates={editPredicates}
+ deletePredicates={deletePredicates}
onEdit={onEdit}
onDelete={onDelete}
onAction={(action, r) => {
diff --git a/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts b/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts
index 5861283df..a4f3dd584 100644
--- a/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts
+++ b/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts
@@ -37,4 +37,47 @@ describe('resolveRowCrudAffordances', () => {
expect(resolveRowCrudAffordances({ wantEditAction: true, wantDeleteAction: true, hasOnEdit: true, hasOnDelete: true }))
.toEqual({ canEdit: true, canDelete: true });
});
+
+ describe('#2614 object form (per-record CEL predicates)', () => {
+ it('passes visibleWhen/disabledWhen through untouched and keeps canEdit/canDelete on', () => {
+ const res = resolveRowCrudAffordances({
+ ...wired,
+ userActions: {
+ edit: { disabledWhen: 'record.frozen == true' },
+ delete: { visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } },
+ },
+ });
+ expect(res.canEdit).toBe(true);
+ expect(res.canDelete).toBe(true);
+ expect(res.editPredicates).toEqual({ disabledWhen: 'record.frozen == true' });
+ expect(res.deletePredicates).toEqual({ visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } });
+ });
+
+ it('object form with enabled:false opts out like the bare boolean (and drops predicates)', () => {
+ const res = resolveRowCrudAffordances({
+ ...wired,
+ userActions: { edit: { enabled: false, disabledWhen: 'record.frozen == true' } },
+ });
+ expect(res.canEdit).toBe(false);
+ expect(res.editPredicates).toBeUndefined();
+ expect(res.canDelete).toBe(true);
+ });
+
+ it('object form without predicates adds nothing (boolean-equivalent)', () => {
+ const res = resolveRowCrudAffordances({ ...wired, userActions: { edit: { enabled: true }, delete: {} } });
+ expect(res).toEqual({ canEdit: true, canDelete: true });
+ expect(res.editPredicates).toBeUndefined();
+ expect(res.deletePredicates).toBeUndefined();
+ });
+
+ it('predicates are not surfaced when the affordance is not wired at all', () => {
+ const res = resolveRowCrudAffordances({
+ hasOnEdit: false,
+ operationsUpdate: true,
+ userActions: { edit: { disabledWhen: 'record.frozen == true' } },
+ });
+ expect(res.canEdit).toBe(false);
+ expect(res.editPredicates).toBeUndefined();
+ });
+ });
});
diff --git a/packages/plugin-grid/src/components/RowActionMenu.tsx b/packages/plugin-grid/src/components/RowActionMenu.tsx
index f49c1a3e5..0198917d7 100644
--- a/packages/plugin-grid/src/components/RowActionMenu.tsx
+++ b/packages/plugin-grid/src/components/RowActionMenu.tsx
@@ -54,6 +54,17 @@ export interface RowActionDef {
[key: string]: any;
}
+/** Per-record CEL predicates gating a built-in Edit/Delete row action
+ * (objectui#2614). Bare CEL string or `{ dialect, source }` envelope,
+ * evaluated per row via `useRowPredicate` — same machinery as custom
+ * actions' `visible` / `disabled`. */
+export interface BuiltinRowActionPredicates {
+ /** Evaluates false → the item is not rendered for this row. Fail-closed. */
+ visibleWhen?: unknown;
+ /** Evaluates true → the item renders disabled for this row. Fail-soft. */
+ disabledWhen?: unknown;
+}
+
export interface RowActionMenuProps {
/** The row data record */
row: any;
@@ -65,6 +76,10 @@ export interface RowActionMenuProps {
canEdit?: boolean;
/** Whether delete operation is available */
canDelete?: boolean;
+ /** Per-record predicates for the built-in Edit item (from `userActions.edit`). */
+ editPredicates?: BuiltinRowActionPredicates;
+ /** Per-record predicates for the built-in Delete item (from `userActions.delete`). */
+ deletePredicates?: BuiltinRowActionPredicates;
/** Callback when edit is clicked */
onEdit?: (row: any) => void;
/** Callback when delete is clicked */
@@ -117,6 +132,54 @@ const RowActionMenuItem: React.FC<{
);
};
+/**
+ * A built-in Edit/Delete menu item gated by per-record CEL predicates
+ * (objectui#2614). Extracted into a component so `useRowPredicate` runs
+ * hook-safe, mirroring `RowActionMenuItem` for custom actions. Note the
+ * evaluation is naturally lazy: this only renders inside the (Radix)
+ * `DropdownMenuContent`, which mounts when the row's "⋮" menu opens — so
+ * declaring predicates costs nothing while the grid renders.
+ *
+ * Posture matches the spec contract (`RowCrudActionOverrideSchema`):
+ * `visibleWhen` fails CLOSED (a faulting predicate hides + warns once, so a
+ * broken predicate can't expose an affordance the author meant to gate);
+ * `disabledWhen` fails soft (button stays enabled — the server hook is the
+ * real enforcement).
+ */
+export const BuiltinRowActionItem: React.FC<{
+ name: 'edit' | 'delete';
+ predicates?: BuiltinRowActionPredicates;
+ row: any;
+ icon: React.ReactNode;
+ label: string;
+ className?: string;
+ onSelect: (row: any) => void;
+}> = ({ name, predicates, row, icon, label, className, onSelect }) => {
+ const isVisible = useRowPredicate(predicates?.visibleWhen, row, {
+ fallback: false,
+ warnOnError: true,
+ label: `builtin:${name}:visibleWhen`,
+ });
+ const isDisabled = useRowPredicate(predicates?.disabledWhen, row, {
+ fallback: false,
+ warnOnError: true,
+ label: `builtin:${name}:disabledWhen`,
+ });
+ if (predicates?.visibleWhen != null && !isVisible) return null;
+ const disabled = predicates?.disabledWhen != null && isDisabled;
+ return (
+ { if (!disabled) onSelect(row); }}
+ data-testid={`row-action-builtin-${name}`}
+ className={className}
+ >
+ {icon}
+ {label}
+
+ );
+};
+
/** Map a schema action variant onto a Button variant. `primary` is the
* accent ("default") button so the action reads as the row's main CTA. */
function toButtonVariant(v: RowActionDef['variant']): 'default' | 'secondary' | 'destructive' | 'ghost' | 'link' {
@@ -161,6 +224,8 @@ export const RowActionMenu: React.FC = ({
rowActionDefs,
canEdit,
canDelete,
+ editPredicates,
+ deletePredicates,
onEdit,
onDelete,
onAction,
@@ -211,16 +276,24 @@ export const RowActionMenu: React.FC = ({
e.stopPropagation()}>
{canEdit && onEdit && (
- onEdit(row)}>
-
- {t('grid.edit')}
-
+ }
+ label={t('grid.edit')}
+ onSelect={onEdit}
+ />
)}
{canDelete && onDelete && (
- onDelete(row)}>
-
- {t('grid.delete')}
-
+ }
+ label={t('grid.delete')}
+ onSelect={onDelete}
+ />
)}
{menuDefs.map(def => (
{
expect(screen.queryByTestId('row-action-inline-resume')).not.toBeInTheDocument();
});
});
+
+/**
+ * objectui#2614 — the BUILT-IN Edit/Delete row actions honor per-record
+ * `visibleWhen` / `disabledWhen` CEL predicates from the object's
+ * `userActions.edit` / `delete` object form. Items are rendered inside a
+ * controlled-open dropdown (same pattern as the data-table's
+ * `DataTableRowActionItem` tests) because Radix mounts menu content only when
+ * open — which is also why predicate evaluation is lazy and free at grid
+ * render time.
+ */
+describe('BuiltinRowActionItem per-record CEL predicates (#2614)', () => {
+ // The downstream MES case: a task_version_check_item row is frozen once its
+ // parent version is published; the Edit button must grey out on frozen rows.
+ const FROZEN = { id: 'r1', name: 'Check item A', frozen: true };
+ const DRAFT = { id: 'r2', name: 'Check item B', frozen: false };
+
+ function renderItem(props: { predicates?: any; row: any; onSelect?: (row: any) => void }) {
+ return render(
+
+
+ menu
+
+ }
+ label="Edit"
+ onSelect={props.onSelect ?? (() => {})}
+ predicates={props.predicates}
+ row={props.row}
+ />
+
+
+ ,
+ );
+ }
+
+ it('renders enabled with no predicates (today’s behavior, zero regression)', () => {
+ renderItem({ row: FROZEN });
+ const item = screen.getByTestId('row-action-builtin-edit');
+ expect(item).toBeInTheDocument();
+ expect(item).not.toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('disabledWhen true → rendered but disabled, and clicks do not fire', () => {
+ const onSelect = vi.fn();
+ renderItem({ predicates: { disabledWhen: 'record.frozen == true' }, row: FROZEN, onSelect });
+ const item = screen.getByTestId('row-action-builtin-edit');
+ expect(item).toHaveAttribute('aria-disabled', 'true');
+ fireEvent.click(item);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('disabledWhen false → enabled, and clicks fire with the row', () => {
+ const onSelect = vi.fn();
+ renderItem({ predicates: { disabledWhen: 'record.frozen == true' }, row: DRAFT, onSelect });
+ const item = screen.getByTestId('row-action-builtin-edit');
+ expect(item).not.toHaveAttribute('aria-disabled', 'true');
+ fireEvent.click(item);
+ expect(onSelect).toHaveBeenCalledWith(DRAFT);
+ });
+
+ it('visibleWhen false → the item is not rendered for that row', () => {
+ renderItem({ predicates: { visibleWhen: 'record.frozen != true' }, row: FROZEN });
+ expect(screen.queryByTestId('row-action-builtin-edit')).not.toBeInTheDocument();
+ });
+
+ it('visibleWhen true → the item renders', () => {
+ renderItem({ predicates: { visibleWhen: 'record.frozen != true' }, row: DRAFT });
+ expect(screen.getByTestId('row-action-builtin-edit')).toBeInTheDocument();
+ });
+
+ it('accepts the canonical { dialect, source } envelope', () => {
+ renderItem({
+ predicates: { visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } },
+ row: FROZEN,
+ });
+ expect(screen.queryByTestId('row-action-builtin-edit')).not.toBeInTheDocument();
+ });
+
+ it('a faulting visibleWhen fails CLOSED (hidden), a faulting disabledWhen fails soft (enabled)', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ renderItem({ predicates: { visibleWhen: 'record.nonexistent.deep == 1' }, row: DRAFT });
+ expect(screen.queryByTestId('row-action-builtin-edit')).not.toBeInTheDocument();
+ } finally {
+ warn.mockRestore();
+ }
+ const warn2 = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ renderItem({ predicates: { disabledWhen: 'record.nonexistent.deep == 1' }, row: DRAFT });
+ expect(screen.getByTestId('row-action-builtin-edit')).not.toHaveAttribute('aria-disabled', 'true');
+ } finally {
+ warn2.mockRestore();
+ }
+ });
+});
diff --git a/packages/plugin-grid/src/rowCrudAffordances.ts b/packages/plugin-grid/src/rowCrudAffordances.ts
index 78c24eb27..659d3d093 100644
--- a/packages/plugin-grid/src/rowCrudAffordances.ts
+++ b/packages/plugin-grid/src/rowCrudAffordances.ts
@@ -27,7 +27,44 @@
*
* `userActions.edit` / `delete` left `undefined` (or `true`) preserves the
* out-of-the-box behaviour — every main list keeps its Edit/Delete kebab.
+ *
+ * Since objectui#2614, `userActions.edit` / `delete` also accept an object
+ * form `{ enabled?, visibleWhen?, disabledWhen? }`: `enabled` carries the
+ * boolean opt-out, and the two CEL predicates gate the affordance
+ * **per record**. The predicates are returned untouched as
+ * `editPredicates` / `deletePredicates` for the row renderer to evaluate
+ * (they never affect the object-level `canEdit` / `canDelete` verdict).
*/
+
+/** Per-record CEL predicates for a built-in row action (objectui#2614).
+ * Kept as authored — bare CEL string or `{ dialect, source }` envelope —
+ * and handed to `useRowPredicate` untouched. */
+export interface RowCrudPredicates {
+ visibleWhen?: unknown;
+ disabledWhen?: unknown;
+}
+
+/** A `userActions.edit` / `delete` flag: bare boolean or the #2614 object form. */
+export type RowCrudUserAction =
+ | boolean
+ | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown };
+
+/** Object-level enabled verdict for a userActions flag (undefined = no opt-out). */
+function isOptedOut(v: RowCrudUserAction | undefined | null): boolean {
+ if (typeof v === 'boolean') return v === false;
+ return v?.enabled === false;
+}
+
+/** Extract the per-record predicates from the object form, if any. */
+function predicatesOf(v: RowCrudUserAction | undefined | null): RowCrudPredicates | undefined {
+ if (v == null || typeof v === 'boolean') return undefined;
+ if (v.visibleWhen == null && v.disabledWhen == null) return undefined;
+ const out: RowCrudPredicates = {};
+ if (v.visibleWhen != null) out.visibleWhen = v.visibleWhen;
+ if (v.disabledWhen != null) out.disabledWhen = v.disabledWhen;
+ return out;
+}
+
export function resolveRowCrudAffordances(opts: {
operationsUpdate?: boolean;
operationsDelete?: boolean;
@@ -36,13 +73,23 @@ export function resolveRowCrudAffordances(opts: {
hasOnEdit?: boolean;
hasOnDelete?: boolean;
/** The object's `userActions` block ({ create, edit, delete, import }). */
- userActions?: { edit?: boolean; delete?: boolean } | null;
-}): { canEdit: boolean; canDelete: boolean } {
- const editOptedOut = opts.userActions?.edit === false;
- const deleteOptedOut = opts.userActions?.delete === false;
+ userActions?: { edit?: RowCrudUserAction; delete?: RowCrudUserAction } | null;
+}): {
+ canEdit: boolean;
+ canDelete: boolean;
+ editPredicates?: RowCrudPredicates;
+ deletePredicates?: RowCrudPredicates;
+} {
+ const editOptedOut = isOptedOut(opts.userActions?.edit);
+ const deleteOptedOut = isOptedOut(opts.userActions?.delete);
const canEdit =
!!((opts.operationsUpdate || opts.wantEditAction) && opts.hasOnEdit) && !editOptedOut;
const canDelete =
!!((opts.operationsDelete || opts.wantDeleteAction) && opts.hasOnDelete) && !deleteOptedOut;
- return { canEdit, canDelete };
+ return {
+ canEdit,
+ canDelete,
+ editPredicates: canEdit ? predicatesOf(opts.userActions?.edit) : undefined,
+ deletePredicates: canDelete ? predicatesOf(opts.userActions?.delete) : undefined,
+ };
}
diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts
index db9f30014..24f0b7a8d 100644
--- a/packages/types/src/data-display.ts
+++ b/packages/types/src/data-display.ts
@@ -461,6 +461,20 @@ export interface DataTableSchema extends BaseSchema {
* Row delete handler
*/
onRowDelete?: (row: any) => void;
+ /**
+ * Per-record CEL predicates gating the built-in row Edit item
+ * (objectui#2614), from the object's `userActions.edit` object form.
+ * `visibleWhen` false → the item is not rendered for that row (fail-closed);
+ * `disabledWhen` true → rendered disabled (fail-soft). Bare CEL string or
+ * `{ dialect: 'cel', source }` envelope, evaluated per row with the record
+ * bound as `record.*` and bare fields.
+ */
+ rowEditPredicates?: { visibleWhen?: unknown; disabledWhen?: unknown };
+ /**
+ * Per-record CEL predicates gating the built-in row Delete item
+ * (objectui#2614), from the object's `userActions.delete` object form.
+ */
+ rowDeletePredicates?: { visibleWhen?: unknown; disabledWhen?: unknown };
/**
* Extra per-row action definitions rendered in the row overflow menu, after
* Edit/Delete. Each is dispatched via {@link onRowActionDef} with the clicked
diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts
index cf97c581e..4e61f8a31 100644
--- a/packages/types/src/zod/data-display.zod.ts
+++ b/packages/types/src/zod/data-display.zod.ts
@@ -147,6 +147,14 @@ export const DataTableSchema = BaseSchema.extend({
reorderableColumns: z.boolean().optional().describe('Allow column reordering'),
onRowEdit: z.function().optional().describe('Row edit handler'),
onRowDelete: z.function().optional().describe('Row delete handler'),
+ rowEditPredicates: z.object({
+ visibleWhen: z.unknown().optional(),
+ disabledWhen: z.unknown().optional(),
+ }).optional().describe('Per-record CEL predicates for the built-in row Edit item (objectui#2614)'),
+ rowDeletePredicates: z.object({
+ visibleWhen: z.unknown().optional(),
+ disabledWhen: z.unknown().optional(),
+ }).optional().describe('Per-record CEL predicates for the built-in row Delete item (objectui#2614)'),
onSelectionChange: z.function().optional().describe('Selection change handler'),
onColumnsReorder: z.function().optional().describe('Column reorder handler'),
frozenColumns: z.number().optional().describe('Number of frozen columns'),