Skip to content

fix(list,grid): honour userActions.delete.visibleWhen per selected record on the selection bar - #6377

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-4420-bulk-delete-visiblewhen
Aug 25, 2026
Merged

fix(list,grid): honour userActions.delete.visibleWhen per selected record on the selection bar#6377
os-support-ai merged 3 commits into
mainfrom
claude/issue-4420-bulk-delete-visiblewhen

Conversation

@claude

@claude claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #4420

The selection bar's built-in Delete ignored userActions.delete.visibleWhen. It read that key as a bare boolean — the object-level verdict (ADR-0103 bucket ∧ userActions ∧ the server's apiOperations ∧ the principal's allowDelete) — with no per-record layer at all. Tick only a record the author's predicate excludes and the bar still offered the red Delete, and pressing it deleted the record the predicate was written 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.

What was ruled

Maintainer, 2026-08-17, behaviour 1 of the card's three — filter the operation and report the skipped. The bar evaluates visibleWhen once per selected record, the delete runs over the allowed subset, and the excluded records are reported through BulkActionDialog's existing bulk-skipped-notice slot. Behaviour 2 (gate the button) and behaviour 3 (declare the key out of scope for sets) were rejected. Three consequences, each implemented and pinned:

  • The button is never hidden or disabled by the predicate. Filtering happens at execution; reporting happens in the dialog.
  • Zero eligible rows still opens the dialog and says so — a legible refusal, not an unexplained absence.
  • The grid path gets the same rule, not a similar one.

Not re-litigated, not re-opened. packages/spec untouched; nothing outside this fix changes what visibleWhen means.

What changed

Package Change
@object-ui/core New partitionRowsByPredicate — the set-shaped counterpart of evalRowPredicate: absent predicate returns rows by reference, a boolean short-circuits, an expression is evaluated per record fail-closed.
@object-ui/plugin-grid partitionBulkRows now delegates to it (one fold, not two). resolveRowCrudAffordances returns objectDeletePredicates. dispatchBulkAction's delete branch partitions the expanded selection. BulkActionDialog declines to run over zero records.
@object-ui/plugin-list The non-grid bar (kanban / calendar / gallery / …) filters the built-in delete to the eligible subset and states the skipped count inline.

No hook was harmed. A bulk gate evaluates N selected records in a loop, and React forbids a hook per iteration — so this reuses evalRowPredicate (through the shared fold), never useRowPredicate. The seam already existed; nothing built a second one.

Why objectDeletePredicates and not deletePredicates. The existing deletePredicates rides canDelete, which folds in the row wiring (operations.delete/rowActionsonDelete). Bulk delete rides onBulkDelete. A consumer wiring only the bulk handler would otherwise have the author's predicate silently dropped — judged by whether the row handler happens to be present. This is the exact reason objectCanDelete already exists beside canDelete; the predicates now have the same pair.

The one design call worth reviewing. In ObjectGrid, an excluded selection routes through BulkActionDialog (which owns the confirm and executes over eligible), while a selection with nothing excluded keeps the consumer's own onBulkDelete flow untouched. The split is deliberate: routing an excluded selection back through onBulkDelete would stack the host's own confirmation dialog behind this one and confirm the same delete twice, and keeping the unexcluded path on the host handler is what makes every object that declares no predicate byte-identical to its previous behaviour (confirm + toast + refresh all still the host's). Declared bulk-delete defs already run through this dialog today, so the excluded path is a shipped route, not a new one.

Tests — all three selections, both bars

packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx and packages/plugin-list/src/__tests__/ListView.bulkDeleteVisibleWhen.test.tsx, encoding the card's repro verbatim: showcase_invoice declares delete: { visibleWhen: "record.status != 'paid'" }.

The excluded fixture row is INV-1011status: 'paid'. INV-1010 is the draft the predicate admits.

  • all-eligible — the whole selection is deleted, no notice. This is the degenerate control: its fixture has no excluded row, so it passes against the unfixed code too. That is what it is for.
  • mixedboth halves: bulk-skipped-notice is rendered and only inv-1010 reaches the delete path.
  • none-eligible — tick only INV-1011: the button still renders and is not disabled, the dialog opens, the notice fires, Run is disabled, nothing is deleted.
  • no predicate declared — a fourth control: the fold is a no-op and the paid invoice is deleted like any other row, so the exclusions above are attributable to the predicate rather than to a new blanket filter.

Ghost-assertion guard — both readings

Run in a second worktree checked out at unmodified origin/main (2e11c8c5b), with the two test files copied in byte-identical (verified by git hash-object) and git status showing nothing else changed.

Failing on origin/main:

 × MIXED: deletes only the allowed subset AND reports the skipped row
 × NONE-ELIGIBLE: the button still renders, and leads to a dialog that refuses
 × MIXED: dispatches only the allowed subset AND reports the skipped row
 × NONE-ELIGIBLE: the button still renders and is not disabled, and the bar says why
TestingLibraryElementError: Unable to find an element by: [data-testid="bulk-skipped-notice"]
 Test Files  2 failed (2)
      Tests  4 failed | 4 passed (8)

The 4 passing there are the two degenerate controls and the two no-predicate controls, exactly as intended.

Those four die on the report half, which would leave the subset half unproven. So a probe was run in the same origin/main tree with the report assertion removed, to make the failure land on which records reached the delete path:

AssertionError: expected [ 'inv-1010', 'inv-1011' ] to deeply equal [ 'inv-1010' ]   (plugin-grid)
AssertionError: expected [ 'inv-1010', 'inv-1011' ] to deeply equal [ 'inv-1010' ]   (plugin-list)

— i.e. on origin/main both bars hand the paid invoice to the delete path. That is the defect, measured. (The probe is a main-side measurement only; it is not one of the delivered assertions and was not committed.)

Passing on this branch (all readings below taken at bef22ef5e, with git status clean, so the tree measured is this commit):

 Test Files  2 passed (2)
      Tests  8 passed (8)

Other local gates, all at bef22ef5e

Command Verdict line
vitest run packages/plugin-grid/ packages/plugin-list/ Test Files 136 passed (136) / Tests 1512 passed (1512)
vitest run packages/core/ Test Files 100 passed (100) / Tests 2023 passed (2023)
pnpm --filter @object-ui/core --filter @object-ui/plugin-grid --filter @object-ui/plugin-list type-check all three Done
pnpm --filter … lint (the three changed packages) 0 errors each (core 515, plugin-list 399, plugin-grid 689 pre-existing warnings)
check:control-bytes OK (scanned 5250 tracked text file(s))
check:changeset-presence 8 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)
check:changeset-no-major No changeset declares a major bump.
check:i18n-keys Every in-scope call-site key resolves against the en pack
check:i18n-drift No en value changed in this range.
check:vi-mock-specifiers OK
check:phantom-deps Every in-scope import is declared by the package that publishes it.
check:self-import OK

check:readme-exports is NOT MEASURED locally, not red: every one of its 293 findings is type entry ./dist/index.d.ts is not on disk -- run pnpm build first, for packages this worktree never built (app-shell, auth, cli, layout, …). It is a missing prerequisite, not a verdict. The risk it covers is nil here — this diff adds one @object-ui/core export, removes none, and touches no README. CI builds everything and measures it properly.

No new i18n key was minted: the list bar reuses the dialog's own grid.bulk.skippedIneligible, already translated in all ten packs. One sentence, one spelling, ten packs that stay in step by construction.

Serial clause — #5853

Per the dispatch, git fetch origin main && git merge origin/main was run before the first edit to packages/plugin-grid/src/ObjectGrid.tsx. #5853 had NOT landed at that pointorigin/main was at 2e11c8c5b and git log f53a8d0ae..origin/main -- packages/plugin-grid/src/ObjectGrid.tsx is empty, so the column-type emit seam at :26 / :2248 is still unpushed. The second lander should re-fetch. My hunks in that file are disjoint from theirs: the resolveRowCrudAffordances destructure at :900 and the bulk dispatcher at ~:2662. Nothing outside my own region was reformatted, tidied or otherwise touched.

Scope

UI affordance only — the triage record measured it as "UI-only leak; server enforcement intact." Not widened into a permissions or security change. #4419 (detail-header half) and #4296 (row kebab ANDs only the object-level permission) are separate cards and are untouched here. Custom bulk action ids keep passing through unfiltered: they route through the action runner with their own gates.

Draft, and staying draft — the PM lands it.

Generated by Claude Code


Generated by Claude Code

claude added 3 commits August 25, 2026 17:05
…cord on the selection bar

The selection bar read `userActions.delete` as a boolean only, so a record the
per-record `visibleWhen` excludes was still offered — and deleted — from the
bulk bar, while the row kebab correctly hid it.

Maintainer ruling 2026-08-17 (behaviour 1): evaluate the predicate per selected
record, run over the allowed subset, report the excluded ones. The button is
never hidden or disabled by the predicate.

- `@object-ui/core`: `partitionRowsByPredicate` — the set-shaped counterpart of
  `evalRowPredicate`, which is the loop a bulk gate needs and a hook cannot be.
- `plugin-grid`: `partitionBulkRows` now delegates to it; `resolveRowCrudAffordances`
  returns `objectDeletePredicates` (the bulk half, gated on `objectCanDelete`).
- `plugin-list`: the non-grid bulk bar filters the built-in `delete` to the
  eligible subset and states the skipped count.
- `BulkActionDialog`: Run declines a zero-record run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
All-eligible / mixed / none-eligible on both selection bars, encoding the
card's repro (`showcase_invoice`, `delete: { visibleWhen: "record.status !=
'paid'" }`, INV-1011 the paid one). The mixed case asserts both halves — the
allowed subset was deleted AND the skipped row was reported through
`bulk-skipped-notice`.

Also adds the built-in delete's dialog route in ObjectGrid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3223.9 KB 3266.6 KB
Main entry chunk (gzip) 154.1 KB 350 KB
Entry file index-DF7MOmaB.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 10.96KB 4.16KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 505.90KB 114.59KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.18KB 47.97KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.45KB
plugin-designer (index.js) 211.95KB 42.75KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 127.52KB 31.01KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.42KB 54.48KB
plugin-kanban (index.js) 52.83KB 14.55KB
plugin-list (index.js) 112.63KB 27.45KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.70KB 7.69KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.49KB 2.14KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 18:20
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 9801765 Aug 25, 2026
28 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-4420-bulk-delete-visiblewhen branch August 25, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Selection-bar Delete ignores userActions.delete.visibleWhen — and what a per-record predicate means over a SET needs deciding first

2 participants