Files
payloadcms/test/trash/e2e.spec.ts
Jacob Fletcher 8a7124a15e fix(next): resolve filterOptions by path (#13779)
Follow up to #11375.

When setting `filterOptions` on relationship or upload fields _that are
nested within a named field_, those options won't be applied to the
`Filter` component in the list view.

This is because of how we key the results when resolving `filterOptions`
on the server. Instead of using the field path as expected, we were
using the field name, causing a failed lookup on the front-end. This
also solves an issue where two fields with the same name would override
each other's `filterOptions`, since field names alone are not unique.

Unrelated: this PR also does some general housekeeping to e2e test
helpers.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211332845301583
2025-09-11 13:24:16 -07:00

1130 lines
39 KiB
TypeScript

import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/filters/index.js'
import * as path from 'path'
import { mapAsync } from 'payload'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import type { Config, Page as PageType, Post } from './payload-types.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { pagesSlug } from './collections/Pages/index.js'
import { postsSlug } from './collections/Posts/index.js'
import { usersSlug } from './collections/Users/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const { afterAll, afterEach, beforeAll, beforeEach, describe } = test
let page: Page
let postsUrl: AdminUrlUtil
let pagesUrl: AdminUrlUtil
let payload: PayloadTestSDK<Config>
let serverURL: string
let usersUrl: AdminUrlUtil
let pagesDocOne: PageType
let postsDocOne: Post
let postsDocTwo: Post
let devUserID: number | string
describe('Trash', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, postsSlug)
pagesUrl = new AdminUrlUtil(serverURL, pagesSlug)
usersUrl = new AdminUrlUtil(serverURL, usersSlug)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
pagesDocOne = await createPageDoc({
title: 'Page',
})
})
afterAll(async () => {
await payload.delete({
collection: pagesSlug,
id: pagesDocOne.id,
})
})
describe('Collection view', () => {
describe('List view', () => {
beforeAll(async () => {
postsDocOne = await createPostDoc({
title: 'Post',
_status: 'published',
})
postsDocTwo = await createPostDoc({
title: 'Post 2',
_status: 'published',
})
})
afterAll(async () => {
await payload.delete({
collection: postsSlug,
id: postsDocOne.id,
trash: true,
})
})
test('should not show trash tab in the list view of a colleciton without trash enabled', async () => {
await page.goto(pagesUrl.list)
await expect(page.locator('#trash-view-pill')).toBeHidden()
})
test('should show trash tab in the list view of a colleciton with trash enabled', async () => {
await page.goto(postsUrl.list)
await expect(page.locator('#trash-view-pill')).toBeVisible()
})
test('should show all posts tab in list view of a collection with trash enabled', async () => {
await page.goto(postsUrl.list)
await expect(page.locator('#all-posts')).toBeVisible()
})
test('Should not show checkbox to delete permanently bulk delete modal in trash disabled collection', async () => {
await page.goto(pagesUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
await expect(page.locator('#delete-forever')).toBeHidden()
})
test('Should show checkbox to delete permanently in bulk delete modal in trash enabled collection', async () => {
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
await expect(page.locator('#delete-forever')).toBeVisible()
})
test('Bulk delete toast message should properly correspond to trash / perma delete', async () => {
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Check the checkbox to delete permanently
await page.locator('#delete-forever').check()
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Permanently deleted 1 Post successfully.',
)
await page.reload()
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
})
})
describe('Edit view', () => {
beforeAll(async () => {
postsDocOne = await createPostDoc({
title: 'Post 1',
_status: 'published',
})
postsDocTwo = await createPostDoc({
title: 'Post 2',
_status: 'published',
})
})
afterAll(async () => {
await payload.delete({
collection: postsSlug,
id: postsDocTwo.id,
trash: true,
})
})
test('Should not show checkbox to delete permanently doc controls delete modal in trash disabled collection', async () => {
await page.goto(pagesUrl.edit(pagesDocOne.id))
const threeDotMenu = page.locator('.doc-controls__popup')
await expect(threeDotMenu).toBeVisible()
await threeDotMenu.click()
await page.locator('.doc-controls__popup #action-delete').click()
await expect(page.locator('#delete-forever')).toBeHidden()
})
test('Should show checkbox to delete permanently doc controls delete modal in trash enabled collection', async () => {
await page.goto(postsUrl.edit(postsDocOne.id))
const threeDotMenu = page.locator('.doc-controls__popup')
await expect(threeDotMenu).toBeVisible()
await threeDotMenu.click()
await page.locator('.doc-controls__popup #action-delete').click()
await expect(page.locator('#delete-forever')).toBeVisible()
})
test('Doc view delete toast message should properly correspond to trash / perma delete', async () => {
await page.goto(postsUrl.edit(postsDocOne.id))
const threeDotMenuOne = page.locator('.doc-controls__popup')
await expect(threeDotMenuOne).toBeVisible()
await threeDotMenuOne.click()
await page.locator('.doc-controls__popup #action-delete').click()
// Check the checkbox to delete permanently
await page.locator('#delete-forever').check()
await page.locator('.delete-document #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Post "Post 1" successfully deleted.',
)
await page.goto(postsUrl.edit(postsDocTwo.id))
const threeDotMenuTwo = page.locator('.doc-controls__popup')
await expect(threeDotMenuTwo).toBeVisible()
await threeDotMenuTwo.click()
await page.locator('.doc-controls__popup #action-delete').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('.delete-document #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Post "Post 2" moved to trash.',
)
})
})
})
describe('Trash view', () => {
describe('List view', () => {
beforeEach(async () => {
postsDocOne = await createPostDoc({
title: 'Post 1',
_status: 'published',
})
})
afterEach(async () => {
await payload.delete({
collection: postsSlug,
id: postsDocOne.id,
trash: true,
})
})
test('Should show `Empty trash` button', async () => {
await page.goto(postsUrl.trash)
await expect(page.locator('#empty-trash-button')).toBeVisible()
})
test('Should disable Empty trash button when there are no trashed docs', async () => {
await page.goto(postsUrl.trash)
await expect(page.locator('#empty-trash-button')).toBeDisabled()
})
test('Should successfully trash a doc from the list view and show it in the trash view', async () => {
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Navigate to the trash view
await page.locator('#trash-view-pill').click()
await expect(page.locator('.row-1 .cell-title')).toHaveText('Post 1')
})
test('Should show `trash` breadcrumb', async () => {
await page.goto(postsUrl.trash)
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'Trash',
)
})
test('Should show `restore` and `delete` buttons', async () => {
const trashedDoc = await createTrashedPostDoc({
title: 'Trashed Post',
})
await page.goto(postsUrl.list)
await page.locator('#trash-view-pill').click()
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'Trash',
)
const selectAll = page.locator('input#select-all')
// Ensure checkbox is visible and attached
await expect(selectAll).toBeAttached()
await expect(selectAll).toBeVisible()
await expect(selectAll).toBeEnabled()
// Wait until the row actually exists to be selectable
await expect(page.locator('.row-1')).toBeVisible()
// eslint-disable-next-line playwright/no-force-option
await selectAll.check({ force: true })
await expect(page.locator('.list-selection__button[aria-label="Restore"]')).toBeVisible()
await expect(page.locator('.list-selection__button[aria-label="Delete"]')).toBeVisible()
await payload.delete({
collection: postsSlug,
id: trashedDoc.id,
trash: true,
})
})
test('Should successfully perma delete all trashed docs with empty trash button', async () => {
await mapAsync([...Array(3)], async () => {
await createTrashedPostDoc({
title: 'Ready for delete',
})
})
await page.goto(postsUrl.trash)
await page.locator('#empty-trash-button').click()
await expect(page.locator('#confirm-empty-trash')).toBeVisible()
await expect(
page.locator('#confirm-empty-trash .confirmation-modal__content'),
).toContainText('You are about to permanently delete 3 Posts from the trash. Are you sure?')
await page.locator('#confirm-empty-trash #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Permanently deleted 3 Posts successfully.',
)
})
test('Should successfully restore all trashed docs with restore button as draft by default', async () => {
await mapAsync([...Array(2)], async () => {
await createTrashedPostDoc({
title: 'Ready for restore',
})
})
await page.goto(postsUrl.trash)
await expect(page.locator('.cell-title', { hasText: 'Ready for restore' })).toHaveCount(2)
await page.locator('input#select-all').check()
await page.locator('.list-selection__button[aria-label="Restore"]').click()
await expect(page.locator('#confirm-restore-many-docs')).toBeVisible()
await expect(
page.locator('#confirm-restore-many-docs .confirmation-modal__content'),
).toContainText('You are about to restore 2 Posts as draft')
await page.locator('#confirm-restore-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Restored 2 Posts successfully.',
)
// Verify that the posts are no longer in the trash view
await expect(page.locator('.cell-title', { hasText: 'Ready for restore' })).toHaveCount(0)
// Navigate back to the list view
await page.goto(postsUrl.list)
// Verify that the posts have been restored and exist in the list view
await expect(page.locator('.row-1 .cell-title')).toHaveText('Ready for restore')
await expect(page.locator('.row-2 .cell-title')).toHaveText('Ready for restore')
// Check that restored docs have `_status = "draft"`
await expect
.poll(async () => {
const { docs } = await payload.find({
collection: postsSlug,
where: {
title: { equals: 'Ready for restore' },
},
})
return docs.length
})
.toBe(2)
await expect
.poll(async () => {
const { docs } = await payload.find({
collection: postsSlug,
where: {
title: { equals: 'Ready for restore' },
},
})
return docs.every((doc) => doc._status === 'draft')
})
.toBe(true)
await payload.delete({
collection: postsSlug,
where: {
title: {
equals: 'Ready for restore',
},
},
})
})
test('Should successfully restore all trashed docs with restore button as published', async () => {
await mapAsync([...Array(2)], async () => {
await createTrashedPostDoc({
title: 'Ready for restore',
})
})
await page.goto(postsUrl.trash)
await expect(page.locator('.cell-title', { hasText: 'Ready for restore' })).toHaveCount(2)
await page.locator('input#select-all').check()
await page.locator('.list-selection__button[aria-label="Restore"]').click()
await expect(page.locator('#confirm-restore-many-docs')).toBeVisible()
await expect(
page.locator('#confirm-restore-many-docs .confirmation-modal__content'),
).toContainText('You are about to restore 2 Posts as draft')
await page.locator('#restore-as-published-many').check()
await page.locator('#confirm-restore-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Restored 2 Posts successfully.',
)
// Verify that the posts are no longer in the trash view
await expect(page.locator('.cell-title', { hasText: 'Ready for restore' })).toHaveCount(0)
// Navigate back to the list view
await page.goto(postsUrl.list)
// Verify that the posts have been restored and exist in the list view
await expect(page.locator('.row-1 .cell-title')).toHaveText('Ready for restore')
await expect(page.locator('.row-2 .cell-title')).toHaveText('Ready for restore')
// Check that restored docs have `_status = "draft"`
await expect
.poll(async () => {
const { docs } = await payload.find({
collection: postsSlug,
where: {
title: { equals: 'Ready for restore' },
},
})
return docs.length
})
.toBe(2)
await expect
.poll(async () => {
const { docs } = await payload.find({
collection: postsSlug,
where: {
title: { equals: 'Ready for restore' },
},
})
return docs.every((doc) => doc._status === 'published')
})
.toBe(true)
await payload.delete({
collection: postsSlug,
where: {
title: {
equals: 'Ready for restore',
},
},
})
})
test('Should successfully delete permanently all selected trashed docs with delete button', async () => {
await mapAsync([...Array(2)], async () => {
await createTrashedPostDoc({
title: 'Ready for delete from delete button',
})
})
await page.goto(postsUrl.trash)
await expect(
page.locator('.cell-title', { hasText: 'Ready for delete from delete button' }),
).toHaveCount(2)
await page.locator('input#select-all').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
await expect(page.locator('#confirm-delete-many-docs')).toBeVisible()
await expect(
page.locator('#confirm-delete-many-docs .confirmation-modal__content'),
).toContainText('You are about to permanently delete 2 Posts from the trash. Are you sure?')
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Permanently deleted 2 Posts successfully.',
)
// Verify that the posts are no longer in the trash view
await expect(
page.locator('.cell-title', { hasText: 'Ready for delete from delete button' }),
).toHaveCount(0)
// Verify that the posts have been permanently deleted
await expect
.poll(async () => {
const deletedPosts = await payload.find({
collection: postsSlug,
trash: true,
where: {
and: [
{
deletedAt: {
exists: true,
},
},
{
title: {
equals: 'Ready for delete from delete button',
},
},
],
},
})
return deletedPosts.docs.length
})
.toBe(0)
})
test('Should properly filter trashed docs through where query builder', async () => {
const createdDocs: Post[] = []
// Create 2 "Test Post" docs
await mapAsync([...Array(2)], async (item, index) => {
const doc = await createTrashedPostDoc({
title: `Test Post ${index + 1}`,
})
createdDocs.push(doc)
})
// Create 2 "Some Post" docs
await mapAsync([...Array(2)], async (item, index) => {
const doc = await createTrashedPostDoc({
title: `Some Post ${index + 1}`,
})
createdDocs.push(doc)
})
await page.goto(postsUrl.trash)
await addListFilter({
page,
fieldLabel: 'Title',
operatorLabel: 'is like',
value: 'Test',
})
await expect(page.locator('.cell-title', { hasText: 'Test Post' })).toHaveCount(2)
await expect(page.locator('.cell-title', { hasText: 'Some Post' })).toHaveCount(0)
// Cleanup: permanently delete the created docs
await mapAsync(createdDocs, async (doc) => {
await payload.delete({
collection: postsSlug,
id: doc.id,
trash: true, // Force permanent delete
})
})
})
})
describe('Edit view', () => {
let trashedPostDocOne: Post
beforeEach(async () => {
trashedPostDocOne = await createTrashedPostDoc({
title: 'Trashed Post',
})
})
afterEach(async () => {
await payload.delete({
collection: postsSlug,
id: trashedPostDocOne.id,
trash: true,
})
})
test('Should show `trash` and doc name in breadcrumbs', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
await expect(page.locator('.step-nav.app-header__step-nav a').nth(2)).toContainText('Trash')
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'Trashed Post',
)
})
test('should show trash banner in the edit view', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
await expect(page.locator('.trash-banner')).toBeVisible()
})
test('Should navigate back to the trash view using the `trash` breadcrumb', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
await page.locator('.step-nav.app-header__step-nav a').nth(2).click()
await expect(page).toHaveURL(/\/admin\/collections\/posts\/trash/)
})
test('Should not render dot menu popup', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
const threeDotMenu = page.locator('.doc-controls__popup')
await expect(threeDotMenu).toBeHidden()
})
test('Should render status block with correct status', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
const statusBlock = page.locator('.doc-controls__status')
await expect(statusBlock).toBeVisible()
await expect(statusBlock).toContainText('Previously Published')
})
test('Should render Permanently Delete and Restore buttons in doc controls', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
const permanentlyDeleteButton = page.locator(
'.doc-controls__controls #action-permanently-delete',
)
await expect(permanentlyDeleteButton).toBeVisible()
const restoreButton = page.locator('.doc-controls__controls #action-restore')
await expect(restoreButton).toBeVisible()
})
test('should successfully permanently delete a trashed doc with Permanently Delete button', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
const permanentlyDeleteButton = page.locator(
'.doc-controls__controls #action-permanently-delete',
)
await expect(permanentlyDeleteButton).toBeVisible()
await permanentlyDeleteButton.click()
await expect(page.locator(`#perma-delete-${trashedPostDocOne.id}`)).toBeVisible()
await expect(
page.locator(`#perma-delete-${trashedPostDocOne.id} .confirmation-modal__content`),
).toContainText('You are about to permanently delete the Post')
await page.locator(`#perma-delete-${trashedPostDocOne.id} #confirm-action`).click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Post "Trashed Post" successfully deleted.',
)
// Verify that the post has been permanently deleted
await expect
.poll(async () => {
const deletedPost = await payload.find({
collection: postsSlug,
trash: true,
where: {
and: [
{
deletedAt: {
exists: true,
},
},
{
id: {
equals: trashedPostDocOne.id,
},
},
],
},
})
return deletedPost.docs.length
})
.toBe(0)
})
test('should successfully restore a trashed doc with Restore button', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
const restoreButton = page.locator('.doc-controls__controls #action-restore')
await expect(restoreButton).toBeVisible()
await restoreButton.click()
await expect(page.locator(`#restore-${trashedPostDocOne.id}`)).toBeVisible()
await expect(
page.locator(`#restore-${trashedPostDocOne.id} .confirmation-modal__content`),
).toContainText('You are about to restore the Post Trashed Post as a draft. Are you sure?')
await page.locator(`#restore-${trashedPostDocOne.id} #confirm-action`).click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Post "Trashed Post" successfully restored.',
)
// Check that restored doc has `_status = "draft"`
await expect
.poll(async () => {
const { docs } = await payload.find({
collection: postsSlug,
where: {
id: { equals: trashedPostDocOne.id },
},
})
return docs.length
})
.toBe(1)
await expect
.poll(async () => {
const { docs } = await payload.find({
collection: postsSlug,
where: {
id: { equals: trashedPostDocOne.id },
},
})
return docs[0]?._status === 'draft'
})
.toBe(true)
})
test('Should render fields as read-only', async () => {
await page.goto(postsUrl.trashEdit(trashedPostDocOne.id))
// Check that the title field is read-only
const titleField = page.locator('#field-title')
await expect(titleField).toBeDisabled()
})
test('Should allow viewing of the Versions tab view from trash edit view', async () => {
const incomingTrashedDoc = await createPostDoc({
title: 'Post 1',
_status: 'published',
})
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Navigate to the trash view
await page.locator('#trash-view-pill').click()
// Assert the URL is /posts/trash
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
await expect(page.locator('table')).toBeVisible()
await expect(page.locator('.row-1 .cell-title')).toHaveText('Post 1')
// Click on the first row to go to the trashed doc edit view
await page.locator('.row-1 .cell-title').click()
await page.getByRole('link', { name: 'Versions' }).waitFor({ state: 'visible' })
await page.getByRole('link', { name: 'Versions' }).click()
await expect(page.locator('.step-nav.app-header__step-nav a').nth(2)).toContainText('Trash')
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'Versions',
)
await payload.delete({
collection: postsSlug,
id: incomingTrashedDoc.id,
trash: true,
})
})
test('Should navigate back to the trashed doc view using the post name breadcrumb from the Versions tab view', async () => {
const incomingTrashedDoc = await createPostDoc({
title: 'Post 1',
_status: 'published',
})
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Navigate to the trash view
await page.locator('#trash-view-pill').click()
// Assert the URL is /posts/trash
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
await expect(page.locator('table')).toBeVisible()
await expect(page.locator('.row-1 .cell-title')).toHaveText('Post 1')
// Click on the first row to go to the trashed doc edit view
await page.locator('.row-1 .cell-title').click()
await page.getByRole('link', { name: 'Versions' }).waitFor({ state: 'visible' })
await page.getByRole('link', { name: 'Versions' }).click()
await expect(page.locator('.step-nav.app-header__step-nav a').nth(2)).toContainText('Trash')
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'Versions',
)
await page.locator('.step-nav.app-header__step-nav a').nth(3).click()
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'Post 1',
)
await payload.delete({
collection: postsSlug,
id: incomingTrashedDoc.id,
trash: true,
})
})
test('Should allow viewing of a specific version from the versions tab in the trash document view', async () => {
const incomingTrashedDoc = await createPostDoc({
title: 'Post 1',
_status: 'published',
})
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Navigate to the trash view
await page.locator('#trash-view-pill').click()
// Assert the URL is /posts/trash
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
await expect(page.locator('table')).toBeVisible()
await expect(page.locator('.row-1 .cell-title')).toHaveText('Post 1')
// Click on the first row to go to the trashed doc edit view
await page.locator('.row-1 .cell-title').click()
await page.getByRole('link', { name: 'Versions' }).waitFor({ state: 'visible' })
await page.getByRole('link', { name: 'Versions' }).click()
// Click on the first version link
await page.locator('.versions table tbody tr td.cell-updatedAt a').first().click()
await expect(page.locator('.step-nav.app-header__step-nav a').nth(2)).toContainText('Trash')
await expect
.poll(async () => {
const text = await page
.locator('.step-nav.app-header__step-nav .step-nav__last')
.innerText()
return text
})
.toMatch(/\w+ \d{1,2}(st|nd|rd|th) \d{4}, \d{1,2}:\d{2} [AP]M/)
await payload.delete({
collection: postsSlug,
id: incomingTrashedDoc.id,
trash: true,
})
})
test('Should allow viewing of the API tab view from trash edit view', async () => {
const incomingTrashedDoc = await createPostDoc({
title: 'Post 1',
_status: 'published',
})
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Navigate to the trash view
await page.locator('#trash-view-pill').click()
// Assert the URL is /posts/trash
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
await expect(page.locator('table')).toBeVisible()
await expect(page.locator('.row-1 .cell-title')).toHaveText('Post 1')
// Click on the first row to go to the trashed doc edit view
await page.locator('.row-1 .cell-title').click()
await page.getByRole('link', { name: 'API' }).waitFor({ state: 'visible' })
await page.getByRole('link', { name: 'API' }).click()
await expect(page.locator('.step-nav.app-header__step-nav a').nth(2)).toContainText('Trash')
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'API',
)
await payload.delete({
collection: postsSlug,
id: incomingTrashedDoc.id,
trash: true,
})
})
test('Should navigate back to the trashed doc view using the post name breadcrumb from the API tab view', async () => {
const incomingTrashedDoc = await createPostDoc({
title: 'Post 1',
_status: 'published',
})
await page.goto(postsUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Navigate to the trash view
await page.locator('#trash-view-pill').click()
// Assert the URL is /posts/trash
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
await expect(page.locator('table')).toBeVisible()
await expect(page.locator('.row-1 .cell-title')).toHaveText('Post 1')
// Click on the first row to go to the trashed doc edit view
await page.locator('.row-1 .cell-title').click()
await page.getByRole('link', { name: 'API' }).waitFor({ state: 'visible' })
await page.getByRole('link', { name: 'API' }).click()
await expect(page.locator('.step-nav.app-header__step-nav a').nth(2)).toContainText('Trash')
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'API',
)
await page.locator('.step-nav.app-header__step-nav a').nth(3).click()
await expect(page.locator('.step-nav.app-header__step-nav .step-nav__last')).toContainText(
'Post 1',
)
await payload.delete({
collection: postsSlug,
id: incomingTrashedDoc.id,
trash: true,
})
})
})
})
describe('Auth enabled collection', () => {
beforeAll(async () => {
// Ensure Dev user exists and store its ID
const { docs } = await payload.find({
collection: usersSlug,
limit: 1,
where: { name: { equals: 'Dev' } },
trash: true,
})
if (docs.length === 0) {
throw new Error('Dev user not found! Ensure test seed data includes a Dev user.')
}
devUserID = docs[0]?.id as number | string
})
async function ensureDevUserTrashed() {
const { docs } = await payload.find({
collection: usersSlug,
where: {
and: [{ name: { equals: 'Dev' } }, { deletedAt: { exists: true } }],
},
limit: 1,
trash: true,
})
if (docs.length === 0) {
// Trash the user if it's not already trashed
await payload.update({
collection: usersSlug,
id: devUserID,
data: { deletedAt: new Date().toISOString() },
})
}
}
test('Should show trash tab in the list view of a collection with auth enabled', async () => {
await page.goto(usersUrl.list)
await expect(page.locator('#trash-view-pill')).toBeVisible()
})
test('Should successfully trash a user from the list view and show it in the trash view', async () => {
await page.goto(usersUrl.list)
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.list-selection__button[aria-label="Delete"]').click()
// Skip the checkbox to delete permanently and default to trashing
await page.locator('#confirm-delete-many-docs #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 User moved to trash.',
)
// Navigate to the trash view
await page.locator('#trash-view-pill').click()
await expect(page.locator('.row-1 .cell-name')).toHaveText('Dev')
})
test('Should be able to access trashed doc edit view from the trash view', async () => {
await ensureDevUserTrashed()
await page.goto(usersUrl.trash)
await expect(page.locator('.row-1 .cell-name')).toHaveText('Dev')
await page.locator('.row-1 .cell-name').click()
await expect(page).toHaveURL(/\/users\/trash\/[a-f0-9]{24}$/)
})
test('Should properly disable auth fields in the trashed user edit view', async () => {
await ensureDevUserTrashed()
await page.goto(usersUrl.trash)
await page.locator('.row-1 .cell-name').click()
await expect(page.locator('input[name="email"]')).toBeDisabled()
await expect(page.locator('#change-password')).toBeDisabled()
await expect(page.locator('#field-name')).toBeDisabled()
await expect(page.locator('#field-roles .rs__input')).toBeDisabled()
})
test('Should properly restore trashed user as draft', async () => {
await ensureDevUserTrashed()
await page.goto(usersUrl.trash)
await expect(page.locator('.row-1 .cell-name')).toHaveText('Dev')
await page.locator('.row-1 .cell-name').click()
await page.locator('.doc-controls__controls #action-restore').click()
await expect(page.locator(`#restore-${devUserID} #confirm-action`)).toBeVisible()
await expect(
page.locator(`#restore-${devUserID} .confirmation-modal__content`),
).toContainText('You are about to restore the User')
await page.locator(`#restore-${devUserID} #confirm-action`).click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'User "Dev" successfully restored.',
)
})
})
})
async function createPageDoc(data: Partial<PageType>): Promise<PageType> {
return payload.create({
collection: pagesSlug,
data,
}) as unknown as Promise<PageType>
}
async function createPostDoc(data: Partial<Post>): Promise<Post> {
return payload.create({
collection: postsSlug,
data,
}) as unknown as Promise<Post>
}
async function createTrashedPostDoc(data: Partial<Post>): Promise<Post> {
return payload.create({
collection: postsSlug,
data: {
...data,
_status: 'published',
deletedAt: new Date().toISOString(), // Set the post as trashed
},
}) as unknown as Promise<Post>
}