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:
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
test/locked-documents/collections/ServerComponents/index.ts
Normal file
25
test/locked-documents/collections/ServerComponents/index.ts
Normal 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
Reference in New Issue
Block a user