fix(next, ui): only show locked docs that are not expired (#8899)

`Issue`:

Previously, documents that were locked but expired would still show in
the list view / render the `DocumentLocked` modal upon other users
entering the document.

The expected outcome should be having expired locked documents seen as
unlocked to other users.

I.e:

- Removing the lock icon from expired locks in the list view.
- Prevent the `DocumentLocked` modal from appearing for other users -
requiring a take over.

`Fix`:

- Only query for locked documents that are not expired, aka their
`updatedAt` dates are greater than the the current time minus the lock
duration.
- Performs a `deleteMany` on expired documents when any user edits any
other document in the same collection.

Fixes #8778 

`TODO`: Add tests
This commit is contained in:
Patrik
2024-10-28 20:05:26 -04:00
committed by GitHub
parent 7a7a2f3918
commit 1e002acce9
7 changed files with 111 additions and 16 deletions

View File

@@ -65,6 +65,7 @@ export const DefaultEditView: React.FC = () => {
initialState,
isEditing,
isInitializing,
lastUpdateTime,
onDelete,
onDrawerCreate,
onDuplicate,
@@ -110,9 +111,13 @@ export const DefaultEditView: React.FC = () => {
const docConfig = collectionConfig || globalConfig
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
const isLockingEnabled = lockDocumentsProp !== false
const lockDurationDefault = 300 // Default 5 minutes in seconds
const lockDuration =
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
let preventLeaveWithoutSaving = true
if (collectionConfig) {
@@ -130,6 +135,12 @@ export const DefaultEditView: React.FC = () => {
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
const isLockExpired = Date.now() > lockExpiryTime
const documentLockStateRef = useRef<{
hasShownLockedModal: boolean
isLocked: boolean
@@ -140,8 +151,6 @@ export const DefaultEditView: React.FC = () => {
user: null,
})
const [lastUpdateTime, setLastUpdateTime] = useState(Date.now())
const classes = [baseClass, (id || globalSlug) && `${baseClass}--is-editing`]
if (globalSlug) {
@@ -230,12 +239,12 @@ export const DefaultEditView: React.FC = () => {
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - lastUpdateTime
const timeSinceLastUpdate = currentTime - editSessionStartTime
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
if (updateLastEdited) {
setLastUpdateTime(currentTime)
setEditSessionStartTime(currentTime)
}
const docPreferences = await getDocPreferences()
@@ -283,6 +292,7 @@ export const DefaultEditView: React.FC = () => {
[
apiRoute,
collectionSlug,
editSessionStartTime,
schemaPath,
getDocPreferences,
globalSlug,
@@ -294,7 +304,6 @@ export const DefaultEditView: React.FC = () => {
setCurrentEditor,
isLockingEnabled,
setDocumentIsLocked,
lastUpdateTime,
],
)
@@ -346,7 +355,8 @@ export const DefaultEditView: React.FC = () => {
currentEditor.id !== user?.id &&
!isReadOnlyForIncomingUser &&
!showTakeOverModal &&
!documentLockStateRef.current?.hasShownLockedModal
!documentLockStateRef.current?.hasShownLockedModal &&
!isLockExpired
return (
<main className={classes.filter(Boolean).join(' ')}>

View File

@@ -85,6 +85,7 @@ const PreviewView: React.FC<Props> = ({
initialState,
isEditing,
isInitializing,
lastUpdateTime,
onSave: onSaveFromProps,
setCurrentEditor,
setDocumentIsLocked,
@@ -109,12 +110,22 @@ const PreviewView: React.FC<Props> = ({
const docConfig = collectionConfig || globalConfig
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
const isLockingEnabled = lockDocumentsProp !== false
const lockDurationDefault = 300 // Default 5 minutes in seconds
const lockDuration =
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
const isLockExpired = Date.now() > lockExpiryTime
const documentLockStateRef = useRef<{
hasShownLockedModal: boolean
isLocked: boolean
@@ -125,8 +136,6 @@ const PreviewView: React.FC<Props> = ({
user: null,
})
const [lastUpdateTime, setLastUpdateTime] = useState(Date.now())
const onSave = useCallback(
(json) => {
reportUpdate({
@@ -170,12 +179,12 @@ const PreviewView: React.FC<Props> = ({
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - lastUpdateTime
const timeSinceLastUpdate = currentTime - editSessionStartTime
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
if (updateLastEdited) {
setLastUpdateTime(currentTime)
setEditSessionStartTime(currentTime)
}
const docPreferences = await getDocPreferences()
@@ -222,12 +231,12 @@ const PreviewView: React.FC<Props> = ({
},
[
collectionSlug,
editSessionStartTime,
globalSlug,
serverURL,
apiRoute,
id,
isLockingEnabled,
lastUpdateTime,
operation,
schemaPath,
getDocPreferences,
@@ -286,7 +295,8 @@ const PreviewView: React.FC<Props> = ({
!isReadOnlyForIncomingUser &&
!showTakeOverModal &&
// eslint-disable-next-line react-compiler/react-compiler
!documentLockStateRef.current?.hasShownLockedModal
!documentLockStateRef.current?.hasShownLockedModal &&
!isLockExpired
return (
<OperationProvider operation={operation}>

View File

@@ -158,6 +158,13 @@ export const findOperation = async <TSlug extends CollectionSlug>(
if (includeLockStatus) {
try {
const lockDocumentsProp = collectionConfig?.lockDocuments
const lockDurationDefault = 300 // Default 5 minutes in seconds
const lockDuration =
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
const lockedDocuments = await payload.find({
collection: 'payload-locked-documents',
depth: 1,
@@ -176,14 +183,27 @@ export const findOperation = async <TSlug extends CollectionSlug>(
in: result.docs.map((doc) => doc.id),
},
},
// Query where the lock is newer than the current time minus lock time
{
updatedAt: {
greater_than: new Date(new Date().getTime() - lockDurationInMilliseconds),
},
},
],
},
})
const now = new Date().getTime()
const lockedDocs = Array.isArray(lockedDocuments?.docs) ? lockedDocuments.docs : []
// Filter out stale locks
const validLockedDocs = lockedDocs.filter((lock) => {
const lastEditedAt = new Date(lock?.updatedAt).getTime()
return lastEditedAt + lockDurationInMilliseconds > now
})
result.docs = result.docs.map((doc) => {
const lockedDoc = lockedDocs.find((lock) => lock?.document?.value === doc.id)
const lockedDoc = validLockedDocs.find((lock) => lock?.document?.value === doc.id)
return {
...doc,
_isLocked: !!lockedDoc,

View File

@@ -112,6 +112,13 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
let lockStatus = null
try {
const lockDocumentsProp = collectionConfig?.lockDocuments
const lockDurationDefault = 300 // Default 5 minutes in seconds
const lockDuration =
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
const lockedDocument = await req.payload.find({
collection: 'payload-locked-documents',
depth: 1,
@@ -130,6 +137,12 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
equals: id,
},
},
// Query where the lock is newer than the current time minus lock time
{
updatedAt: {
greater_than: new Date(new Date().getTime() - lockDurationInMilliseconds),
},
},
],
},
})

View File

@@ -106,6 +106,7 @@ const DocumentInfo: React.FC<
const [documentIsLocked, setDocumentIsLocked] = useState<boolean | undefined>(false)
const [currentEditor, setCurrentEditor] = useState<ClientUser | null>(null)
const [lastUpdateTime, setLastUpdateTime] = useState<number>(null)
const isInitializing = initialState === undefined || data === undefined
const [unpublishedVersions, setUnpublishedVersions] =
@@ -228,9 +229,11 @@ const DocumentInfo: React.FC<
if (docs.length > 0) {
const newEditor = docs[0].user?.value
const lastUpdatedAt = new Date(docs[0].updatedAt).getTime()
if (newEditor && newEditor.id !== currentEditor?.id) {
setCurrentEditor(newEditor)
setDocumentIsLocked(true)
setLastUpdateTime(lastUpdatedAt)
}
} else {
setDocumentIsLocked(false)
@@ -685,6 +688,7 @@ const DocumentInfo: React.FC<
initialState,
isInitializing,
isLoading,
lastUpdateTime,
onSave,
preferencesKey,
publishedDoc,

View File

@@ -67,6 +67,7 @@ export type DocumentInfoContext = {
initialState?: FormState
isInitializing: boolean
isLoading: boolean
lastUpdateTime?: number
preferencesKey?: string
publishedDoc?: { _status?: string } & TypeWithID & TypeWithTimestamps
setCurrentEditor?: React.Dispatch<React.SetStateAction<ClientUser>>

View File

@@ -246,7 +246,24 @@ export const buildFormState = async ({
}
}
const lockDurationDefault = 300 // Default 5 minutes in seconds
const lockDocumentsProp = collectionSlug
? req.payload.config.collections.find((c) => c.slug === collectionSlug)?.lockDocuments
: req.payload.config.globals.find((g) => g.slug === globalSlug)?.lockDocuments
const lockDuration =
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
const now = new Date().getTime()
if (lockedDocumentQuery) {
// Query where the lock is newer than the current time minus the lock duration
lockedDocumentQuery.and.push({
updatedAt: {
greater_than: new Date(now - lockDurationInMilliseconds).toISOString(),
},
})
const lockedDocument = await req.payload.find({
collection: 'payload-locked-documents',
depth: 1,
@@ -272,7 +289,27 @@ export const buildFormState = async ({
return { lockedState, state: result }
} else {
// If no lock document exists, create it
// Delete Many Locks that are older than their updatedAt + lockDuration
// If NO ACTIVE lock document exists, first delete any expired locks and then create a fresh lock
// Where updatedAt is older than the duration that is specified in the config
const deleteExpiredLocksQuery = {
and: [
{ 'document.relationTo': { equals: collectionSlug } },
{ 'document.value': { equals: id } },
{
updatedAt: {
less_than: new Date(now - lockDurationInMilliseconds).toISOString(),
},
},
],
}
await req.payload.db.deleteMany({
collection: 'payload-locked-documents',
req,
where: deleteExpiredLocksQuery,
})
await req.payload.db.create({
collection: 'payload-locked-documents',
data: {