Files
payload/test/bulk-edit/e2e.spec.ts
Jarrod Flesch 86e48ae70b test: bulk edit flaky selectors (#12950)
https://github.com/payloadcms/payload/pull/12861 introduced some flaky
test selectors. Specifically bulk editing values and then looking for
the previous values in the table rows.

This PR fixes the flakes and fixes eslint errors in `findTableRow` and
`findTableCell` helper funcitons.
2025-06-26 22:40:19 -04:00

657 lines
20 KiB
TypeScript

import type { BrowserContext, Locator, Page } from '@playwright/test'
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
import { expect, test } from '@playwright/test'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { Config, Post } from './payload-types.js'
import {
ensureCompilationIsDone,
exactText,
findTableCell,
initPageConsoleErrorCatch,
selectTableRow,
// throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { postsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
let context: BrowserContext
let payload: PayloadTestSDK<Config>
let serverURL: string
test.describe('Bulk Edit', () => {
let page: Page
let postsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, postsSlug)
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test.beforeEach(async () => {
// await throttleTest({ page, context, delay: 'Fast 3G' })
})
test('should not show "select all across pages" button if already selected all', async () => {
await deleteAllPosts()
await createPost({ title: 'Post 1' })
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await expect(page.locator('button#select-all-across-pages')).toBeHidden()
})
test('should update selection state after deselecting item following select all', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
// Deselect the first row
await page.locator('.row-1 input').click()
// eslint-disable-next-line jest-dom/prefer-checked
await expect(page.locator('input#select-all')).not.toHaveAttribute('checked', '')
})
test('should delete many', async () => {
await deleteAllPosts()
const titleOfPostToDelete1 = 'Post to delete (published)'
const titleOfPostToDelete2 = 'Post to delete (draft)'
await Promise.all([
createPost({ title: titleOfPostToDelete1 }),
createPost({ title: titleOfPostToDelete2 }, { draft: true }),
])
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToDelete1)
await selectTableRow(page, titleOfPostToDelete2)
await page.locator('.delete-documents__toggle').click()
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Deleted 2 Posts successfully.',
)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete1}")`)).toBeHidden()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete2}")`)).toBeHidden()
})
test('should publish many', async () => {
await deleteAllPosts()
const titleOfPostToPublish1 = 'Post to publish (already published)'
const titleOfPostToPublish2 = 'Post to publish (draft)'
await Promise.all([
createPost({ title: titleOfPostToPublish1 }),
createPost({ title: titleOfPostToPublish2 }, { draft: true }),
])
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToPublish1)
await selectTableRow(page, titleOfPostToPublish2)
await page.locator('.list-selection__button[aria-label="Publish"]').click()
await page.locator('#publish-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(await findTableCell(page, '_status', titleOfPostToPublish1)).toContainText(
'Published',
)
await expect(await findTableCell(page, '_status', titleOfPostToPublish2)).toContainText(
'Published',
)
})
test('should unpublish many', async () => {
await deleteAllPosts()
const titleOfPostToUnpublish1 = 'Post to unpublish (published)'
const titleOfPostToUnpublish2 = 'Post to unpublish (already draft)'
await Promise.all([
createPost({ title: titleOfPostToUnpublish1 }),
createPost({ title: titleOfPostToUnpublish2 }, { draft: true }),
])
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToUnpublish1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToUnpublish2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToUnpublish1)
await selectTableRow(page, titleOfPostToUnpublish2)
await page.locator('.list-selection__button[aria-label="Unpublish"]').click()
await page.locator('#unpublish-posts #confirm-action').click()
await expect(await findTableCell(page, '_status', titleOfPostToUnpublish1)).toContainText(
'Draft',
)
await expect(await findTableCell(page, '_status', titleOfPostToUnpublish2)).toContainText(
'Draft',
)
})
test('should update many', async () => {
await deleteAllPosts()
const updatedPostTitle = 'Post (Updated)'
Array.from({ length: 3 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
for (let i = 1; i <= 3; i++) {
const invertedIndex = 4 - i
await expect(page.locator(`.row-${invertedIndex} .cell-title`)).toContainText(`Post ${i}`)
}
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
const { field, modal } = await selectFieldToEdit(page, {
fieldLabel: 'Title',
fieldID: 'title',
})
await field.fill(updatedPostTitle)
await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 3 Posts successfully.',
)
for (let i = 1; i <= 3; i++) {
const invertedIndex = 4 - i
await expect(page.locator(`.row-${invertedIndex} .cell-title`)).toContainText(
updatedPostTitle,
)
}
})
test('should publish many from drawer', async () => {
await deleteAllPosts()
const titleOfPostToPublish1 = 'Post to unpublish (published)'
const titleOfPostToPublish2 = 'Post to publish (already draft)'
await Promise.all([
createPost({ title: titleOfPostToPublish1 }),
createPost({ title: titleOfPostToPublish2 }, { draft: true }),
])
const description = 'published document'
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToPublish1)
await selectTableRow(page, titleOfPostToPublish2)
await page.locator('.edit-many__toggle').click()
const { field, modal } = await selectFieldToEdit(page, {
fieldLabel: 'Description',
fieldID: 'description',
})
await field.fill(description)
// Bulk edit the selected rows to `published` status
await modal.locator('.form-submit .edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(await findTableCell(page, '_status', titleOfPostToPublish1)).toContainText(
'Published',
)
await expect(await findTableCell(page, '_status', titleOfPostToPublish2)).toContainText(
'Published',
)
})
test('should draft many from drawer', async () => {
await deleteAllPosts()
const titleOfPostToDraft1 = 'Post to draft (published)'
const titleOfPostToDraft2 = 'Post to draft (draft)'
await Promise.all([
createPost({ title: titleOfPostToDraft1 }),
createPost({ title: titleOfPostToDraft2 }, { draft: true }),
])
const description = 'draft document'
await page.goto(postsUrl.list)
await selectTableRow(page, titleOfPostToDraft1)
await selectTableRow(page, titleOfPostToDraft2)
await page.locator('.edit-many__toggle').click()
const { field, modal } = await selectFieldToEdit(page, {
fieldLabel: 'Description',
fieldID: 'description',
})
await field.fill(description)
// Bulk edit the selected rows to `draft` status
await modal.locator('.form-submit .edit-many__draft').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(await findTableCell(page, '_status', titleOfPostToDraft1)).toContainText('Draft')
await expect(await findTableCell(page, '_status', titleOfPostToDraft2)).toContainText('Draft')
})
test('should delete all on page', async () => {
await deleteAllPosts()
Array.from({ length: 3 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(3)
await page.locator('input#select-all').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 3 Posts successfully.',
)
await page.locator('.collection-list__no-results').isVisible()
})
test('should delete all with filters and across pages', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await expect(page.locator('.collection-list__page-info')).toContainText('1-5 of 6')
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 6 Posts successfully.',
)
await page.locator('.collection-list__no-results').isVisible()
})
test('should update all with filters and across pages', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
await page.locator('.edit-many__toggle').click()
const { field } = await selectFieldToEdit(page, {
fieldLabel: 'Title',
fieldID: 'title',
})
const updatedTitle = 'Post (Updated)'
await field.fill(updatedTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 6 Posts successfully.',
)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await expect(page.locator('.row-1 .cell-title')).toContainText(updatedTitle)
})
test('should not override un-edited values if it has a defaultValue', async () => {
await deleteAllPosts()
const postData = {
title: 'Post 1',
array: [
{
optional: 'some optional array field',
innerArrayOfFields: [
{
innerOptional: 'some inner optional array field',
},
],
},
],
group: {
defaultValueField: 'This is NOT the default value',
title: 'some title',
},
blocks: [
{
textFieldForBlock: 'some text for block text',
blockType: 'textBlock',
},
],
defaultValueField: 'This is NOT the default value',
}
const updatedPostTitle = 'Post 1 (Updated)'
const { id: postID } = await createPost(postData)
await page.goto(postsUrl.list)
const { modal } = await selectAllAndEditMany(page)
const { field } = await selectFieldToEdit(page, {
fieldLabel: 'Title',
fieldID: 'title',
})
await field.fill(updatedPostTitle)
await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.',
)
const updatedPost = await payload.find({
collection: postsSlug,
limit: 1,
depth: 0,
where: {
id: {
equals: postID,
},
},
})
expect(updatedPost.docs[0]).toMatchObject({
...postData,
title: updatedPostTitle,
})
})
test('should bulk edit fields with subfields', async () => {
await deleteAllPosts()
await createPost()
await page.goto(postsUrl.list)
await selectAllAndEditMany(page)
const { modal, field } = await selectFieldToEdit(page, {
fieldLabel: 'Group > Title',
fieldID: 'group__title',
})
await field.fill('New Group Title')
await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.',
)
const updatedPost = await payload
.find({
collection: 'posts',
limit: 1,
})
?.then((res) => res.docs[0])
expect(updatedPost?.group?.title).toBe('New Group Title')
})
test('should not display fields options lacking read and update permissions', async () => {
await deleteAllPosts()
await createPost()
await page.goto(postsUrl.list)
const { modal } = await selectAllAndEditMany(page)
await expect(
modal.locator('.field-select .rs__option', { hasText: exactText('No Read') }),
).toBeHidden()
await expect(
modal.locator('.field-select .rs__option', { hasText: exactText('No Update') }),
).toBeHidden()
})
test('should thread field permissions through subfields', async () => {
await deleteAllPosts()
await createPost()
await page.goto(postsUrl.list)
await selectAllAndEditMany(page)
const { field } = await selectFieldToEdit(page, { fieldLabel: 'Array', fieldID: 'array' })
await field.locator('button.array-field__add-row').click()
await expect(field.locator('#field-array__0__optional')).toBeVisible()
await expect(field.locator('#field-array__0__noRead')).toBeHidden()
await expect(field.locator('#field-array__0__noUpdate')).toBeDisabled()
})
test('should toggle list selections off on successful publish', async () => {
await deleteAllPosts()
const postCount = 3
Array.from({ length: postCount }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` }, { draft: true })
})
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.list-selection__button[aria-label="Publish"]').click()
await page.locator('#publish-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
`Updated ${postCount} Posts successfully.`,
)
await expect(page.locator('.table input#select-all[checked]')).toBeHidden()
for (let i = 1; i < postCount + 1; i++) {
await expect(
page.locator(`table tbody tr .row-${i} input[type="checkbox"][checked]`),
).toBeHidden()
}
})
test('should toggle list selections off on successful unpublish', async () => {
await deleteAllPosts()
const postCount = 3
Array.from({ length: postCount }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}`, _status: 'published' })
})
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.list-selection__button[aria-label="Unpublish"]').click()
await page.locator('#unpublish-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
`Updated ${postCount} Posts successfully.`,
)
await expect(page.locator('.table input#select-all[checked]')).toBeHidden()
for (let i = 1; i < postCount + 1; i++) {
await expect(
page.locator(`table tbody tr .row-${i} input[type="checkbox"][checked]`),
).toBeHidden()
}
})
test('should toggle list selections off on successful edit', async () => {
await deleteAllPosts()
const bulkEditValue = 'test'
const postCount = 3
Array.from({ length: postCount }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.list-selection__button[aria-label="Edit"]').click()
const editDrawer = page.locator('dialog#edit-posts')
await expect(editDrawer).toBeVisible()
const fieldSelect = editDrawer.locator('.field-select')
await expect(fieldSelect).toBeVisible()
const fieldSelectControl = fieldSelect.locator('.rs__control')
await expect(fieldSelectControl).toBeVisible()
await fieldSelectControl.click()
const titleOption = fieldSelect.locator('.rs__option:has-text("Title")').first()
await titleOption.click()
await editDrawer.locator('input#field-title').fill(bulkEditValue)
await editDrawer.locator('button[type="submit"]:has-text("Publish changes")').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
`Updated ${postCount} Posts successfully.`,
)
await expect(page.locator('.table input#select-all[checked]')).toBeHidden()
for (let i = 1; i < postCount + 1; i++) {
await expect(
page.locator(`table tbody tr .row-${i} input[type="checkbox"][checked]`),
).toBeHidden()
}
})
})
async function selectFieldToEdit(
page: Page,
{
fieldLabel,
fieldID,
}: {
fieldID: string
fieldLabel: string
},
): Promise<{ field: Locator; modal: Locator }> {
// ensure modal is open, open if needed
const isModalOpen = await page.locator('#edit-posts').isVisible()
if (!isModalOpen) {
await page.locator('.edit-many__toggle').click()
}
const modal = page.locator('#edit-posts')
await expect(modal).toBeVisible()
await modal.locator('.field-select .rs__control').click()
await modal.locator('.field-select .rs__option', { hasText: exactText(fieldLabel) }).click()
const field = modal.locator(`#field-${fieldID}`)
await expect(field).toBeVisible()
return { modal, field }
}
async function selectAllAndEditMany(page: Page): Promise<{ modal: Locator }> {
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
const modal = page.locator('#edit-posts')
await expect(modal).toBeVisible()
return { modal }
}
async function deleteAllPosts() {
await payload.delete({ collection: postsSlug, where: { id: { exists: true } } })
}
async function createPost(
dataOverrides?: Partial<Post>,
overrides?: Record<string, unknown>,
): Promise<Post> {
return payload.create({
collection: postsSlug,
...(overrides || {}),
data: {
title: 'Post Title',
...(dataOverrides || {}),
},
}) as unknown as Promise<Post>
}