fix(next, ui): exclude expired locks for globals (#8914)
Continued PR off of https://github.com/payloadcms/payload/pull/8899
This commit is contained in:
@@ -16,7 +16,11 @@ import './index.scss'
|
|||||||
const baseClass = 'dashboard'
|
const baseClass = 'dashboard'
|
||||||
|
|
||||||
export type DashboardProps = {
|
export type DashboardProps = {
|
||||||
globalData: Array<{ data: { _isLocked: boolean; _userEditing: ClientUser | null }; slug: string }>
|
globalData: Array<{
|
||||||
|
data: { _isLocked: boolean; _lastEditedAt: string; _userEditing: ClientUser | null }
|
||||||
|
lockDuration?: number
|
||||||
|
slug: string
|
||||||
|
}>
|
||||||
Link: React.ComponentType<any>
|
Link: React.ComponentType<any>
|
||||||
navGroups?: ReturnType<typeof groupNavItems>
|
navGroups?: ReturnType<typeof groupNavItems>
|
||||||
permissions: Permissions
|
permissions: Permissions
|
||||||
@@ -95,7 +99,7 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
|
|||||||
let createHREF: string
|
let createHREF: string
|
||||||
let href: string
|
let href: string
|
||||||
let hasCreatePermission: boolean
|
let hasCreatePermission: boolean
|
||||||
let lockStatus = null
|
let isLocked = null
|
||||||
let userEditing = null
|
let userEditing = null
|
||||||
|
|
||||||
if (type === EntityType.collection) {
|
if (type === EntityType.collection) {
|
||||||
@@ -130,9 +134,24 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
|
|||||||
const globalLockData = globalData.find(
|
const globalLockData = globalData.find(
|
||||||
(global) => global.slug === entity.slug,
|
(global) => global.slug === entity.slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (globalLockData) {
|
if (globalLockData) {
|
||||||
lockStatus = globalLockData.data._isLocked
|
isLocked = globalLockData.data._isLocked
|
||||||
userEditing = globalLockData.data._userEditing
|
userEditing = globalLockData.data._userEditing
|
||||||
|
|
||||||
|
// Check if the lock is expired
|
||||||
|
const lockDuration = globalLockData?.lockDuration
|
||||||
|
const lastEditedAt = new Date(
|
||||||
|
globalLockData.data?._lastEditedAt,
|
||||||
|
).getTime()
|
||||||
|
|
||||||
|
const lockDurationInMilliseconds = lockDuration * 1000
|
||||||
|
const lockExpirationTime = lastEditedAt + lockDurationInMilliseconds
|
||||||
|
|
||||||
|
if (new Date().getTime() > lockExpirationTime) {
|
||||||
|
isLocked = false
|
||||||
|
userEditing = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +159,7 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
|
|||||||
<li key={entityIndex}>
|
<li key={entityIndex}>
|
||||||
<Card
|
<Card
|
||||||
actions={
|
actions={
|
||||||
lockStatus && user?.id !== userEditing?.id ? (
|
isLocked && user?.id !== userEditing?.id ? (
|
||||||
<Locked className={`${baseClass}__locked`} user={userEditing} />
|
<Locked className={`${baseClass}__locked`} user={userEditing} />
|
||||||
) : hasCreatePermission && type === EntityType.collection ? (
|
) : hasCreatePermission && type === EntityType.collection ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
|
|||||||
visibleEntities,
|
visibleEntities,
|
||||||
} = initPageResult
|
} = initPageResult
|
||||||
|
|
||||||
|
const lockDurationDefault = 300 // Default 5 minutes in seconds
|
||||||
|
|
||||||
const CustomDashboardComponent = config.admin.components?.views?.Dashboard
|
const CustomDashboardComponent = config.admin.components?.views?.Dashboard
|
||||||
|
|
||||||
const collections = config.collections.filter(
|
const collections = config.collections.filter(
|
||||||
@@ -48,16 +50,26 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
|
|||||||
visibleEntities.globals.includes(global.slug),
|
visibleEntities.globals.includes(global.slug),
|
||||||
)
|
)
|
||||||
|
|
||||||
const globalSlugs = config.globals.map((global) => global.slug)
|
const globalConfigs = config.globals.map((global) => ({
|
||||||
|
slug: global.slug,
|
||||||
|
lockDuration:
|
||||||
|
global.lockDocuments === false
|
||||||
|
? null // Set lockDuration to null if locking is disabled
|
||||||
|
: typeof global.lockDocuments === 'object'
|
||||||
|
? global.lockDocuments.duration
|
||||||
|
: lockDurationDefault,
|
||||||
|
}))
|
||||||
|
|
||||||
// Filter the slugs based on permissions and visibility
|
// Filter the slugs based on permissions and visibility
|
||||||
const filteredGlobalSlugs = globalSlugs.filter(
|
const filteredGlobalConfigs = globalConfigs.filter(
|
||||||
(slug) =>
|
({ slug, lockDuration }) =>
|
||||||
permissions?.globals?.[slug]?.read?.permission && visibleEntities.globals.includes(slug),
|
lockDuration !== null && // Ensure lockDuration is valid
|
||||||
|
permissions?.globals?.[slug]?.read?.permission &&
|
||||||
|
visibleEntities.globals.includes(slug),
|
||||||
)
|
)
|
||||||
|
|
||||||
const globalData = await Promise.all(
|
const globalData = await Promise.all(
|
||||||
filteredGlobalSlugs.map(async (slug) => {
|
filteredGlobalConfigs.map(async ({ slug, lockDuration }) => {
|
||||||
const data = await payload.findGlobal({
|
const data = await payload.findGlobal({
|
||||||
slug,
|
slug,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
@@ -67,6 +79,7 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
|
|||||||
return {
|
return {
|
||||||
slug,
|
slug,
|
||||||
data,
|
data,
|
||||||
|
lockDuration,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export const findOneOperation = async <T extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
doc._isLocked = !!lockStatus
|
doc._isLocked = !!lockStatus
|
||||||
|
doc._lastEditedAt = lockStatus?.updatedAt ?? null
|
||||||
doc._userEditing = lockStatus?.user?.value ?? null
|
doc._userEditing = lockStatus?.user?.value ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const buildFormState = async ({
|
|||||||
}: {
|
}: {
|
||||||
req: PayloadRequest
|
req: PayloadRequest
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
lockedState?: { isLocked: boolean; user: ClientUser | number | string }
|
lockedState?: { isLocked: boolean; lastEditedAt: string; user: ClientUser | number | string }
|
||||||
state: FormState
|
state: FormState
|
||||||
}> => {
|
}> => {
|
||||||
const reqData: BuildFormStateArgs = (req.data || {}) as BuildFormStateArgs
|
const reqData: BuildFormStateArgs = (req.data || {}) as BuildFormStateArgs
|
||||||
@@ -242,7 +242,7 @@ export const buildFormState = async ({
|
|||||||
}
|
}
|
||||||
} else if (globalSlug) {
|
} else if (globalSlug) {
|
||||||
lockedDocumentQuery = {
|
lockedDocumentQuery = {
|
||||||
globalSlug: { equals: globalSlug },
|
and: [{ globalSlug: { equals: globalSlug } }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +275,7 @@ export const buildFormState = async ({
|
|||||||
if (lockedDocument.docs && lockedDocument.docs.length > 0) {
|
if (lockedDocument.docs && lockedDocument.docs.length > 0) {
|
||||||
const lockedState = {
|
const lockedState = {
|
||||||
isLocked: true,
|
isLocked: true,
|
||||||
|
lastEditedAt: lockedDocument.docs[0]?.updatedAt,
|
||||||
user: lockedDocument.docs[0]?.user?.value,
|
user: lockedDocument.docs[0]?.user?.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,19 +290,32 @@ export const buildFormState = async ({
|
|||||||
|
|
||||||
return { lockedState, state: result }
|
return { lockedState, state: result }
|
||||||
} else {
|
} else {
|
||||||
// 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
|
// 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
|
// Where updatedAt is older than the duration that is specified in the config
|
||||||
const deleteExpiredLocksQuery = {
|
let deleteExpiredLocksQuery
|
||||||
and: [
|
|
||||||
{ 'document.relationTo': { equals: collectionSlug } },
|
if (collectionSlug) {
|
||||||
{ 'document.value': { equals: id } },
|
deleteExpiredLocksQuery = {
|
||||||
{
|
and: [
|
||||||
updatedAt: {
|
{ 'document.relationTo': { equals: collectionSlug } },
|
||||||
less_than: new Date(now - lockDurationInMilliseconds).toISOString(),
|
{
|
||||||
|
updatedAt: {
|
||||||
|
less_than: new Date(now - lockDurationInMilliseconds).toISOString(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
}
|
||||||
|
} else if (globalSlug) {
|
||||||
|
deleteExpiredLocksQuery = {
|
||||||
|
and: [
|
||||||
|
{ globalSlug: { equals: globalSlug } },
|
||||||
|
{
|
||||||
|
updatedAt: {
|
||||||
|
less_than: new Date(now - lockDurationInMilliseconds).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await req.payload.db.deleteMany({
|
await req.payload.db.deleteMany({
|
||||||
@@ -330,6 +344,7 @@ export const buildFormState = async ({
|
|||||||
|
|
||||||
const lockedState = {
|
const lockedState = {
|
||||||
isLocked: true,
|
isLocked: true,
|
||||||
|
lastEditedAt: new Date().toISOString(),
|
||||||
user: req.user,
|
user: req.user,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export const getFormState = async (args: {
|
|||||||
serverURL: SanitizedConfig['serverURL']
|
serverURL: SanitizedConfig['serverURL']
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
token?: string
|
token?: string
|
||||||
}): Promise<{ lockedState?: { isLocked: boolean; user: ClientUser }; state: FormState }> => {
|
}): Promise<{
|
||||||
|
lockedState?: { isLocked: boolean; lastEditedAt: string; user: ClientUser }
|
||||||
|
state: FormState
|
||||||
|
}> => {
|
||||||
const { apiRoute, body, onError, serverURL, signal, token } = args
|
const { apiRoute, body, onError, serverURL, signal, token } = args
|
||||||
|
|
||||||
const res = await fetch(`${serverURL}${apiRoute}/form-state`, {
|
const res = await fetch(`${serverURL}${apiRoute}/form-state`, {
|
||||||
@@ -24,7 +27,7 @@ export const getFormState = async (args: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const json = (await res.json()) as {
|
const json = (await res.json()) as {
|
||||||
lockedState?: { isLocked: boolean; user: ClientUser }
|
lockedState?: { isLocked: boolean; lastEditedAt: string; user: ClientUser }
|
||||||
state: FormState
|
state: FormState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
test/locked-documents/collections/Tests/index.ts
Normal file
22
test/locked-documents/collections/Tests/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
export const testsSlug = 'tests'
|
||||||
|
|
||||||
|
export const TestsCollection: CollectionConfig = {
|
||||||
|
slug: testsSlug,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'text',
|
||||||
|
},
|
||||||
|
lockDocuments: {
|
||||||
|
duration: 5,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'text',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
versions: {
|
||||||
|
drafts: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
|||||||
import { devUser, regularUser } from '../credentials.js'
|
import { devUser, regularUser } from '../credentials.js'
|
||||||
import { PagesCollection, pagesSlug } from './collections/Pages/index.js'
|
import { PagesCollection, pagesSlug } from './collections/Pages/index.js'
|
||||||
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
|
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
|
||||||
|
import { TestsCollection } from './collections/Tests/index.js'
|
||||||
import { Users } from './collections/Users/index.js'
|
import { Users } from './collections/Users/index.js'
|
||||||
|
import { AdminGlobal } from './globals/Admin/index.js'
|
||||||
import { MenuGlobal } from './globals/Menu/index.js'
|
import { MenuGlobal } from './globals/Menu/index.js'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
@@ -17,8 +19,8 @@ export default buildConfigWithDefaults({
|
|||||||
baseDir: path.resolve(dirname),
|
baseDir: path.resolve(dirname),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collections: [PagesCollection, PostsCollection, Users],
|
collections: [PagesCollection, PostsCollection, TestsCollection, Users],
|
||||||
globals: [MenuGlobal],
|
globals: [AdminGlobal, MenuGlobal],
|
||||||
onInit: async (payload) => {
|
onInit: async (payload) => {
|
||||||
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
|
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
|
||||||
await payload.create({
|
await payload.create({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
|
|||||||
import type { TypeWithID } from 'payload'
|
import type { TypeWithID } from 'payload'
|
||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
import exp from 'constants'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { mapAsync } from 'payload'
|
import { mapAsync } from 'payload'
|
||||||
import { wait } from 'payload/shared'
|
import { wait } from 'payload/shared'
|
||||||
@@ -32,6 +33,7 @@ let page: Page
|
|||||||
let globalUrl: AdminUrlUtil
|
let globalUrl: AdminUrlUtil
|
||||||
let postsUrl: AdminUrlUtil
|
let postsUrl: AdminUrlUtil
|
||||||
let pagesUrl: AdminUrlUtil
|
let pagesUrl: AdminUrlUtil
|
||||||
|
let testsUrl: AdminUrlUtil
|
||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
let serverURL: string
|
let serverURL: string
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ describe('locked documents', () => {
|
|||||||
globalUrl = new AdminUrlUtil(serverURL, 'menu')
|
globalUrl = new AdminUrlUtil(serverURL, 'menu')
|
||||||
postsUrl = new AdminUrlUtil(serverURL, 'posts')
|
postsUrl = new AdminUrlUtil(serverURL, 'posts')
|
||||||
pagesUrl = new AdminUrlUtil(serverURL, 'pages')
|
pagesUrl = new AdminUrlUtil(serverURL, 'pages')
|
||||||
|
testsUrl = new AdminUrlUtil(serverURL, 'tests')
|
||||||
|
|
||||||
const context = await browser.newContext()
|
const context = await browser.newContext()
|
||||||
page = await context.newPage()
|
page = await context.newPage()
|
||||||
@@ -82,6 +85,8 @@ describe('locked documents', () => {
|
|||||||
let anotherPostDoc
|
let anotherPostDoc
|
||||||
let user2
|
let user2
|
||||||
let lockedDoc
|
let lockedDoc
|
||||||
|
let testDoc
|
||||||
|
let testLockedDoc
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
postDoc = await createPostDoc({
|
postDoc = await createPostDoc({
|
||||||
@@ -92,6 +97,10 @@ describe('locked documents', () => {
|
|||||||
text: 'another post',
|
text: 'another post',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
testDoc = await createTestDoc({
|
||||||
|
text: 'test doc',
|
||||||
|
})
|
||||||
|
|
||||||
user2 = await payload.create({
|
user2 = await payload.create({
|
||||||
collection: 'users',
|
collection: 'users',
|
||||||
data: {
|
data: {
|
||||||
@@ -114,6 +123,21 @@ describe('locked documents', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
testLockedDoc = await payload.create({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
data: {
|
||||||
|
document: {
|
||||||
|
relationTo: 'tests',
|
||||||
|
value: testDoc.id,
|
||||||
|
},
|
||||||
|
globalSlug: undefined,
|
||||||
|
user: {
|
||||||
|
relationTo: 'users',
|
||||||
|
value: user2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -122,6 +146,11 @@ describe('locked documents', () => {
|
|||||||
id: lockedDoc.id,
|
id: lockedDoc.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
id: testLockedDoc.id,
|
||||||
|
})
|
||||||
|
|
||||||
await payload.delete({
|
await payload.delete({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
id: postDoc.id,
|
id: postDoc.id,
|
||||||
@@ -132,6 +161,11 @@ describe('locked documents', () => {
|
|||||||
id: anotherPostDoc.id,
|
id: anotherPostDoc.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'tests',
|
||||||
|
id: testDoc.id,
|
||||||
|
})
|
||||||
|
|
||||||
await payload.delete({
|
await payload.delete({
|
||||||
collection: 'users',
|
collection: 'users',
|
||||||
id: user2.id,
|
id: user2.id,
|
||||||
@@ -140,18 +174,31 @@ describe('locked documents', () => {
|
|||||||
|
|
||||||
test('should show lock icon on document row if locked', async () => {
|
test('should show lock icon on document row if locked', async () => {
|
||||||
await page.goto(postsUrl.list)
|
await page.goto(postsUrl.list)
|
||||||
await page.waitForURL(postsUrl.list)
|
await page.waitForURL(new RegExp(postsUrl.list))
|
||||||
|
|
||||||
await expect(page.locator('.table .row-2 .locked svg')).toBeVisible()
|
await expect(page.locator('.table .row-2 .locked svg')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should not show lock icon on document row if unlocked', async () => {
|
test('should not show lock icon on document row if unlocked', async () => {
|
||||||
await page.goto(postsUrl.list)
|
await page.goto(postsUrl.list)
|
||||||
await page.waitForURL(postsUrl.list)
|
await page.waitForURL(new RegExp(postsUrl.list))
|
||||||
|
|
||||||
await expect(page.locator('.table .row-3 .checkbox-input__input')).toBeVisible()
|
await expect(page.locator('.table .row-3 .checkbox-input__input')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should not show lock icon on document if expired', async () => {
|
||||||
|
await page.goto(testsUrl.list)
|
||||||
|
await page.waitForURL(new RegExp(testsUrl.list))
|
||||||
|
|
||||||
|
// Need to wait for lock duration to expire (lockDuration: 5 seconds)
|
||||||
|
// eslint-disable-next-line payload/no-wait-function
|
||||||
|
await wait(5000)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
await expect(page.locator('.table .row-1 .checkbox-input__input')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test('should not show lock icon on document row if locked by current user', async () => {
|
test('should not show lock icon on document row if locked by current user', async () => {
|
||||||
await page.goto(postsUrl.edit(anotherPostDoc.id))
|
await page.goto(postsUrl.edit(anotherPostDoc.id))
|
||||||
await page.waitForURL(postsUrl.edit(anotherPostDoc.id))
|
await page.waitForURL(postsUrl.edit(anotherPostDoc.id))
|
||||||
@@ -162,7 +209,7 @@ describe('locked documents', () => {
|
|||||||
await page.reload()
|
await page.reload()
|
||||||
|
|
||||||
await page.goto(postsUrl.list)
|
await page.goto(postsUrl.list)
|
||||||
await page.waitForURL(postsUrl.list)
|
await page.waitForURL(new RegExp(postsUrl.list))
|
||||||
|
|
||||||
await expect(page.locator('.table .row-1 .checkbox-input__input')).toBeVisible()
|
await expect(page.locator('.table .row-1 .checkbox-input__input')).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -280,18 +327,146 @@ describe('locked documents', () => {
|
|||||||
|
|
||||||
describe('document locking / unlocking - one user', () => {
|
describe('document locking / unlocking - one user', () => {
|
||||||
let postDoc
|
let postDoc
|
||||||
|
let postDocTwo
|
||||||
|
let expiredDocOne
|
||||||
|
let expiredLockedDocOne
|
||||||
|
let expiredDocTwo
|
||||||
|
let expiredLockedDocTwo
|
||||||
|
let testDoc
|
||||||
|
let user2
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
postDoc = await createPostDoc({
|
postDoc = await createPostDoc({
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
postDocTwo = await createPostDoc({
|
||||||
|
text: 'post doc two',
|
||||||
|
})
|
||||||
|
|
||||||
|
user2 = await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'user2@payloadcms.com',
|
||||||
|
password: '1234',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expiredDocOne = await createTestDoc({
|
||||||
|
text: 'expired doc one',
|
||||||
|
})
|
||||||
|
|
||||||
|
expiredLockedDocOne = await payload.create({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
data: {
|
||||||
|
document: {
|
||||||
|
relationTo: 'tests',
|
||||||
|
value: expiredDocOne.id,
|
||||||
|
},
|
||||||
|
globalSlug: undefined,
|
||||||
|
user: {
|
||||||
|
relationTo: 'users',
|
||||||
|
value: user2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expiredDocTwo = await createTestDoc({
|
||||||
|
text: 'expired doc two',
|
||||||
|
})
|
||||||
|
|
||||||
|
expiredLockedDocTwo = await payload.create({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
data: {
|
||||||
|
document: {
|
||||||
|
relationTo: 'tests',
|
||||||
|
value: expiredDocTwo.id,
|
||||||
|
},
|
||||||
|
globalSlug: undefined,
|
||||||
|
user: {
|
||||||
|
relationTo: 'users',
|
||||||
|
value: user2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
testDoc = await createTestDoc({ text: 'hello' })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await payload.delete({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
id: expiredDocOne.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
id: expiredDocTwo.id,
|
||||||
|
})
|
||||||
|
|
||||||
await payload.delete({
|
await payload.delete({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
id: postDoc.id,
|
id: postDoc.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'posts',
|
||||||
|
id: postDocTwo.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'tests',
|
||||||
|
id: expiredDocOne.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'tests',
|
||||||
|
id: expiredDocTwo.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'tests',
|
||||||
|
id: testDoc.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should delete all expired locked documents upon initial editing of unlocked document', async () => {
|
||||||
|
await page.goto(testsUrl.list)
|
||||||
|
await page.waitForURL(new RegExp(testsUrl.list))
|
||||||
|
|
||||||
|
await expect(page.locator('.table .row-2 .locked svg')).toBeVisible()
|
||||||
|
await expect(page.locator('.table .row-3 .locked svg')).toBeVisible()
|
||||||
|
|
||||||
|
// eslint-disable-next-line payload/no-wait-function
|
||||||
|
await wait(5000)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
await expect(page.locator('.table .row-2 .checkbox-input__input')).toBeVisible()
|
||||||
|
await expect(page.locator('.table .row-3 .checkbox-input__input')).toBeVisible()
|
||||||
|
|
||||||
|
const lockedTestDocs = await payload.find({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
pagination: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(lockedTestDocs.docs.length).toBe(2)
|
||||||
|
|
||||||
|
await page.goto(testsUrl.edit(testDoc.id))
|
||||||
|
await page.waitForURL(testsUrl.edit(testDoc.id))
|
||||||
|
|
||||||
|
const textInput = page.locator('#field-text')
|
||||||
|
await textInput.fill('some test doc')
|
||||||
|
|
||||||
|
// eslint-disable-next-line payload/no-wait-function
|
||||||
|
await wait(500)
|
||||||
|
|
||||||
|
const lockedDocs = await payload.find({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
pagination: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(lockedDocs.docs.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should lock document upon initial editing of unlocked document', async () => {
|
test('should lock document upon initial editing of unlocked document', async () => {
|
||||||
@@ -316,53 +491,6 @@ describe('locked documents', () => {
|
|||||||
expect(lockedDocs.docs.length).toBe(1)
|
expect(lockedDocs.docs.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should unlock document on navigate away', async () => {
|
|
||||||
await page.goto(postsUrl.edit(postDoc.id))
|
|
||||||
await page.waitForURL(postsUrl.edit(postDoc.id))
|
|
||||||
|
|
||||||
const textInput = page.locator('#field-text')
|
|
||||||
await textInput.fill('hello world')
|
|
||||||
|
|
||||||
// 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('header.app-header a[href="/admin/collections/posts"]').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)
|
|
||||||
|
|
||||||
expect(page.url()).toContain(postsUrl.list)
|
|
||||||
|
|
||||||
const unlockedDocs = await payload.find({
|
|
||||||
collection: lockedDocumentCollection,
|
|
||||||
limit: 1,
|
|
||||||
pagination: false,
|
|
||||||
where: {
|
|
||||||
'document.value': { equals: postDoc.id },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(unlockedDocs.docs.length).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should unlock document on save / publish', async () => {
|
test('should unlock document on save / publish', async () => {
|
||||||
await page.goto(postsUrl.edit(postDoc.id))
|
await page.goto(postsUrl.edit(postDoc.id))
|
||||||
await page.waitForURL(postsUrl.edit(postDoc.id))
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
||||||
@@ -444,6 +572,60 @@ describe('locked documents', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(unlockedDocs.docs.length).toBe(1)
|
expect(unlockedDocs.docs.length).toBe(1)
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
where: {
|
||||||
|
'document.value': { equals: postDoc.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should unlock document on navigate away', async () => {
|
||||||
|
await page.goto(postsUrl.edit(postDocTwo.id))
|
||||||
|
await page.waitForURL(postsUrl.edit(postDocTwo.id))
|
||||||
|
|
||||||
|
const textInput = page.locator('#field-text')
|
||||||
|
await textInput.fill('hello world')
|
||||||
|
|
||||||
|
// eslint-disable-next-line payload/no-wait-function
|
||||||
|
await wait(1000)
|
||||||
|
|
||||||
|
const lockedDocs = await payload.find({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
where: {
|
||||||
|
'document.value': { equals: postDocTwo.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(lockedDocs.docs.length).toBe(1)
|
||||||
|
|
||||||
|
await page.locator('header.app-header a[href="/admin/collections/posts"]').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)
|
||||||
|
|
||||||
|
expect(page.url()).toContain(postsUrl.list)
|
||||||
|
|
||||||
|
const unlockedDocs = await payload.find({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
where: {
|
||||||
|
'document.value': { equals: postDoc.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(unlockedDocs.docs.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -451,12 +633,18 @@ describe('locked documents', () => {
|
|||||||
let postDoc
|
let postDoc
|
||||||
let user2
|
let user2
|
||||||
let lockedDoc
|
let lockedDoc
|
||||||
|
let expiredTestDoc
|
||||||
|
let expiredTestLockedDoc
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
postDoc = await createPostDoc({
|
postDoc = await createPostDoc({
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expiredTestDoc = await createTestDoc({
|
||||||
|
text: 'expired doc',
|
||||||
|
})
|
||||||
|
|
||||||
user2 = await payload.create({
|
user2 = await payload.create({
|
||||||
collection: 'users',
|
collection: 'users',
|
||||||
data: {
|
data: {
|
||||||
@@ -479,6 +667,21 @@ describe('locked documents', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expiredTestLockedDoc = await payload.create({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
data: {
|
||||||
|
document: {
|
||||||
|
relationTo: 'tests',
|
||||||
|
value: expiredTestDoc.id,
|
||||||
|
},
|
||||||
|
globalSlug: undefined,
|
||||||
|
user: {
|
||||||
|
relationTo: 'users',
|
||||||
|
value: user2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -492,10 +695,20 @@ describe('locked documents', () => {
|
|||||||
id: lockedDoc.id,
|
id: lockedDoc.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
id: expiredTestLockedDoc.id,
|
||||||
|
})
|
||||||
|
|
||||||
await payload.delete({
|
await payload.delete({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
id: postDoc.id,
|
id: postDoc.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'tests',
|
||||||
|
id: expiredTestDoc.id,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should show Document Locked modal for incoming user when entering locked document', async () => {
|
test('should show Document Locked modal for incoming user when entering locked document', async () => {
|
||||||
@@ -514,7 +727,7 @@ describe('locked documents', () => {
|
|||||||
await wait(500)
|
await wait(500)
|
||||||
|
|
||||||
await page.goto(postsUrl.list)
|
await page.goto(postsUrl.list)
|
||||||
await page.waitForURL(postsUrl.list)
|
await page.waitForURL(new RegExp(postsUrl.list))
|
||||||
|
|
||||||
// eslint-disable-next-line payload/no-wait-function
|
// eslint-disable-next-line payload/no-wait-function
|
||||||
await wait(500)
|
await wait(500)
|
||||||
@@ -531,6 +744,37 @@ describe('locked documents', () => {
|
|||||||
expect(page.url()).toContain(postsUrl.list)
|
expect(page.url()).toContain(postsUrl.list)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should not show Document Locked modal for incoming user when entering expired locked document', async () => {
|
||||||
|
const lockedDoc = await payload.find({
|
||||||
|
collection: lockedDocumentCollection,
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
where: {
|
||||||
|
'document.value': { equals: expiredTestDoc.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(lockedDoc.docs.length).toBe(1)
|
||||||
|
|
||||||
|
// eslint-disable-next-line payload/no-wait-function
|
||||||
|
await wait(500)
|
||||||
|
|
||||||
|
await page.goto(testsUrl.list)
|
||||||
|
await page.waitForURL(new RegExp(testsUrl.list))
|
||||||
|
|
||||||
|
// Need to wait for lock duration to expire (lockDuration: 5 seconds)
|
||||||
|
// eslint-disable-next-line payload/no-wait-function
|
||||||
|
await wait(5000)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
await page.goto(testsUrl.edit(expiredTestDoc.id))
|
||||||
|
await page.waitForURL(testsUrl.edit(expiredTestDoc.id))
|
||||||
|
|
||||||
|
const modalContainer = page.locator('.payload__modal-container')
|
||||||
|
await expect(modalContainer).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
test('should show fields in read-only if incoming user views locked doc in read-only mode', async () => {
|
test('should show fields in read-only if incoming user views locked doc in read-only mode', async () => {
|
||||||
await page.goto(postsUrl.edit(postDoc.id))
|
await page.goto(postsUrl.edit(postDoc.id))
|
||||||
await page.waitForURL(postsUrl.edit(postDoc.id))
|
await page.waitForURL(postsUrl.edit(postDoc.id))
|
||||||
@@ -954,7 +1198,7 @@ describe('locked documents', () => {
|
|||||||
|
|
||||||
test('should show lock on document card in dashboard view if locked', async () => {
|
test('should show lock on document card in dashboard view if locked', async () => {
|
||||||
await page.goto(postsUrl.admin)
|
await page.goto(postsUrl.admin)
|
||||||
await page.waitForURL(postsUrl.admin)
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
||||||
|
|
||||||
const globalCardList = page.locator('.dashboard__group').nth(1)
|
const globalCardList = page.locator('.dashboard__group').nth(1)
|
||||||
await expect(globalCardList.locator('#card-menu .locked svg')).toBeVisible()
|
await expect(globalCardList.locator('#card-menu .locked svg')).toBeVisible()
|
||||||
@@ -970,7 +1214,7 @@ describe('locked documents', () => {
|
|||||||
await wait(500)
|
await wait(500)
|
||||||
|
|
||||||
await page.goto(postsUrl.admin)
|
await page.goto(postsUrl.admin)
|
||||||
await page.waitForURL(postsUrl.admin)
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
||||||
|
|
||||||
const globalCardList = page.locator('.dashboard__group').nth(1)
|
const globalCardList = page.locator('.dashboard__group').nth(1)
|
||||||
await expect(globalCardList.locator('#card-menu .locked')).toBeHidden()
|
await expect(globalCardList.locator('#card-menu .locked')).toBeHidden()
|
||||||
@@ -986,7 +1230,7 @@ describe('locked documents', () => {
|
|||||||
await page.reload()
|
await page.reload()
|
||||||
|
|
||||||
await page.goto(postsUrl.admin)
|
await page.goto(postsUrl.admin)
|
||||||
await page.waitForURL(postsUrl.admin)
|
await page.waitForURL(new RegExp(postsUrl.admin))
|
||||||
|
|
||||||
const globalCardList = page.locator('.dashboard__group').nth(1)
|
const globalCardList = page.locator('.dashboard__group').nth(1)
|
||||||
await expect(globalCardList.locator('#card-menu .locked')).toBeHidden()
|
await expect(globalCardList.locator('#card-menu .locked')).toBeHidden()
|
||||||
@@ -994,13 +1238,6 @@ describe('locked documents', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function createPostDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
|
||||||
return payload.create({
|
|
||||||
collection: 'posts',
|
|
||||||
data,
|
|
||||||
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPageDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
async function createPageDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
||||||
return payload.create({
|
return payload.create({
|
||||||
collection: 'pages',
|
collection: 'pages',
|
||||||
@@ -1008,6 +1245,20 @@ async function createPageDoc(data: any): Promise<Record<string, unknown> & TypeW
|
|||||||
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createPostDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
||||||
|
return payload.create({
|
||||||
|
collection: 'posts',
|
||||||
|
data,
|
||||||
|
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestDoc(data: any): Promise<Record<string, unknown> & TypeWithID> {
|
||||||
|
return payload.create({
|
||||||
|
collection: 'tests',
|
||||||
|
data,
|
||||||
|
}) as unknown as Promise<Record<string, unknown> & TypeWithID>
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteAllPosts() {
|
async function deleteAllPosts() {
|
||||||
await payload.delete({ collection: postsSlug, where: { id: { exists: true } } })
|
await payload.delete({ collection: postsSlug, where: { id: { exists: true } } })
|
||||||
}
|
}
|
||||||
|
|||||||
16
test/locked-documents/globals/Admin/index.ts
Normal file
16
test/locked-documents/globals/Admin/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { GlobalConfig } from 'payload'
|
||||||
|
|
||||||
|
export const adminSlug = 'admin'
|
||||||
|
|
||||||
|
export const AdminGlobal: GlobalConfig = {
|
||||||
|
slug: adminSlug,
|
||||||
|
lockDocuments: {
|
||||||
|
duration: 10,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'adminText',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface Config {
|
|||||||
collections: {
|
collections: {
|
||||||
pages: Page;
|
pages: Page;
|
||||||
posts: Post;
|
posts: Post;
|
||||||
|
tests: Test;
|
||||||
users: User;
|
users: User;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
@@ -22,6 +23,7 @@ export interface Config {
|
|||||||
defaultIDType: string;
|
defaultIDType: string;
|
||||||
};
|
};
|
||||||
globals: {
|
globals: {
|
||||||
|
admin: Admin;
|
||||||
menu: Menu;
|
menu: Menu;
|
||||||
};
|
};
|
||||||
locale: null;
|
locale: null;
|
||||||
@@ -69,6 +71,17 @@ export interface Post {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
_status?: ('draft' | 'published') | null;
|
_status?: ('draft' | 'published') | null;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "tests".
|
||||||
|
*/
|
||||||
|
export interface Test {
|
||||||
|
id: string;
|
||||||
|
text?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
_status?: ('draft' | 'published') | null;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
@@ -102,6 +115,10 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: 'posts';
|
relationTo: 'posts';
|
||||||
value: string | Post;
|
value: string | Post;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'tests';
|
||||||
|
value: string | Test;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: string | User;
|
||||||
@@ -148,6 +165,16 @@ export interface PayloadMigration {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "admin".
|
||||||
|
*/
|
||||||
|
export interface Admin {
|
||||||
|
id: string;
|
||||||
|
adminText?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "menu".
|
* via the `definition` "menu".
|
||||||
|
|||||||
Reference in New Issue
Block a user