fix(next, ui): ensures selectAll in the list view ignores locked documents (#8813)
Fixes #8783
This commit is contained in:
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 } } })
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user