test: dedicated bulk edit test suite (#11756)

Consolidates all bulk edit related tests into a single, dedicated suite.

Currently, bulk edit tests are dispersed throughout the Admin > General
and the Versions test suites, which are considerably bloated for their
own purposes. This made them very hard to locate, mentally digest, and
add on new tests. Going forward, many more tests specifically for bulk
edit will need to be written. This gives us a simple, isolated place for
that.

With this change are also a few improvements to the tests themselves to
make them more predictable and efficient.
This commit is contained in:
Jacob Fletcher
2025-03-18 13:31:51 -04:00
committed by GitHub
parent 3f23160a96
commit a44a252f31
19 changed files with 2847 additions and 459 deletions

View File

@@ -279,6 +279,7 @@ jobs:
- admin-root
- auth
- auth-basic
- bulk-edit
- joins
- field-error-states
- fields-relationship

View File

@@ -137,64 +137,6 @@ export const Posts: CollectionConfig = {
},
],
},
{
name: 'arrayOfFields',
type: 'array',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'optional',
type: 'text',
},
{
name: 'innerArrayOfFields',
type: 'array',
fields: [
{
name: 'innerOptional',
type: 'text',
},
],
},
],
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'testing',
},
{
name: 'title',
type: 'text',
},
],
},
{
name: 'someBlock',
type: 'blocks',
blocks: [
{
slug: 'textBlock',
fields: [
{
name: 'textFieldForBlock',
type: 'text',
},
],
},
],
},
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'testing',
},
{
name: 'relationship',
type: 'relationship',

View File

@@ -6,7 +6,6 @@ import type { Config, Geo, Post } from '../../payload-types.js'
import {
ensureCompilationIsDone,
exactText,
getRoutes,
initPageConsoleErrorCatch,
saveDocAndAssert,
@@ -781,205 +780,6 @@ describe('General', () => {
expect(page.url()).toContain(postsUrl.list)
})
test('should bulk delete all on page', async () => {
await deleteAllPosts()
await Promise.all([createPost(), createPost(), createPost()])
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.delete-documents__toggle').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 3 Posts successfully.',
)
// Poll until router has refreshed
await expect.poll(() => page.locator('.collection-list__no-results').isVisible()).toBeTruthy()
})
test('should bulk delete 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('.delete-documents__toggle').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 6 Posts successfully.',
)
// Poll until router has refreshed
await expect.poll(() => page.locator('.table table > tbody > tr').count()).toBe(0)
})
test('should bulk update', async () => {
// First, delete all posts created by the seed
await deleteAllPosts()
const post1Title = 'Post'
const updatedPostTitle = `${post1Title} (Updated)`
await Promise.all([createPost({ title: post1Title }), createPost(), createPost()])
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
await titleInput.fill(updatedPostTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 3 Posts successfully.',
)
await expect(page.locator('.row-1 .cell-title')).toContainText(updatedPostTitle)
await expect(page.locator('.row-2 .cell-title')).toContainText(updatedPostTitle)
await expect(page.locator('.row-3 .cell-title')).toContainText(updatedPostTitle)
})
test('should not override un-edited values in bulk edit if it has a defaultValue', async () => {
await deleteAllPosts()
const post1Title = 'Post'
const postData = {
title: 'Post',
arrayOfFields: [
{
optional: 'some optional array field',
innerArrayOfFields: [
{
innerOptional: 'some inner optional array field',
},
],
},
],
group: {
defaultValueField: 'not the group default value',
title: 'some title',
},
someBlock: [
{
textFieldForBlock: 'some text for block text',
blockType: 'textBlock',
},
],
defaultValueField: 'not the default value',
}
const updatedPostTitle = `${post1Title} (Updated)`
await createPost(postData)
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await titleOption.click()
const titleInput = page.locator('#field-title')
await titleInput.fill(updatedPostTitle)
await page.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,
})
expect(updatedPost.docs[0].title).toBe(updatedPostTitle)
expect(updatedPost.docs[0].arrayOfFields.length).toBe(1)
expect(updatedPost.docs[0].arrayOfFields[0].optional).toBe('some optional array field')
expect(updatedPost.docs[0].arrayOfFields[0].innerArrayOfFields.length).toBe(1)
expect(updatedPost.docs[0].someBlock[0].textFieldForBlock).toBe('some text for block text')
expect(updatedPost.docs[0].defaultValueField).toBe('not the default value')
})
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 bulk update with filters and across pages', async () => {
// First, delete all posts created by the seed
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()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
const updatedTitle = `Post (Updated)`
await titleInput.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.',
)
// Poll until router has refreshed
await expect.poll(() => page.locator('.table table > tbody > tr').count()).toBe(5)
await expect(page.locator('.row-1 .cell-title')).toContainText(updatedTitle)
})
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 save globals', async () => {
await page.goto(postsUrl.global(globalSlug))
@@ -1079,10 +879,6 @@ describe('General', () => {
})
})
async function deleteAllPosts() {
await payload.delete({ collection: postsCollectionSlug, where: { id: { exists: true } } })
}
async function createPost(overrides?: Partial<Post>): Promise<Post> {
return payload.create({
collection: postsCollectionSlug,

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -232,31 +233,6 @@ export interface Post {
[k: string]: unknown;
}[]
| null;
arrayOfFields?:
| {
optional?: string | null;
innerArrayOfFields?:
| {
innerOptional?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
group?: {
defaultValueField?: string | null;
title?: string | null;
};
someBlock?:
| {
textFieldForBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'textBlock';
}[]
| null;
defaultValueField?: string | null;
relationship?: (string | null) | Post;
users?: (string | null) | User;
customCell?: string | null;
@@ -676,36 +652,6 @@ export interface PostsSelect<T extends boolean = true> {
description?: T;
number?: T;
richText?: T;
arrayOfFields?:
| T
| {
optional?: T;
innerArrayOfFields?:
| T
| {
innerOptional?: T;
id?: T;
};
id?: T;
};
group?:
| T
| {
defaultValueField?: T;
title?: T;
};
someBlock?:
| T
| {
textBlock?:
| T
| {
textFieldForBlock?: T;
id?: T;
blockName?: T;
};
};
defaultValueField?: T;
relationship?: T;
users?: T;
customCell?: T;

2
test/bulk-edit/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,86 @@
import type { CollectionConfig } from 'payload'
import { postsSlug } from '../../shared.js'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
versions: {
drafts: true,
},
admin: {
useAsTitle: 'title',
defaultColumns: ['id', 'title', 'description', '_status'],
pagination: {
defaultLimit: 5,
limits: [5, 10, 15],
},
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'This is a default value',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'This is a default value',
},
{
name: 'title',
type: 'text',
},
],
},
{
name: 'array',
type: 'array',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'optional',
type: 'text',
},
{
name: 'innerArrayOfFields',
type: 'array',
fields: [
{
name: 'innerOptional',
type: 'text',
},
],
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'textBlock',
fields: [
{
name: 'textFieldForBlock',
type: 'text',
},
],
},
],
},
],
}

38
test/bulk-edit/config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { PostsCollection } from './collections/Posts/index.js'
import { postsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'example post',
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

444
test/bulk-edit/e2e.spec.ts Normal file
View File

@@ -0,0 +1,444 @@
import type { BrowserContext, 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('#delete-posts #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('.publish-many__toggle').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(findTableCell(page, '_status', titleOfPostToPublish1)).toContainText('Published')
await expect(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('.unpublish-many__toggle').click()
await page.locator('#unpublish-posts #confirm-action').click()
await expect(findTableCell(page, '_status', titleOfPostToUnpublish1)).toContainText('Draft')
await expect(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()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
await titleInput.fill(updatedPostTitle)
await page.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)
// Bulk edit the selected rows to `published` status
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const options = page.locator('.rs__option')
const field = options.locator('text=Description')
await field.click()
await page.locator('#field-description').fill(description)
await page.locator('.form-submit .edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(findTableCell(page, '_status', titleOfPostToPublish1)).toContainText('Published')
await expect(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()
await page.locator('.field-select .rs__control').click()
const options = page.locator('.rs__option')
const field = options.locator('text=Description')
await field.click()
await page.locator('#field-description').fill(description)
await page.locator('.form-submit .edit-many__draft').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(findTableCell(page, '_status', titleOfPostToDraft1)).toContainText('Draft')
await expect(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('.delete-documents__toggle').click()
await page.locator('#delete-posts #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('.delete-documents__toggle').click()
await page.locator('#delete-posts #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()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
const updatedTitle = `Post (Updated)`
await titleInput.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)
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await titleOption.click()
const titleInput = page.locator('#field-title')
await titleInput.fill(updatedPostTitle)
await page.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,
})
})
})
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>
}

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -0,0 +1,327 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
posts: Post;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title?: string | null;
description?: string | null;
defaultValueField?: string | null;
group?: {
defaultValueField?: string | null;
title?: string | null;
};
array?:
| {
optional?: string | null;
innerArrayOfFields?:
| {
innerOptional?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
blocks?:
| {
textFieldForBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'textBlock';
}[]
| null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
description?: T;
defaultValueField?: T;
group?:
| T
| {
defaultValueField?: T;
title?: T;
};
array?:
| T
| {
optional?: T;
innerArrayOfFields?:
| T
| {
innerOptional?: T;
id?: T;
};
id?: T;
};
blocks?:
| T
| {
textBlock?:
| T
| {
textFieldForBlock?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

File diff suppressed because it is too large Load Diff

1
test/bulk-edit/shared.ts Normal file
View File

@@ -0,0 +1 @@
export const postsSlug = 'posts'

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

9
test/bulk-edit/types.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { RequestContext as OriginalRequestContext } from 'payload'
declare module 'payload' {
// Create a new interface that merges your additional fields with the original one
export interface RequestContext extends OriginalRequestContext {
myObject?: string
// ...
}
}

View File

@@ -36,10 +36,8 @@ import {
changeLocale,
ensureCompilationIsDone,
exactText,
findTableCell,
initPageConsoleErrorCatch,
saveDocAndAssert,
selectTableRow,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { assertNetworkRequests } from '../helpers/e2e/assertNetworkRequests.js'
@@ -47,7 +45,6 @@ import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { waitForAutoSaveToRunAndComplete } from '../helpers/waitForAutoSaveToRunAndComplete.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { titleToDelete } from './shared.js'
import {
autosaveCollectionSlug,
autoSaveGlobalSlug,
@@ -125,130 +122,6 @@ describe('Versions', () => {
postURL = new AdminUrlUtil(serverURL, postCollectionSlug)
})
// This test has to run before bulk updates that will rename the title
test('should delete', async () => {
await page.goto(url.list)
const rows = page.locator(`tr`)
const rowToDelete = rows.filter({ hasText: titleToDelete })
await rowToDelete.locator('.cell-_select input').check()
await page.locator('.delete-documents__toggle').click()
await page.locator('#delete-draft-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Deleted 1 Draft Post successfully.',
)
await expect(page.locator('.row-1 .cell-title')).not.toHaveText(titleToDelete)
})
test('bulk update - should publish many', async () => {
await page.goto(url.list)
// Select specific rows by title
await selectTableRow(page, 'Published Title')
await selectTableRow(page, 'Draft Title')
// Bulk edit the selected rows
await page.locator('.publish-many__toggle').click()
await page.locator('#publish-draft-posts #confirm-action').click()
// Check that the statuses for each row has been updated to `published`
await expect(findTableCell(page, '_status', 'Published Title')).toContainText('Published')
await expect(findTableCell(page, '_status', 'Draft Title')).toContainText('Published')
})
test('bulk publish with autosave documents', async () => {
const title = 'autosave title'
const description = 'autosave description'
await page.goto(autosaveURL.create)
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await wait(500)
await expect(page.locator('#field-title')).toBeEnabled()
await page.locator('#field-title').fill(title)
await expect(page.locator('#field-description')).toBeEnabled()
await page.locator('#field-description').fill(description)
await waitForAutoSaveToRunAndComplete(page)
await page.goto(autosaveURL.list)
await expect(findTableCell(page, '_status', title)).toContainText('Draft')
await selectTableRow(page, title)
await page.locator('.publish-many__toggle').click()
await page.locator('#publish-autosave-posts #confirm-action').click()
await expect(findTableCell(page, '_status', title)).toContainText('Published')
})
test('bulk update - should unpublish many', async () => {
await page.goto(url.list)
// Select specific rows by title
await selectTableRow(page, 'Published Title')
await selectTableRow(page, 'Draft Title')
// Bulk edit the selected rows
await page.locator('.unpublish-many__toggle').click()
await page.locator('#unpublish-draft-posts #confirm-action').click()
// Check that the statuses for each row has been updated to `draft`
await expect(findTableCell(page, '_status', 'Published Title')).toContainText('Draft')
await expect(findTableCell(page, '_status', 'Draft Title')).toContainText('Draft')
})
test('bulk update — should publish changes', async () => {
const description = 'published document'
await page.goto(url.list)
// Select specific rows by title
await selectTableRow(page, 'Published Title')
await selectTableRow(page, 'Draft Title')
// Bulk edit the selected rows to `published` status
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const options = page.locator('.rs__option')
const field = options.locator('text=Description')
await field.click()
await page.locator('#field-description').fill(description)
await page.locator('.form-submit .edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Draft Posts successfully.',
)
// Check that the statuses for each row has been updated to `published`
await expect(findTableCell(page, '_status', 'Published Title')).toContainText('Published')
await expect(findTableCell(page, '_status', 'Draft Title')).toContainText('Published')
})
test('bulk update — should draft changes', async () => {
const description = 'draft document'
await page.goto(url.list)
// Select specific rows by title
await selectTableRow(page, 'Published Title')
await selectTableRow(page, 'Draft Title')
// Bulk edit the selected rows to `draft` status
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const options = page.locator('.rs__option')
const field = options.locator('text=Description')
await field.click()
await page.locator('#field-description').fill(description)
await page.locator('.form-submit .edit-many__draft').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Draft Posts successfully.',
)
// Check that the statuses for each row has been updated to `draft`
await expect(findTableCell(page, '_status', 'Published Title')).toContainText('Draft')
await expect(findTableCell(page, '_status', 'Draft Title')).toContainText('Draft')
})
test('collection — has versions tab', async () => {
await page.goto(url.list)
await page.locator('tbody tr .cell-title a').first().click()

View File

@@ -6,7 +6,6 @@ import type { DraftPost } from './payload-types.js'
import { devUser } from '../credentials.js'
import { executePromises } from '../helpers/executePromises.js'
import { titleToDelete } from './shared.js'
import {
autosaveWithValidateCollectionSlug,
diffCollectionSlug,
@@ -113,18 +112,6 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
draft: false,
})
await _payload.create({
collection: draftCollectionSlug,
data: {
blocksField,
description: 'Description',
title: titleToDelete,
},
depth: 0,
overrideAccess: true,
draft: true,
})
await _payload.create({
collection: autosaveWithValidateCollectionSlug,
data: {

View File

@@ -1 +0,0 @@
export const titleToDelete = 'Title To Delete'

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/form-state/config.ts"],
"@payload-config": ["./test/bulk-edit/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],