fix(next, ui): ensures selectAll in the list view ignores locked documents (#8813)

Fixes #8783
This commit is contained in:
Patrik
2024-10-21 16:18:34 -04:00
committed by GitHub
parent fe25b54fff
commit 2908c9adde
10 changed files with 189 additions and 21 deletions

View File

@@ -10,14 +10,16 @@ import type { CollectionRouteHandler } from '../types.js'
import { headersWithCors } from '../../../utilities/headersWithCors.js' import { headersWithCors } from '../../../utilities/headersWithCors.js'
export const deleteDoc: CollectionRouteHandler = async ({ collection, req }) => { export const deleteDoc: CollectionRouteHandler = async ({ collection, req }) => {
const { depth, where } = req.query as { const { depth, overrideLock, where } = req.query as {
depth?: string depth?: string
overrideLock?: string
where?: Where where?: Where
} }
const result = await deleteOperation({ const result = await deleteOperation({
collection, collection,
depth: isNumber(depth) ? Number(depth) : undefined, depth: isNumber(depth) ? Number(depth) : undefined,
overrideLock: Boolean(overrideLock === 'true'),
req, req,
where, where,
}) })

View File

@@ -14,6 +14,7 @@ export const deleteByID: CollectionRouteHandlerWithID = async ({
}) => { }) => {
const { searchParams } = req const { searchParams } = req
const depth = searchParams.get('depth') const depth = searchParams.get('depth')
const overrideLock = searchParams.get('overrideLock')
const id = sanitizeCollectionID({ const id = sanitizeCollectionID({
id: incomingID, id: incomingID,
@@ -25,6 +26,7 @@ export const deleteByID: CollectionRouteHandlerWithID = async ({
id, id,
collection, collection,
depth: isNumber(depth) ? depth : undefined, depth: isNumber(depth) ? depth : undefined,
overrideLock: Boolean(overrideLock === 'true'),
req, req,
}) })

View File

@@ -10,10 +10,11 @@ import type { CollectionRouteHandler } from '../types.js'
import { headersWithCors } from '../../../utilities/headersWithCors.js' import { headersWithCors } from '../../../utilities/headersWithCors.js'
export const update: CollectionRouteHandler = async ({ collection, req }) => { 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 depth?: string
draft?: string draft?: string
limit?: string limit?: string
overrideLock?: string
where?: Where where?: Where
} }
@@ -23,6 +24,7 @@ export const update: CollectionRouteHandler = async ({ collection, req }) => {
depth: isNumber(depth) ? Number(depth) : undefined, depth: isNumber(depth) ? Number(depth) : undefined,
draft: draft === 'true', draft: draft === 'true',
limit: isNumber(limit) ? Number(limit) : undefined, limit: isNumber(limit) ? Number(limit) : undefined,
overrideLock: Boolean(overrideLock === 'true'),
req, req,
where, where,
}) })

View File

@@ -16,6 +16,7 @@ export const updateByID: CollectionRouteHandlerWithID = async ({
const depth = searchParams.get('depth') const depth = searchParams.get('depth')
const autosave = searchParams.get('autosave') === 'true' const autosave = searchParams.get('autosave') === 'true'
const draft = searchParams.get('draft') === 'true' const draft = searchParams.get('draft') === 'true'
const overrideLock = searchParams.get('overrideLock')
const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined
const id = sanitizeCollectionID({ const id = sanitizeCollectionID({
@@ -31,6 +32,7 @@ export const updateByID: CollectionRouteHandlerWithID = async ({
data: req.data, data: req.data,
depth: isNumber(depth) ? Number(depth) : undefined, depth: isNumber(depth) ? Number(depth) : undefined,
draft, draft,
overrideLock: Boolean(overrideLock === 'true'),
publishSpecificLocale, publishSpecificLocale,
req, req,
}) })

View File

@@ -343,7 +343,7 @@ export const DefaultEditView: React.FC = () => {
const shouldShowDocumentLockedModal = const shouldShowDocumentLockedModal =
documentIsLocked && documentIsLocked &&
currentEditor && currentEditor &&
currentEditor.id !== user.id && currentEditor.id !== user?.id &&
!isReadOnlyForIncomingUser && !isReadOnlyForIncomingUser &&
!showTakeOverModal && !showTakeOverModal &&
!documentLockStateRef.current?.hasShownLockedModal !documentLockStateRef.current?.hasShownLockedModal

View File

@@ -26,7 +26,7 @@ export type Props = {
} }
export const DeleteMany: React.FC<Props> = (props) => { export const DeleteMany: React.FC<Props> = (props) => {
const { collection: { slug, labels: { plural } } = {} } = props const { collection: { slug, labels: { plural, singular } } = {} } = props
const { permissions } = useAuth() const { permissions } = useAuth()
const { const {
@@ -65,8 +65,22 @@ export const DeleteMany: React.FC<Props> = (props) => {
try { try {
const json = await res.json() const json = await res.json()
toggleModal(modalSlug) 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() toggleAll()
router.replace( router.replace(
stringifyParams({ stringifyParams({
@@ -96,11 +110,13 @@ export const DeleteMany: React.FC<Props> = (props) => {
addDefaultError, addDefaultError,
api, api,
getQueryParams, getQueryParams,
i18n.language, i18n,
modalSlug, modalSlug,
plural,
router, router,
selectAll, selectAll,
serverURL, serverURL,
singular,
slug, slug,
stringifyParams, stringifyParams,
t, t,

View File

@@ -27,7 +27,7 @@ export type PublishManyProps = {
export const PublishMany: React.FC<PublishManyProps> = (props) => { export const PublishMany: React.FC<PublishManyProps> = (props) => {
const { clearRouteCache } = useRouteCache() const { clearRouteCache } = useRouteCache()
const { collection: { slug, labels: { plural }, versions } = {} } = props const { collection: { slug, labels: { plural, singular }, versions } = {} } = props
const { const {
config: { config: {
@@ -71,8 +71,22 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
try { try {
const json = await res.json() const json = await res.json()
toggleModal(modalSlug) 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( router.replace(
stringifyParams({ stringifyParams({
params: { params: {
@@ -99,10 +113,12 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
addDefaultError, addDefaultError,
api, api,
getQueryParams, getQueryParams,
i18n.language, i18n,
modalSlug, modalSlug,
plural,
selectAll, selectAll,
serverURL, serverURL,
singular,
slug, slug,
t, t,
toggleModal, toggleModal,

View File

@@ -26,7 +26,7 @@ export type UnpublishManyProps = {
} }
export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => { export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
const { collection: { slug, labels: { plural }, versions } = {} } = props const { collection: { slug, labels: { plural, singular }, versions } = {} } = props
const { const {
config: { config: {
@@ -69,8 +69,22 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
try { try {
const json = await res.json() const json = await res.json()
toggleModal(modalSlug) 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( router.replace(
stringifyParams({ stringifyParams({
params: { params: {
@@ -96,10 +110,12 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
addDefaultError, addDefaultError,
api, api,
getQueryParams, getQueryParams,
i18n.language, i18n,
modalSlug, modalSlug,
plural,
selectAll, selectAll,
serverURL, serverURL,
singular,
slug, slug,
t, t,
toggleModal, toggleModal,

View File

@@ -104,7 +104,7 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
) )
const getQueryParams = useCallback( const getQueryParams = useCallback(
(additionalParams?: Where): string => { (additionalWhereParams?: Where): string => {
let where: Where let where: Where
if (selectAll === SelectAllStatus.AllAvailable) { if (selectAll === SelectAllStatus.AllAvailable) {
const params = searchParams?.where as Where const params = searchParams?.where as Where
@@ -126,9 +126,9 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
}, },
} }
} }
if (additionalParams) { if (additionalWhereParams) {
where = { where = {
and: [{ ...additionalParams }, where], and: [{ ...additionalWhereParams }, where],
} }
} }
return qs.stringify( return qs.stringify(

View File

@@ -3,16 +3,23 @@ import type { TypeWithID } from 'payload'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import * as path from 'path' import * as path from 'path'
import { mapAsync } from 'payload'
import { wait } from 'payload/shared' import { wait } from 'payload/shared'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js' import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import type { Config } from './payload-types.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 { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.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 filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -160,7 +167,7 @@ describe('locked documents', () => {
await expect(page.locator('.table .row-1 .checkbox-input__input')).toBeVisible() 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.goto(postsUrl.list)
await page.locator('input#select-all').check() await page.locator('input#select-all').check()
await page.locator('.delete-documents__toggle').click() await page.locator('.delete-documents__toggle').click()
@@ -168,6 +175,107 @@ describe('locked documents', () => {
'You are about to delete 2 Posts', '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', () => { describe('document locking / unlocking - one user', () => {
@@ -899,3 +1007,7 @@ async function createPageDoc(data: any): Promise<Record<string, unknown> & TypeW
data, data,
}) as unknown as Promise<Record<string, unknown> & TypeWithID> }) as unknown as Promise<Record<string, unknown> & TypeWithID>
} }
async function deleteAllPosts() {
await payload.delete({ collection: postsSlug, where: { id: { exists: true } } })
}