Skip to content

Commit ca02cdc

Browse files
authored
feat: collection-level disableBulkDelete (#17207)
## Summary Backport of #16944 to the `3.x` branch. Adds a collection-level `disableBulkDelete` config option, completing the work started in #12850 (which added collection-level `disableBulkEdit`). Previously, `disableBulkDelete` only existed as a List View UI prop, so setting it hid the bulk delete button but did **not** prevent bulk deletes via the REST, GraphQL, or Local API. A user with `delete` permission could still delete every document at once through `payload.delete({ where })` or `DELETE /api/:collection?where=...`. This brings `disableBulkDelete` to full parity with `disableBulkEdit`: - New collection config option `disableBulkDelete`. - Server-side enforcement: the bulk `deleteOperation` now throws a `403` when `disableBulkDelete` is set and access is not overridden, mirroring the existing guard in `updateOperation`. - The bulk delete UI action is hidden automatically, because the option is threaded into the List view props. Single-document deletes (`deleteByID`) are unaffected, so "delete one, not delete many" works out of the box without a custom `access.delete` check. ## Note on the 3.x adaptation On `main` the entire list view is rendered through a single `renderListView` in `packages/ui`, so one change covered the standard list, trash, and hierarchy views. On `3.x` the list view is still rendered from `packages/next`, and the folder view is separate, so the config is threaded into both: - `packages/next/src/views/List/index.tsx` - `packages/ui/src/views/CollectionFolder/index.tsx` both mirroring exactly how `disableBulkEdit` is already resolved from `collectionConfig` in those files. ## Note on trash / soft delete This scopes `disableBulkDelete` to the bulk delete operation, exactly mirroring how `disableBulkEdit` guards the bulk update operation. In Payload, bulk "move to trash" is a bulk update (it sets `deletedAt`), so it runs through `updateOperation` and is therefore governed by `disableBulkEdit`, not `disableBulkDelete`. A collection that wants to lock down both bulk edits and bulk removals can set both flags, and they compose cleanly. ## Test plan - [x] Added a `disabled-bulk-delete-docs` collection with `disableBulkDelete: true` in `test/collections-rest`. - [x] Added an integration test mirroring the existing `disableBulkEdit` test: REST bulk `DELETE` returns `403`, Local API bulk delete with `overrideAccess: false` rejects with `APIError`, single-document delete by `id` still works, and bulk delete still works when access is overridden. - [ ] `pnpm run test:int collections-rest -t "bulk"` (run against 3.x before merge) Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
1 parent ca72620 commit ca02cdc

8 files changed

Lines changed: 79 additions & 2 deletions

File tree

‎docs/configuration/collections.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ The following options are available:
8686
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
8787
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
8888
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks. [More details](../queries/select). |
89+
| `disableBulkDelete` | Disable the bulk delete operation for the collection in the admin panel and the REST API |
8990
| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API |
9091

9192
_\* An asterisk denotes that a property is required._

‎packages/next/src/views/List/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ export const renderListView = async (
417417
...listViewSlots,
418418
collectionSlug,
419419
columnState,
420-
disableBulkDelete,
420+
disableBulkDelete: collectionConfig.disableBulkDelete ?? disableBulkDelete,
421421
disableBulkEdit: collectionConfig.disableBulkEdit ?? disableBulkEdit,
422422
disableQueryPresets,
423423
enableRowSelections,

‎packages/payload/src/collections/config/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,10 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
619619
* Default field to sort by in collection list view
620620
*/
621621
defaultSort?: Sort
622+
/**
623+
* Disable the bulk delete operation for the collection in the admin panel and the API
624+
*/
625+
disableBulkDelete?: boolean
622626
/**
623627
* Disable the bulk edit operation for the collection in the admin panel and the API
624628
*/

‎packages/payload/src/collections/operations/delete.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export const deleteOperation = async <
5252
): Promise<BulkOperationResult<TSlug, TSelect>> => {
5353
let args = incomingArgs
5454

55+
if (args.collection.config.disableBulkDelete && !args.overrideAccess) {
56+
throw new APIError(`Collection ${args.collection.config.slug} has disabled bulk delete`, 403)
57+
}
58+
5559
try {
5660
const shouldCommit = !args.disableTransaction && (await initTransaction(args.req))
5761
// /////////////////////////////////////

‎packages/ui/src/views/CollectionFolder/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ function CollectionFolderViewInContext(props: CollectionFolderViewInContextProps
265265
Actions={[
266266
!smallBreak && (
267267
<ListSelection
268-
disableBulkDelete={disableBulkDelete}
268+
disableBulkDelete={collectionConfig.disableBulkDelete ?? disableBulkDelete}
269269
disableBulkEdit={collectionConfig.disableBulkEdit ?? disableBulkEdit}
270270
folderAssignedCollections={
271271
Array.isArray(folderType) ? folderType : [collectionSlug]

‎test/collections-rest/config.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,17 @@ export default buildConfigWithDefaults({
281281
],
282282
disableBulkEdit: true,
283283
},
284+
{
285+
slug: 'disabled-bulk-delete-docs',
286+
fields: [
287+
{
288+
name: 'text',
289+
type: 'text',
290+
},
291+
],
292+
disableBulkDelete: true,
293+
versions: false,
294+
},
284295
LargeDocuments,
285296
],
286297
bodyParser: {

‎test/collections-rest/int.spec.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,6 +1919,38 @@ describe('collections-rest', () => {
19191919
}),
19201920
).resolves.toBeTruthy()
19211921
})
1922+
1923+
it('should disable bulk delete for the collection with disableBulkDelete: true', async () => {
1924+
const res = await restClient.DELETE('/disabled-bulk-delete-docs?where[id][equals]=0')
1925+
expect(res.status).toBe(403)
1926+
1927+
await expect(
1928+
payload.delete({
1929+
collection: 'disabled-bulk-delete-docs',
1930+
where: {},
1931+
overrideAccess: false,
1932+
}),
1933+
).rejects.toBeInstanceOf(APIError)
1934+
1935+
const doc = await payload.create({
1936+
collection: 'disabled-bulk-delete-docs',
1937+
data: { text: 'should be deletable by id' },
1938+
})
1939+
1940+
await expect(
1941+
payload.delete({
1942+
collection: 'disabled-bulk-delete-docs',
1943+
id: doc.id,
1944+
}),
1945+
).resolves.toBeTruthy()
1946+
1947+
await expect(
1948+
payload.delete({
1949+
collection: 'disabled-bulk-delete-docs',
1950+
where: {},
1951+
}),
1952+
).resolves.toBeTruthy()
1953+
})
19221954
})
19231955

19241956
async function createPost(overrides?: Partial<Post>) {

‎test/collections-rest/payload-types.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface Config {
7676
'error-on-hooks': ErrorOnHook;
7777
endpoints: Endpoint;
7878
'disabled-bulk-edit-docs': DisabledBulkEditDoc;
79+
'disabled-bulk-delete-docs': DisabledBulkDeleteDoc;
7980
'large-documents': LargeDocument;
8081
'payload-kv': PayloadKv;
8182
users: User;
@@ -94,6 +95,7 @@ export interface Config {
9495
'error-on-hooks': ErrorOnHooksSelect<false> | ErrorOnHooksSelect<true>;
9596
endpoints: EndpointsSelect<false> | EndpointsSelect<true>;
9697
'disabled-bulk-edit-docs': DisabledBulkEditDocsSelect<false> | DisabledBulkEditDocsSelect<true>;
98+
'disabled-bulk-delete-docs': DisabledBulkDeleteDocsSelect<false> | DisabledBulkDeleteDocsSelect<true>;
9799
'large-documents': LargeDocumentsSelect<false> | LargeDocumentsSelect<true>;
98100
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
99101
users: UsersSelect<false> | UsersSelect<true>;
@@ -262,6 +264,16 @@ export interface DisabledBulkEditDoc {
262264
updatedAt: string;
263265
createdAt: string;
264266
}
267+
/**
268+
* This interface was referenced by `Config`'s JSON-Schema
269+
* via the `definition` "disabled-bulk-delete-docs".
270+
*/
271+
export interface DisabledBulkDeleteDoc {
272+
id: string;
273+
text?: string | null;
274+
updatedAt: string;
275+
createdAt: string;
276+
}
265277
/**
266278
* This interface was referenced by `Config`'s JSON-Schema
267279
* via the `definition` "large-documents".
@@ -362,6 +374,10 @@ export interface PayloadLockedDocument {
362374
relationTo: 'disabled-bulk-edit-docs';
363375
value: string | DisabledBulkEditDoc;
364376
} | null)
377+
| ({
378+
relationTo: 'disabled-bulk-delete-docs';
379+
value: string | DisabledBulkDeleteDoc;
380+
} | null)
365381
| ({
366382
relationTo: 'large-documents';
367383
value: string | LargeDocument;
@@ -518,6 +534,15 @@ export interface DisabledBulkEditDocsSelect<T extends boolean = true> {
518534
updatedAt?: T;
519535
createdAt?: T;
520536
}
537+
/**
538+
* This interface was referenced by `Config`'s JSON-Schema
539+
* via the `definition` "disabled-bulk-delete-docs_select".
540+
*/
541+
export interface DisabledBulkDeleteDocsSelect<T extends boolean = true> {
542+
text?: T;
543+
updatedAt?: T;
544+
createdAt?: T;
545+
}
521546
/**
522547
* This interface was referenced by `Config`'s JSON-Schema
523548
* via the `definition` "large-documents_select".

0 commit comments

Comments
 (0)