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:
@@ -65,6 +65,7 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
initialState,
|
initialState,
|
||||||
isEditing,
|
isEditing,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
|
lastUpdateTime,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDrawerCreate,
|
onDrawerCreate,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -110,9 +111,13 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
const docConfig = collectionConfig || globalConfig
|
const docConfig = collectionConfig || globalConfig
|
||||||
|
|
||||||
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
|
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
|
||||||
|
|
||||||
const isLockingEnabled = lockDocumentsProp !== false
|
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
|
let preventLeaveWithoutSaving = true
|
||||||
|
|
||||||
if (collectionConfig) {
|
if (collectionConfig) {
|
||||||
@@ -130,6 +135,12 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
|
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
|
||||||
const [showTakeOverModal, setShowTakeOverModal] = 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<{
|
const documentLockStateRef = useRef<{
|
||||||
hasShownLockedModal: boolean
|
hasShownLockedModal: boolean
|
||||||
isLocked: boolean
|
isLocked: boolean
|
||||||
@@ -140,8 +151,6 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
user: null,
|
user: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [lastUpdateTime, setLastUpdateTime] = useState(Date.now())
|
|
||||||
|
|
||||||
const classes = [baseClass, (id || globalSlug) && `${baseClass}--is-editing`]
|
const classes = [baseClass, (id || globalSlug) && `${baseClass}--is-editing`]
|
||||||
|
|
||||||
if (globalSlug) {
|
if (globalSlug) {
|
||||||
@@ -230,12 +239,12 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
const onChange: FormProps['onChange'][0] = useCallback(
|
const onChange: FormProps['onChange'][0] = useCallback(
|
||||||
async ({ formState: prevFormState }) => {
|
async ({ formState: prevFormState }) => {
|
||||||
const currentTime = Date.now()
|
const currentTime = Date.now()
|
||||||
const timeSinceLastUpdate = currentTime - lastUpdateTime
|
const timeSinceLastUpdate = currentTime - editSessionStartTime
|
||||||
|
|
||||||
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
|
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
|
||||||
|
|
||||||
if (updateLastEdited) {
|
if (updateLastEdited) {
|
||||||
setLastUpdateTime(currentTime)
|
setEditSessionStartTime(currentTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
const docPreferences = await getDocPreferences()
|
const docPreferences = await getDocPreferences()
|
||||||
@@ -283,6 +292,7 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
[
|
[
|
||||||
apiRoute,
|
apiRoute,
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
|
editSessionStartTime,
|
||||||
schemaPath,
|
schemaPath,
|
||||||
getDocPreferences,
|
getDocPreferences,
|
||||||
globalSlug,
|
globalSlug,
|
||||||
@@ -294,7 +304,6 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
setCurrentEditor,
|
setCurrentEditor,
|
||||||
isLockingEnabled,
|
isLockingEnabled,
|
||||||
setDocumentIsLocked,
|
setDocumentIsLocked,
|
||||||
lastUpdateTime,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,7 +355,8 @@ export const DefaultEditView: React.FC = () => {
|
|||||||
currentEditor.id !== user?.id &&
|
currentEditor.id !== user?.id &&
|
||||||
!isReadOnlyForIncomingUser &&
|
!isReadOnlyForIncomingUser &&
|
||||||
!showTakeOverModal &&
|
!showTakeOverModal &&
|
||||||
!documentLockStateRef.current?.hasShownLockedModal
|
!documentLockStateRef.current?.hasShownLockedModal &&
|
||||||
|
!isLockExpired
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={classes.filter(Boolean).join(' ')}>
|
<main className={classes.filter(Boolean).join(' ')}>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
initialState,
|
initialState,
|
||||||
isEditing,
|
isEditing,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
|
lastUpdateTime,
|
||||||
onSave: onSaveFromProps,
|
onSave: onSaveFromProps,
|
||||||
setCurrentEditor,
|
setCurrentEditor,
|
||||||
setDocumentIsLocked,
|
setDocumentIsLocked,
|
||||||
@@ -109,12 +110,22 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
const docConfig = collectionConfig || globalConfig
|
const docConfig = collectionConfig || globalConfig
|
||||||
|
|
||||||
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
|
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
|
||||||
|
|
||||||
const isLockingEnabled = lockDocumentsProp !== false
|
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 [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
|
||||||
const [showTakeOverModal, setShowTakeOverModal] = 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<{
|
const documentLockStateRef = useRef<{
|
||||||
hasShownLockedModal: boolean
|
hasShownLockedModal: boolean
|
||||||
isLocked: boolean
|
isLocked: boolean
|
||||||
@@ -125,8 +136,6 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
user: null,
|
user: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [lastUpdateTime, setLastUpdateTime] = useState(Date.now())
|
|
||||||
|
|
||||||
const onSave = useCallback(
|
const onSave = useCallback(
|
||||||
(json) => {
|
(json) => {
|
||||||
reportUpdate({
|
reportUpdate({
|
||||||
@@ -170,12 +179,12 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
const onChange: FormProps['onChange'][0] = useCallback(
|
const onChange: FormProps['onChange'][0] = useCallback(
|
||||||
async ({ formState: prevFormState }) => {
|
async ({ formState: prevFormState }) => {
|
||||||
const currentTime = Date.now()
|
const currentTime = Date.now()
|
||||||
const timeSinceLastUpdate = currentTime - lastUpdateTime
|
const timeSinceLastUpdate = currentTime - editSessionStartTime
|
||||||
|
|
||||||
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
|
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
|
||||||
|
|
||||||
if (updateLastEdited) {
|
if (updateLastEdited) {
|
||||||
setLastUpdateTime(currentTime)
|
setEditSessionStartTime(currentTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
const docPreferences = await getDocPreferences()
|
const docPreferences = await getDocPreferences()
|
||||||
@@ -222,12 +231,12 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
|
editSessionStartTime,
|
||||||
globalSlug,
|
globalSlug,
|
||||||
serverURL,
|
serverURL,
|
||||||
apiRoute,
|
apiRoute,
|
||||||
id,
|
id,
|
||||||
isLockingEnabled,
|
isLockingEnabled,
|
||||||
lastUpdateTime,
|
|
||||||
operation,
|
operation,
|
||||||
schemaPath,
|
schemaPath,
|
||||||
getDocPreferences,
|
getDocPreferences,
|
||||||
@@ -286,7 +295,8 @@ const PreviewView: React.FC<Props> = ({
|
|||||||
!isReadOnlyForIncomingUser &&
|
!isReadOnlyForIncomingUser &&
|
||||||
!showTakeOverModal &&
|
!showTakeOverModal &&
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
!documentLockStateRef.current?.hasShownLockedModal
|
!documentLockStateRef.current?.hasShownLockedModal &&
|
||||||
|
!isLockExpired
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OperationProvider operation={operation}>
|
<OperationProvider operation={operation}>
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ export const findOperation = async <TSlug extends CollectionSlug>(
|
|||||||
|
|
||||||
if (includeLockStatus) {
|
if (includeLockStatus) {
|
||||||
try {
|
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({
|
const lockedDocuments = await payload.find({
|
||||||
collection: 'payload-locked-documents',
|
collection: 'payload-locked-documents',
|
||||||
depth: 1,
|
depth: 1,
|
||||||
@@ -176,14 +183,27 @@ export const findOperation = async <TSlug extends CollectionSlug>(
|
|||||||
in: result.docs.map((doc) => doc.id),
|
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 : []
|
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) => {
|
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 {
|
return {
|
||||||
...doc,
|
...doc,
|
||||||
_isLocked: !!lockedDoc,
|
_isLocked: !!lockedDoc,
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
|
|||||||
let lockStatus = null
|
let lockStatus = null
|
||||||
|
|
||||||
try {
|
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({
|
const lockedDocument = await req.payload.find({
|
||||||
collection: 'payload-locked-documents',
|
collection: 'payload-locked-documents',
|
||||||
depth: 1,
|
depth: 1,
|
||||||
@@ -130,6 +137,12 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
|
|||||||
equals: id,
|
equals: id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Query where the lock is newer than the current time minus lock time
|
||||||
|
{
|
||||||
|
updatedAt: {
|
||||||
|
greater_than: new Date(new Date().getTime() - lockDurationInMilliseconds),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ const DocumentInfo: React.FC<
|
|||||||
|
|
||||||
const [documentIsLocked, setDocumentIsLocked] = useState<boolean | undefined>(false)
|
const [documentIsLocked, setDocumentIsLocked] = useState<boolean | undefined>(false)
|
||||||
const [currentEditor, setCurrentEditor] = useState<ClientUser | null>(null)
|
const [currentEditor, setCurrentEditor] = useState<ClientUser | null>(null)
|
||||||
|
const [lastUpdateTime, setLastUpdateTime] = useState<number>(null)
|
||||||
|
|
||||||
const isInitializing = initialState === undefined || data === undefined
|
const isInitializing = initialState === undefined || data === undefined
|
||||||
const [unpublishedVersions, setUnpublishedVersions] =
|
const [unpublishedVersions, setUnpublishedVersions] =
|
||||||
@@ -228,9 +229,11 @@ const DocumentInfo: React.FC<
|
|||||||
|
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
const newEditor = docs[0].user?.value
|
const newEditor = docs[0].user?.value
|
||||||
|
const lastUpdatedAt = new Date(docs[0].updatedAt).getTime()
|
||||||
if (newEditor && newEditor.id !== currentEditor?.id) {
|
if (newEditor && newEditor.id !== currentEditor?.id) {
|
||||||
setCurrentEditor(newEditor)
|
setCurrentEditor(newEditor)
|
||||||
setDocumentIsLocked(true)
|
setDocumentIsLocked(true)
|
||||||
|
setLastUpdateTime(lastUpdatedAt)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setDocumentIsLocked(false)
|
setDocumentIsLocked(false)
|
||||||
@@ -685,6 +688,7 @@ const DocumentInfo: React.FC<
|
|||||||
initialState,
|
initialState,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
lastUpdateTime,
|
||||||
onSave,
|
onSave,
|
||||||
preferencesKey,
|
preferencesKey,
|
||||||
publishedDoc,
|
publishedDoc,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export type DocumentInfoContext = {
|
|||||||
initialState?: FormState
|
initialState?: FormState
|
||||||
isInitializing: boolean
|
isInitializing: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
lastUpdateTime?: number
|
||||||
preferencesKey?: string
|
preferencesKey?: string
|
||||||
publishedDoc?: { _status?: string } & TypeWithID & TypeWithTimestamps
|
publishedDoc?: { _status?: string } & TypeWithID & TypeWithTimestamps
|
||||||
setCurrentEditor?: React.Dispatch<React.SetStateAction<ClientUser>>
|
setCurrentEditor?: React.Dispatch<React.SetStateAction<ClientUser>>
|
||||||
|
|||||||
@@ -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) {
|
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({
|
const lockedDocument = await req.payload.find({
|
||||||
collection: 'payload-locked-documents',
|
collection: 'payload-locked-documents',
|
||||||
depth: 1,
|
depth: 1,
|
||||||
@@ -272,7 +289,27 @@ export const buildFormState = async ({
|
|||||||
|
|
||||||
return { lockedState, state: result }
|
return { lockedState, state: result }
|
||||||
} else {
|
} 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({
|
await req.payload.db.create({
|
||||||
collection: 'payload-locked-documents',
|
collection: 'payload-locked-documents',
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in New Issue
Block a user