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, 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(' ')}>

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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),
},
},
], ],
}, },
}) })

View File

@@ -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,

View File

@@ -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>>

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) { 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: {