diff --git a/.changeset/4420-bulk-delete-visiblewhen.md b/.changeset/4420-bulk-delete-visiblewhen.md new file mode 100644 index 0000000000..eafb37e6e6 --- /dev/null +++ b/.changeset/4420-bulk-delete-visiblewhen.md @@ -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. diff --git a/packages/core/src/evaluator/listConditional.ts b/packages/core/src/evaluator/listConditional.ts index 23c4b2168d..e081912c21 100644 --- a/packages/core/src/evaluator/listConditional.ts +++ b/packages/core/src/evaluator/listConditional.ts @@ -334,6 +334,71 @@ export function evalRowPredicate( return verdict.value; } +/** The two halves of a {@link partitionRowsByPredicate} verdict over a selection. */ +export interface RowPartition { + /** 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>( + pred: FieldRulePredicate | boolean | undefined | null, + rows: readonly TRow[], + opts: Omit = {}, +): RowPartition { + 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 // --------------------------------------------------------------------------- diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 13026dcbd4..7ffc32b836 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -897,7 +897,7 @@ export const ObjectGrid: React.FC = ({ // 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, @@ -2659,12 +2659,68 @@ export const ObjectGrid: React.FC = ({ 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) diff --git a/packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx b/packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx new file mode 100644 index 0000000000..2ce75c3ac2 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx @@ -0,0 +1,254 @@ +/** + * 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. + */ + +/** + * The selection bar's built-in **Delete** honours + * `userActions.delete.visibleWhen` PER SELECTED RECORD (objectui#4420). + * + * The bar used to read that key as a bare boolean — bucket ∧ `userActions` + * ∧ `apiOperations` ∧ the principal's `allowDelete`, all of which describe the + * OBJECT — with no per-record layer at all. Tick only a record the predicate + * excludes and the bar still offered the red Delete, and pressing it deleted + * the record the author had written the predicate to protect. The row kebab + * on the very same screen hid its Delete correctly, so one declared key meant + * two different things on two surfaces. + * + * ## The ruled behaviour (maintainer, 2026-08-17 — behaviour 1 of three) + * + * Filter the operation and report the skipped: evaluate per record, run over + * the allowed subset, report the excluded ones through `BulkActionDialog`'s + * `bulk-skipped-notice` slot. Behaviour 2 (gate the button) and behaviour 3 + * (declare the key out of scope for sets) were rejected. So the button is + * **never hidden or disabled** by the predicate, and an all-excluded selection + * still opens the dialog — "a legible refusal, not a hidden button whose + * absence is unexplained". + * + * ## The fixture, and which row is the excluded one + * + * The card's repro verbatim: `showcase_invoice` declares + * `delete: { visibleWhen: "record.status != 'paid'" }`. **`INV-1011` is the + * excluded row** — it is the one with `status: 'paid'`, and it is the row + * every assertion below is really about. The all-eligible case is a deliberate + * DEGENERATE CONTROL: its fixture has no excluded row, so it passes against + * the unfixed code too. That is what it is for — it pins the untouched path, + * and it is the mixed / none-eligible cases that carry the regression. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +vi.mock('@object-ui/permissions', () => ({ + usePermissions: () => ({ + isLoaded: false, + checkField: () => true, + getObjectApiOperations: () => undefined, + can: () => true, + }), +})); + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +const OBJECT = 'showcase_invoice'; + +/** `INV-1011` is paid — the row `visibleWhen` excludes. */ +const PAID = { id: 'inv-1011', name: 'INV-1011', status: 'paid' }; +/** `INV-1010` is a draft — the row `visibleWhen` admits. */ +const DRAFT = { id: 'inv-1010', name: 'INV-1010', status: 'draft' }; + +/** + * The object's declared per-record delete gate, in the OBJECT `userActions` + * vocabulary (`{ enabled?, visibleWhen?, disabledWhen? }`, objectui#2614) — not + * the VIEW's same-named toolbar block. + */ +const DELETE_VISIBLE_WHEN = { visibleWhen: "record.status != 'paid'" }; + +interface Harness { + onBulkDelete: ReturnType; + dataSource: any; +} + +function renderGrid(opts: { + rows: Array>; + /** Omit to declare NO per-record gate — the ungated control. */ + userActionsDelete?: unknown; +}): Harness { + const onBulkDelete = vi.fn(); + const dataSource: any = { + find: vi.fn(async () => ({ + data: opts.rows.map(r => ({ ...r })), + total: opts.rows.length, + hasMore: false, + pageSize: 50, + })), + delete: vi.fn(async () => ({ success: true })), + update: vi.fn(async () => ({ success: true })), + getObjectSchema: async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + name: { type: 'text', label: 'Number' }, + status: { type: 'text', label: 'Status' }, + }, + ...(opts.userActionsDelete === undefined + ? {} + : { userActions: { delete: opts.userActionsDelete } }), + }), + }; + render( + {}} + onBulkDelete={onBulkDelete} + />, + ); + return { onBulkDelete, dataSource }; +} + +/** Every `role="checkbox"` on screen; index 0 is the header's select-all. */ +function checkboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="checkbox"]')) as HTMLElement[]; +} + +/** Render, wait for rows AND the async object-schema fetch, then tick rows. */ +async function renderAndSelect( + opts: Parameters[0] & { selectRowNames: string[] }, +): Promise { + const harness = renderGrid(opts); + for (const row of opts.rows) { + await waitFor(() => expect(screen.getByText(String(row.name))).toBeInTheDocument()); + } + // The delete affordance is derived from `getObjectSchema`, so an assertion + // taken before it lands would read the pre-fetch (underived) state. + await waitFor(() => expect(checkboxes().length).toBeGreaterThan(opts.rows.length)); + const all = checkboxes(); + for (const name of opts.selectRowNames) { + const index = opts.rows.findIndex(r => r.name === name); + // +1 skips the header select-all checkbox. + fireEvent.click(all[index + 1]); + } + return harness; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('selection-bar Delete vs `userActions.delete.visibleWhen` (objectui#4420)', () => { + it('ALL-ELIGIBLE: deletes the whole selection through the host handler — the degenerate control', async () => { + // No excluded row in this fixture, so this case passes against the unfixed + // code as well. It is here to pin that an all-eligible selection keeps the + // consumer's own delete flow (confirm + toast + refresh) untouched. + const { onBulkDelete, dataSource } = await renderAndSelect({ + rows: [DRAFT, { id: 'inv-1012', name: 'INV-1012', status: 'draft' }], + userActionsDelete: DELETE_VISIBLE_WHEN, + selectRowNames: ['INV-1010', 'INV-1012'], + }); + + fireEvent.click(await screen.findByTestId('bulk-action-delete')); + + await waitFor(() => expect(onBulkDelete).toHaveBeenCalledTimes(1)); + expect(onBulkDelete.mock.calls[0][0].map((r: any) => r.id)).toEqual(['inv-1010', 'inv-1012']); + // Nothing was excluded, so nothing to report: no dialog interposes. + expect(screen.queryByTestId('bulk-skipped-notice')).not.toBeInTheDocument(); + expect(dataSource.delete).not.toHaveBeenCalled(); + }); + + it('MIXED: deletes only the allowed subset AND reports the skipped row', async () => { + const { onBulkDelete, dataSource } = await renderAndSelect({ + rows: [DRAFT, PAID], + userActionsDelete: DELETE_VISIBLE_WHEN, + selectRowNames: ['INV-1010', 'INV-1011'], + }); + + fireEvent.click(await screen.findByTestId('bulk-action-delete')); + + // Half one — the excluded row is REPORTED, through the slot built for this + // shape rather than by silently shrinking the count. + expect(await screen.findByTestId('bulk-skipped-notice')).toBeInTheDocument(); + // The dialog previews what it will actually act on: the draft, not the + // paid invoice. + expect(screen.getByText('• INV-1010')).toBeInTheDocument(); + expect(screen.queryByText('• INV-1011')).not.toBeInTheDocument(); + + fireEvent.click(await screen.findByRole('button', { name: 'Run' })); + + // Half two — the allowed subset was deleted, and ONLY it. `INV-1011` is + // the row the predicate excludes; before this fix it was deleted too. + await waitFor(() => expect(dataSource.delete).toHaveBeenCalledTimes(1)); + expect(dataSource.delete).toHaveBeenCalledWith(OBJECT, 'inv-1010'); + expect(dataSource.delete).not.toHaveBeenCalledWith(OBJECT, 'inv-1011'); + // The host's whole-selection handler is not the executor on this path — + // routing back through it would confirm the same delete twice. + expect(onBulkDelete).not.toHaveBeenCalled(); + }); + + it('NONE-ELIGIBLE: the button still renders, and leads to a dialog that refuses', async () => { + const { onBulkDelete, dataSource } = await renderAndSelect({ + rows: [DRAFT, PAID], + userActionsDelete: DELETE_VISIBLE_WHEN, + // The card's repro exactly: tick ONLY the paid invoice. + selectRowNames: ['INV-1011'], + }); + + // Ruled: never hidden, never disabled by the predicate. + const button = await screen.findByTestId('bulk-action-delete'); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + + fireEvent.click(button); + + // …and the refusal is legible: the dialog opens, says what it skipped, and + // declines to run over zero records. + expect(await screen.findByTestId('bulk-skipped-notice')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Run' })).toBeDisabled(); + expect(dataSource.delete).not.toHaveBeenCalled(); + expect(onBulkDelete).not.toHaveBeenCalled(); + }); + + it('an object declaring NO per-record gate keeps the whole selection', async () => { + // Control group for the fold itself: with no `visibleWhen` the partition is + // a no-op, so the paid invoice is deleted like any other row. This is what + // makes the exclusions above attributable to the predicate rather than to + // some new blanket filter. + const { onBulkDelete } = await renderAndSelect({ + rows: [DRAFT, PAID], + selectRowNames: ['INV-1010', 'INV-1011'], + }); + + fireEvent.click(await screen.findByTestId('bulk-action-delete')); + + await waitFor(() => expect(onBulkDelete).toHaveBeenCalledTimes(1)); + expect(onBulkDelete.mock.calls[0][0].map((r: any) => r.id)).toEqual(['inv-1010', 'inv-1011']); + expect(screen.queryByTestId('bulk-skipped-notice')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/bulkEligibility.ts b/packages/plugin-grid/src/bulkEligibility.ts index 31db95a0a7..9afb51afda 100644 --- a/packages/plugin-grid/src/bulkEligibility.ts +++ b/packages/plugin-grid/src/bulkEligibility.ts @@ -39,7 +39,7 @@ * made row-scoped predicates land in a record-free evaluation. */ -import { evalRowPredicate, type FieldContainerLike } from '@object-ui/core'; +import { partitionRowsByPredicate, type FieldContainerLike } from '@object-ui/core'; import type { BulkActionDef } from '@object-ui/types'; export interface BulkEligibility { @@ -79,8 +79,14 @@ export function hasVisibilityGate(def: BulkEligibilityDef | null | undefined): b /** * Split selected records into the ones this def may act on and a count of the - * ones it may not. A def without `visible` is a no-op: `rows` is returned by - * REFERENCE so the common case allocates nothing and downstream memos hold. + * ones it may not. + * + * The loop, the boolean short-circuit and the fail-closed posture all live in + * `@object-ui/core`'s {@link partitionRowsByPredicate} — the ONE per-record + * fold every bulk surface shares (the built-in selection-bar Delete reads it + * through the same primitive, objectui#4420). This function is the `def`-shaped + * door onto it: it knows only that a bulk def spells its predicate `visible` + * and labels warnings with the def's name. */ export function partitionBulkRows>( def: BulkEligibilityDef | null | undefined, @@ -96,38 +102,10 @@ export function partitionBulkRows>( fields?: FieldContainerLike; } = {}, ): BulkEligibility { - const visible = def?.visible; - if (visible == null || visible === '') { - return { eligible: rows as TRow[], skipped: 0 }; - } - // A BOOLEAN `visible` is a verdict, not an expression — short-circuit it - // exactly as `useCondition` / `useRowPredicate` do (objectui#3492). Handing - // it to the engine instead produced `{ dialect: 'cel', source: undefined }`, - // which faults ("AST-only evaluation not yet supported") and fails CLOSED — - // so `visible: true`, the most explicit way to say "always offer this", - // silently disqualified every selected record and hid the button from - // everyone. `BulkActionDefSchema.visible` is `ExpressionInputSchema` (no - // boolean), so `objectstack build` cannot emit this shape — hand-written view - // JSON and in-process callers constructing defs can, and did. - if (typeof visible === 'boolean') { - return visible - ? { eligible: rows as TRow[], skipped: 0 } - : { eligible: [], skipped: rows.length }; - } - - const eligible = (rows as TRow[]).filter(row => - evalRowPredicate(visible as never, row, { - // Fail CLOSED, exactly as the row kebab does: a predicate that faults - // must not hand the user a button that acts on records it was written to - // exclude. `warnOnError` makes the resulting hide diagnosable (once per - // predicate) instead of silent. - fallback: false, - scope: opts.scope, - fields: opts.fields, - warnOnError: true, - label: def?.name, - }), - ); - - return { eligible, skipped: rows.length - eligible.length }; + return partitionRowsByPredicate(def?.visible as never, rows, { + scope: opts.scope, + fields: opts.fields, + warnOnError: true, + label: def?.name, + }); } diff --git a/packages/plugin-grid/src/components/BulkActionDialog.tsx b/packages/plugin-grid/src/components/BulkActionDialog.tsx index 7e210d38f0..a29cebd321 100644 --- a/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -266,6 +266,22 @@ export const BulkActionDialog: React.FC = ({ const maxRecords = def?.maxRecords ?? Infinity; const overLimit = rows.length > maxRecords; + /** + * [objectui#4420] Nothing survived the eligibility fold. + * + * The dialog still OPENS on an all-excluded selection — that is the ruled + * shape for the built-in Delete (maintainer, 2026-08-17): "a legible + * refusal, not a hidden button whose absence is unexplained". What it must + * not do is offer to run: `Affected records (0)` beside an enabled Run + * button reads as a live operation, and pressing it reports + * `Succeeded 0 / 0` — a success panel for a run that never had a subject. + * The skipped notice above already carries the WHY, so the refusal needs no + * new copy, only a control that declines. + * + * Deliberately `rows.length`, not "skipped > 0": a run over zero records is + * meaningless for every def, however the selection got here. + */ + const noEligibleRows = rows.length === 0; const handleRun = useCallback(async () => { if (!def) return; @@ -531,7 +547,7 @@ export const BulkActionDialog: React.FC = ({ diff --git a/packages/plugin-grid/src/rowCrudAffordances.ts b/packages/plugin-grid/src/rowCrudAffordances.ts index 82a4ef3bb5..504f84d668 100644 --- a/packages/plugin-grid/src/rowCrudAffordances.ts +++ b/packages/plugin-grid/src/rowCrudAffordances.ts @@ -151,6 +151,20 @@ export function resolveRowCrudAffordances(opts: { objectCanDelete: boolean; editPredicates?: RowCrudPredicates; deletePredicates?: RowCrudPredicates; + /** + * [objectui#4420] The per-record delete predicates tied to + * {@link objectCanDelete} rather than to `canDelete` — the BULK bar's half. + * + * `deletePredicates` above rides `canDelete`, which folds in the ROW wiring + * (`operations.delete`/`rowActions` ∧ `onDelete`). That is right for the row + * kebab and wrong for the selection bar for the same reason `objectCanDelete` + * exists: bulk delete rides `onBulkDelete`, so a consumer that wires only the + * bulk handler would otherwise have its author-declared `visibleWhen` + * silently dropped — judged by whether the *row* handler happens to be + * present. Same predicates, gated on the same verdict the bulk bar itself is + * gated on. + */ + objectDeletePredicates?: RowCrudPredicates; } { // The object-level verdict comes from the shared policy — bucket default, // `userActions` override, then the server's effective operation set. The row @@ -174,6 +188,7 @@ export function resolveRowCrudAffordances(opts: { objectCanDelete, editPredicates: canEdit ? aff.editPredicates : undefined, deletePredicates: canDelete ? aff.deletePredicates : undefined, + objectDeletePredicates: objectCanDelete ? aff.deletePredicates : undefined, }; } diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 8106a7c6e1..322126febe 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -15,13 +15,13 @@ import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@obje import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher'; import { ViewSettingsPopover } from './components/ViewSettingsPopover'; import { UserFilters } from './UserFilters'; -import { SchemaRenderer, useNavigationOverlay, classifyLoadError } from '@object-ui/react'; +import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope } from '@object-ui/react'; import type { LoadErrorKind } from '@object-ui/react'; import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; import { detectStatusField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, normalizeListViewSchema, rowHeightToDensityMode, mergeFilterNodes, columnIdentity, collectPredicateFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES } from '@object-ui/core'; +import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, partitionRowsByPredicate, normalizeListViewSchema, rowHeightToDensityMode, mergeFilterNodes, columnIdentity, collectPredicateFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES } from '@object-ui/core'; import { useObjectTranslation, useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -1179,6 +1179,61 @@ export const ListView = React.forwardRef(({ return declared.filter((a: unknown) => String(a).toLowerCase() !== 'delete'); }, [schema.bulkActions, schema.objectName, objectDef, effectiveApiOps, canDo]); + /** + * [objectui#4420] The PER-RECORD half of the same key, for the same bar. + * + * `permittedBulkActions` above reads `userActions.delete` as a BOOLEAN — the + * object-level verdict — and that is all it ever read. Since objectui#2614 + * the key also accepts `{ enabled?, visibleWhen?, disabledWhen? }`, whose + * `visibleWhen` gates the affordance **per record**; the row kebab has + * honoured it since, and this bar did not. Tick only a record the predicate + * excludes and the bar still offered the red Delete — the same declared key + * meaning two different things on two surfaces. + * + * ## What was ruled (maintainer, 2026-08-17 — behaviour 1 of three) + * + * Filter the operation and report the skipped. The bar evaluates + * `visibleWhen` once per selected record, Delete runs over the allowed + * SUBSET, and the excluded records are reported rather than silently + * dropped. Two rejected alternatives, restated because each is a way to + * misread this code: the button is **never hidden or disabled** by the + * predicate (behaviour 2 — one stray tick would disable the whole bar), and + * the predicate is **not** declared out of scope for set operations + * (behaviour 3 — the key must not mean different things on two surfaces). + * A selection where EVERY row is excluded therefore still renders the + * button; what the user gets is a legible refusal, not an absence. + * + * ## Why `evalRowPredicate`, never `useRowPredicate` + * + * A bulk gate evaluates N records in a LOOP, and React forbids a hook per + * iteration. `partitionRowsByPredicate` is that loop — the shared, + * fail-closed fold in `@object-ui/core` that the grid's own bulk bar reads + * through `partitionBulkRows`, so the two bars cannot drift on what + * "eligible" means. + * + * ## Why `objectDef`, and only the built-in `delete` + * + * The predicates come off the OBJECT's `userActions` block (the CRUD + * predicate vocabulary), never the VIEW's `schema.userActions` (toolbar + * policy) — the same name collision `toolbarFlags` above is pinned against. + * Only the built-in `delete` entry is filtered: custom action ids route + * through the action runner carrying their own gates, exactly as the + * object-level gate above leaves them alone. + */ + const deleteVisibleWhen = React.useMemo( + () => resolveEffectiveCrudAffordances(objectDef as any, effectiveApiOps).deletePredicates?.visibleWhen, + [objectDef, effectiveApiOps], + ); + const predicateScope = usePredicateScope(); + const bulkDeleteEligibility = React.useMemo( + () => partitionRowsByPredicate(deleteVisibleWhen as never, selectedRows as Array>, { + scope: predicateScope, + fields: objectDef?.fields, + label: 'delete', + }), + [deleteVisibleWhen, selectedRows, predicateScope, objectDef], + ); + /** * [#4647] Is the grid toolbar's inline-edit toggle offered at all? * @@ -3532,13 +3587,23 @@ export const ListView = React.forwardRef(({ const actionStr = String(action).toLowerCase(); const isDestructive = actionStr.includes('delete') || actionStr.includes('remove') || actionStr.includes('destroy'); const Icon = isDestructive ? Trash2 : null; + // [objectui#4420] The built-in `delete` runs over the ALLOWED + // SUBSET — the records `userActions.delete.visibleWhen` admits — + // never the raw tick list. The button itself is untouched by the + // predicate (ruled: never hidden, never disabled); what shrinks + // is what it acts on, and the notice below owns up to it. Only + // the canonical `delete` is filtered: every other id routes + // through the action runner with its own gates. + const rowsForAction = actionStr === 'delete' + ? (bulkDeleteEligibility.eligible as any[]) + : selectedRows; return (