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
34 changes: 34 additions & 0 deletions .changeset/4420-bulk-delete-visiblewhen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/core': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-list': patch
---

The selection bar's built-in **Delete** now honours `userActions.delete.visibleWhen`
per selected record (objectui#4420). It used to read that key as a bare boolean — the
object-level verdict only — so ticking a record the author's predicate excludes still
offered the red Delete, and pressing it deleted the record the predicate was written to
protect. The row kebab on the same screen hid its Delete correctly, so one declared key
meant two different things on two surfaces.

Ruled by the maintainer on 2026-08-17 (behaviour 1 of the card's three): **filter the
operation and report the skipped**. The bar evaluates the predicate once per selected
record, the delete runs over the allowed subset, and the excluded records are reported
rather than silently dropped. The button itself is never hidden or disabled by the
predicate — a mixed selection is not punished for one stray tick — and a selection where
every row is excluded is a legible refusal rather than an unexplained absence.

- `@object-ui/core` gains `partitionRowsByPredicate`, the set-shaped counterpart of
`evalRowPredicate`: the fail-closed per-record fold a bulk gate needs, written once.
A bulk gate evaluates N records in a loop, which is why it can never be a hook.
- `@object-ui/plugin-grid`'s bulk bar routes an excluded selection through
`BulkActionDialog`, whose existing `bulk-skipped-notice` slot reports the skipped
count; a selection with nothing excluded keeps the consumer's own delete flow
untouched. `resolveRowCrudAffordances` now also returns `objectDeletePredicates` —
the bulk half of the same predicates, gated on the object verdict rather than on the
row `onDelete` wiring. The dialog declines to run over zero records.
- `@object-ui/plugin-list`'s non-grid bulk bar (kanban / calendar / gallery / …) filters
the built-in `delete` to the eligible subset and states the skipped count inline.

Custom bulk action ids are untouched: they route through the action runner carrying
their own gates. This is a UI affordance — server enforcement was never the leak.
65 changes: 65 additions & 0 deletions packages/core/src/evaluator/listConditional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,71 @@ export function evalRowPredicate(
return verdict.value;
}

/** The two halves of a {@link partitionRowsByPredicate} verdict over a selection. */
export interface RowPartition<TRow> {
/** Records whose predicate passed — the ones an operation may act on. */
eligible: TRow[];
/**
* How many records were filtered out. A caller that shrinks a user's
* selection owes them this number: a run over fewer records than they picked
* must say so rather than quietly shrinking.
*/
skipped: number;
}

/**
* Split a set of records by one per-record predicate — the SET-shaped
* counterpart of {@link evalRowPredicate}, and the reason a bulk gate never
* needs a hook.
*
* A bulk affordance spans N records, so its gate is a **loop**, and React
* forbids calling `useRowPredicate` (or any hook) per iteration. Every bulk
* surface therefore reaches for `evalRowPredicate` directly; this wrapper is
* that loop written once, with the three cases each caller was otherwise
* re-deriving:
*
* - **absent** (`null` / `undefined` / blank string) — nothing is gated, so
* `rows` is returned BY REFERENCE and `skipped` is 0. The common case
* allocates nothing and a caller's downstream memo still holds.
* - **boolean** — a verdict, not an expression, short-circuited exactly as
* `useCondition` / `useRowPredicate` do. Handing `true` to the engine
* produces `{ dialect: 'cel', source: undefined }`, which faults and — on
* this fail-closed path — would disqualify every record, so the most
* explicit way to say "always" would exclude everyone (objectui#3492).
* - **expression** — evaluated once per record with that record in scope,
* **fail-closed**: a predicate that faults must not hand the caller records
* it was written to exclude. `warnOnError` (default `true`) makes the
* resulting exclusion diagnosable once per predicate instead of silent.
*
* The fail-closed default is what separates this from a lenient record-free
* evaluation, and the difference is not mere strictness: evaluated with NO
* record bound, `record.status != 'paid'` returns `true` for every row —
* including the ones it was written to exclude — so an authored gate is not
* weakened, it is inverted for half its inputs (objectui#3067).
*/
export function partitionRowsByPredicate<TRow extends Record<string, unknown>>(
pred: FieldRulePredicate | boolean | undefined | null,
rows: readonly TRow[],
opts: Omit<RowPredicateOptions, 'fallback' | 'rowless'> = {},
): RowPartition<TRow> {
if (pred == null || pred === '') {
return { eligible: rows as TRow[], skipped: 0 };
}
if (typeof pred === 'boolean') {
return pred
? { eligible: rows as TRow[], skipped: 0 }
: { eligible: [], skipped: rows.length };
}
const eligible = (rows as TRow[]).filter(row =>
evalRowPredicate(pred, row, {
...opts,
warnOnError: opts.warnOnError ?? true,
fallback: false,
}),
);
return { eligible, skipped: rows.length - eligible.length };
}

// ---------------------------------------------------------------------------
// Conditional formatting
// ---------------------------------------------------------------------------
Expand Down
62 changes: 59 additions & 3 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// Resolved HERE, above the error / loading early returns, rather than beside
// the row-actions column it feeds: the record-level layer below is a hook and
// may not sit behind a conditional return.
const { canEdit, canDelete, objectCanDelete, editPredicates, deletePredicates } = resolveRowCrudAffordances({
const { canEdit, canDelete, objectCanDelete, editPredicates, deletePredicates, objectDeletePredicates } = resolveRowCrudAffordances({
operationsUpdate: operations?.update,
operationsDelete: operations?.delete,
wantEditAction,
Expand Down Expand Up @@ -2659,12 +2659,68 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
setSelectionResetKey(k => k + 1);
};

/**
* [objectui#4420] The built-in Delete, as a def — so the ONE selection whose
* records the predicate split can be reported on the surface built to report
* it. Constructed only when the dialog route is taken (see below), never
* rendered in the bar: the bar's Delete stays the legacy string button it has
* always been, because the ruling is explicit that the predicate must not
* change whether the button is offered.
*/
const builtInDeleteDef = (): BulkActionDef => ({
name: 'delete',
label: resolveActionLabel(schema.objectName, 'delete', formatActionLabel('delete')),
operation: 'delete',
variant: 'danger',
});

const dispatchBulkAction = (action: string, rows: any[]) => {
void (async () => {
const expanded = await resolveBulkRows(rows);
if (action === 'delete' && onBulkDelete) {
onBulkDelete(expanded);
resetSelection();
// [objectui#4420] `userActions.delete.visibleWhen` gates the built-in
// Delete PER RECORD — the same key, the same fail-closed fold and the
// same evaluator the row kebab and the rich-def bar already run
// (`partitionBulkRows` → `partitionRowsByPredicate`). Applied to the
// EXPANDED set for the reason #3067 states one line down in
// `dispatchBulkActionDef`: "select all N matching" pulls in records no
// on-screen check ever evaluated.
//
// The predicates come from `objectDeletePredicates`, not
// `deletePredicates`: the latter rides `canDelete`, which folds in the
// ROW wiring (`onDelete`), and bulk delete rides `onBulkDelete`. A
// consumer wiring only the bulk handler would otherwise have the
// author's predicate silently dropped.
const { eligible, skipped } = partitionBulkRows(
{ name: 'delete', visible: objectDeletePredicates?.visibleWhen as never },
expanded,
{ scope: predicateScope, fields: objectSchema?.fields },
);
if (skipped === 0) {
// Nothing was excluded, so there is nothing to report and no reason
// to change surfaces: the consumer's own delete flow — which owns the
// confirmation, the toast and the refresh — runs exactly as before.
// This is also what keeps every object that declares no predicate at
// all byte-identical to its previous behaviour.
onBulkDelete(eligible);
resetSelection();
return;
}
// Something WAS excluded. The run must own up to it, and
// `BulkActionDialog`'s `bulk-skipped-notice` is the slot built for this
// shape (objectui#3067) — so the delete is confirmed and executed
// there, over `eligible` only. Routing it back through `onBulkDelete`
// instead would put the host's own confirmation dialog behind this
// one and confirm the same delete twice.
//
// `eligible` may be EMPTY, and that case deliberately still opens the
// dialog: the maintainer ruled a selection where every row is excluded
// must produce "a legible refusal, not a hidden button whose absence is
// unexplained". The dialog reports zero affected records beside the
// skipped notice, and declines to run (see `noEligibleRows` there).
setActiveBulkDef(builtInDeleteDef());
setActiveBulkRows(eligible);
setActiveBulkSkipped(skipped);
return;
}
// A string bulk action (e.g. a consumer-registered runner handler)
Expand Down
Loading
Loading