diff --git a/packages/next/src/views/Document/getIsLocked.ts b/packages/next/src/views/Document/getIsLocked.ts index 695a54893..64173161a 100644 --- a/packages/next/src/views/Document/getIsLocked.ts +++ b/packages/next/src/views/Document/getIsLocked.ts @@ -7,6 +7,7 @@ import type { } from 'payload' import { sanitizeID } from '@payloadcms/ui/shared' +import { extractID } from 'payload/shared' type Args = { collectionConfig?: SanitizedCollectionConfig @@ -93,12 +94,12 @@ export const getIsLocked = async ({ }) if (docs.length > 0) { - const newEditor = docs[0].user?.value + const currentEditor = docs[0].user?.value const lastUpdateTime = new Date(docs[0].updatedAt).getTime() - if (newEditor?.id !== req.user.id) { + if (extractID(currentEditor) !== req.user.id) { return { - currentEditor: newEditor, + currentEditor, isLocked: true, lastUpdateTime, } diff --git a/packages/ui/src/elements/DocumentLocked/index.tsx b/packages/ui/src/elements/DocumentLocked/index.tsx index eb6677632..f01ac0f78 100644 --- a/packages/ui/src/elements/DocumentLocked/index.tsx +++ b/packages/ui/src/elements/DocumentLocked/index.tsx @@ -3,6 +3,7 @@ import type { ClientUser } from 'payload' import React, { useEffect } from 'react' +import { useRouteCache } from '../../providers/RouteCache/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { isClientUserObject } from '../../utilities/isClientUserObject.js' @@ -38,6 +39,7 @@ export const DocumentLocked: React.FC<{ }> = ({ handleGoBack, isActive, onReadOnly, onTakeOver, updatedAt, user }) => { const { closeModal, openModal } = useModal() const { t } = useTranslation() + const { clearRouteCache } = useRouteCache() const { startRouteTransition } = useRouteTransition() useEffect(() => { @@ -86,6 +88,7 @@ export const DocumentLocked: React.FC<{ onClick={() => { onReadOnly() closeModal(modalSlug) + clearRouteCache() }} size="large" > @@ -95,7 +98,7 @@ export const DocumentLocked: React.FC<{ buttonStyle="primary" id={`${modalSlug}-take-over`} onClick={() => { - void onTakeOver() + onTakeOver() closeModal(modalSlug) }} size="large" diff --git a/packages/ui/src/elements/DocumentTakeOver/index.tsx b/packages/ui/src/elements/DocumentTakeOver/index.tsx index 28f333647..24c38cfee 100644 --- a/packages/ui/src/elements/DocumentTakeOver/index.tsx +++ b/packages/ui/src/elements/DocumentTakeOver/index.tsx @@ -1,6 +1,7 @@ 'use client' import React, { useEffect } from 'react' +import { useRouteCache } from '../../providers/RouteCache/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { Button } from '../Button/index.js' @@ -19,6 +20,7 @@ export const DocumentTakeOver: React.FC<{ const { closeModal, openModal } = useModal() const { t } = useTranslation() const { startRouteTransition } = useRouteTransition() + const { clearRouteCache } = useRouteCache() useEffect(() => { if (isActive) { @@ -52,6 +54,7 @@ export const DocumentTakeOver: React.FC<{ onClick={() => { onReadOnly() closeModal(modalSlug) + clearRouteCache() }} size="large" > diff --git a/packages/ui/src/utilities/handleTakeOver.tsx b/packages/ui/src/utilities/handleTakeOver.tsx index e7cfea14f..8130684a0 100644 --- a/packages/ui/src/utilities/handleTakeOver.tsx +++ b/packages/ui/src/utilities/handleTakeOver.tsx @@ -1,32 +1,47 @@ import type { ClientUser } from 'payload' -export const handleTakeOver = ( - id: number | string, - collectionSlug: string, - globalSlug: string, - user: ClientUser | number | string, - isWithinDoc: boolean, - updateDocumentEditor: ( - docID: number | string, - slug: string, - user: ClientUser | number | string, - ) => Promise, - setCurrentEditor: (value: React.SetStateAction) => void, +export interface HandleTakeOverParams { + clearRouteCache?: () => void + collectionSlug?: string documentLockStateRef: React.RefObject<{ hasShownLockedModal: boolean isLocked: boolean user: ClientUser | number | string - }>, - isLockingEnabled: boolean, - setIsReadOnlyForIncomingUser?: (value: React.SetStateAction) => void, -): void => { + }> + globalSlug?: string + id: number | string + isLockingEnabled: boolean + isWithinDoc: boolean + setCurrentEditor: (value: React.SetStateAction) => void + setIsReadOnlyForIncomingUser?: (value: React.SetStateAction) => void + updateDocumentEditor: ( + docID: number | string, + slug: string, + user: ClientUser | number | string, + ) => Promise + user: ClientUser | number | string +} + +export const handleTakeOver = async ({ + id, + clearRouteCache, + collectionSlug, + documentLockStateRef, + globalSlug, + isLockingEnabled, + isWithinDoc, + setCurrentEditor, + setIsReadOnlyForIncomingUser, + updateDocumentEditor, + user, +}: HandleTakeOverParams): Promise => { if (!isLockingEnabled) { return } try { // 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) { documentLockStateRef.current.hasShownLockedModal = true @@ -44,6 +59,11 @@ export const handleTakeOver = ( if (isWithinDoc && setIsReadOnlyForIncomingUser) { 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) { // eslint-disable-next-line no-console console.error('Error during document takeover:', error) diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 0af9a7e35..01a63a233 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -28,6 +28,7 @@ import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLivePreviewContext } from '../../providers/LivePreview/context.js' import { OperationProvider } from '../../providers/Operation/index.js' +import { useRouteCache } from '../../providers/RouteCache/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { UploadControlsProvider } from '../../providers/UploadControls/index.js' @@ -87,6 +88,7 @@ export function DefaultEditView({ initialState, isEditing, isInitializing, + isLocked, isTrashed, lastUpdateTime, redirectAfterCreate, @@ -134,6 +136,7 @@ export function DefaultEditView({ const { resetUploadEdits } = useUploadEdits() const { getFormState } = useServerFunctions() const { startRouteTransition } = useRouteTransition() + const { clearRouteCache } = useRouteCache() const { isLivePreviewEnabled, isLivePreviewing, @@ -511,6 +514,7 @@ export function DefaultEditView({ initialState={!isInitializing && initialState} isDocumentForm={true} isInitializing={isInitializing} + key={`${isLocked}`} method={id ? 'PATCH' : 'POST'} onChange={[onChange]} onSuccess={onSave} @@ -527,17 +531,18 @@ export function DefaultEditView({ setShowTakeOverModal(false) }} onTakeOver={() => - handleTakeOver( + handleTakeOver({ id, + clearRouteCache, collectionSlug, + documentLockStateRef: documentLockState, globalSlug, - user, - false, - updateDocumentEditor, - setCurrentEditor, - documentLockState, isLockingEnabled, - ) + isWithinDoc: false, + setCurrentEditor, + updateDocumentEditor, + user, + }) } updatedAt={lastUpdateTime} user={currentEditor} @@ -597,18 +602,19 @@ export function DefaultEditView({ onRestore={onRestore} onSave={onSave} onTakeOver={() => - handleTakeOver( + handleTakeOver({ id, + clearRouteCache, collectionSlug, + documentLockStateRef: documentLockState, globalSlug, - user, - true, - updateDocumentEditor, - setCurrentEditor, - documentLockState, isLockingEnabled, + isWithinDoc: true, + setCurrentEditor, setIsReadOnlyForIncomingUser, - ) + updateDocumentEditor, + user, + }) } permissions={docPermissions} readOnlyForIncomingUser={isReadOnlyForIncomingUser} diff --git a/test/locked-documents/collections/Posts/fields/CustomTextFieldServer.tsx b/test/locked-documents/collections/Posts/fields/CustomTextFieldServer.tsx new file mode 100644 index 000000000..58f902d55 --- /dev/null +++ b/test/locked-documents/collections/Posts/fields/CustomTextFieldServer.tsx @@ -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 ( + + ) +} diff --git a/test/locked-documents/collections/ServerComponents/index.ts b/test/locked-documents/collections/ServerComponents/index.ts new file mode 100644 index 000000000..6375d267d --- /dev/null +++ b/test/locked-documents/collections/ServerComponents/index.ts @@ -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', + }, + ], +} diff --git a/test/locked-documents/config.ts b/test/locked-documents/config.ts index 1967cb3e1..7f30a4f72 100644 --- a/test/locked-documents/config.ts +++ b/test/locked-documents/config.ts @@ -4,6 +4,7 @@ import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { PagesCollection } from './collections/Pages/index.js' import { PostsCollection } from './collections/Posts/index.js' +import { ServerComponentsCollection } from './collections/ServerComponents/index.js' import { TestsCollection } from './collections/Tests/index.js' import { Users } from './collections/Users/index.js' import { AdminGlobal } from './globals/Admin/index.js' @@ -19,7 +20,13 @@ export default buildConfigWithDefaults({ baseDir: path.resolve(dirname), }, }, - collections: [PagesCollection, PostsCollection, TestsCollection, Users], + collections: [ + PagesCollection, + PostsCollection, + ServerComponentsCollection, + TestsCollection, + Users, + ], globals: [AdminGlobal, MenuGlobal], onInit: async (payload) => { if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { diff --git a/test/locked-documents/e2e.spec.ts b/test/locked-documents/e2e.spec.ts index 585f4cefa..a70a7db28 100644 --- a/test/locked-documents/e2e.spec.ts +++ b/test/locked-documents/e2e.spec.ts @@ -13,6 +13,7 @@ import type { Page as PageType, PayloadLockedDocument, Post, + ServerComponent, Test, User, } from './payload-types.js' @@ -25,7 +26,7 @@ import { } 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 { TEST_TIMEOUT_LONG } from '../playwright.config.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -38,6 +39,7 @@ let page: Page let globalUrl: AdminUrlUtil let postsUrl: AdminUrlUtil let pagesUrl: AdminUrlUtil +let serverComponentsUrl: AdminUrlUtil let testsUrl: AdminUrlUtil let payload: PayloadTestSDK let serverURL: string @@ -50,6 +52,7 @@ describe('Locked Documents', () => { globalUrl = new AdminUrlUtil(serverURL, 'menu') postsUrl = new AdminUrlUtil(serverURL, 'posts') pagesUrl = new AdminUrlUtil(serverURL, 'pages') + serverComponentsUrl = new AdminUrlUtil(serverURL, 'server-components') testsUrl = new AdminUrlUtil(serverURL, 'tests') const context = await browser.newContext() @@ -633,11 +636,19 @@ describe('Locked Documents', () => { let expiredPostDoc: Post let expiredPostLockedDoc: PayloadLockedDocument + let serverComponentDoc: ServerComponent + let lockedServerComponentDoc: PayloadLockedDocument + beforeAll(async () => { postDoc = await createPostDoc({ text: 'new post doc', }) + serverComponentDoc = await payload.create({ + collection: 'server-components', + data: {}, + }) + expiredTestDoc = await createTestDoc({ text: 'expired doc', }) @@ -701,6 +712,21 @@ describe('Locked Documents', () => { 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 () => { @@ -714,6 +740,11 @@ describe('Locked Documents', () => { id: lockedDoc.id, }) + await payload.delete({ + collection: lockedDocumentCollection, + id: lockedServerComponentDoc.id, + }) + await payload.delete({ collection: lockedDocumentCollection, id: expiredTestLockedDoc.id, @@ -724,6 +755,11 @@ describe('Locked Documents', () => { id: postDoc.id, }) + await payload.delete({ + collection: 'server-components', + id: serverComponentDoc.id, + }) + await payload.delete({ collection: 'tests', id: expiredTestDoc.id, @@ -814,23 +850,46 @@ describe('Locked Documents', () => { await expect(richTextRoot).toHaveAttribute('contenteditable', 'false') 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( /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', () => { let postDoc: Post let user2: User let lockedDoc: PayloadLockedDocument + let serverComponentDoc: ServerComponent + let lockedServerComponentsDoc: PayloadLockedDocument beforeAll(async () => { postDoc = await createPostDoc({ text: 'hello', }) + serverComponentDoc = await payload.create({ + collection: 'server-components', + data: {}, + }) + user2 = await payload.create({ collection: 'users', 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 () => { @@ -871,6 +945,21 @@ describe('Locked Documents', () => { collection: 'posts', 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 () => { @@ -908,18 +997,41 @@ describe('Locked Documents', () => { 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', () => { let postDoc: Post let user2: User let lockedDoc: PayloadLockedDocument + let serverComponentsDoc: ServerComponent + let lockedServerComponentsDoc: PayloadLockedDocument beforeAll(async () => { postDoc = await createPostDoc({ text: 'hello', }) + serverComponentsDoc = await payload.create({ + collection: 'server-components', + data: {}, + }) + user2 = await payload.create({ collection: 'users', 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 () => { @@ -956,10 +1083,20 @@ describe('Locked Documents', () => { id: lockedDoc.id, }) + await payload.delete({ + collection: lockedDocumentCollection, + id: lockedServerComponentsDoc.id, + }) + await payload.delete({ collection: 'posts', 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 () => { @@ -999,10 +1136,33 @@ describe('Locked Documents', () => { 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', () => { let postDoc: Post + let serverComponentsDoc: ServerComponent let user2: User beforeAll(async () => { @@ -1010,6 +1170,11 @@ describe('Locked Documents', () => { text: 'hello', }) + serverComponentsDoc = await payload.create({ + collection: 'server-components', + data: {}, + }) + user2 = await payload.create({ collection: 'users', data: { @@ -1030,6 +1195,11 @@ describe('Locked Documents', () => { collection: 'posts', 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 () => { await page.goto(postsUrl.edit(postDoc.id)) @@ -1190,6 +1360,57 @@ describe('Locked Documents', () => { // fields should be readOnly / disabled 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', () => { diff --git a/test/locked-documents/payload-types.ts b/test/locked-documents/payload-types.ts index 00676285c..17c56f981 100644 --- a/test/locked-documents/payload-types.ts +++ b/test/locked-documents/payload-types.ts @@ -69,6 +69,7 @@ export interface Config { collections: { pages: Page; posts: Post; + 'server-components': ServerComponent; tests: Test; users: User; 'payload-locked-documents': PayloadLockedDocument; @@ -79,6 +80,7 @@ export interface Config { collectionsSelect: { pages: PagesSelect | PagesSelect; posts: PostsSelect | PostsSelect; + 'server-components': ServerComponentsSelect | ServerComponentsSelect; tests: TestsSelect | TestsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; @@ -161,6 +163,31 @@ export interface Post { createdAt: string; _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 * via the `definition` "tests". @@ -213,6 +240,10 @@ export interface PayloadLockedDocument { relationTo: 'posts'; value: string | Post; } | null) + | ({ + relationTo: 'server-components'; + value: string | ServerComponent; + } | null) | ({ relationTo: 'tests'; value: string | Test; @@ -285,6 +316,16 @@ export interface PostsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "server-components_select". + */ +export interface ServerComponentsSelect { + customTextServer?: T; + richText?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "tests_select".