diff --git a/packages/next/src/routes/rest/collections/delete.ts b/packages/next/src/routes/rest/collections/delete.ts index 2241529ceee..c005019fa51 100644 --- a/packages/next/src/routes/rest/collections/delete.ts +++ b/packages/next/src/routes/rest/collections/delete.ts @@ -10,14 +10,16 @@ import type { CollectionRouteHandler } from '../types.js' import { headersWithCors } from '../../../utilities/headersWithCors.js' export const deleteDoc: CollectionRouteHandler = async ({ collection, req }) => { - const { depth, where } = req.query as { + const { depth, overrideLock, where } = req.query as { depth?: string + overrideLock?: string where?: Where } const result = await deleteOperation({ collection, depth: isNumber(depth) ? Number(depth) : undefined, + overrideLock: Boolean(overrideLock === 'true'), req, where, }) diff --git a/packages/next/src/routes/rest/collections/deleteByID.ts b/packages/next/src/routes/rest/collections/deleteByID.ts index 572bb2c42e6..2aa15089342 100644 --- a/packages/next/src/routes/rest/collections/deleteByID.ts +++ b/packages/next/src/routes/rest/collections/deleteByID.ts @@ -14,6 +14,7 @@ export const deleteByID: CollectionRouteHandlerWithID = async ({ }) => { const { searchParams } = req const depth = searchParams.get('depth') + const overrideLock = searchParams.get('overrideLock') const id = sanitizeCollectionID({ id: incomingID, @@ -25,6 +26,7 @@ export const deleteByID: CollectionRouteHandlerWithID = async ({ id, collection, depth: isNumber(depth) ? depth : undefined, + overrideLock: Boolean(overrideLock === 'true'), req, }) diff --git a/packages/next/src/routes/rest/collections/update.ts b/packages/next/src/routes/rest/collections/update.ts index 3edecc1c70f..1ebc070051e 100644 --- a/packages/next/src/routes/rest/collections/update.ts +++ b/packages/next/src/routes/rest/collections/update.ts @@ -10,10 +10,11 @@ import type { CollectionRouteHandler } from '../types.js' import { headersWithCors } from '../../../utilities/headersWithCors.js' export const update: CollectionRouteHandler = async ({ collection, req }) => { - const { depth, draft, limit, where } = req.query as { + const { depth, draft, limit, overrideLock, where } = req.query as { depth?: string draft?: string limit?: string + overrideLock?: string where?: Where } @@ -23,6 +24,7 @@ export const update: CollectionRouteHandler = async ({ collection, req }) => { depth: isNumber(depth) ? Number(depth) : undefined, draft: draft === 'true', limit: isNumber(limit) ? Number(limit) : undefined, + overrideLock: Boolean(overrideLock === 'true'), req, where, }) diff --git a/packages/next/src/routes/rest/collections/updateByID.ts b/packages/next/src/routes/rest/collections/updateByID.ts index ca64dfb4798..cf7ca625979 100644 --- a/packages/next/src/routes/rest/collections/updateByID.ts +++ b/packages/next/src/routes/rest/collections/updateByID.ts @@ -16,6 +16,7 @@ export const updateByID: CollectionRouteHandlerWithID = async ({ const depth = searchParams.get('depth') const autosave = searchParams.get('autosave') === 'true' const draft = searchParams.get('draft') === 'true' + const overrideLock = searchParams.get('overrideLock') const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined const id = sanitizeCollectionID({ @@ -31,6 +32,7 @@ export const updateByID: CollectionRouteHandlerWithID = async ({ data: req.data, depth: isNumber(depth) ? Number(depth) : undefined, draft, + overrideLock: Boolean(overrideLock === 'true'), publishSpecificLocale, req, }) diff --git a/packages/next/src/views/Edit/Default/index.tsx b/packages/next/src/views/Edit/Default/index.tsx index 17830b8c6f4..1d4cf22838b 100644 --- a/packages/next/src/views/Edit/Default/index.tsx +++ b/packages/next/src/views/Edit/Default/index.tsx @@ -343,7 +343,7 @@ export const DefaultEditView: React.FC = () => { const shouldShowDocumentLockedModal = documentIsLocked && currentEditor && - currentEditor.id !== user.id && + currentEditor.id !== user?.id && !isReadOnlyForIncomingUser && !showTakeOverModal && !documentLockStateRef.current?.hasShownLockedModal diff --git a/packages/ui/src/elements/DeleteMany/index.tsx b/packages/ui/src/elements/DeleteMany/index.tsx index 3883674e51f..188789d354c 100644 --- a/packages/ui/src/elements/DeleteMany/index.tsx +++ b/packages/ui/src/elements/DeleteMany/index.tsx @@ -26,7 +26,7 @@ export type Props = { } export const DeleteMany: React.FC = (props) => { - const { collection: { slug, labels: { plural } } = {} } = props + const { collection: { slug, labels: { plural, singular } } = {} } = props const { permissions } = useAuth() const { @@ -65,8 +65,22 @@ export const DeleteMany: React.FC = (props) => { try { const json = await res.json() toggleModal(modalSlug) - if (res.status < 400) { - toast.success(json.message || t('general:deletedSuccessfully')) + + const deletedDocs = json?.docs.length || 0 + const successLabel = deletedDocs > 1 ? plural : singular + + if (res.status < 400 || deletedDocs > 0) { + toast.success( + t('general:deletedCountSuccessfully', { + count: deletedDocs, + label: getTranslation(successLabel, i18n), + }), + ) + if (json?.errors.length > 0) { + toast.error(json.message, { + description: json.errors.map((error) => error.message).join('\n'), + }) + } toggleAll() router.replace( stringifyParams({ @@ -96,11 +110,13 @@ export const DeleteMany: React.FC = (props) => { addDefaultError, api, getQueryParams, - i18n.language, + i18n, modalSlug, + plural, router, selectAll, serverURL, + singular, slug, stringifyParams, t, diff --git a/packages/ui/src/elements/PublishMany/index.tsx b/packages/ui/src/elements/PublishMany/index.tsx index e3e7daaa94d..7e90e605d73 100644 --- a/packages/ui/src/elements/PublishMany/index.tsx +++ b/packages/ui/src/elements/PublishMany/index.tsx @@ -27,7 +27,7 @@ export type PublishManyProps = { export const PublishMany: React.FC = (props) => { const { clearRouteCache } = useRouteCache() - const { collection: { slug, labels: { plural }, versions } = {} } = props + const { collection: { slug, labels: { plural, singular }, versions } = {} } = props const { config: { @@ -71,8 +71,22 @@ export const PublishMany: React.FC = (props) => { try { const json = await res.json() toggleModal(modalSlug) - if (res.status < 400) { - toast.success(t('general:updatedSuccessfully')) + + const deletedDocs = json?.docs.length || 0 + const successLabel = deletedDocs > 1 ? plural : singular + + if (res.status < 400 || deletedDocs > 0) { + toast.success( + t('general:updatedCountSuccessfully', { + count: deletedDocs, + label: getTranslation(successLabel, i18n), + }), + ) + if (json?.errors.length > 0) { + toast.error(json.message, { + description: json.errors.map((error) => error.message).join('\n'), + }) + } router.replace( stringifyParams({ params: { @@ -99,10 +113,12 @@ export const PublishMany: React.FC = (props) => { addDefaultError, api, getQueryParams, - i18n.language, + i18n, modalSlug, + plural, selectAll, serverURL, + singular, slug, t, toggleModal, diff --git a/packages/ui/src/elements/UnpublishMany/index.tsx b/packages/ui/src/elements/UnpublishMany/index.tsx index 2d8fba666d8..4bfc0a237b7 100644 --- a/packages/ui/src/elements/UnpublishMany/index.tsx +++ b/packages/ui/src/elements/UnpublishMany/index.tsx @@ -26,7 +26,7 @@ export type UnpublishManyProps = { } export const UnpublishMany: React.FC = (props) => { - const { collection: { slug, labels: { plural }, versions } = {} } = props + const { collection: { slug, labels: { plural, singular }, versions } = {} } = props const { config: { @@ -69,8 +69,22 @@ export const UnpublishMany: React.FC = (props) => { try { const json = await res.json() toggleModal(modalSlug) - if (res.status < 400) { - toast.success(t('general:updatedSuccessfully')) + + const deletedDocs = json?.docs.length || 0 + const successLabel = deletedDocs > 1 ? plural : singular + + if (res.status < 400 || deletedDocs > 0) { + toast.success( + t('general:updatedCountSuccessfully', { + count: deletedDocs, + label: getTranslation(successLabel, i18n), + }), + ) + if (json?.errors.length > 0) { + toast.error(json.message, { + description: json.errors.map((error) => error.message).join('\n'), + }) + } router.replace( stringifyParams({ params: { @@ -96,10 +110,12 @@ export const UnpublishMany: React.FC = (props) => { addDefaultError, api, getQueryParams, - i18n.language, + i18n, modalSlug, + plural, selectAll, serverURL, + singular, slug, t, toggleModal, diff --git a/packages/ui/src/providers/Selection/index.tsx b/packages/ui/src/providers/Selection/index.tsx index c29a3d65629..4eccf3591ee 100644 --- a/packages/ui/src/providers/Selection/index.tsx +++ b/packages/ui/src/providers/Selection/index.tsx @@ -104,7 +104,7 @@ export const SelectionProvider: React.FC = ({ children, docs = [], totalD ) const getQueryParams = useCallback( - (additionalParams?: Where): string => { + (additionalWhereParams?: Where): string => { let where: Where if (selectAll === SelectAllStatus.AllAvailable) { const params = searchParams?.where as Where @@ -126,9 +126,9 @@ export const SelectionProvider: React.FC = ({ children, docs = [], totalD }, } } - if (additionalParams) { + if (additionalWhereParams) { where = { - and: [{ ...additionalParams }, where], + and: [{ ...additionalWhereParams }, where], } } return qs.stringify( diff --git a/test/locked-documents/e2e.spec.ts b/test/locked-documents/e2e.spec.ts index 0ca07c04cb8..52fa14b8bab 100644 --- a/test/locked-documents/e2e.spec.ts +++ b/test/locked-documents/e2e.spec.ts @@ -3,16 +3,23 @@ import type { TypeWithID } from 'payload' import { expect, test } from '@playwright/test' import * as path from 'path' +import { mapAsync } from 'payload' import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import type { PayloadTestSDK } from '../helpers/sdk/index.js' import type { Config } from './payload-types.js' -import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js' +import { + ensureCompilationIsDone, + exactText, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' -import { TEST_TIMEOUT } from '../playwright.config.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT } from '../playwright.config.js' +import { postsSlug } from './collections/Posts/index.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -160,7 +167,7 @@ describe('locked documents', () => { await expect(page.locator('.table .row-1 .checkbox-input__input')).toBeVisible() }) - test('should only allow bulk delete on unlocked documents', async () => { + test('should only allow bulk delete on unlocked documents on current page', async () => { await page.goto(postsUrl.list) await page.locator('input#select-all').check() await page.locator('.delete-documents__toggle').click() @@ -168,6 +175,107 @@ describe('locked documents', () => { 'You are about to delete 2 Posts', ) }) + + test('should only allow bulk delete on unlocked documents on all pages', async () => { + await mapAsync([...Array(9)], async () => { + await createPostDoc({ + text: 'Ready for delete', + }) + }) + + await page.reload() + + await page.goto(postsUrl.list) + await page.waitForURL(new RegExp(postsUrl.list)) + + await page.locator('input#select-all').check() + await page.locator('.list-selection .list-selection__button').click() + await page.locator('.delete-documents__toggle').click() + await page.locator('#confirm-delete').click() + await expect(page.locator('.cell-_select')).toHaveCount(1) + }) + + test('should only allow bulk publish on unlocked documents on all pages', async () => { + await mapAsync([...Array(10)], async () => { + await createPostDoc({ + text: 'Ready for delete', + }) + }) + + await page.reload() + + await page.goto(postsUrl.list) + await page.waitForURL(new RegExp(postsUrl.list)) + + await page.locator('input#select-all').check() + await page.locator('.list-selection .list-selection__button').click() + await page.locator('.publish-many__toggle').click() + await page.locator('#confirm-publish').click() + + const paginator = page.locator('.paginator') + + await paginator.locator('button').nth(1).click() + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') + await expect(page.locator('.row-1 .cell-_status')).toContainText('Draft') + }) + + test('should only allow bulk unpublish on unlocked documents on all pages', async () => { + await page.goto(postsUrl.list) + await page.waitForURL(new RegExp(postsUrl.list)) + + await page.locator('input#select-all').check() + await page.locator('.list-selection .list-selection__button').click() + await page.locator('.unpublish-many__toggle').click() + await page.locator('#confirm-unpublish').click() + await expect(page.locator('.payload-toast-container .toast-success')).toHaveText( + 'Updated 10 Posts successfully.', + ) + }) + + test('should only allow bulk edit on unlocked documents on all pages', async () => { + await page.goto(postsUrl.list) + await page.waitForURL(new RegExp(postsUrl.list)) + + const bulkText = 'Bulk update title' + + await page.locator('input#select-all').check() + await page.locator('.list-selection .list-selection__button').click() + await page.locator('.edit-many__toggle').click() + + await page.locator('.field-select .rs__control').click() + + const textOption = page.locator('.field-select .rs__option', { + hasText: exactText('Text'), + }) + + await expect(textOption).toBeVisible() + + await textOption.click() + + const textInput = page.locator('#field-text') + + await expect(textInput).toBeVisible() + + await textInput.fill(bulkText) + + await page.locator('.form-submit button[type="submit"].edit-many__publish').click() + await expect(page.locator('.payload-toast-container .toast-error')).toContainText( + 'Unable to update 1 out of 11 Posts.', + ) + + await page.locator('.edit-many__header__close').click() + + await page.reload() + + await expect(page.locator('.row-1 .cell-text')).toContainText(bulkText) + await expect(page.locator('.row-2 .cell-text')).toContainText(bulkText) + + const paginator = page.locator('.paginator') + + await paginator.locator('button').nth(1).click() + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') + await expect(page.locator('.row-1 .cell-text')).toContainText('hello') + }) }) describe('document locking / unlocking - one user', () => { @@ -899,3 +1007,7 @@ async function createPageDoc(data: any): Promise & TypeW data, }) as unknown as Promise & TypeWithID> } + +async function deleteAllPosts() { + await payload.delete({ collection: postsSlug, where: { id: { exists: true } } }) +}