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
22 changes: 22 additions & 0 deletions .changeset/row-crud-cel-predicates-2614.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions packages/app-shell/src/utils/crudAffordances.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
53 changes: 48 additions & 5 deletions packages/app-shell/src/utils/crudAffordances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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(
<PredicateScopeProvider scope={{}}>
<DropdownMenu open modal={false}>
<DropdownMenuTrigger>menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DataTableBuiltinRowActionItem
name="edit"
icon={<Edit className="mr-2 h-4 w-4" />}
label="Edit"
onSelect={props.onSelect ?? (() => {})}
predicates={props.predicates}
row={props.row}
/>
</DropdownMenuContent>
</DropdownMenu>
</PredicateScopeProvider>,
);
}

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();
});
});
76 changes: 65 additions & 11 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DropdownMenuItem
disabled={disabled}
onClick={() => { if (!disabled) onSelect(row); }}
data-testid={`row-action-builtin-${name}`}
className={className}
>
{icon}
{label}
</DropdownMenuItem>
);
};

/**
* 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
Expand Down Expand Up @@ -1796,10 +1844,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
{schema.onRowEdit && (
<DropdownMenuItem onClick={() => schema.onRowEdit?.(row)}>
<Edit className="mr-2 h-4 w-4" />
{t('table.edit')}
</DropdownMenuItem>
<DataTableBuiltinRowActionItem
name="edit"
predicates={schema.rowEditPredicates}
row={row}
icon={<Edit className="mr-2 h-4 w-4" />}
label={t('table.edit')}
onSelect={(r) => schema.onRowEdit?.(r)}
/>
)}
{/* Child-object custom actions (e.g. a related
list surfacing the child's `list_item`
Expand All @@ -1815,13 +1867,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
))}
{schema.onRowDelete && (schema.onRowEdit || customActions.length > 0) && <DropdownMenuSeparator />}
{schema.onRowDelete && (
<DropdownMenuItem
onClick={() => schema.onRowDelete?.(row)}
<DataTableBuiltinRowActionItem
name="delete"
predicates={schema.rowDeletePredicates}
row={row}
icon={<Trash2 className="mr-2 h-4 w-4" />}
label={t('table.delete')}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('table.delete')}
</DropdownMenuItem>
onSelect={(r) => schema.onRowDelete?.(r)}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
Expand Down
Loading
Loading