fix: lock documents using the live-preview view (#8343)

Updates:
- Exports `handleGoBack`, `handleBackToDashboard`, & `handleTakeOver`
functions to consolidate logic in default edit view & live-preview edit
view.

- Only unlock document on navigation away from edit view entirely (aka
do not unlock document if switching between tabs like `edit` -->
`live-preview` --> `versions` --> `api`
This commit is contained in:
Patrik
2024-09-24 16:38:11 -04:00
committed by GitHub
parent 32c8d2821b
commit 57f93c97a1
8 changed files with 393 additions and 98 deletions

View File

@@ -17,7 +17,13 @@ import {
useEditDepth, useEditDepth,
useUploadEdits, useUploadEdits,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { formatAdminURL, getFormState } from '@payloadcms/ui/shared' import {
formatAdminURL,
getFormState,
handleBackToDashboard,
handleGoBack,
handleTakeOver,
} from '@payloadcms/ui/shared'
import { useRouter, useSearchParams } from 'next/navigation.js' import { useRouter, useSearchParams } from 'next/navigation.js'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
@@ -151,89 +157,6 @@ export const DefaultEditView: React.FC = () => {
return false return false
}) })
const handleTakeOver = useCallback(() => {
if (!isLockingEnabled) {
return
}
try {
// Call updateDocumentEditor to update the document's owner to the current user
void updateDocumentEditor(id, collectionSlug ?? globalSlug, user)
documentLockStateRef.current.hasShownLockedModal = true
// Update the locked state to reflect the current user as the owner
documentLockStateRef.current = {
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal,
isLocked: true,
user,
}
setCurrentEditor(user)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error during document takeover:', error)
}
}, [
updateDocumentEditor,
id,
collectionSlug,
globalSlug,
user,
setCurrentEditor,
isLockingEnabled,
])
const handleTakeOverWithinDoc = useCallback(() => {
if (!isLockingEnabled) {
return
}
try {
// Call updateDocumentEditor to update the document's owner to the current user
void updateDocumentEditor(id, collectionSlug ?? globalSlug, user)
// Update the locked state to reflect the current user as the owner
documentLockStateRef.current = {
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal,
isLocked: true,
user,
}
setCurrentEditor(user)
// Ensure the document is editable for the incoming user
setIsReadOnlyForIncomingUser(false)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error during document takeover:', error)
}
}, [
updateDocumentEditor,
id,
collectionSlug,
globalSlug,
user,
setCurrentEditor,
isLockingEnabled,
])
const handleGoBack = useCallback(() => {
const redirectRoute = formatAdminURL({
adminRoute,
path: collectionSlug ? `/collections/${collectionSlug}` : '/',
})
router.push(redirectRoute)
}, [adminRoute, collectionSlug, router])
const handleBackToDashboard = useCallback(() => {
setShowTakeOverModal(false)
const redirectRoute = formatAdminURL({
adminRoute,
path: '/',
})
router.push(redirectRoute)
}, [adminRoute, router])
const onSave = useCallback( const onSave = useCallback(
(json) => { (json) => {
reportUpdate({ reportUpdate({
@@ -373,7 +296,19 @@ export const DefaultEditView: React.FC = () => {
return return
} }
if ((id || globalSlug) && documentIsLocked) { const currentPath = window.location.pathname
const documentId = id || globalSlug
// Routes where we do NOT want to unlock the document
const stayWithinDocumentPaths = ['preview', 'api', 'versions']
const isStayingWithinDocument = stayWithinDocumentPaths.some((path) =>
currentPath.includes(path),
)
// Unlock the document only if we're actually navigating away from the document
if (documentId && documentIsLocked && !isStayingWithinDocument) {
// Check if this user is still the current editor // Check if this user is still the current editor
if (documentLockStateRef.current?.user?.id === user.id) { if (documentLockStateRef.current?.user?.id === user.id) {
void unlockDocument(id, collectionSlug ?? globalSlug) void unlockDocument(id, collectionSlug ?? globalSlug)
@@ -421,20 +356,32 @@ export const DefaultEditView: React.FC = () => {
{BeforeDocument} {BeforeDocument}
{isLockingEnabled && shouldShowDocumentLockedModal && !isReadOnlyForIncomingUser && ( {isLockingEnabled && shouldShowDocumentLockedModal && !isReadOnlyForIncomingUser && (
<DocumentLocked <DocumentLocked
handleGoBack={handleGoBack} handleGoBack={() => handleGoBack({ adminRoute, collectionSlug, router })}
isActive={shouldShowDocumentLockedModal} isActive={shouldShowDocumentLockedModal}
onReadOnly={() => { onReadOnly={() => {
setIsReadOnlyForIncomingUser(true) setIsReadOnlyForIncomingUser(true)
setShowTakeOverModal(false) setShowTakeOverModal(false)
}} }}
onTakeOver={handleTakeOver} onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
false,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
)
}
updatedAt={lastUpdateTime} updatedAt={lastUpdateTime}
user={currentEditor} user={currentEditor}
/> />
)} )}
{isLockingEnabled && showTakeOverModal && ( {isLockingEnabled && showTakeOverModal && (
<DocumentTakeOver <DocumentTakeOver
handleBackToDashboard={handleBackToDashboard} handleBackToDashboard={() => handleBackToDashboard({ adminRoute, router })}
isActive={showTakeOverModal} isActive={showTakeOverModal}
onReadOnly={() => { onReadOnly={() => {
setIsReadOnlyForIncomingUser(true) setIsReadOnlyForIncomingUser(true)
@@ -469,7 +416,20 @@ export const DefaultEditView: React.FC = () => {
onDrawerCreate={onDrawerCreate} onDrawerCreate={onDrawerCreate}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
onSave={onSave} onSave={onSave}
onTakeOver={handleTakeOverWithinDoc} onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
true,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
setIsReadOnlyForIncomingUser,
)
}
permissions={docPermissions} permissions={docPermissions}
readOnlyForIncomingUser={isReadOnlyForIncomingUser} readOnlyForIncomingUser={isReadOnlyForIncomingUser}
redirectAfterDelete={redirectAfterDelete} redirectAfterDelete={redirectAfterDelete}
@@ -494,6 +454,7 @@ export const DefaultEditView: React.FC = () => {
requirePassword={!id} requirePassword={!id}
setSchemaPath={setSchemaPath} setSchemaPath={setSchemaPath}
setValidateBeforeSubmit={setValidateBeforeSubmit} setValidateBeforeSubmit={setValidateBeforeSubmit}
// eslint-disable-next-line react-compiler/react-compiler
useAPIKey={auth.useAPIKey} useAPIKey={auth.useAPIKey}
username={data?.username} username={data?.username}
verify={auth.verify} verify={auth.verify}

View File

@@ -5,6 +5,7 @@ import type {
ClientConfig, ClientConfig,
ClientField, ClientField,
ClientGlobalConfig, ClientGlobalConfig,
ClientUser,
Data, Data,
LivePreviewConfig, LivePreviewConfig,
} from 'payload' } from 'payload'
@@ -21,9 +22,17 @@ import {
useDocumentInfo, useDocumentInfo,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared' import {
import React, { Fragment, useCallback } from 'react' getFormState,
handleBackToDashboard,
handleGoBack,
handleTakeOver,
} from '@payloadcms/ui/shared'
import { useRouter } from 'next/navigation.js'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { DocumentLocked } from '../../elements/DocumentLocked/index.js'
import { DocumentTakeOver } from '../../elements/DocumentTakeOver/index.js'
import { LeaveWithoutSaving } from '../../elements/LeaveWithoutSaving/index.js' import { LeaveWithoutSaving } from '../../elements/LeaveWithoutSaving/index.js'
import { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js' import { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js'
import { SetDocumentTitle } from '../Edit/Default/SetDocumentTitle/index.js' import { SetDocumentTitle } from '../Edit/Default/SetDocumentTitle/index.js'
@@ -63,9 +72,11 @@ const PreviewView: React.FC<Props> = ({
BeforeDocument, BeforeDocument,
BeforeFields, BeforeFields,
collectionSlug, collectionSlug,
currentEditor,
disableActions, disableActions,
disableLeaveWithoutSaving, disableLeaveWithoutSaving,
docPermissions, docPermissions,
documentIsLocked,
getDocPreferences, getDocPreferences,
globalSlug, globalSlug,
hasPublishPermission, hasPublishPermission,
@@ -75,6 +86,10 @@ const PreviewView: React.FC<Props> = ({
isEditing, isEditing,
isInitializing, isInitializing,
onSave: onSaveFromProps, onSave: onSaveFromProps,
setCurrentEditor,
setDocumentIsLocked,
unlockDocument,
updateDocumentEditor,
} = useDocumentInfo() } = useDocumentInfo()
const operation = id ? 'update' : 'create' const operation = id ? 'update' : 'create'
@@ -82,13 +97,36 @@ const PreviewView: React.FC<Props> = ({
const { const {
config: { config: {
admin: { user: userSlug }, admin: { user: userSlug },
routes: { admin: adminRoute },
}, },
} = useConfig() } = useConfig()
const router = useRouter()
const { t } = useTranslation() const { t } = useTranslation()
const { previewWindowType } = useLivePreviewContext() const { previewWindowType } = useLivePreviewContext()
const { refreshCookieAsync, user } = useAuth() const { refreshCookieAsync, user } = useAuth()
const { reportUpdate } = useDocumentEvents() const { reportUpdate } = useDocumentEvents()
const docConfig = collectionConfig || globalConfig
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
const isLockingEnabled = lockDocumentsProp !== false
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const documentLockStateRef = useRef<{
hasShownLockedModal: boolean
isLocked: boolean
user: ClientUser
} | null>({
hasShownLockedModal: false,
isLocked: false,
user: null,
})
const [lastUpdateTime, setLastUpdateTime] = useState(Date.now())
const onSave = useCallback( const onSave = useCallback(
(json) => { (json) => {
reportUpdate({ reportUpdate({
@@ -103,6 +141,11 @@ const PreviewView: React.FC<Props> = ({
void refreshCookieAsync() void refreshCookieAsync()
} }
// Unlock the document after save
if ((id || globalSlug) && isLockingEnabled) {
setDocumentIsLocked(false)
}
if (typeof onSaveFromProps === 'function') { if (typeof onSaveFromProps === 'function') {
void onSaveFromProps({ void onSaveFromProps({
...json, ...json,
@@ -110,47 +153,194 @@ const PreviewView: React.FC<Props> = ({
}) })
} }
}, },
[collectionSlug, id, onSaveFromProps, refreshCookieAsync, reportUpdate, user, userSlug], [
collectionSlug,
globalSlug,
id,
isLockingEnabled,
onSaveFromProps,
refreshCookieAsync,
reportUpdate,
setDocumentIsLocked,
user,
userSlug,
],
) )
const onChange: FormProps['onChange'][0] = useCallback( const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - lastUpdateTime
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
if (updateLastEdited) {
setLastUpdateTime(currentTime)
}
const docPreferences = await getDocPreferences() const docPreferences = await getDocPreferences()
const { state } = await getFormState({ const { lockedState, state } = await getFormState({
apiRoute, apiRoute,
body: { body: {
id, id,
collectionSlug,
docPreferences, docPreferences,
formState: prevFormState, formState: prevFormState,
globalSlug,
operation, operation,
returnLockStatus: isLockingEnabled ? true : false,
schemaPath, schemaPath,
updateLastEdited,
}, },
serverURL, serverURL,
}) })
setDocumentIsLocked(true)
if (isLockingEnabled) {
const previousOwnerId = documentLockStateRef.current?.user?.id
if (lockedState) {
if (!documentLockStateRef.current || lockedState.user.id !== previousOwnerId) {
if (previousOwnerId === user.id && lockedState.user.id !== user.id) {
setShowTakeOverModal(true)
documentLockStateRef.current.hasShownLockedModal = true
}
documentLockStateRef.current = documentLockStateRef.current = {
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal || false,
isLocked: true,
user: lockedState.user,
}
setCurrentEditor(lockedState.user)
}
}
}
return state return state
}, },
[serverURL, apiRoute, id, operation, schemaPath, getDocPreferences], [
collectionSlug,
globalSlug,
serverURL,
apiRoute,
id,
isLockingEnabled,
lastUpdateTime,
operation,
schemaPath,
getDocPreferences,
setCurrentEditor,
setDocumentIsLocked,
user,
],
) )
// Clean up when the component unmounts or when the document is unlocked
useEffect(() => {
return () => {
if (!isLockingEnabled) {
return
}
const currentPath = window.location.pathname
const documentId = id || globalSlug
// Routes where we do NOT want to unlock the document
const stayWithinDocumentPaths = ['preview', 'api', 'versions']
const isStayingWithinDocument = stayWithinDocumentPaths.some((path) =>
currentPath.includes(path),
)
// Unlock the document only if we're actually navigating away from the document
if (documentId && documentIsLocked && !isStayingWithinDocument) {
// Check if this user is still the current editor
if (documentLockStateRef.current?.user?.id === user.id) {
void unlockDocument(id, collectionSlug ?? globalSlug)
setDocumentIsLocked(false)
setCurrentEditor(null)
}
}
setShowTakeOverModal(false)
}
}, [
collectionSlug,
globalSlug,
id,
unlockDocument,
user.id,
setCurrentEditor,
isLockingEnabled,
documentIsLocked,
setDocumentIsLocked,
])
const shouldShowDocumentLockedModal =
documentIsLocked &&
currentEditor &&
currentEditor.id !== user.id &&
!isReadOnlyForIncomingUser &&
!showTakeOverModal &&
// eslint-disable-next-line react-compiler/react-compiler
!documentLockStateRef.current?.hasShownLockedModal
return ( return (
<OperationProvider operation={operation}> <OperationProvider operation={operation}>
<Form <Form
action={action} action={action}
className={`${baseClass}__form`} className={`${baseClass}__form`}
disabled={!hasSavePermission} disabled={isReadOnlyForIncomingUser || !hasSavePermission}
initialState={initialState} initialState={initialState}
isInitializing={isInitializing} isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'} method={id ? 'PATCH' : 'POST'}
onChange={[onChange]} onChange={[onChange]}
onSuccess={onSave} onSuccess={onSave}
> >
{isLockingEnabled && shouldShowDocumentLockedModal && !isReadOnlyForIncomingUser && (
<DocumentLocked
handleGoBack={() => handleGoBack({ adminRoute, collectionSlug, router })}
isActive={shouldShowDocumentLockedModal}
onReadOnly={() => {
setIsReadOnlyForIncomingUser(true)
setShowTakeOverModal(false)
}}
onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
false,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
)
}
updatedAt={lastUpdateTime}
user={currentEditor}
/>
)}
{isLockingEnabled && showTakeOverModal && (
<DocumentTakeOver
handleBackToDashboard={() => handleBackToDashboard({ adminRoute, router })}
isActive={showTakeOverModal}
onReadOnly={() => {
setIsReadOnlyForIncomingUser(true)
setShowTakeOverModal(false)
}}
/>
)}
{((collectionConfig && {((collectionConfig &&
!(collectionConfig.versions?.drafts && collectionConfig.versions?.drafts?.autosave)) || !(collectionConfig.versions?.drafts && collectionConfig.versions?.drafts?.autosave)) ||
(globalConfig && (globalConfig &&
!(globalConfig.versions?.drafts && globalConfig.versions?.drafts?.autosave))) && !(globalConfig.versions?.drafts && globalConfig.versions?.drafts?.autosave))) &&
!disableLeaveWithoutSaving && <LeaveWithoutSaving />} !disableLeaveWithoutSaving &&
!isReadOnlyForIncomingUser && <LeaveWithoutSaving />}
<SetDocumentStepNav <SetDocumentStepNav
collectionSlug={collectionSlug} collectionSlug={collectionSlug}
globalLabel={globalConfig?.label} globalLabel={globalConfig?.label}
@@ -174,8 +364,24 @@ const PreviewView: React.FC<Props> = ({
hasSavePermission={hasSavePermission} hasSavePermission={hasSavePermission}
id={id} id={id}
isEditing={isEditing} isEditing={isEditing}
onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
true,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
setIsReadOnlyForIncomingUser,
)
}
permissions={docPermissions} permissions={docPermissions}
readOnlyForIncomingUser={isReadOnlyForIncomingUser}
slug={collectionConfig?.slug || globalConfig?.slug} slug={collectionConfig?.slug || globalConfig?.slug}
user={currentEditor}
/> />
<div <div
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`] className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
@@ -197,7 +403,7 @@ const PreviewView: React.FC<Props> = ({
docPermissions={docPermissions} docPermissions={docPermissions}
fields={fields} fields={fields}
forceSidebarWrap forceSidebarWrap
readOnly={!hasSavePermission} readOnly={isReadOnlyForIncomingUser || !hasSavePermission}
schemaPath={collectionSlug || globalSlug} schemaPath={collectionSlug || globalSlug}
/> />
{AfterDocument} {AfterDocument}

View File

@@ -235,7 +235,7 @@ export const DocumentControls: React.FC<{
<Button <Button
buttonStyle="secondary" buttonStyle="secondary"
id="take-over" id="take-over"
onClick={() => void onTakeOver()} onClick={onTakeOver}
size="medium" size="medium"
type="button" type="button"
> >

View File

@@ -20,5 +20,8 @@ export {
type Group, type Group,
groupNavItems, groupNavItems,
} from '../../utilities/groupNavItems.js' } from '../../utilities/groupNavItems.js'
export { handleBackToDashboard } from '../../utilities/handleBackToDashboard.js'
export { handleGoBack } from '../../utilities/handleGoBack.js'
export { handleTakeOver } from '../../utilities/handleTakeOver.js'
export { hasSavePermission } from '../../utilities/hasSavePermission.js' export { hasSavePermission } from '../../utilities/hasSavePermission.js'
export { isEditing } from '../../utilities/isEditing.js' export { isEditing } from '../../utilities/isEditing.js'

View File

@@ -0,0 +1,16 @@
import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime.js'
import { formatAdminURL } from './formatAdminURL.js'
type BackToDashboardProps = {
adminRoute: string
router: AppRouterInstance
}
export const handleBackToDashboard = ({ adminRoute, router }: BackToDashboardProps) => {
const redirectRoute = formatAdminURL({
adminRoute,
path: '/',
})
router.push(redirectRoute)
}

View File

@@ -0,0 +1,17 @@
import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime.js'
import { formatAdminURL } from './formatAdminURL.js'
type GoBackProps = {
adminRoute: string
collectionSlug: string
router: AppRouterInstance
}
export const handleGoBack = ({ adminRoute, collectionSlug, router }: GoBackProps) => {
const redirectRoute = formatAdminURL({
adminRoute,
path: collectionSlug ? `/collections/${collectionSlug}` : '/',
})
router.push(redirectRoute)
}

View File

@@ -0,0 +1,47 @@
import type { ClientUser } from 'payload'
export const handleTakeOver = (
id: number | string,
collectionSlug: string,
globalSlug: string,
user: ClientUser,
isWithinDoc: boolean,
updateDocumentEditor: (docId: number | string, slug: string, user: ClientUser) => Promise<void>,
setCurrentEditor: (value: React.SetStateAction<ClientUser>) => void,
documentLockStateRef: React.RefObject<{
hasShownLockedModal: boolean
isLocked: boolean
user: ClientUser
}>,
isLockingEnabled: boolean,
setIsReadOnlyForIncomingUser?: (value: React.SetStateAction<boolean>) => void,
): void => {
if (!isLockingEnabled) {
return
}
try {
// Call updateDocumentEditor to update the document's owner to the current user
void updateDocumentEditor(id, collectionSlug ?? globalSlug, user)
if (!isWithinDoc) {
documentLockStateRef.current.hasShownLockedModal = true
}
// Update the locked state to reflect the current user as the owner
documentLockStateRef.current = {
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal,
isLocked: true,
user,
}
setCurrentEditor(user)
// If this is a takeover within the document, ensure the document is editable
if (isWithinDoc && setIsReadOnlyForIncomingUser) {
setIsReadOnlyForIncomingUser(false)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error during document takeover:', error)
}
}

View File

@@ -292,6 +292,51 @@ describe('locked documents', () => {
expect(unlockedDocs.docs.length).toBe(0) expect(unlockedDocs.docs.length).toBe(0)
}) })
test('should keep document locked when navigating to other tabs i.e. api', async () => {
await page.goto(postsUrl.edit(postDoc.id))
await page.waitForURL(postsUrl.edit(postDoc.id))
const textInput = page.locator('#field-text')
await textInput.fill('testing tab navigation...')
// eslint-disable-next-line payload/no-wait-function
await wait(500)
const lockedDocs = await payload.find({
collection: lockedDocumentCollection,
limit: 1,
pagination: false,
where: {
'document.value': { equals: postDoc.id },
},
})
expect(lockedDocs.docs.length).toBe(1)
await page.locator('li[aria-label="API"] a').click()
// Locate the modal container
const modalContainer = page.locator('.payload__modal-container')
await expect(modalContainer).toBeVisible()
// Click the "Leave anyway" button
await page.locator('.leave-without-saving__controls .btn--style-primary').click()
// eslint-disable-next-line payload/no-wait-function
await wait(500)
const unlockedDocs = await payload.find({
collection: lockedDocumentCollection,
limit: 1,
pagination: false,
where: {
'document.value': { equals: postDoc.id },
},
})
expect(unlockedDocs.docs.length).toBe(1)
})
}) })
describe('document locking - incoming user', () => { describe('document locking - incoming user', () => {