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'),