Currently, Payload renders all custom components on initial compile of the admin panel. This is problematic for two key reasons: 1. Custom components do not receive contextual data, i.e. fields do not receive their field data, edit views do not receive their document data, etc. 2. Components are unnecessarily rendered before they are used This was initially required to support React Server Components within the Payload Admin Panel for two key reasons: 1. Fields can be dynamically rendered within arrays, blocks, etc. 2. Documents can be recursively rendered within a "drawer" UI, i.e. relationship fields 3. Payload supports server/client component composition In order to achieve this, components need to be rendered on the server and passed as "slots" to the client. Currently, the pattern for this is to render custom server components in the "client config". Then when a view or field is needed to be rendered, we first check the client config for a "pre-rendered" component, otherwise render our client-side fallback component. But for the reasons listed above, this pattern doesn't exactly make custom server components very useful within the Payload Admin Panel, which is where this PR comes in. Now, instead of pre-rendering all components on initial compile, we're able to render custom components _on demand_, only as they are needed. To achieve this, we've established [this pattern](https://github.com/payloadcms/payload/pull/8481) of React Server Functions in the Payload Admin Panel. With Server Functions, we can iterate the Payload Config and return JSX through React's `text/x-component` content-type. This means we're able to pass contextual props to custom components, such as data for fields and views. ## Breaking Changes 1. Add the following to your root layout file, typically located at `(app)/(payload)/layout.tsx`: ```diff /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ + import type { ServerFunctionClient } from 'payload' import config from '@payload-config' import { RootLayout } from '@payloadcms/next/layouts' import { handleServerFunctions } from '@payloadcms/next/utilities' import React from 'react' import { importMap } from './admin/importMap.js' import './custom.scss' type Args = { children: React.ReactNode } + const serverFunctions: ServerFunctionClient = async function (args) { + 'use server' + return handleServerFunctions({ + ...args, + config, + importMap, + }) + } const Layout = ({ children }: Args) => ( <RootLayout config={config} importMap={importMap} + serverFunctions={serverFunctions} > {children} </RootLayout> ) export default Layout ``` 2. If you were previously posting to the `/api/form-state` endpoint, it no longer exists. Instead, you'll need to invoke the `form-state` Server Function, which can be done through the _new_ `getFormState` utility: ```diff - import { getFormState } from '@payloadcms/ui' - const { state } = await getFormState({ - apiRoute: '', - body: { - // ... - }, - serverURL: '' - }) + const { getFormState } = useServerFunctions() + + const { state } = await getFormState({ + // ... + }) ``` ## Breaking Changes ```diff - useFieldProps() - useCellProps() ``` More details coming soon. --------- Co-authored-by: Alessio Gravili <alessio@gravili.de> Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com> Co-authored-by: James <james@trbl.design>
1325 lines
37 KiB
TypeScript
1325 lines
37 KiB
TypeScript
import type { Page } from '@playwright/test'
|
|
import type { TypeWithID } from 'payload'
|
|
|
|
import { expect, test } from '@playwright/test'
|
|
import * as path from 'path'
|
|
import { mapAsync } from 'payload'
|
|
import { wait } from 'payload/shared'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
|
import type { Config } from './payload-types.js'
|
|
|
|
import {
|
|
ensureCompilationIsDone,
|
|
exactText,
|
|
initPageConsoleErrorCatch,
|
|
saveDocAndAssert,
|
|
} from '../helpers.js'
|
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
|
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
|
import { postsSlug } from './collections/Posts/index.js'
|
|
|
|
const filename = fileURLToPath(import.meta.url)
|
|
const dirname = path.dirname(filename)
|
|
|
|
const { beforeAll, afterAll, describe } = test
|
|
|
|
const lockedDocumentCollection = 'payload-locked-documents'
|
|
|
|
let page: Page
|
|
let globalUrl: AdminUrlUtil
|
|
let postsUrl: AdminUrlUtil
|
|
let pagesUrl: AdminUrlUtil
|
|
let testsUrl: AdminUrlUtil
|
|
let payload: PayloadTestSDK<Config>
|
|
let serverURL: string
|
|
|
|
describe('locked documents', () => {
|
|
beforeAll(async ({ browser }, testInfo) => {
|
|
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
|
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
|
|
|
|
globalUrl = new AdminUrlUtil(serverURL, 'menu')
|
|
postsUrl = new AdminUrlUtil(serverURL, 'posts')
|
|
pagesUrl = new AdminUrlUtil(serverURL, 'pages')
|
|
testsUrl = new AdminUrlUtil(serverURL, 'tests')
|
|
|
|
const context = await browser.newContext()
|
|
page = await context.newPage()
|
|
|
|
initPageConsoleErrorCatch(page)
|
|
|
|
await ensureCompilationIsDone({ page, serverURL })
|
|
})
|
|
|
|
describe('disabled locking', () => {
|
|
test('should prevent locking of documents if lockDocuments is false', async () => {
|
|
const { id } = await createPageDoc({})
|
|
|
|
await page.goto(pagesUrl.edit(id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('hello world')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const lockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: id },
|
|
},
|
|
})
|
|
|
|
expect(lockedDocs.docs.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('list view - collections', () => {
|
|
let postDoc
|
|
let anotherPostDoc
|
|
let user2
|
|
let lockedDoc
|
|
let testDoc
|
|
let testLockedDoc
|
|
|
|
beforeAll(async () => {
|
|
postDoc = await createPostDoc({
|
|
text: 'hello',
|
|
})
|
|
|
|
anotherPostDoc = await createPostDoc({
|
|
text: 'another post',
|
|
})
|
|
|
|
testDoc = await createTestDoc({
|
|
text: 'test doc',
|
|
})
|
|
|
|
user2 = await payload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: 'user2@payloadcms.com',
|
|
password: '1234',
|
|
roles: ['is_user'],
|
|
},
|
|
})
|
|
|
|
lockedDoc = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'posts',
|
|
value: postDoc.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
testLockedDoc = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'tests',
|
|
value: testDoc.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await payload.delete({
|
|
collection: 'users',
|
|
id: user2.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: testLockedDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: postDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: anotherPostDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'tests',
|
|
id: testDoc.id,
|
|
})
|
|
})
|
|
|
|
test('should show lock icon on document row if locked', async () => {
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
await expect(page.locator('.table .row-2 .locked svg')).toBeVisible()
|
|
})
|
|
|
|
test('should not show lock icon on document row if unlocked', async () => {
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
await expect(page.locator('.table .row-3 .checkbox-input__input')).toBeVisible()
|
|
})
|
|
|
|
test('should not show lock icon on document if expired', async () => {
|
|
await page.goto(testsUrl.list)
|
|
await page.waitForURL(new RegExp(testsUrl.list))
|
|
|
|
// Need to wait for lock duration to expire (lockDuration: 5 seconds)
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(5000)
|
|
|
|
await page.reload()
|
|
|
|
await expect(page.locator('.table .row-1 .checkbox-input__input')).toBeVisible()
|
|
})
|
|
|
|
test('should not show lock icon on document row if locked by current user', async () => {
|
|
await page.goto(postsUrl.edit(anotherPostDoc.id))
|
|
await page.waitForURL(postsUrl.edit(anotherPostDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('testing')
|
|
|
|
await page.reload()
|
|
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
await expect(page.locator('.table .row-1 .checkbox-input__input')).toBeVisible()
|
|
})
|
|
|
|
test('should only allow bulk delete on unlocked documents on current page', async () => {
|
|
await page.goto(postsUrl.list)
|
|
await page.locator('input#select-all').check()
|
|
await page.locator('.delete-documents__toggle').click()
|
|
await expect(page.locator('.delete-documents__content p')).toHaveText(
|
|
'You are about to delete 2 Posts',
|
|
)
|
|
})
|
|
|
|
test('should only allow bulk delete on unlocked documents on all pages', async () => {
|
|
await mapAsync([...Array(9)], async () => {
|
|
await createPostDoc({
|
|
text: 'Ready for delete',
|
|
})
|
|
})
|
|
|
|
await page.reload()
|
|
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
await page.locator('input#select-all').check()
|
|
await page.locator('.list-selection .list-selection__button').click()
|
|
await page.locator('.delete-documents__toggle').click()
|
|
await page.locator('#confirm-delete').click()
|
|
await expect(page.locator('.cell-_select')).toHaveCount(1)
|
|
})
|
|
|
|
test('should only allow bulk publish on unlocked documents on all pages', async () => {
|
|
await mapAsync([...Array(10)], async () => {
|
|
await createPostDoc({
|
|
text: 'Ready for delete',
|
|
})
|
|
})
|
|
|
|
await page.reload()
|
|
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
await page.locator('input#select-all').check()
|
|
await page.locator('.list-selection .list-selection__button').click()
|
|
await page.locator('.publish-many__toggle').click()
|
|
await page.locator('#confirm-publish').click()
|
|
|
|
const paginator = page.locator('.paginator')
|
|
|
|
await paginator.locator('button').nth(1).click()
|
|
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
|
|
await expect(page.locator('.row-1 .cell-_status')).toContainText('Draft')
|
|
})
|
|
|
|
test('should only allow bulk unpublish on unlocked documents on all pages', async () => {
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
await page.locator('input#select-all').check()
|
|
await page.locator('.list-selection .list-selection__button').click()
|
|
await page.locator('.unpublish-many__toggle').click()
|
|
await page.locator('#confirm-unpublish').click()
|
|
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
|
|
'Updated 10 Posts successfully.',
|
|
)
|
|
})
|
|
|
|
test('should only allow bulk edit on unlocked documents on all pages', async () => {
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
const bulkText = 'Bulk update title'
|
|
|
|
await page.locator('input#select-all').check()
|
|
await page.locator('.list-selection .list-selection__button').click()
|
|
await page.locator('.edit-many__toggle').click()
|
|
|
|
await page.locator('.field-select .rs__control').click()
|
|
|
|
const textOption = page.locator('.field-select .rs__option', {
|
|
hasText: exactText('Text'),
|
|
})
|
|
|
|
await expect(textOption).toBeVisible()
|
|
|
|
await textOption.click()
|
|
|
|
const textInput = page.locator('#field-text')
|
|
|
|
await expect(textInput).toBeVisible()
|
|
|
|
await textInput.fill(bulkText)
|
|
|
|
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
|
|
await expect(page.locator('.payload-toast-container .toast-error')).toContainText(
|
|
'Unable to update 1 out of 11 Posts.',
|
|
)
|
|
|
|
await page.locator('.edit-many__header__close').click()
|
|
|
|
await page.reload()
|
|
|
|
await expect(page.locator('.row-1 .cell-text')).toContainText(bulkText)
|
|
await expect(page.locator('.row-2 .cell-text')).toContainText(bulkText)
|
|
|
|
const paginator = page.locator('.paginator')
|
|
|
|
await paginator.locator('button').nth(1).click()
|
|
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
|
|
await expect(page.locator('.row-1 .cell-text')).toContainText('hello')
|
|
})
|
|
})
|
|
|
|
describe('document locking / unlocking - one user', () => {
|
|
let postDoc
|
|
let postDocTwo
|
|
let expiredDocOne
|
|
let expiredLockedDocOne
|
|
let expiredDocTwo
|
|
let expiredLockedDocTwo
|
|
let testDoc
|
|
let user2
|
|
|
|
beforeAll(async () => {
|
|
postDoc = await createPostDoc({
|
|
text: 'hello',
|
|
})
|
|
|
|
postDocTwo = await createPostDoc({
|
|
text: 'post doc two',
|
|
})
|
|
|
|
user2 = await payload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: 'user2@payloadcms.com',
|
|
password: '1234',
|
|
roles: ['is_user'],
|
|
},
|
|
})
|
|
|
|
expiredDocOne = await createTestDoc({
|
|
text: 'expired doc one',
|
|
})
|
|
|
|
expiredLockedDocOne = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'tests',
|
|
value: expiredDocOne.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
expiredDocTwo = await createTestDoc({
|
|
text: 'expired doc two',
|
|
})
|
|
|
|
expiredLockedDocTwo = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'tests',
|
|
value: expiredDocTwo.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
testDoc = await createTestDoc({ text: 'hello' })
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await payload.delete({
|
|
collection: 'users',
|
|
id: user2.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: expiredLockedDocOne.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: expiredLockedDocTwo.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: postDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: postDocTwo.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'tests',
|
|
id: expiredDocOne.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'tests',
|
|
id: expiredDocTwo.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'tests',
|
|
id: testDoc.id,
|
|
})
|
|
})
|
|
|
|
test('should delete all expired locked documents upon initial editing of unlocked document', async () => {
|
|
await page.goto(testsUrl.list)
|
|
await page.waitForURL(new RegExp(testsUrl.list))
|
|
|
|
await expect(page.locator('.table .row-2 .locked svg')).toBeVisible()
|
|
await expect(page.locator('.table .row-3 .locked svg')).toBeVisible()
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(5000)
|
|
|
|
await page.reload()
|
|
|
|
await expect(page.locator('.table .row-2 .checkbox-input__input')).toBeVisible()
|
|
await expect(page.locator('.table .row-3 .checkbox-input__input')).toBeVisible()
|
|
|
|
const lockedTestDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
pagination: false,
|
|
})
|
|
|
|
expect(lockedTestDocs.docs.length).toBe(2)
|
|
|
|
await page.goto(testsUrl.edit(testDoc.id))
|
|
await page.waitForURL(testsUrl.edit(testDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('some test doc')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const lockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
pagination: false,
|
|
})
|
|
|
|
expect(lockedDocs.docs.length).toBe(1)
|
|
})
|
|
|
|
test('should lock document upon initial editing of unlocked document', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('hello world')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const lockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
expect(lockedDocs.docs.length).toBe(1)
|
|
})
|
|
|
|
test('should unlock document on save / publish', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('hello world')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const lockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
expect(lockedDocs.docs.length).toBe(1)
|
|
|
|
await saveDocAndAssert(page)
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const unlockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
expect(unlockedDocs.docs.length).toBe(0)
|
|
})
|
|
|
|
test('should keep document locked when navigating to other tabs i.e. api', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('testing tab navigation...')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const lockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
expect(lockedDocs.docs.length).toBe(1)
|
|
|
|
await page.locator('li[aria-label="API"] a').click()
|
|
|
|
// Locate the modal container
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
// Click the "Leave anyway" button
|
|
await page.locator('.leave-without-saving__controls .btn--style-primary').click()
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const unlockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
expect(unlockedDocs.docs.length).toBe(1)
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
})
|
|
|
|
test('should unlock document on navigate away', async () => {
|
|
await page.goto(postsUrl.edit(postDocTwo.id))
|
|
await page.waitForURL(postsUrl.edit(postDocTwo.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('hello world')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(1000)
|
|
|
|
const lockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDocTwo.id },
|
|
},
|
|
})
|
|
|
|
expect(lockedDocs.docs.length).toBe(1)
|
|
|
|
await page.locator('header.app-header a[href="/admin/collections/posts"]').click()
|
|
|
|
// Locate the modal container
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
// Click the "Leave anyway" button
|
|
await page.locator('.leave-without-saving__controls .btn--style-primary').click()
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
expect(page.url()).toContain(postsUrl.list)
|
|
|
|
const unlockedDocs = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
expect(unlockedDocs.docs.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('document locking - incoming user', () => {
|
|
let postDoc
|
|
let user2
|
|
let lockedDoc
|
|
let expiredTestDoc
|
|
let expiredTestLockedDoc
|
|
|
|
beforeAll(async () => {
|
|
postDoc = await createPostDoc({
|
|
text: 'new post doc',
|
|
})
|
|
|
|
expiredTestDoc = await createTestDoc({
|
|
text: 'expired doc',
|
|
})
|
|
|
|
user2 = await payload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: 'user2@payloadcms.com',
|
|
password: '1234',
|
|
roles: ['is_user'],
|
|
},
|
|
})
|
|
|
|
lockedDoc = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'posts',
|
|
value: postDoc.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
expiredTestLockedDoc = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'tests',
|
|
value: expiredTestDoc.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await payload.delete({
|
|
collection: 'users',
|
|
id: user2.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: expiredTestLockedDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: postDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'tests',
|
|
id: expiredTestDoc.id,
|
|
})
|
|
})
|
|
|
|
test('should show Document Locked modal for incoming user when entering locked document', async () => {
|
|
await page.goto(postsUrl.list)
|
|
await page.waitForURL(new RegExp(postsUrl.list))
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
await page.locator('#document-locked-go-back').click()
|
|
|
|
// should go back to collection list view
|
|
expect(page.url()).toContain(postsUrl.list)
|
|
})
|
|
|
|
test('should not show Document Locked modal for incoming user when entering expired locked document', async () => {
|
|
await page.goto(testsUrl.list)
|
|
await page.waitForURL(new RegExp(testsUrl.list))
|
|
|
|
// Need to wait for lock duration to expire (lockDuration: 5 seconds)
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(5000)
|
|
|
|
await page.reload()
|
|
|
|
await page.goto(testsUrl.edit(expiredTestDoc.id))
|
|
await page.waitForURL(testsUrl.edit(expiredTestDoc.id))
|
|
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeHidden()
|
|
})
|
|
|
|
test('should show fields in read-only if incoming user views locked doc in read-only mode', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
// Click read-only button to view doc in read-only mode
|
|
await page.locator('#document-locked-view-read-only').click()
|
|
|
|
// save buttons should be readOnly / disabled
|
|
await expect(page.locator('#action-save-draft')).toBeDisabled()
|
|
await expect(page.locator('#action-save')).toBeDisabled()
|
|
await expect(page.locator('.doc-controls__dots')).toBeHidden()
|
|
|
|
// fields should be readOnly / disabled
|
|
await expect(page.locator('#field-text')).toBeDisabled()
|
|
})
|
|
})
|
|
|
|
describe('document take over - modal - incoming user', () => {
|
|
let postDoc
|
|
let user2
|
|
let lockedDoc
|
|
|
|
beforeAll(async () => {
|
|
postDoc = await createPostDoc({
|
|
text: 'hello',
|
|
})
|
|
|
|
user2 = await payload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: 'user2@payloadcms.com',
|
|
password: '1234',
|
|
roles: ['is_user'],
|
|
},
|
|
})
|
|
|
|
lockedDoc = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'posts',
|
|
value: postDoc.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await payload.delete({
|
|
collection: 'users',
|
|
id: user2.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: postDoc.id,
|
|
})
|
|
})
|
|
|
|
test('should update user data if incoming user takes over from document modal', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
// Click take-over button to take over editing rights of locked doc
|
|
await page.locator('#document-locked-take-over').click()
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(1000)
|
|
|
|
const lockedDoc = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
expect(lockedDoc.docs.length).toBe(1)
|
|
|
|
const userEmail =
|
|
// eslint-disable-next-line playwright/no-conditional-in-test
|
|
lockedDoc.docs[0].user.value &&
|
|
typeof lockedDoc.docs[0].user.value === 'object' &&
|
|
'email' in lockedDoc.docs[0].user.value &&
|
|
lockedDoc.docs[0].user.value.email
|
|
|
|
expect(userEmail).toEqual('dev@payloadcms.com')
|
|
})
|
|
})
|
|
|
|
describe('document take over - doc - incoming user', () => {
|
|
let postDoc
|
|
let user2
|
|
let lockedDoc
|
|
|
|
beforeAll(async () => {
|
|
postDoc = await createPostDoc({
|
|
text: 'hello',
|
|
})
|
|
|
|
user2 = await payload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: 'user2@payloadcms.com',
|
|
password: '1234',
|
|
roles: ['is_user'],
|
|
},
|
|
})
|
|
|
|
lockedDoc = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: {
|
|
relationTo: 'posts',
|
|
value: postDoc.id,
|
|
},
|
|
globalSlug: undefined,
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await payload.delete({
|
|
collection: 'users',
|
|
id: user2.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedDoc.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: postDoc.id,
|
|
})
|
|
})
|
|
|
|
test('should update user data if incoming user takes over from within document', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
// Click read-only button to view doc in read-only mode
|
|
await page.locator('#document-locked-view-read-only').click()
|
|
|
|
await page.locator('#take-over').click()
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
const lockedDoc = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
expect(lockedDoc.docs.length).toBe(1)
|
|
|
|
const userEmail =
|
|
// eslint-disable-next-line playwright/no-conditional-in-test
|
|
lockedDoc.docs[0].user.value &&
|
|
typeof lockedDoc.docs[0].user.value === 'object' &&
|
|
'email' in lockedDoc.docs[0].user.value &&
|
|
lockedDoc.docs[0].user.value.email
|
|
|
|
expect(userEmail).toEqual('dev@payloadcms.com')
|
|
})
|
|
})
|
|
|
|
describe('document locking - previous user', () => {
|
|
let postDoc
|
|
let user2
|
|
|
|
beforeAll(async () => {
|
|
postDoc = await createPostDoc({
|
|
text: 'hello',
|
|
})
|
|
|
|
user2 = await payload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: 'user2@payloadcms.com',
|
|
password: '1234',
|
|
roles: ['is_user'],
|
|
},
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await payload.delete({
|
|
collection: 'users',
|
|
id: user2.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: 'posts',
|
|
id: postDoc.id,
|
|
})
|
|
})
|
|
test('should show Document Take Over modal for previous user if taken over', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('hello world')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
// Retrieve document id from payload locks collection
|
|
const lockedDoc = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
// Update payload-locks collection document with different user
|
|
await payload.update({
|
|
id: lockedDoc.docs[0].id,
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(1000)
|
|
|
|
// Try to edit the document again as the "old" user
|
|
await textInput.fill('goodbye')
|
|
|
|
// Wait for Take Over modal to appear
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedDoc.docs[0].id,
|
|
})
|
|
})
|
|
|
|
test('should take previous user back to dashboard on dashboard button click', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('hello world')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
// Retrieve document id from payload locks collection
|
|
const lockedDoc = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
// Update payload-locks collection document with different user
|
|
await payload.update({
|
|
id: lockedDoc.docs[0].id,
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(1000)
|
|
|
|
// Try to edit the document again as the "old" user
|
|
await textInput.fill('goodbye')
|
|
|
|
// Wait for Take Over modal to appear
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
// Click read-only button to view doc in read-only mode
|
|
await page.locator('#document-take-over-back-to-dashboard').click()
|
|
|
|
expect(page.url()).toContain(postsUrl.admin)
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedDoc.docs[0].id,
|
|
})
|
|
})
|
|
|
|
test('should show fields in read-only if previous user views doc in read-only mode', async () => {
|
|
await page.goto(postsUrl.edit(postDoc.id))
|
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
|
|
const textInput = page.locator('#field-text')
|
|
await textInput.fill('hello world')
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
// Retrieve document id from payload locks collection
|
|
const lockedDoc = await payload.find({
|
|
collection: lockedDocumentCollection,
|
|
limit: 1,
|
|
pagination: false,
|
|
where: {
|
|
'document.value': { equals: postDoc.id },
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
// Update payload-locks collection document with different user
|
|
await payload.update({
|
|
id: lockedDoc.docs[0].id,
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
// Try to edit the document again as the "old" user
|
|
await textInput.fill('goodbye')
|
|
|
|
// Wait for Take Over modal to appear
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeVisible()
|
|
|
|
// Click read-only button to view doc in read-only mode
|
|
await page.locator('#document-take-over-view-read-only').click()
|
|
|
|
// save buttons should be readOnly / disabled
|
|
await expect(page.locator('#action-save-draft')).toBeDisabled()
|
|
await expect(page.locator('#action-save')).toBeDisabled()
|
|
|
|
// fields should be readOnly / disabled
|
|
await expect(page.locator('#field-text')).toBeDisabled()
|
|
})
|
|
})
|
|
|
|
describe('dashboard - globals', () => {
|
|
let user2
|
|
let lockedMenuGlobal
|
|
let lockedAdminGlobal
|
|
|
|
beforeAll(async () => {
|
|
user2 = await payload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: 'user2@payloadcms.com',
|
|
password: '1234',
|
|
roles: ['is_user'],
|
|
},
|
|
})
|
|
|
|
lockedAdminGlobal = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: undefined,
|
|
globalSlug: 'admin',
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
lockedMenuGlobal = await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: undefined,
|
|
globalSlug: 'menu',
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await payload.delete({
|
|
collection: 'users',
|
|
id: user2.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedAdminGlobal.id,
|
|
})
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedMenuGlobal.id,
|
|
})
|
|
})
|
|
|
|
test('should show lock on document card in dashboard view if locked', async () => {
|
|
await page.goto(postsUrl.admin)
|
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
|
|
|
await expect(page.locator('.dashboard__card-list #card-menu .locked svg')).toBeVisible()
|
|
})
|
|
|
|
test('should not show lock on document card in dashboard view if unlocked', async () => {
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedMenuGlobal.id,
|
|
})
|
|
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(500)
|
|
|
|
await page.goto(postsUrl.admin)
|
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
|
|
|
await expect(page.locator('.dashboard__card-list #card-menu .locked')).toBeHidden()
|
|
})
|
|
|
|
test('should not show lock on document card in dashboard view if locked by current user', async () => {
|
|
await page.goto(globalUrl.global('menu'))
|
|
await page.waitForURL(globalUrl.global('menu'))
|
|
|
|
const textInput = page.locator('#field-globalText')
|
|
await textInput.fill('this is a global menu text field')
|
|
|
|
await page.reload()
|
|
|
|
await page.goto(postsUrl.admin)
|
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
|
|
|
await expect(page.locator('.dashboard__card-list #card-menu .locked')).toBeHidden()
|
|
})
|
|
|
|
test('should not show lock on document card in dashboard view if lock expired', async () => {
|
|
await page.goto(postsUrl.admin)
|
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
|
|
|
await expect(page.locator('.dashboard__card-list #card-admin .locked svg')).toBeVisible()
|
|
|
|
// Need to wait for lock duration to expire (lockDuration: 10 seconds)
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(10000)
|
|
|
|
await page.reload()
|
|
|
|
await expect(page.locator('.dashboard__card-list #card-admin .locked')).toBeHidden()
|
|
|
|
await payload.delete({
|
|
collection: lockedDocumentCollection,
|
|
id: lockedAdminGlobal.id,
|
|
})
|
|
})
|
|
|
|
test('should not show Document Locked modal when entering global with an expired lock', async () => {
|
|
await payload.create({
|
|
collection: lockedDocumentCollection,
|
|
data: {
|
|
document: undefined,
|
|
globalSlug: 'admin',
|
|
user: {
|
|
relationTo: 'users',
|
|
value: user2.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
await page.goto(postsUrl.admin)
|
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
|
|
|
await expect(page.locator('.dashboard__card-list #card-admin .locked svg')).toBeVisible()
|
|
|
|
// Need to wait for lock duration to expire (lockDuration: 10 seconds)
|
|
// eslint-disable-next-line payload/no-wait-function
|
|
await wait(10000)
|
|
|
|
await page.reload()
|
|
|
|
await expect(page.locator('.dashboard__card-list #card-admin .locked')).toBeHidden()
|
|
|
|
await page.locator('.card-admin a').click()
|
|
|
|
const modalContainer = page.locator('.payload__modal-container')
|
|
await expect(modalContainer).toBeHidden()
|
|
})
|
|
})
|
|
})
|
|
|
|
async function createPageDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
|
return payload.create({
|
|
collection: 'pages',
|
|
data,
|
|
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
|
}
|
|
|
|
async function createPostDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
|
return payload.create({
|
|
collection: 'posts',
|
|
data,
|
|
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
|
}
|
|
|
|
async function createTestDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
|
return payload.create({
|
|
collection: 'tests',
|
|
data,
|
|
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
|
}
|
|
|
|
async function deleteAllPosts() {
|
|
await payload.delete({ collection: postsSlug, where: { id: { exists: true } } })
|
|
}
|