Skip to content

Commit 1e002ac

Browse files
authored
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
1 parent 7a7a2f3 commit 1e002ac

File tree

7 files changed

+111
-16
lines changed

7 files changed

+111
-16
lines changed

packages/next/src/views/Edit/Default/index.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const DefaultEditView: React.FC = () => {
6565
initialState,
6666
isEditing,
6767
isInitializing,
68+
lastUpdateTime,
6869
onDelete,
6970
onDrawerCreate,
7071
onDuplicate,
@@ -110,9 +111,13 @@ export const DefaultEditView: React.FC = () => {
110111
const docConfig = collectionConfig || globalConfig
111112

112113
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
113-
114114
const isLockingEnabled = lockDocumentsProp !== false
115115

116+
const lockDurationDefault = 300 // Default 5 minutes in seconds
117+
const lockDuration =
118+
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
119+
const lockDurationInMilliseconds = lockDuration * 1000
120+
116121
let preventLeaveWithoutSaving = true
117122

118123
if (collectionConfig) {
@@ -130,6 +135,12 @@ export const DefaultEditView: React.FC = () => {
130135
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
131136
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
132137

138+
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
139+
140+
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
141+
142+
const isLockExpired = Date.now() > lockExpiryTime
143+
133144
const documentLockStateRef = useRef<{
134145
hasShownLockedModal: boolean
135146
isLocked: boolean
@@ -140,8 +151,6 @@ export const DefaultEditView: React.FC = () => {
140151
user: null,
141152
})
142153

143-
const [lastUpdateTime, setLastUpdateTime] = useState(Date.now())
144-
145154
const classes = [baseClass, (id || globalSlug) && `${baseClass}--is-editing`]
146155

147156
if (globalSlug) {
@@ -230,12 +239,12 @@ export const DefaultEditView: React.FC = () => {
230239
const onChange: FormProps['onChange'][0] = useCallback(
231240
async ({ formState: prevFormState }) => {
232241
const currentTime = Date.now()
233-
const timeSinceLastUpdate = currentTime - lastUpdateTime
242+
const timeSinceLastUpdate = currentTime - editSessionStartTime
234243

235244
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
236245

237246
if (updateLastEdited) {
238-
setLastUpdateTime(currentTime)
247+
setEditSessionStartTime(currentTime)
239248
}
240249

241250
const docPreferences = await getDocPreferences()
@@ -283,6 +292,7 @@ export const DefaultEditView: React.FC = () => {
283292
[
284293
apiRoute,
285294
collectionSlug,
295+
editSessionStartTime,
286296
schemaPath,
287297
getDocPreferences,
288298
globalSlug,
@@ -294,7 +304,6 @@ export const DefaultEditView: React.FC = () => {
294304
setCurrentEditor,
295305
isLockingEnabled,
296306
setDocumentIsLocked,
297-
lastUpdateTime,
298307
],
299308
)
300309

@@ -346,7 +355,8 @@ export const DefaultEditView: React.FC = () => {
346355
currentEditor.id !== user?.id &&
347356
!isReadOnlyForIncomingUser &&
348357
!showTakeOverModal &&
349-
!documentLockStateRef.current?.hasShownLockedModal
358+
!documentLockStateRef.current?.hasShownLockedModal &&
359+
!isLockExpired
350360

351361
return (
352362
<main className={classes.filter(Boolean).join(' ')}>

packages/next/src/views/LivePreview/index.client.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const PreviewView: React.FC<Props> = ({
8585
initialState,
8686
isEditing,
8787
isInitializing,
88+
lastUpdateTime,
8889
onSave: onSaveFromProps,
8990
setCurrentEditor,
9091
setDocumentIsLocked,
@@ -109,12 +110,22 @@ const PreviewView: React.FC<Props> = ({
109110
const docConfig = collectionConfig || globalConfig
110111

111112
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
112-
113113
const isLockingEnabled = lockDocumentsProp !== false
114114

115+
const lockDurationDefault = 300 // Default 5 minutes in seconds
116+
const lockDuration =
117+
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
118+
const lockDurationInMilliseconds = lockDuration * 1000
119+
115120
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
116121
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
117122

123+
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
124+
125+
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
126+
127+
const isLockExpired = Date.now() > lockExpiryTime
128+
118129
const documentLockStateRef = useRef<{
119130
hasShownLockedModal: boolean
120131
isLocked: boolean
@@ -125,8 +136,6 @@ const PreviewView: React.FC<Props> = ({
125136
user: null,
126137
})
127138

128-
const [lastUpdateTime, setLastUpdateTime] = useState(Date.now())
129-
130139
const onSave = useCallback(
131140
(json) => {
132141
reportUpdate({
@@ -170,12 +179,12 @@ const PreviewView: React.FC<Props> = ({
170179
const onChange: FormProps['onChange'][0] = useCallback(
171180
async ({ formState: prevFormState }) => {
172181
const currentTime = Date.now()
173-
const timeSinceLastUpdate = currentTime - lastUpdateTime
182+
const timeSinceLastUpdate = currentTime - editSessionStartTime
174183

175184
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
176185

177186
if (updateLastEdited) {
178-
setLastUpdateTime(currentTime)
187+
setEditSessionStartTime(currentTime)
179188
}
180189

181190
const docPreferences = await getDocPreferences()
@@ -222,12 +231,12 @@ const PreviewView: React.FC<Props> = ({
222231
},
223232
[
224233
collectionSlug,
234+
editSessionStartTime,
225235
globalSlug,
226236
serverURL,
227237
apiRoute,
228238
id,
229239
isLockingEnabled,
230-
lastUpdateTime,
231240
operation,
232241
schemaPath,
233242
getDocPreferences,
@@ -286,7 +295,8 @@ const PreviewView: React.FC<Props> = ({
286295
!isReadOnlyForIncomingUser &&
287296
!showTakeOverModal &&
288297
// eslint-disable-next-line react-compiler/react-compiler
289-
!documentLockStateRef.current?.hasShownLockedModal
298+
!documentLockStateRef.current?.hasShownLockedModal &&
299+
!isLockExpired
290300

291301
return (
292302
<OperationProvider operation={operation}>

packages/payload/src/collections/operations/find.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ export const findOperation = async <TSlug extends CollectionSlug>(
158158

159159
if (includeLockStatus) {
160160
try {
161+
const lockDocumentsProp = collectionConfig?.lockDocuments
162+
163+
const lockDurationDefault = 300 // Default 5 minutes in seconds
164+
const lockDuration =
165+
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
166+
const lockDurationInMilliseconds = lockDuration * 1000
167+
161168
const lockedDocuments = await payload.find({
162169
collection: 'payload-locked-documents',
163170
depth: 1,
@@ -176,14 +183,27 @@ export const findOperation = async <TSlug extends CollectionSlug>(
176183
in: result.docs.map((doc) => doc.id),
177184
},
178185
},
186+
// Query where the lock is newer than the current time minus lock time
187+
{
188+
updatedAt: {
189+
greater_than: new Date(new Date().getTime() - lockDurationInMilliseconds),
190+
},
191+
},
179192
],
180193
},
181194
})
182195

196+
const now = new Date().getTime()
183197
const lockedDocs = Array.isArray(lockedDocuments?.docs) ? lockedDocuments.docs : []
184198

199+
// Filter out stale locks
200+
const validLockedDocs = lockedDocs.filter((lock) => {
201+
const lastEditedAt = new Date(lock?.updatedAt).getTime()
202+
return lastEditedAt + lockDurationInMilliseconds > now
203+
})
204+
185205
result.docs = result.docs.map((doc) => {
186-
const lockedDoc = lockedDocs.find((lock) => lock?.document?.value === doc.id)
206+
const lockedDoc = validLockedDocs.find((lock) => lock?.document?.value === doc.id)
187207
return {
188208
...doc,
189209
_isLocked: !!lockedDoc,

packages/payload/src/collections/operations/findByID.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
112112
let lockStatus = null
113113

114114
try {
115+
const lockDocumentsProp = collectionConfig?.lockDocuments
116+
117+
const lockDurationDefault = 300 // Default 5 minutes in seconds
118+
const lockDuration =
119+
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
120+
const lockDurationInMilliseconds = lockDuration * 1000
121+
115122
const lockedDocument = await req.payload.find({
116123
collection: 'payload-locked-documents',
117124
depth: 1,
@@ -130,6 +137,12 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
130137
equals: id,
131138
},
132139
},
140+
// Query where the lock is newer than the current time minus lock time
141+
{
142+
updatedAt: {
143+
greater_than: new Date(new Date().getTime() - lockDurationInMilliseconds),
144+
},
145+
},
133146
],
134147
},
135148
})

packages/ui/src/providers/DocumentInfo/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const DocumentInfo: React.FC<
106106

107107
const [documentIsLocked, setDocumentIsLocked] = useState<boolean | undefined>(false)
108108
const [currentEditor, setCurrentEditor] = useState<ClientUser | null>(null)
109+
const [lastUpdateTime, setLastUpdateTime] = useState<number>(null)
109110

110111
const isInitializing = initialState === undefined || data === undefined
111112
const [unpublishedVersions, setUnpublishedVersions] =
@@ -228,9 +229,11 @@ const DocumentInfo: React.FC<
228229

229230
if (docs.length > 0) {
230231
const newEditor = docs[0].user?.value
232+
const lastUpdatedAt = new Date(docs[0].updatedAt).getTime()
231233
if (newEditor && newEditor.id !== currentEditor?.id) {
232234
setCurrentEditor(newEditor)
233235
setDocumentIsLocked(true)
236+
setLastUpdateTime(lastUpdatedAt)
234237
}
235238
} else {
236239
setDocumentIsLocked(false)
@@ -685,6 +688,7 @@ const DocumentInfo: React.FC<
685688
initialState,
686689
isInitializing,
687690
isLoading,
691+
lastUpdateTime,
688692
onSave,
689693
preferencesKey,
690694
publishedDoc,

packages/ui/src/providers/DocumentInfo/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type DocumentInfoContext = {
6767
initialState?: FormState
6868
isInitializing: boolean
6969
isLoading: boolean
70+
lastUpdateTime?: number
7071
preferencesKey?: string
7172
publishedDoc?: { _status?: string } & TypeWithID & TypeWithTimestamps
7273
setCurrentEditor?: React.Dispatch<React.SetStateAction<ClientUser>>

packages/ui/src/utilities/buildFormState.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,24 @@ export const buildFormState = async ({
246246
}
247247
}
248248

249+
const lockDurationDefault = 300 // Default 5 minutes in seconds
250+
const lockDocumentsProp = collectionSlug
251+
? req.payload.config.collections.find((c) => c.slug === collectionSlug)?.lockDocuments
252+
: req.payload.config.globals.find((g) => g.slug === globalSlug)?.lockDocuments
253+
254+
const lockDuration =
255+
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
256+
const lockDurationInMilliseconds = lockDuration * 1000
257+
const now = new Date().getTime()
258+
249259
if (lockedDocumentQuery) {
260+
// Query where the lock is newer than the current time minus the lock duration
261+
lockedDocumentQuery.and.push({
262+
updatedAt: {
263+
greater_than: new Date(now - lockDurationInMilliseconds).toISOString(),
264+
},
265+
})
266+
250267
const lockedDocument = await req.payload.find({
251268
collection: 'payload-locked-documents',
252269
depth: 1,
@@ -272,7 +289,27 @@ export const buildFormState = async ({
272289

273290
return { lockedState, state: result }
274291
} else {
275-
// If no lock document exists, create it
292+
// Delete Many Locks that are older than their updatedAt + lockDuration
293+
// If NO ACTIVE lock document exists, first delete any expired locks and then create a fresh lock
294+
// Where updatedAt is older than the duration that is specified in the config
295+
const deleteExpiredLocksQuery = {
296+
and: [
297+
{ 'document.relationTo': { equals: collectionSlug } },
298+
{ 'document.value': { equals: id } },
299+
{
300+
updatedAt: {
301+
less_than: new Date(now - lockDurationInMilliseconds).toISOString(),
302+
},
303+
},
304+
],
305+
}
306+
307+
await req.payload.db.deleteMany({
308+
collection: 'payload-locked-documents',
309+
req,
310+
where: deleteExpiredLocksQuery,
311+
})
312+
276313
await req.payload.db.create({
277314
collection: 'payload-locked-documents',
278315
data: {

0 commit comments

Comments
 (0)