diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 93be89de00..d4cdbba873 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -85,6 +85,7 @@ The following options are available: | `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | | `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. | | `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 | +| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API | _\* An asterisk denotes that a property is required._ diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 92663627d8..ddef9ce0d0 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -274,7 +274,7 @@ export const renderListView = async ( collectionSlug, columnState, disableBulkDelete, - disableBulkEdit, + disableBulkEdit: collectionConfig.disableBulkEdit ?? disableBulkEdit, disableQueryPresets, enableRowSelections, hasCreatePermission, diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 1ba3d13c5e..43f80ae91d 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -440,6 +440,10 @@ export type CollectionConfig = { * Default field to sort by in collection list view */ defaultSort?: Sort + /** + * Disable the bulk edit operation for the collection in the admin panel and the API + */ + disableBulkEdit?: boolean /** * When true, do not show the "Duplicate" button while editing documents within this collection and prevent `duplicate` from all APIs */ diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index ea5bb613de..9b836e0d5e 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -15,7 +15,7 @@ import type { import executeAccess from '../../auth/executeAccess.js' import { combineQueries } from '../../database/combineQueries.js' import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js' -import { APIError } from '../../errors/index.js' +import { APIError, Forbidden } from '../../errors/index.js' import { type CollectionSlug, deepCopyObjectSimple } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' @@ -63,6 +63,10 @@ export const updateOperation = async < ): Promise> => { let args = incomingArgs + if (args.collection.config.disableBulkEdit && !args.overrideAccess) { + throw new APIError(`Collection ${args.collection.config.slug} has disabled bulk edit`, 403) + } + try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) diff --git a/packages/ui/src/views/CollectionFolder/index.tsx b/packages/ui/src/views/CollectionFolder/index.tsx index 678e55431b..98c58b60b7 100644 --- a/packages/ui/src/views/CollectionFolder/index.tsx +++ b/packages/ui/src/views/CollectionFolder/index.tsx @@ -264,7 +264,7 @@ function CollectionFolderViewInContext(props: CollectionFolderViewInContextProps !smallBreak && ( ), diff --git a/test/admin/collections/DisableBulkEdit.ts b/test/admin/collections/DisableBulkEdit.ts new file mode 100644 index 0000000000..f4ac92685e --- /dev/null +++ b/test/admin/collections/DisableBulkEdit.ts @@ -0,0 +1,7 @@ +import type { CollectionConfig } from 'payload' + +export const DisableBulkEdit: CollectionConfig = { + slug: 'disable-bulk-edit', + fields: [], + disableBulkEdit: true, +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 1873d20ee1..44537855ef 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-exports */ import { fileURLToPath } from 'node:url' import path from 'path' @@ -8,6 +7,7 @@ import { BaseListFilter } from './collections/BaseListFilter.js' import { CustomFields } from './collections/CustomFields/index.js' import { CustomViews1 } from './collections/CustomViews1.js' import { CustomViews2 } from './collections/CustomViews2.js' +import { DisableBulkEdit } from './collections/DisableBulkEdit.js' import { DisableCopyToLocale } from './collections/DisableCopyToLocale.js' import { DisableDuplicate } from './collections/DisableDuplicate.js' import { EditMenuItems } from './collections/editMenuItems.js' @@ -180,6 +180,7 @@ export default buildConfigWithDefaults({ ListDrawer, Placeholder, UseAsTitleGroupField, + DisableBulkEdit, ], globals: [ GlobalHidden, diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index b625945a98..4e0dd5b0a5 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -66,6 +66,7 @@ describe('List View', () => { let with300DocumentsUrl: AdminUrlUtil let withListViewUrl: AdminUrlUtil let placeholderUrl: AdminUrlUtil + let disableBulkEditUrl: AdminUrlUtil let user: any let serverURL: string @@ -90,6 +91,7 @@ describe('List View', () => { customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug) withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug) placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug) + disableBulkEditUrl = new AdminUrlUtil(serverURL, 'disable-bulk-edit') const context = await browser.newContext() page = await context.newPage() initPageConsoleErrorCatch(page) @@ -1302,6 +1304,16 @@ describe('List View', () => { await page.locator('#confirm-delete-many-docs #confirm-action').click() await expect(page.locator('.cell-_select')).toHaveCount(1) }) + + test('should hide edit many from collection with disableBulkEdit: true', async () => { + await payload.create({ collection: 'disable-bulk-edit', data: {} }) + await page.goto(disableBulkEditUrl.list) + + // select one row + await page.locator('.row-1 .cell-_select input').check() + // ensure the edit many button is hidden + await expect(page.locator('.edit-many button')).toBeHidden() + }) }) describe('pagination', () => { diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index f7e71a1975..88fb0f3d5d 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -92,6 +92,7 @@ export interface Config { 'with-list-drawer': WithListDrawer; placeholder: Placeholder; 'use-as-title-group-field': UseAsTitleGroupField; + 'disable-bulk-edit': DisableBulkEdit; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -123,6 +124,7 @@ export interface Config { 'with-list-drawer': WithListDrawerSelect | WithListDrawerSelect; placeholder: PlaceholderSelect | PlaceholderSelect; 'use-as-title-group-field': UseAsTitleGroupFieldSelect | UseAsTitleGroupFieldSelect; + 'disable-bulk-edit': DisableBulkEditSelect | DisableBulkEditSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -547,6 +549,15 @@ export interface UseAsTitleGroupField { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "disable-bulk-edit". + */ +export interface DisableBulkEdit { + id: string; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -653,6 +664,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'use-as-title-group-field'; value: string | UseAsTitleGroupField; + } | null) + | ({ + relationTo: 'disable-bulk-edit'; + value: string | DisableBulkEdit; } | null); globalSlug?: string | null; user: { @@ -1037,6 +1052,14 @@ export interface UseAsTitleGroupFieldSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "disable-bulk-edit_select". + */ +export interface DisableBulkEditSelect { + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". diff --git a/test/collections-rest/config.ts b/test/collections-rest/config.ts index 7b8d89d7ba..49bd6e6544 100644 --- a/test/collections-rest/config.ts +++ b/test/collections-rest/config.ts @@ -270,6 +270,16 @@ export default buildConfigWithDefaults({ path: `/${method}-test`, })), }, + { + slug: 'disabled-bulk-edit-docs', + fields: [ + { + name: 'text', + type: 'text', + }, + ], + disableBulkEdit: true, + }, ], endpoints: [ { diff --git a/test/collections-rest/int.spec.ts b/test/collections-rest/int.spec.ts index 553d2d4f21..e233f4622b 100644 --- a/test/collections-rest/int.spec.ts +++ b/test/collections-rest/int.spec.ts @@ -1797,6 +1797,28 @@ describe('collections-rest', () => { expect((await result.json()).message.startsWith('Route not found')).toBeTruthy() } }) + + it('should disable bulk edit for the collection with disableBulkEdit: true', async () => { + const res = await restClient.PATCH('/disabled-bulk-edit-docs?where[id][equals]=0', {}) + expect(res.status).toBe(403) + + await expect( + payload.update({ + collection: 'disabled-bulk-edit-docs', + where: {}, + data: {}, + overrideAccess: false, + }), + ).rejects.toBeInstanceOf(APIError) + + await expect( + payload.update({ + collection: 'disabled-bulk-edit-docs', + where: {}, + data: {}, + }), + ).resolves.toBeTruthy() + }) }) async function createPost(overrides?: Partial) { diff --git a/test/collections-rest/payload-types.ts b/test/collections-rest/payload-types.ts index bd6de3d7fa..1a7fca99d8 100644 --- a/test/collections-rest/payload-types.ts +++ b/test/collections-rest/payload-types.ts @@ -75,6 +75,7 @@ export interface Config { 'custom-id-number': CustomIdNumber; 'error-on-hooks': ErrorOnHook; endpoints: Endpoint; + 'disabled-bulk-edit-docs': DisabledBulkEditDoc; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -90,6 +91,7 @@ export interface Config { 'custom-id-number': CustomIdNumberSelect | CustomIdNumberSelect; 'error-on-hooks': ErrorOnHooksSelect | ErrorOnHooksSelect; endpoints: EndpointsSelect | EndpointsSelect; + 'disabled-bulk-edit-docs': DisabledBulkEditDocsSelect | DisabledBulkEditDocsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -247,6 +249,16 @@ export interface Endpoint { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "disabled-bulk-edit-docs". + */ +export interface DisabledBulkEditDoc { + id: string; + text?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -303,6 +315,10 @@ export interface PayloadLockedDocument { relationTo: 'endpoints'; value: string | Endpoint; } | null) + | ({ + relationTo: 'disabled-bulk-edit-docs'; + value: string | DisabledBulkEditDoc; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -446,6 +462,15 @@ export interface EndpointsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "disabled-bulk-edit-docs_select". + */ +export interface DisabledBulkEditDocsSelect { + text?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select".