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,
useUploadEdits,
} 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 React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
@@ -151,89 +157,6 @@ export const DefaultEditView: React.FC = () => {
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(
(json) => {
reportUpdate({
@@ -373,7 +296,19 @@ export const DefaultEditView: React.FC = () => {
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
if (documentLockStateRef.current?.user?.id === user.id) {
void unlockDocument(id, collectionSlug ?? globalSlug)
@@ -421,20 +356,32 @@ export const DefaultEditView: React.FC = () => {
{BeforeDocument}
{isLockingEnabled && shouldShowDocumentLockedModal && !isReadOnlyForIncomingUser && (
<DocumentLocked
handleGoBack={handleGoBack}
handleGoBack={() => handleGoBack({ adminRoute, collectionSlug, router })}
isActive={shouldShowDocumentLockedModal}
onReadOnly={() => {
setIsReadOnlyForIncomingUser(true)
setShowTakeOverModal(false)
}}
onTakeOver={handleTakeOver}
onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
false,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
)
}
updatedAt={lastUpdateTime}
user={currentEditor}
/>
)}
{isLockingEnabled && showTakeOverModal && (
<DocumentTakeOver
handleBackToDashboard={handleBackToDashboard}
handleBackToDashboard={() => handleBackToDashboard({ adminRoute, router })}
isActive={showTakeOverModal}
onReadOnly={() => {
setIsReadOnlyForIncomingUser(true)
@@ -469,7 +416,20 @@ export const DefaultEditView: React.FC = () => {
onDrawerCreate={onDrawerCreate}
onDuplicate={onDuplicate}
onSave={onSave}
onTakeOver={handleTakeOverWithinDoc}
onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
true,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
setIsReadOnlyForIncomingUser,
)
}
permissions={docPermissions}
readOnlyForIncomingUser={isReadOnlyForIncomingUser}
redirectAfterDelete={redirectAfterDelete}
@@ -494,6 +454,7 @@ export const DefaultEditView: React.FC = () => {
requirePassword={!id}
setSchemaPath={setSchemaPath}
setValidateBeforeSubmit={setValidateBeforeSubmit}
// eslint-disable-next-line react-compiler/react-compiler
useAPIKey={auth.useAPIKey}
username={data?.username}
verify={auth.verify}

View File

@@ -5,6 +5,7 @@ import type {
ClientConfig,
ClientField,
ClientGlobalConfig,
ClientUser,
Data,
LivePreviewConfig,
} from 'payload'
@@ -21,9 +22,17 @@ import {
useDocumentInfo,
useTranslation,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import React, { Fragment, useCallback } from 'react'
import {
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 { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js'
import { SetDocumentTitle } from '../Edit/Default/SetDocumentTitle/index.js'
@@ -63,9 +72,11 @@ const PreviewView: React.FC<Props> = ({
BeforeDocument,
BeforeFields,
collectionSlug,
currentEditor,
disableActions,
disableLeaveWithoutSaving,
docPermissions,
documentIsLocked,
getDocPreferences,
globalSlug,
hasPublishPermission,
@@ -75,6 +86,10 @@ const PreviewView: React.FC<Props> = ({
isEditing,
isInitializing,
onSave: onSaveFromProps,
setCurrentEditor,
setDocumentIsLocked,
unlockDocument,
updateDocumentEditor,
} = useDocumentInfo()
const operation = id ? 'update' : 'create'
@@ -82,13 +97,36 @@ const PreviewView: React.FC<Props> = ({
const {
config: {
admin: { user: userSlug },
routes: { admin: adminRoute },
},
} = useConfig()
const router = useRouter()
const { t } = useTranslation()
const { previewWindowType } = useLivePreviewContext()
const { refreshCookieAsync, user } = useAuth()
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(
(json) => {
reportUpdate({
@@ -103,6 +141,11 @@ const PreviewView: React.FC<Props> = ({
void refreshCookieAsync()
}
// Unlock the document after save
if ((id || globalSlug) && isLockingEnabled) {
setDocumentIsLocked(false)
}
if (typeof onSaveFromProps === 'function') {
void onSaveFromProps({
...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(
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 { state } = await getFormState({
const { lockedState, state } = await getFormState({
apiRoute,
body: {
id,
collectionSlug,
docPreferences,
formState: prevFormState,
globalSlug,
operation,
returnLockStatus: isLockingEnabled ? true : false,
schemaPath,
updateLastEdited,
},
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
},
[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 (
<OperationProvider operation={operation}>
<Form
action={action}
className={`${baseClass}__form`}
disabled={!hasSavePermission}
disabled={isReadOnlyForIncomingUser || !hasSavePermission}
initialState={initialState}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
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.versions?.drafts && collectionConfig.versions?.drafts?.autosave)) ||
(globalConfig &&
!(globalConfig.versions?.drafts && globalConfig.versions?.drafts?.autosave))) &&
!disableLeaveWithoutSaving && <LeaveWithoutSaving />}
!disableLeaveWithoutSaving &&
!isReadOnlyForIncomingUser && <LeaveWithoutSaving />}
<SetDocumentStepNav
collectionSlug={collectionSlug}
globalLabel={globalConfig?.label}
@@ -174,8 +364,24 @@ const PreviewView: React.FC<Props> = ({
hasSavePermission={hasSavePermission}
id={id}
isEditing={isEditing}
onTakeOver={() =>
handleTakeOver(
id,
collectionSlug,
globalSlug,
user,
true,
updateDocumentEditor,
setCurrentEditor,
documentLockStateRef,
isLockingEnabled,
setIsReadOnlyForIncomingUser,
)
}
permissions={docPermissions}
readOnlyForIncomingUser={isReadOnlyForIncomingUser}
slug={collectionConfig?.slug || globalConfig?.slug}
user={currentEditor}
/>
<div
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
@@ -197,7 +403,7 @@ const PreviewView: React.FC<Props> = ({
docPermissions={docPermissions}
fields={fields}
forceSidebarWrap
readOnly={!hasSavePermission}
readOnly={isReadOnlyForIncomingUser || !hasSavePermission}
schemaPath={collectionSlug || globalSlug}
/>
{AfterDocument}

View File

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

View File

@@ -20,5 +20,8 @@ export {
type Group,
groupNavItems,
} 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 { 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)
})
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', () => {