fix: server component readonly state issues with document locking (#13878)

### What?

Fixed two bugs with readonly state for server-rendered components (like
richtext fields and custom server fields):

1. Server components remained readonly after a user took over a locked
document
2. Server components were not readonly when viewing in "read only" mode
until page refresh

### Why?

Both issues stemmed from server-rendered components using their initial
readonly state that was baked in during server-side rendering, rather
than respecting dynamic readonly state changes:

1. **Takeover bug**: When a user took over a locked document,
client-side readonly state was updated but server components continued
using their initial readonly state because the server-side state wasn't
refreshed properly.

2. **Read-only view bug**: When entering "read only" mode, server
components weren't immediately updated to reflect the new readonly state
without a page refresh.

The root cause was that server-side `buildFormState` was called with
`readOnly: isLocked` during initial render, and individual field
components used this initial state rather than respecting dynamic
document-level readonly changes.

### How?

1. **Fixed race condition in `handleTakeOver`**: Made the function async
and await the `updateDocumentEditor` call before calling
`clearRouteCache()` to ensure the database is updated before page reload

2. **Improved editor comparison in `getIsLocked`**: Used `extractID()`
helper to properly compare editor IDs when the editor might be a
reference object

3. **Ensured cache clearing for all takeover scenarios**: Call
`clearRouteCache()` for both DocumentLocked modal and DocumentControls
takeovers to refresh server-side state

4. **Added Form key to force re-render**: Added `key={isLocked}` to the
Form component so it re-renders when the lock state changes, ensuring
all child components get fresh readonly state for both takeover and
read-only view scenarios


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211373627247885
This commit is contained in:
Patrik
2025-09-24 15:11:10 -04:00
committed by GitHub
parent 7f35213c73
commit 512a8fa19f
10 changed files with 387 additions and 38 deletions

View File

@@ -7,6 +7,7 @@ import type {
} from 'payload' } from 'payload'
import { sanitizeID } from '@payloadcms/ui/shared' import { sanitizeID } from '@payloadcms/ui/shared'
import { extractID } from 'payload/shared'
type Args = { type Args = {
collectionConfig?: SanitizedCollectionConfig collectionConfig?: SanitizedCollectionConfig
@@ -93,12 +94,12 @@ export const getIsLocked = async ({
}) })
if (docs.length > 0) { if (docs.length > 0) {
const newEditor = docs[0].user?.value const currentEditor = docs[0].user?.value
const lastUpdateTime = new Date(docs[0].updatedAt).getTime() const lastUpdateTime = new Date(docs[0].updatedAt).getTime()
if (newEditor?.id !== req.user.id) { if (extractID(currentEditor) !== req.user.id) {
return { return {
currentEditor: newEditor, currentEditor,
isLocked: true, isLocked: true,
lastUpdateTime, lastUpdateTime,
} }

View File

@@ -3,6 +3,7 @@ import type { ClientUser } from 'payload'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useRouteCache } from '../../providers/RouteCache/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { isClientUserObject } from '../../utilities/isClientUserObject.js' import { isClientUserObject } from '../../utilities/isClientUserObject.js'
@@ -38,6 +39,7 @@ export const DocumentLocked: React.FC<{
}> = ({ handleGoBack, isActive, onReadOnly, onTakeOver, updatedAt, user }) => { }> = ({ handleGoBack, isActive, onReadOnly, onTakeOver, updatedAt, user }) => {
const { closeModal, openModal } = useModal() const { closeModal, openModal } = useModal()
const { t } = useTranslation() const { t } = useTranslation()
const { clearRouteCache } = useRouteCache()
const { startRouteTransition } = useRouteTransition() const { startRouteTransition } = useRouteTransition()
useEffect(() => { useEffect(() => {
@@ -86,6 +88,7 @@ export const DocumentLocked: React.FC<{
onClick={() => { onClick={() => {
onReadOnly() onReadOnly()
closeModal(modalSlug) closeModal(modalSlug)
clearRouteCache()
}} }}
size="large" size="large"
> >
@@ -95,7 +98,7 @@ export const DocumentLocked: React.FC<{
buttonStyle="primary" buttonStyle="primary"
id={`${modalSlug}-take-over`} id={`${modalSlug}-take-over`}
onClick={() => { onClick={() => {
void onTakeOver() onTakeOver()
closeModal(modalSlug) closeModal(modalSlug)
}} }}
size="large" size="large"

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useRouteCache } from '../../providers/RouteCache/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js' import { Button } from '../Button/index.js'
@@ -19,6 +20,7 @@ export const DocumentTakeOver: React.FC<{
const { closeModal, openModal } = useModal() const { closeModal, openModal } = useModal()
const { t } = useTranslation() const { t } = useTranslation()
const { startRouteTransition } = useRouteTransition() const { startRouteTransition } = useRouteTransition()
const { clearRouteCache } = useRouteCache()
useEffect(() => { useEffect(() => {
if (isActive) { if (isActive) {
@@ -52,6 +54,7 @@ export const DocumentTakeOver: React.FC<{
onClick={() => { onClick={() => {
onReadOnly() onReadOnly()
closeModal(modalSlug) closeModal(modalSlug)
clearRouteCache()
}} }}
size="large" size="large"
> >

View File

@@ -1,32 +1,47 @@
import type { ClientUser } from 'payload' import type { ClientUser } from 'payload'
export const handleTakeOver = ( export interface HandleTakeOverParams {
id: number | string, clearRouteCache?: () => void
collectionSlug: string, collectionSlug?: string
globalSlug: string,
user: ClientUser | number | string,
isWithinDoc: boolean,
updateDocumentEditor: (
docID: number | string,
slug: string,
user: ClientUser | number | string,
) => Promise<void>,
setCurrentEditor: (value: React.SetStateAction<ClientUser | number | string>) => void,
documentLockStateRef: React.RefObject<{ documentLockStateRef: React.RefObject<{
hasShownLockedModal: boolean hasShownLockedModal: boolean
isLocked: boolean isLocked: boolean
user: ClientUser | number | string user: ClientUser | number | string
}>, }>
isLockingEnabled: boolean, globalSlug?: string
setIsReadOnlyForIncomingUser?: (value: React.SetStateAction<boolean>) => void, id: number | string
): void => { isLockingEnabled: boolean
isWithinDoc: boolean
setCurrentEditor: (value: React.SetStateAction<ClientUser | number | string>) => void
setIsReadOnlyForIncomingUser?: (value: React.SetStateAction<boolean>) => void
updateDocumentEditor: (
docID: number | string,
slug: string,
user: ClientUser | number | string,
) => Promise<void>
user: ClientUser | number | string
}
export const handleTakeOver = async ({
id,
clearRouteCache,
collectionSlug,
documentLockStateRef,
globalSlug,
isLockingEnabled,
isWithinDoc,
setCurrentEditor,
setIsReadOnlyForIncomingUser,
updateDocumentEditor,
user,
}: HandleTakeOverParams): Promise<void> => {
if (!isLockingEnabled) { if (!isLockingEnabled) {
return return
} }
try { try {
// Call updateDocumentEditor to update the document's owner to the current user // Call updateDocumentEditor to update the document's owner to the current user
void updateDocumentEditor(id, collectionSlug ?? globalSlug, user) await updateDocumentEditor(id, collectionSlug ?? globalSlug, user)
if (!isWithinDoc) { if (!isWithinDoc) {
documentLockStateRef.current.hasShownLockedModal = true documentLockStateRef.current.hasShownLockedModal = true
@@ -44,6 +59,11 @@ export const handleTakeOver = (
if (isWithinDoc && setIsReadOnlyForIncomingUser) { if (isWithinDoc && setIsReadOnlyForIncomingUser) {
setIsReadOnlyForIncomingUser(false) setIsReadOnlyForIncomingUser(false)
} }
// Need to clear the route cache to refresh the page and update readOnly state for server rendered components
if (clearRouteCache) {
clearRouteCache()
}
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('Error during document takeover:', error) console.error('Error during document takeover:', error)

View File

@@ -28,6 +28,7 @@ import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useLivePreviewContext } from '../../providers/LivePreview/context.js' import { useLivePreviewContext } from '../../providers/LivePreview/context.js'
import { OperationProvider } from '../../providers/Operation/index.js' import { OperationProvider } from '../../providers/Operation/index.js'
import { useRouteCache } from '../../providers/RouteCache/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { UploadControlsProvider } from '../../providers/UploadControls/index.js' import { UploadControlsProvider } from '../../providers/UploadControls/index.js'
@@ -87,6 +88,7 @@ export function DefaultEditView({
initialState, initialState,
isEditing, isEditing,
isInitializing, isInitializing,
isLocked,
isTrashed, isTrashed,
lastUpdateTime, lastUpdateTime,
redirectAfterCreate, redirectAfterCreate,
@@ -134,6 +136,7 @@ export function DefaultEditView({
const { resetUploadEdits } = useUploadEdits() const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions() const { getFormState } = useServerFunctions()
const { startRouteTransition } = useRouteTransition() const { startRouteTransition } = useRouteTransition()
const { clearRouteCache } = useRouteCache()
const { const {
isLivePreviewEnabled, isLivePreviewEnabled,
isLivePreviewing, isLivePreviewing,
@@ -511,6 +514,7 @@ export function DefaultEditView({
initialState={!isInitializing && initialState} initialState={!isInitializing && initialState}
isDocumentForm={true} isDocumentForm={true}
isInitializing={isInitializing} isInitializing={isInitializing}
key={`${isLocked}`}
method={id ? 'PATCH' : 'POST'} method={id ? 'PATCH' : 'POST'}
onChange={[onChange]} onChange={[onChange]}
onSuccess={onSave} onSuccess={onSave}
@@ -527,17 +531,18 @@ export function DefaultEditView({
setShowTakeOverModal(false) setShowTakeOverModal(false)
}} }}
onTakeOver={() => onTakeOver={() =>
handleTakeOver( handleTakeOver({
id, id,
clearRouteCache,
collectionSlug, collectionSlug,
documentLockStateRef: documentLockState,
globalSlug, globalSlug,
user,
false,
updateDocumentEditor,
setCurrentEditor,
documentLockState,
isLockingEnabled, isLockingEnabled,
) isWithinDoc: false,
setCurrentEditor,
updateDocumentEditor,
user,
})
} }
updatedAt={lastUpdateTime} updatedAt={lastUpdateTime}
user={currentEditor} user={currentEditor}
@@ -597,18 +602,19 @@ export function DefaultEditView({
onRestore={onRestore} onRestore={onRestore}
onSave={onSave} onSave={onSave}
onTakeOver={() => onTakeOver={() =>
handleTakeOver( handleTakeOver({
id, id,
clearRouteCache,
collectionSlug, collectionSlug,
documentLockStateRef: documentLockState,
globalSlug, globalSlug,
user,
true,
updateDocumentEditor,
setCurrentEditor,
documentLockState,
isLockingEnabled, isLockingEnabled,
isWithinDoc: true,
setCurrentEditor,
setIsReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser,
) updateDocumentEditor,
user,
})
} }
permissions={docPermissions} permissions={docPermissions}
readOnlyForIncomingUser={isReadOnlyForIncomingUser} readOnlyForIncomingUser={isReadOnlyForIncomingUser}

View File

@@ -0,0 +1,22 @@
import type { TextFieldServerComponent } from 'payload'
import type React from 'react'
import { TextField } from '@payloadcms/ui'
export const CustomTextFieldServer: TextFieldServerComponent = ({
clientField,
path,
permissions,
readOnly,
schemaPath,
}) => {
return (
<TextField
field={clientField}
path={path}
permissions={permissions}
readOnly={readOnly}
schemaPath={schemaPath}
/>
)
}

View File

@@ -0,0 +1,25 @@
import type { CollectionConfig } from 'payload'
export const serverComponentsSlug = 'server-components'
export const ServerComponentsCollection: CollectionConfig = {
slug: serverComponentsSlug,
admin: {
useAsTitle: 'customTextServer',
},
fields: [
{
name: 'customTextServer',
type: 'text',
admin: {
components: {
Field: '/collections/Posts/fields/CustomTextFieldServer.tsx#CustomTextFieldServer',
},
},
},
{
name: 'richText',
type: 'richText',
},
],
}

View File

@@ -4,6 +4,7 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { PagesCollection } from './collections/Pages/index.js' import { PagesCollection } from './collections/Pages/index.js'
import { PostsCollection } from './collections/Posts/index.js' import { PostsCollection } from './collections/Posts/index.js'
import { ServerComponentsCollection } from './collections/ServerComponents/index.js'
import { TestsCollection } from './collections/Tests/index.js' import { TestsCollection } from './collections/Tests/index.js'
import { Users } from './collections/Users/index.js' import { Users } from './collections/Users/index.js'
import { AdminGlobal } from './globals/Admin/index.js' import { AdminGlobal } from './globals/Admin/index.js'
@@ -19,7 +20,13 @@ export default buildConfigWithDefaults({
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),
}, },
}, },
collections: [PagesCollection, PostsCollection, TestsCollection, Users], collections: [
PagesCollection,
PostsCollection,
ServerComponentsCollection,
TestsCollection,
Users,
],
globals: [AdminGlobal, MenuGlobal], globals: [AdminGlobal, MenuGlobal],
onInit: async (payload) => { onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {

View File

@@ -13,6 +13,7 @@ import type {
Page as PageType, Page as PageType,
PayloadLockedDocument, PayloadLockedDocument,
Post, Post,
ServerComponent,
Test, Test,
User, User,
} from './payload-types.js' } from './payload-types.js'
@@ -25,7 +26,7 @@ import {
} from '../helpers.js' } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -38,6 +39,7 @@ let page: Page
let globalUrl: AdminUrlUtil let globalUrl: AdminUrlUtil
let postsUrl: AdminUrlUtil let postsUrl: AdminUrlUtil
let pagesUrl: AdminUrlUtil let pagesUrl: AdminUrlUtil
let serverComponentsUrl: AdminUrlUtil
let testsUrl: AdminUrlUtil let testsUrl: AdminUrlUtil
let payload: PayloadTestSDK<Config> let payload: PayloadTestSDK<Config>
let serverURL: string let serverURL: string
@@ -50,6 +52,7 @@ describe('Locked Documents', () => {
globalUrl = new AdminUrlUtil(serverURL, 'menu') globalUrl = new AdminUrlUtil(serverURL, 'menu')
postsUrl = new AdminUrlUtil(serverURL, 'posts') postsUrl = new AdminUrlUtil(serverURL, 'posts')
pagesUrl = new AdminUrlUtil(serverURL, 'pages') pagesUrl = new AdminUrlUtil(serverURL, 'pages')
serverComponentsUrl = new AdminUrlUtil(serverURL, 'server-components')
testsUrl = new AdminUrlUtil(serverURL, 'tests') testsUrl = new AdminUrlUtil(serverURL, 'tests')
const context = await browser.newContext() const context = await browser.newContext()
@@ -633,11 +636,19 @@ describe('Locked Documents', () => {
let expiredPostDoc: Post let expiredPostDoc: Post
let expiredPostLockedDoc: PayloadLockedDocument let expiredPostLockedDoc: PayloadLockedDocument
let serverComponentDoc: ServerComponent
let lockedServerComponentDoc: PayloadLockedDocument
beforeAll(async () => { beforeAll(async () => {
postDoc = await createPostDoc({ postDoc = await createPostDoc({
text: 'new post doc', text: 'new post doc',
}) })
serverComponentDoc = await payload.create({
collection: 'server-components',
data: {},
})
expiredTestDoc = await createTestDoc({ expiredTestDoc = await createTestDoc({
text: 'expired doc', text: 'expired doc',
}) })
@@ -701,6 +712,21 @@ describe('Locked Documents', () => {
updatedAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(), updatedAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
}, },
}) })
lockedServerComponentDoc = await payload.create({
collection: lockedDocumentCollection,
data: {
document: {
relationTo: 'server-components',
value: serverComponentDoc.id,
},
globalSlug: undefined,
user: {
relationTo: 'users',
value: user2.id,
},
},
})
}) })
afterAll(async () => { afterAll(async () => {
@@ -714,6 +740,11 @@ describe('Locked Documents', () => {
id: lockedDoc.id, id: lockedDoc.id,
}) })
await payload.delete({
collection: lockedDocumentCollection,
id: lockedServerComponentDoc.id,
})
await payload.delete({ await payload.delete({
collection: lockedDocumentCollection, collection: lockedDocumentCollection,
id: expiredTestLockedDoc.id, id: expiredTestLockedDoc.id,
@@ -724,6 +755,11 @@ describe('Locked Documents', () => {
id: postDoc.id, id: postDoc.id,
}) })
await payload.delete({
collection: 'server-components',
id: serverComponentDoc.id,
})
await payload.delete({ await payload.delete({
collection: 'tests', collection: 'tests',
id: expiredTestDoc.id, id: expiredTestDoc.id,
@@ -814,23 +850,46 @@ describe('Locked Documents', () => {
await expect(richTextRoot).toHaveAttribute('contenteditable', 'false') await expect(richTextRoot).toHaveAttribute('contenteditable', 'false')
await expect(richTextRoot).toHaveAttribute('aria-readonly', 'true') await expect(richTextRoot).toHaveAttribute('aria-readonly', 'true')
// wrapper has read-only class (nice-to-have) // wrapper has read-only class
await expect(page.locator('.rich-text-lexical').first()).toHaveClass( await expect(page.locator('.rich-text-lexical').first()).toHaveClass(
/rich-text-lexical--read-only/, /rich-text-lexical--read-only/,
) )
}) })
test('should show server rendered fields in read-only if incoming user views locked doc in read-only mode', async () => {
await page.goto(serverComponentsUrl.edit(serverComponentDoc.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()
// Wait for the modal to disappear
await expect(modalContainer).toBeHidden()
// fields should be readOnly / disabled
await expect(page.locator('#field-customTextServer')).toBeDisabled()
})
}) })
describe('document take over - modal - incoming user', () => { describe('document take over - modal - incoming user', () => {
let postDoc: Post let postDoc: Post
let user2: User let user2: User
let lockedDoc: PayloadLockedDocument let lockedDoc: PayloadLockedDocument
let serverComponentDoc: ServerComponent
let lockedServerComponentsDoc: PayloadLockedDocument
beforeAll(async () => { beforeAll(async () => {
postDoc = await createPostDoc({ postDoc = await createPostDoc({
text: 'hello', text: 'hello',
}) })
serverComponentDoc = await payload.create({
collection: 'server-components',
data: {},
})
user2 = await payload.create({ user2 = await payload.create({
collection: 'users', collection: 'users',
data: { data: {
@@ -854,6 +913,21 @@ describe('Locked Documents', () => {
}, },
}, },
}) })
lockedServerComponentsDoc = await payload.create({
collection: lockedDocumentCollection,
data: {
document: {
relationTo: 'server-components',
value: serverComponentDoc.id,
},
globalSlug: undefined,
user: {
relationTo: 'users',
value: user2.id,
},
},
})
}) })
afterAll(async () => { afterAll(async () => {
@@ -871,6 +945,21 @@ describe('Locked Documents', () => {
collection: 'posts', collection: 'posts',
id: postDoc.id, id: postDoc.id,
}) })
await payload.delete({
collection: lockedDocumentCollection,
id: lockedDoc.id,
})
await payload.delete({
collection: lockedDocumentCollection,
id: lockedServerComponentsDoc.id,
})
await payload.delete({
collection: 'server-components',
id: serverComponentDoc.id,
})
}) })
test('should update user data if incoming user takes over from document modal', async () => { test('should update user data if incoming user takes over from document modal', async () => {
@@ -908,18 +997,41 @@ describe('Locked Documents', () => {
expect(userEmail).toEqual('dev@payloadcms.com') expect(userEmail).toEqual('dev@payloadcms.com')
}) })
test('should render server rendered fields as editable on take over from document modal', async () => {
await page.goto(serverComponentsUrl.edit(serverComponentDoc.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()
// Wait for the modal to disappear
await expect(modalContainer).toBeHidden()
// server fields should be enabled
await expect(page.locator('#field-customTextServer')).toBeEnabled()
})
}) })
describe('document take over - doc - incoming user', () => { describe('document take over - doc - incoming user', () => {
let postDoc: Post let postDoc: Post
let user2: User let user2: User
let lockedDoc: PayloadLockedDocument let lockedDoc: PayloadLockedDocument
let serverComponentsDoc: ServerComponent
let lockedServerComponentsDoc: PayloadLockedDocument
beforeAll(async () => { beforeAll(async () => {
postDoc = await createPostDoc({ postDoc = await createPostDoc({
text: 'hello', text: 'hello',
}) })
serverComponentsDoc = await payload.create({
collection: 'server-components',
data: {},
})
user2 = await payload.create({ user2 = await payload.create({
collection: 'users', collection: 'users',
data: { data: {
@@ -943,6 +1055,21 @@ describe('Locked Documents', () => {
}, },
}, },
}) })
lockedServerComponentsDoc = await payload.create({
collection: lockedDocumentCollection,
data: {
document: {
relationTo: 'server-components',
value: serverComponentsDoc.id,
},
globalSlug: undefined,
user: {
relationTo: 'users',
value: user2.id,
},
},
})
}) })
afterAll(async () => { afterAll(async () => {
@@ -956,10 +1083,20 @@ describe('Locked Documents', () => {
id: lockedDoc.id, id: lockedDoc.id,
}) })
await payload.delete({
collection: lockedDocumentCollection,
id: lockedServerComponentsDoc.id,
})
await payload.delete({ await payload.delete({
collection: 'posts', collection: 'posts',
id: postDoc.id, id: postDoc.id,
}) })
await payload.delete({
collection: 'server-components',
id: serverComponentsDoc.id,
})
}) })
test('should update user data if incoming user takes over from within document', async () => { test('should update user data if incoming user takes over from within document', async () => {
@@ -999,10 +1136,33 @@ describe('Locked Documents', () => {
expect(userEmail).toEqual('dev@payloadcms.com') expect(userEmail).toEqual('dev@payloadcms.com')
}) })
test('should render server rendered fields as editable after incoming user takes over from within document', async () => {
await page.goto(serverComponentsUrl.edit(serverComponentsDoc.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()
// Wait for the modal to disappear
await expect(modalContainer).toBeHidden()
await expect(page.locator('#field-customTextServer')).toBeDisabled()
await page.locator('#take-over').click()
// eslint-disable-next-line payload/no-wait-function
await wait(500)
await expect(page.locator('#field-customTextServer')).toBeEnabled()
})
}) })
describe('document locking - previous user', () => { describe('document locking - previous user', () => {
let postDoc: Post let postDoc: Post
let serverComponentsDoc: ServerComponent
let user2: User let user2: User
beforeAll(async () => { beforeAll(async () => {
@@ -1010,6 +1170,11 @@ describe('Locked Documents', () => {
text: 'hello', text: 'hello',
}) })
serverComponentsDoc = await payload.create({
collection: 'server-components',
data: {},
})
user2 = await payload.create({ user2 = await payload.create({
collection: 'users', collection: 'users',
data: { data: {
@@ -1030,6 +1195,11 @@ describe('Locked Documents', () => {
collection: 'posts', collection: 'posts',
id: postDoc.id, id: postDoc.id,
}) })
await payload.delete({
collection: 'server-components',
id: serverComponentsDoc.id,
})
}) })
test('should show Document Take Over modal for previous user if taken over', async () => { test('should show Document Take Over modal for previous user if taken over', async () => {
await page.goto(postsUrl.edit(postDoc.id)) await page.goto(postsUrl.edit(postDoc.id))
@@ -1190,6 +1360,57 @@ describe('Locked Documents', () => {
// fields should be readOnly / disabled // fields should be readOnly / disabled
await expect(page.locator('#field-text')).toBeDisabled() await expect(page.locator('#field-text')).toBeDisabled()
}) })
test('should show server rendered fields in read-only mode if previous user views doc in read-only mode', async () => {
await page.goto(serverComponentsUrl.edit(serverComponentsDoc.id))
const textInput = page.locator('#field-customTextServer')
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: serverComponentsDoc.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 as number | string,
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()
// fields should be readOnly / disabled
await expect(page.locator('#field-customTextServer')).toBeDisabled()
})
}) })
describe('dashboard - globals', () => { describe('dashboard - globals', () => {

View File

@@ -69,6 +69,7 @@ export interface Config {
collections: { collections: {
pages: Page; pages: Page;
posts: Post; posts: Post;
'server-components': ServerComponent;
tests: Test; tests: Test;
users: User; users: User;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
@@ -79,6 +80,7 @@ export interface Config {
collectionsSelect: { collectionsSelect: {
pages: PagesSelect<false> | PagesSelect<true>; pages: PagesSelect<false> | PagesSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>; posts: PostsSelect<false> | PostsSelect<true>;
'server-components': ServerComponentsSelect<false> | ServerComponentsSelect<true>;
tests: TestsSelect<false> | TestsSelect<true>; tests: TestsSelect<false> | TestsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -161,6 +163,31 @@ export interface Post {
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "server-components".
*/
export interface ServerComponent {
id: string;
customTextServer?: string | null;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tests". * via the `definition` "tests".
@@ -213,6 +240,10 @@ export interface PayloadLockedDocument {
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: string | Post;
} | null) } | null)
| ({
relationTo: 'server-components';
value: string | ServerComponent;
} | null)
| ({ | ({
relationTo: 'tests'; relationTo: 'tests';
value: string | Test; value: string | Test;
@@ -285,6 +316,16 @@ export interface PostsSelect<T extends boolean = true> {
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "server-components_select".
*/
export interface ServerComponentsSelect<T extends boolean = true> {
customTextServer?: T;
richText?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tests_select". * via the `definition` "tests_select".