Files
payload/test/locked-documents/e2e.spec.ts
Jacob Fletcher c96fa613bc feat!: on demand rsc (#8364)
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>
2024-11-11 13:59:05 -05:00

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 } } })
}