chore: merge conflicts

This commit is contained in:
Jessica Chowdhury
2025-08-05 12:36:10 +01:00
676 changed files with 29599 additions and 5863 deletions

View File

@@ -84,7 +84,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {
menu: Menu;
@@ -124,7 +124,7 @@ export interface UserAuthOperations {
* via the `definition` "posts".
*/
export interface Post {
id: string;
id: number;
title?: string | null;
content?: {
root: {
@@ -149,7 +149,7 @@ export interface Post {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -193,7 +193,7 @@ export interface Media {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -217,24 +217,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -244,10 +244,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -267,7 +267,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "menu".
*/
export interface Menu {
id: string;
id: number;
globalText?: string | null;
updatedAt?: string | null;
createdAt?: string | null;

View File

@@ -6,7 +6,7 @@ import { devUser } from 'credentials.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openNav } from 'helpers/e2e/toggleNav.js'
import path from 'path'
import { email, wait } from 'payload/shared'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
@@ -16,7 +16,6 @@ import {
closeNav,
ensureCompilationIsDone,
exactText,
getRoutes,
initPageConsoleErrorCatch,
login,
saveDocAndAssert,
@@ -71,7 +70,6 @@ describe('Access Control', () => {
let disabledFields: AdminUrlUtil
let serverURL: string
let context: BrowserContext
let logoutURL: string
let authFields: AdminUrlUtil
beforeAll(async ({ browser }, testInfo) => {
@@ -98,17 +96,6 @@ describe('Access Control', () => {
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
await login({ page, serverURL })
await ensureCompilationIsDone({ page, serverURL })
const {
admin: {
routes: { logout: logoutRoute },
},
routes: { admin: adminRoute },
} = getRoutes({})
logoutURL = `${serverURL}${adminRoute}${logoutRoute}`
})
describe('fields', () => {
@@ -515,6 +502,13 @@ describe('Access Control', () => {
describe('global', () => {
test('should restrict update access based on document field', async () => {
await payload.updateGlobal({
slug: userRestrictedGlobalSlug,
data: {
name: 'dev@payloadcms.com',
},
})
await page.goto(userRestrictedGlobalURL.global(userRestrictedGlobalSlug))
await expect(page.locator('#field-name')).toBeVisible()
await expect(page.locator('#field-name')).toHaveValue(devUser.email)

View File

@@ -0,0 +1,60 @@
'use client'
import { toast, useListDrawer, useListDrawerContext, useTranslation } from '@payloadcms/ui'
import React, { useCallback } from 'react'
export const CustomListDrawer = () => {
const [isCreating, setIsCreating] = React.useState(false)
// this is the _outer_ drawer context (if any), not the one for the list drawer below
const { refresh } = useListDrawerContext()
const { t } = useTranslation()
const [ListDrawer, ListDrawerToggler] = useListDrawer({
collectionSlugs: ['custom-list-drawer'],
})
const createDoc = useCallback(async () => {
if (isCreating) {
return
}
setIsCreating(true)
try {
await fetch('/api/custom-list-drawer', {
body: JSON.stringify({}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
setIsCreating(false)
toast.success(
t('general:successfullyCreated', {
label: 'Custom List Drawer',
}),
)
// In the root document view, there is no outer drawer context, so this will be `undefined`
if (typeof refresh === 'function') {
await refresh()
}
} catch (_err) {
console.error('Error creating document:', _err) // eslint-disable-line no-console
setIsCreating(false)
}
}, [isCreating, refresh, t])
return (
<div>
<button id="create-custom-list-drawer-doc" onClick={createDoc} type="button">
{isCreating ? 'Creating...' : 'Create Document'}
</button>
<ListDrawer />
<ListDrawerToggler id="open-custom-list-drawer">Open list drawer</ListDrawerToggler>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const CustomListDrawer: CollectionConfig = {
slug: 'custom-list-drawer',
fields: [
{
name: 'customListDrawer',
type: 'ui',
admin: {
components: {
Field: '/collections/CustomListDrawer/Component.js#CustomListDrawer',
},
},
},
],
}

View File

@@ -293,6 +293,13 @@ export const Posts: CollectionConfig = {
name: 'file',
type: 'text',
},
{
name: 'noReadAccessField',
type: 'text',
access: {
read: () => false,
},
},
],
labels: {
plural: slugPluralLabel,

View File

@@ -5,6 +5,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Array } from './collections/Array.js'
import { BaseListFilter } from './collections/BaseListFilter.js'
import { CustomFields } from './collections/CustomFields/index.js'
import { CustomListDrawer } from './collections/CustomListDrawer/index.js'
import { CustomViews1 } from './collections/CustomViews1.js'
import { CustomViews2 } from './collections/CustomViews2.js'
import { DisableBulkEdit } from './collections/DisableBulkEdit.js'
@@ -185,6 +186,7 @@ export default buildConfigWithDefaults({
Placeholder,
UseAsTitleGroupField,
DisableBulkEdit,
CustomListDrawer,
],
globals: [
GlobalHidden,

View File

@@ -35,10 +35,12 @@ let payload: PayloadTestSDK<Config>
import { devUser } from 'credentials.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToNextPage, goToPreviousPage } from 'helpers/e2e/goToNextPage.js'
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import { deletePreferences } from 'helpers/e2e/preferences.js'
import { sortColumn } from 'helpers/e2e/sortColumn.js'
import { toggleColumn, waitForColumnInURL } from 'helpers/e2e/toggleColumn.js'
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import { closeListDrawer } from 'helpers/e2e/toggleListDrawer.js'
@@ -630,7 +632,7 @@ describe('List View', () => {
const tableItems = page.locator(tableRowLocator)
await expect(tableItems).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.goto(`${postsUrl.list}?limit=5&page=2`)
@@ -642,7 +644,7 @@ describe('List View', () => {
})
await page.waitForURL(new RegExp(`${postsUrl.list}\\?limit=5&page=1`))
await expect(page.locator('.collection-list__page-info')).toHaveText('1-3 of 3')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-3 of 3')
})
test('should reset filter values for every additional filter', async () => {
@@ -784,6 +786,27 @@ describe('List View', () => {
).toBeHidden()
})
test('should show no results when queryin on a field a user cannot read', async () => {
await payload.create({
collection: postsCollectionSlug,
data: {
noReadAccessField: 'test',
},
})
await page.goto(postsUrl.list)
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'No Read Access Field',
operatorLabel: 'equals',
value: 'test',
})
await expect(whereBuilder.locator('.condition__value input')).toBeVisible()
await expect(page.locator('.collection-list__no-results')).toBeVisible()
})
test('should properly paginate many documents', async () => {
await page.goto(with300DocumentsUrl.list)
@@ -1355,13 +1378,13 @@ describe('List View', () => {
await page.reload()
await expect(page.locator(tableRowLocator)).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.locator('.paginator button').nth(1).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
await page.locator('.paginator button').nth(0).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=1')
await goToPreviousPage(page)
await expect(page.locator(tableRowLocator)).toHaveCount(5)
})
@@ -1375,7 +1398,7 @@ describe('List View', () => {
await page.reload()
const tableItems = page.locator(tableRowLocator)
await expect(tableItems).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 16')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 16')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.locator('.per-page .popup-button').click()
@@ -1387,11 +1410,11 @@ describe('List View', () => {
await expect(tableItems).toHaveCount(15)
await expect(page.locator('.per-page .per-page__base-button')).toContainText('Per Page: 15')
await page.locator('.paginator button').nth(1).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
await expect(tableItems).toHaveCount(1)
await expect(page.locator('.per-page')).toContainText('Per Page: 15') // ensure this hasn't changed
await expect(page.locator('.collection-list__page-info')).toHaveText('16-16 of 16')
await expect(page.locator('.page-controls__page-info')).toHaveText('16-16 of 16')
})
})
@@ -1410,17 +1433,13 @@ describe('List View', () => {
test('should sort', async () => {
await page.reload()
const upChevron = page.locator('#heading-number .sort-column__asc')
const downChevron = page.locator('#heading-number .sort-column__desc')
await upChevron.click()
await page.waitForURL(/sort=number/)
await sortColumn(page, { fieldPath: 'number', fieldLabel: 'Number', targetState: 'asc' })
await expect(page.locator('.row-1 .cell-number')).toHaveText('1')
await expect(page.locator('.row-2 .cell-number')).toHaveText('2')
await downChevron.click()
await page.waitForURL(/sort=-number/)
await sortColumn(page, { fieldPath: 'number', fieldLabel: 'Number', targetState: 'desc' })
await expect(page.locator('.row-1 .cell-number')).toHaveText('2')
await expect(page.locator('.row-2 .cell-number')).toHaveText('1')
@@ -1434,25 +1453,31 @@ describe('List View', () => {
hasText: exactText('Named Group > Some Text Field'),
})
.click()
const upChevron = page.locator('#heading-namedGroup__someTextField .sort-column__asc')
const downChevron = page.locator('#heading-namedGroup__someTextField .sort-column__desc')
await upChevron.click()
await page.waitForURL(/sort=namedGroup.someTextField/)
await sortColumn(page, {
fieldPath: 'namedGroup.someTextField',
fieldLabel: 'Named Group > Some Text Field',
targetState: 'asc',
})
await expect(page.locator('.row-1 .cell-namedGroup__someTextField')).toHaveText(
'<No Some Text Field>',
)
await expect(page.locator('.row-2 .cell-namedGroup__someTextField')).toHaveText(
'nested group text field',
)
await downChevron.click()
await page.waitForURL(/sort=-namedGroup.someTextField/)
await sortColumn(page, {
fieldPath: 'namedGroup.someTextField',
fieldLabel: 'Named Group > Some Text Field',
targetState: 'desc',
})
await expect(page.locator('.row-1 .cell-namedGroup__someTextField')).toHaveText(
'nested group text field',
)
await expect(page.locator('.row-2 .cell-namedGroup__someTextField')).toHaveText(
'<No Some Text Field>',
)
@@ -1466,29 +1491,31 @@ describe('List View', () => {
hasText: exactText('Named Tab > Nested Text Field In Named Tab'),
})
.click()
const upChevron = page.locator(
'#heading-namedTab__nestedTextFieldInNamedTab .sort-column__asc',
)
const downChevron = page.locator(
'#heading-namedTab__nestedTextFieldInNamedTab .sort-column__desc',
)
await upChevron.click()
await page.waitForURL(/sort=namedTab.nestedTextFieldInNamedTab/)
await sortColumn(page, {
fieldPath: 'namedTab.nestedTextFieldInNamedTab',
fieldLabel: 'Named Tab > Nested Text Field In Named Tab',
targetState: 'asc',
})
await expect(page.locator('.row-1 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)
await expect(page.locator('.row-2 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await downChevron.click()
await page.waitForURL(/sort=-namedTab.nestedTextFieldInNamedTab/)
await sortColumn(page, {
fieldPath: 'namedTab.nestedTextFieldInNamedTab',
fieldLabel: 'Named Tab > Nested Text Field In Named Tab',
targetState: 'desc',
})
await expect(page.locator('.row-1 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await expect(page.locator('.row-2 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)
@@ -1649,6 +1676,69 @@ describe('List View', () => {
'Custom placeholder',
)
})
test('should reset list selection when query params change', async () => {
await deleteAllPosts()
await Promise.all(Array.from({ length: 12 }, (_, i) => createPost({ title: `post${i + 1}` })))
await page.goto(postsUrl.list)
const pageOneButton = page.locator('.paginator__page', { hasText: '1' })
await expect(pageOneButton).toBeVisible()
await pageOneButton.click()
await page.locator('.checkbox-input:has(#select-all)').locator('input').click()
await expect(page.locator('.checkbox-input:has(#select-all)').locator('input')).toBeChecked()
await expect(page.locator('.list-selection')).toContainText('5 selected')
const pageTwoButton = page.locator('.paginator__page', { hasText: '2' })
await expect(pageTwoButton).toBeVisible()
await pageTwoButton.click()
await expect(
page.locator('.checkbox-input:has(#select-all) input:not([checked])'),
).toBeVisible()
await page.locator('.row-1 .cell-_select input').check()
await page.locator('.row-2 .cell-_select input').check()
await expect(page.locator('.list-selection')).toContainText('2 selected')
})
test('should refresh custom list drawer using the refresh method from context', async () => {
const url = new AdminUrlUtil(serverURL, 'custom-list-drawer')
await payload.delete({
collection: 'custom-list-drawer',
where: { id: { exists: true } },
})
const { id } = await payload.create({
collection: 'custom-list-drawer',
data: {},
})
await page.goto(url.list)
await expect(page.locator('.table > table > tbody > tr')).toHaveCount(1)
await page.goto(url.edit(id))
await page.locator('#open-custom-list-drawer').click()
const drawer = page.locator('[id^=list-drawer_1_]')
await expect(drawer).toBeVisible()
await expect(drawer.locator('.table > table > tbody > tr')).toHaveCount(1)
await drawer.locator('.list-header__create-new-button.doc-drawer__toggler').click()
const createNewDrawer = page.locator('[id^=doc-drawer_custom-list-drawer_1_]')
await createNewDrawer.locator('#create-custom-list-drawer-doc').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await createNewDrawer.locator('.doc-drawer__header-close').click()
await expect(drawer.locator('.table > table > tbody > tr')).toHaveCount(2)
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {

View File

@@ -93,6 +93,7 @@ export interface Config {
placeholder: Placeholder;
'use-as-title-group-field': UseAsTitleGroupField;
'disable-bulk-edit': DisableBulkEdit;
'custom-list-drawer': CustomListDrawer;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -125,6 +126,7 @@ export interface Config {
placeholder: PlaceholderSelect<false> | PlaceholderSelect<true>;
'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>;
'disable-bulk-edit': DisableBulkEditSelect<false> | DisableBulkEditSelect<true>;
'custom-list-drawer': CustomListDrawerSelect<false> | CustomListDrawerSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -272,6 +274,7 @@ export interface Post {
wavelengths?: ('fm' | 'am') | null;
selectField?: ('option1' | 'option2')[] | null;
file?: string | null;
noReadAccessField?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -565,6 +568,15 @@ export interface DisableBulkEdit {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-list-drawer".
*/
export interface CustomListDrawer {
id: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -675,6 +687,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'disable-bulk-edit';
value: string | DisableBulkEdit;
} | null)
| ({
relationTo: 'custom-list-drawer';
value: string | CustomListDrawer;
} | null);
globalSlug?: string | null;
user: {
@@ -807,6 +823,7 @@ export interface PostsSelect<T extends boolean = true> {
wavelengths?: T;
selectField?: T;
file?: T;
noReadAccessField?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -1074,6 +1091,14 @@ export interface DisableBulkEditSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-list-drawer_select".
*/
export interface CustomListDrawerSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -2,10 +2,9 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { v4 as uuid } from 'uuid'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { seed } from './seed.js'
import {
apiKeysSlug,
namedSaveToJWTValue,
@@ -263,33 +262,7 @@ export default buildConfigWithDefaults({
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
custom: 'Hello, world!',
email: devUser.email,
password: devUser.password,
roles: ['admin'],
},
})
await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
},
onInit: seed,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},

View File

@@ -1,8 +1,8 @@
import type { BrowserContext, Page } from '@playwright/test'
import type { SanitizedConfig } from 'payload'
import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
import { openNav } from 'helpers/e2e/toggleNav.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { v4 as uuid } from 'uuid'
@@ -15,6 +15,7 @@ import {
exactText,
getRoutes,
initPageConsoleErrorCatch,
login,
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
@@ -28,59 +29,12 @@ const dirname = path.dirname(filename)
let payload: PayloadTestSDK<Config>
const { beforeAll, describe } = test
const { beforeAll, afterAll, describe } = test
const headers = {
'Content-Type': 'application/json',
}
const createFirstUser = async ({
page,
serverURL,
}: {
customAdminRoutes?: SanitizedConfig['admin']['routes']
customRoutes?: SanitizedConfig['routes']
page: Page
serverURL: string
}) => {
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getRoutes({})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
// forget to fill out confirm password
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText(
'This field is required.',
)
// make them match, but does not pass password validation
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill('12')
await page.locator('#field-confirm-password').fill('12')
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.password .field-error')).toHaveText(
'This value must be longer than the minimum length of 3 characters.',
)
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)
await page.locator('#field-custom').fill('Hello, world!')
await page.locator('.form-submit > button').click()
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.not.toContain('create-first-user')
}
describe('Auth', () => {
let page: Page
let context: BrowserContext
@@ -97,169 +51,288 @@ describe('Auth', () => {
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
// Undo onInit seeding, as we need to test this without having a user created, or testing create-first-user
await reInitializeDB({
serverURL,
snapshotKey: 'auth',
deleteOnly: true,
})
await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
await createFirstUser({ page, serverURL })
await ensureCompilationIsDone({ page, serverURL })
})
describe('passwords', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug)
})
test('should allow change password', async () => {
await page.goto(url.account)
const emailBeforeSave = await page.locator('#field-email').inputValue()
await page.locator('#change-password').click()
await page.locator('#field-password').fill('password')
// should fail to save without confirm password
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('This field is required.'),
}),
).toBeVisible()
// should fail to save with incorrect confirm password
await page.locator('#field-confirm-password').fill('wrong password')
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('Passwords do not match.'),
}),
).toBeVisible()
// should succeed with matching confirm password
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page, '#action-save')
// should still have the same email
await expect(page.locator('#field-email')).toHaveValue(emailBeforeSave)
})
test('should prevent new user creation without confirm password', async () => {
await page.goto(url.create)
await page.locator('#field-email').fill('dev2@payloadcms.com')
await page.locator('#field-password').fill('password')
// should fail to save without confirm password
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('This field is required.'),
}),
).toBeVisible()
// should succeed with matching confirm password
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page, '#action-save')
})
})
describe('authenticated users', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug)
})
test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account)
await expect(page.locator('#users-api-result')).toHaveText('Hello, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Hello, world!')
const field = page.locator('#field-custom')
await field.fill('Goodbye, world!')
await saveDocAndAssert(page)
await expect(page.locator('#users-api-result')).toHaveText('Goodbye, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Goodbye, world!')
})
})
describe('api-keys', () => {
let user
describe('create first user', () => {
beforeAll(async () => {
url = new AdminUrlUtil(serverURL, apiKeysSlug)
await reInitializeDB({
serverURL,
snapshotKey: 'create-first-user',
deleteOnly: true,
})
user = await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
await payload.delete({
collection: slug,
where: {
email: {
exists: true,
},
},
})
})
test('should enable api key', async () => {
await page.goto(url.create)
async function waitForVisibleAuthFields() {
await expect(page.locator('#field-email')).toBeVisible()
await expect(page.locator('#field-password')).toBeVisible()
await expect(page.locator('#field-confirm-password')).toBeVisible()
}
// click enable api key checkbox
await page.locator('#field-enableAPIKey').click()
test('should create first user and redirect to admin', async () => {
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getRoutes({})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
await expect(page.locator('.create-first-user')).toBeVisible()
await waitForVisibleAuthFields()
// forget to fill out confirm password
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText(
'This field is required.',
)
// make them match, but does not pass password validation
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill('12')
await page.locator('#field-confirm-password').fill('12')
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.password .field-error')).toHaveText(
'This value must be longer than the minimum length of 3 characters.',
)
// should fill out all fields correctly
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)
await page.locator('#field-custom').fill('Hello, world!')
await page.locator('.form-submit > button').click()
// assert that the value is set
const apiKeyLocator = page.locator('#apiKey')
await expect
.poll(async () => await apiKeyLocator.inputValue(), { timeout: POLL_TOPASS_TIMEOUT })
.toBeDefined()
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.not.toContain('create-first-user')
})
})
const apiKey = await apiKeyLocator.inputValue()
describe('non create first user', () => {
beforeAll(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'auth',
deleteOnly: false,
})
await saveDocAndAssert(page)
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
await expect(async () => {
const apiKeyAfterSave = await apiKeyLocator.inputValue()
expect(apiKey).toStrictEqual(apiKeyAfterSave)
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
await login({ page, serverURL })
})
describe('passwords', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug)
})
afterAll(async () => {
// reset password to original password
await page.goto(url.account)
await page.locator('#change-password').click()
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)
await saveDocAndAssert(page, '#action-save')
})
test('should allow change password', async () => {
await page.goto(url.account)
const emailBeforeSave = await page.locator('#field-email').inputValue()
await page.locator('#change-password').click()
await page.locator('#field-password').fill('password')
// should fail to save without confirm password
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('This field is required.'),
}),
).toBeVisible()
// should fail to save with incorrect confirm password
await page.locator('#field-confirm-password').fill('wrong password')
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('Passwords do not match.'),
}),
).toBeVisible()
// should succeed with matching confirm password
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page, '#action-save')
// should still have the same email
await expect(page.locator('#field-email')).toHaveValue(emailBeforeSave)
})
test('should prevent new user creation without confirm password', async () => {
await page.goto(url.create)
await page.locator('#field-email').fill('dev2@payloadcms.com')
await page.locator('#field-password').fill('password')
// should fail to save without confirm password
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('This field is required.'),
}),
).toBeVisible()
// should succeed with matching confirm password
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page, '#action-save')
})
})
test('should disable api key', async () => {
await page.goto(url.edit(user.id))
describe('authenticated users', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug)
})
// click enable api key checkbox
await page.locator('#field-enableAPIKey').click()
test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account)
await expect(page.locator('#users-api-result')).toHaveText('Hello, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Hello, world!')
const field = page.locator('#field-custom')
await field.fill('Goodbye, world!')
await saveDocAndAssert(page)
await expect(page.locator('#users-api-result')).toHaveText('Goodbye, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Goodbye, world!')
})
// assert that the apiKey field is hidden
await expect(page.locator('#apiKey')).toBeHidden()
// Need to test unlocking documents on logout here as this test suite does not auto login users
test('should unlock document on logout after editing without saving', async () => {
await page.goto(url.list)
await saveDocAndAssert(page)
await page.locator('.table .row-1 .cell-custom a').click()
// use the api key in a fetch to assert that it is disabled
await expect(async () => {
const response = await fetch(`${apiURL}/${apiKeysSlug}/me`, {
headers: {
...headers,
Authorization: `${apiKeysSlug} API-Key ${user.apiKey}`,
const textInput = page.locator('#field-namedSaveToJWT')
await expect(textInput).toBeVisible()
const docID = (await page.locator('.render-title').getAttribute('data-doc-id')) as string
const lockDocRequest = page.waitForResponse(
(response) =>
response.request().method() === 'POST' && response.request().url() === url.edit(docID),
)
await textInput.fill('some text')
await lockDocRequest
const lockedDocs = await payload.find({
collection: 'payload-locked-documents',
limit: 1,
pagination: false,
})
await expect.poll(() => lockedDocs.docs.length).toBe(1)
await openNav(page)
await page.locator('.nav .nav__controls a[href="/admin/logout"]').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 .confirmation-modal__controls .btn--style-primary')
.click()
await expect(page.locator('.login')).toBeVisible()
const unlockedDocs = await payload.find({
collection: 'payload-locked-documents',
limit: 1,
pagination: false,
})
await expect.poll(() => unlockedDocs.docs.length).toBe(0)
// added so tests after this do not need to re-login
await login({ page, serverURL })
})
})
describe('api-keys', () => {
let user
beforeAll(async () => {
url = new AdminUrlUtil(serverURL, apiKeysSlug)
user = await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
}).then((res) => res.json())
})
})
expect(response.user).toBeNull()
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
test('should enable api key', async () => {
await page.goto(url.create)
// click enable api key checkbox
await page.locator('#field-enableAPIKey').click()
// assert that the value is set
const apiKeyLocator = page.locator('#apiKey')
await expect
.poll(async () => await apiKeyLocator.inputValue(), { timeout: POLL_TOPASS_TIMEOUT })
.toBeDefined()
const apiKey = await apiKeyLocator.inputValue()
await saveDocAndAssert(page)
await expect(async () => {
const apiKeyAfterSave = await apiKeyLocator.inputValue()
expect(apiKey).toStrictEqual(apiKeyAfterSave)
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('should disable api key', async () => {
await page.goto(url.edit(user.id))
// click enable api key checkbox
await page.locator('#field-enableAPIKey').click()
// assert that the apiKey field is hidden
await expect(page.locator('#apiKey')).toBeHidden()
await saveDocAndAssert(page)
// use the api key in a fetch to assert that it is disabled
await expect(async () => {
const response = await fetch(`${apiURL}/${apiKeysSlug}/me`, {
headers: {
...headers,
Authorization: `${apiKeysSlug} API-Key ${user.apiKey}`,
},
}).then((res) => res.json())
expect(response.user).toBeNull()
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
})
})

View File

@@ -262,6 +262,42 @@ describe('Auth', () => {
expect(data.user.custom).toBe('Goodbye, world!')
})
it('keeps apiKey encrypted in DB after refresh operation', async () => {
const apiKey = '987e6543-e21b-12d3-a456-426614174999'
const user = await payload.create({
collection: slug,
data: { email: 'user@example.com', password: 'Password123', apiKey, enableAPIKey: true },
})
const { token } = await payload.login({
collection: 'users',
data: { email: 'user@example.com', password: 'Password123' },
})
await restClient.POST('/users/refresh-token', {
headers: { Authorization: `JWT ${token}` },
})
const raw = await payload.db.findOne<any>({
collection: 'users',
req: { locale: 'en' } as any,
where: { id: { equals: user.id } },
})
expect(raw?.apiKey).not.toContain('-') // still ciphertext
})
it('returns a user with decrypted apiKey after refresh', async () => {
const { token } = await payload.login({
collection: 'users',
data: { email: 'user@example.com', password: 'Password123' },
})
const res = await restClient
.POST('/users/refresh-token', {
headers: { Authorization: `JWT ${token}` },
})
.then((r) => r.json())
expect(res.user.apiKey).toMatch(/[0-9a-f-]{36}/) // UUID string
})
it('should allow a user to be created', async () => {
const response = await restClient.POST(`/${slug}`, {
body: JSON.stringify({
@@ -498,13 +534,21 @@ describe('Auth', () => {
describe('Account Locking', () => {
const userEmail = 'lock@me.com'
const tryLogin = async () => {
await restClient.POST(`/${slug}/login`, {
body: JSON.stringify({
email: userEmail,
password: 'bad',
}),
const tryLogin = async (success?: boolean) => {
const res = await restClient.POST(`/${slug}/login`, {
body: JSON.stringify(
success
? {
email: userEmail,
password,
}
: {
email: userEmail,
password: 'bad',
},
),
})
return await res.json()
}
beforeAll(async () => {
@@ -530,10 +574,32 @@ describe('Auth', () => {
})
})
beforeEach(async () => {
await payload.db.updateOne({
collection: slug,
data: {
lockUntil: null,
loginAttempts: 0,
},
where: {
email: {
equals: userEmail,
},
},
})
})
const lockedMessage = 'This user is locked due to having too many failed login attempts.'
const incorrectMessage = 'The email or password provided is incorrect.'
it('should lock the user after too many attempts', async () => {
await tryLogin()
await tryLogin()
await tryLogin() // Let it call multiple times, therefore the unlock condition has no bug.
const user1 = await tryLogin()
const user2 = await tryLogin()
const user3 = await tryLogin() // Let it call multiple times, therefore the unlock condition has no bug.
expect(user1.errors[0].message).toBe(incorrectMessage)
expect(user2.errors[0].message).toBe(incorrectMessage)
expect(user3.errors[0].message).toBe(lockedMessage)
const userResult = await payload.find({
collection: slug,
@@ -546,10 +612,98 @@ describe('Auth', () => {
},
})
const { lockUntil, loginAttempts } = userResult.docs[0]
const { lockUntil, loginAttempts } = userResult.docs[0]!
expect(loginAttempts).toBe(2)
expect(lockUntil).toBeDefined()
const successfulLogin = await tryLogin(true)
expect(successfulLogin.errors?.[0].message).toBe(
'This user is locked due to having too many failed login attempts.',
)
})
it('should lock the user after too many parallel attempts', async () => {
const tryLoginAttempts = 100
const users = await Promise.allSettled(
Array.from({ length: tryLoginAttempts }, () => tryLogin()),
)
expect(users).toHaveLength(tryLoginAttempts)
// Expect min. 8 locked message max. 2 incorrect messages.
const lockedMessages = users.filter(
(result) =>
result.status === 'fulfilled' && result.value?.errors?.[0]?.message === lockedMessage,
)
const incorrectMessages = users.filter(
(result) =>
result.status === 'fulfilled' &&
result.value?.errors?.[0]?.message === incorrectMessage,
)
const userResult = await payload.find({
collection: slug,
limit: 1,
showHiddenFields: true,
where: {
email: {
equals: userEmail,
},
},
})
const { lockUntil, loginAttempts } = userResult.docs[0]!
// loginAttempts does not have to be exactly the same amount of login attempts. If this ran sequentially, login attempts would stop
// incrementing after maxLoginAttempts is reached. Since this is run in parallel, it can increment more than maxLoginAttempts, but it is not
// expected to and can be less depending on the timing.
expect(loginAttempts).toBeGreaterThan(3)
expect(lockUntil).toBeDefined()
expect(incorrectMessages.length).toBeLessThanOrEqual(2)
expect(lockedMessages.length).toBeGreaterThanOrEqual(tryLoginAttempts - 2)
const successfulLogin = await tryLogin(true)
expect(successfulLogin.errors?.[0].message).toBe(
'This user is locked due to having too many failed login attempts.',
)
})
it('ensure that login session expires if max login attempts is reached within narrow time-frame', async () => {
const tryLoginAttempts = 5
// If there are 100 parallel login attempts, 99 incorrect and 1 correct one, we do not want the correct one to be able to consistently be able
// to login successfully.
const user = await tryLogin(true)
const firstMeResponse = await restClient.GET(`/${slug}/me`, {
headers: {
Authorization: `JWT ${user.token}`,
},
})
expect(firstMeResponse.status).toBe(200)
const firstMeData = await firstMeResponse.json()
expect(firstMeData.token).toBeDefined()
expect(firstMeData.user.email).toBeDefined()
await Promise.allSettled(Array.from({ length: tryLoginAttempts }, () => tryLogin()))
const secondMeResponse = await restClient.GET(`/${slug}/me`, {
headers: {
Authorization: `JWT ${user.token}`,
},
})
expect(secondMeResponse.status).toBe(200)
const secondMeData = await secondMeResponse.json()
expect(secondMeData.user).toBeNull()
expect(secondMeData.token).not.toBeDefined()
})
it('should unlock account once lockUntil period is over', async () => {

View File

@@ -248,11 +248,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions: {
id: string;
createdAt?: string | null;
expiresAt: string;
}[];
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -270,11 +272,13 @@ export interface PartialDisableLocalStrategy {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions: {
id: string;
createdAt?: string | null;
expiresAt: string;
}[];
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -316,11 +320,13 @@ export interface PublicUser {
_verificationToken?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions: {
id: string;
createdAt?: string | null;
expiresAt: string;
}[];
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**

34
test/auth/seed.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { Config } from 'payload'
import { v4 as uuid } from 'uuid'
import { devUser } from '../credentials.js'
import { apiKeysSlug } from './shared.js'
export const seed: Config['onInit'] = async (payload) => {
await payload.create({
collection: 'users',
data: {
custom: 'Hello, world!',
email: devUser.email,
password: devUser.password,
roles: ['admin'],
},
})
await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
}

View File

@@ -320,7 +320,7 @@ test.describe('Bulk Edit', () => {
await page.goto(postsUrl.list)
await expect(page.locator('.collection-list__page-info')).toContainText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toContainText('1-5 of 6')
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)

View File

@@ -0,0 +1,19 @@
/* eslint-disable no-restricted-exports */
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { getConfig } from './getConfig.js'
const config = getConfig()
import { postgresAdapter } from '@payloadcms/db-postgres'
export const databaseAdapter = postgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL || 'postgres://127.0.0.1:5432/payloadtests',
},
logger: true,
})
export default buildConfigWithDefaults({
...config,
db: databaseAdapter,
})

View File

@@ -1,933 +1,4 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { TextField } from 'payload'
import { randomUUID } from 'crypto'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { seed } from './seed.js'
import {
customIDsSlug,
customSchemaSlug,
defaultValuesSlug,
errorOnUnnamedFieldsSlug,
fakeCustomIDsSlug,
fieldsPersistanceSlug,
pgMigrationSlug,
placesSlug,
postsSlug,
relationASlug,
relationBSlug,
relationshipsMigrationSlug,
} from './shared.js'
import { getConfig } from './getConfig.js'
const defaultValueField: TextField = {
name: 'defaultValue',
type: 'text',
defaultValue: 'default value from database',
}
export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'categories',
versions: { drafts: true },
fields: [
{
type: 'text',
name: 'title',
},
],
},
{
slug: 'categories-custom-id',
versions: { drafts: true },
fields: [
{
type: 'number',
name: 'id',
},
],
},
{
slug: postsSlug,
fields: [
{
name: 'title',
type: 'text',
required: true,
// access: { read: () => false },
},
{
type: 'relationship',
relationTo: 'categories',
name: 'category',
},
{
type: 'relationship',
relationTo: 'categories-custom-id',
name: 'categoryCustomID',
},
{
name: 'localized',
type: 'text',
localized: true,
},
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
type: 'blocks',
name: 'blocks',
blocks: [
{
slug: 'block-third',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [
{
slug: 'block-fourth',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [],
},
],
},
],
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'D1',
fields: [
{
name: 'D2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
type: 'tabs',
tabs: [
{
fields: [
{
name: 'D3',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
name: 'D4',
type: 'text',
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
{
name: 'hasTransaction',
type: 'checkbox',
hooks: {
beforeChange: [({ req }) => !!req.transactionID],
},
admin: {
readOnly: true,
},
},
{
name: 'throwAfterChange',
type: 'checkbox',
defaultValue: false,
hooks: {
afterChange: [
({ value }) => {
if (value) {
throw new Error('throw after change')
}
},
],
},
},
{
name: 'arrayWithIDs',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
name: 'blocksWithIDs',
type: 'blocks',
blocks: [
{
slug: 'block-first',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
{
type: 'group',
name: 'group',
fields: [{ name: 'text', type: 'text' }],
},
{
type: 'tabs',
tabs: [
{
name: 'tab',
fields: [{ name: 'text', type: 'text' }],
},
],
},
],
hooks: {
beforeOperation: [
({ args, operation, req }) => {
if (operation === 'update') {
const defaultIDType = req.payload.db.defaultIDType
if (defaultIDType === 'number' && typeof args.id === 'string') {
throw new Error('ID was not sanitized to a number properly')
}
}
return args
},
],
},
},
{
slug: errorOnUnnamedFieldsSlug,
fields: [
{
type: 'tabs',
tabs: [
{
label: 'UnnamedTab',
fields: [
{
name: 'groupWithinUnnamedTab',
type: 'group',
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
],
},
],
},
],
},
{
slug: defaultValuesSlug,
fields: [
{
name: 'title',
type: 'text',
},
defaultValueField,
{
name: 'array',
type: 'array',
// default array with one object to test subfield defaultValue properties for Mongoose
defaultValue: [{}],
fields: [defaultValueField],
},
{
name: 'group',
type: 'group',
// we need to have to use as default in order to have subfield defaultValue properties directly for Mongoose
defaultValue: {},
fields: [defaultValueField],
},
{
name: 'select',
type: 'select',
defaultValue: 'default',
options: [
{ value: 'option0', label: 'Option 0' },
{ value: 'option1', label: 'Option 1' },
{ value: 'default', label: 'Default' },
],
},
{
name: 'point',
type: 'point',
defaultValue: [10, 20],
},
{
name: 'escape',
type: 'text',
defaultValue: "Thanks, we're excited for you to join us.",
},
],
},
{
slug: relationASlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation As',
singular: 'Relation A',
},
},
{
slug: relationBSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'relationship',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation Bs',
singular: 'Relation B',
},
},
{
slug: pgMigrationSlug,
fields: [
{
name: 'relation1',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'myArray',
type: 'array',
fields: [
{
name: 'relation2',
type: 'relationship',
relationTo: 'relation-b',
},
{
name: 'mySubArray',
type: 'array',
fields: [
{
name: 'relation3',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
{
name: 'myGroup',
type: 'group',
fields: [
{
name: 'relation4',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
{
name: 'myBlocks',
type: 'blocks',
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'relation5',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'relation6',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
],
versions: true,
},
{
slug: customSchemaSlug,
dbName: 'customs',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'relationship',
type: 'relationship',
hasMany: true,
relationTo: 'relation-a',
},
{
name: 'select',
type: 'select',
dbName: ({ tableName }) => `${tableName}_customSelect`,
enumName: 'selectEnum',
hasMany: true,
options: ['a', 'b', 'c'],
},
{
name: 'radio',
type: 'select',
enumName: 'radioEnum',
options: ['a', 'b', 'c'],
},
{
name: 'array',
type: 'array',
dbName: 'customArrays',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block-second',
dbName: 'customBlocks',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
],
},
],
versions: {
drafts: true,
},
},
{
slug: placesSlug,
fields: [
{
name: 'country',
type: 'text',
},
{
name: 'city',
type: 'text',
},
],
},
{
slug: 'virtual-relations',
admin: { useAsTitle: 'postTitle' },
access: { read: () => true },
fields: [
{
name: 'postTitle',
type: 'text',
virtual: 'post.title',
},
{
name: 'postTitleHidden',
type: 'text',
virtual: 'post.title',
hidden: true,
},
{
name: 'postCategoryTitle',
type: 'text',
virtual: 'post.category.title',
},
{
name: 'postCategoryID',
type: 'json',
virtual: 'post.category.id',
},
{
name: 'postCategoryCustomID',
type: 'number',
virtual: 'post.categoryCustomID.id',
},
{
name: 'postID',
type: 'json',
virtual: 'post.id',
},
{
name: 'postLocalized',
type: 'text',
virtual: 'post.localized',
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
{
name: 'customID',
type: 'relationship',
relationTo: 'custom-ids',
},
{
name: 'customIDValue',
type: 'text',
virtual: 'customID.id',
},
],
versions: { drafts: true },
},
{
slug: fieldsPersistanceSlug,
fields: [
{
name: 'text',
type: 'text',
virtual: true,
},
{
name: 'textHooked',
type: 'text',
virtual: true,
hooks: { afterRead: [() => 'hooked'] },
},
{
name: 'array',
type: 'array',
virtual: true,
fields: [],
},
{
type: 'row',
fields: [
{
type: 'text',
name: 'textWithinRow',
virtual: true,
},
],
},
{
type: 'collapsible',
fields: [
{
type: 'text',
name: 'textWithinCollapsible',
virtual: true,
},
],
label: 'Colllapsible',
},
{
type: 'tabs',
tabs: [
{
label: 'tab',
fields: [
{
type: 'text',
name: 'textWithinTabs',
virtual: true,
},
],
},
],
},
],
},
{
slug: customIDsSlug,
fields: [
{
name: 'id',
type: 'text',
admin: {
readOnly: true,
},
hooks: {
beforeChange: [
({ value, operation }) => {
if (operation === 'create') {
return randomUUID()
}
return value
},
],
},
},
{
name: 'title',
type: 'text',
},
],
versions: { drafts: true },
},
{
slug: fakeCustomIDsSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'id',
type: 'text',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'myTab',
fields: [
{
name: 'id',
type: 'text',
},
],
},
],
},
],
},
{
slug: relationshipsMigrationSlug,
fields: [
{
type: 'relationship',
relationTo: 'default-values',
name: 'relationship',
},
{
type: 'relationship',
relationTo: ['default-values'],
name: 'relationship_2',
},
],
versions: true,
},
{
slug: 'compound-indexes',
fields: [
{
name: 'one',
type: 'text',
},
{
name: 'two',
type: 'text',
},
{
name: 'three',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'four',
type: 'text',
},
],
},
],
indexes: [
{
fields: ['one', 'two'],
unique: true,
},
{
fields: ['three', 'group.four'],
unique: true,
},
],
},
{
slug: 'aliases',
fields: [
{
name: 'thisIsALongFieldNameThatCanCauseAPostgresErrorEvenThoughWeSetAShorterDBName',
dbName: 'shortname',
type: 'array',
fields: [
{
name: 'nestedArray',
type: 'array',
dbName: 'short_nested_1',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
},
],
},
{
slug: 'blocks-docs',
fields: [
{
type: 'blocks',
localized: true,
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocksLocalized',
},
{
type: 'blocks',
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocks',
},
],
},
{
slug: 'unique-fields',
fields: [
{
name: 'slugField',
type: 'text',
unique: true,
},
],
},
],
globals: [
{
slug: 'header',
fields: [
{
name: 'itemsLvl1',
type: 'array',
dbName: 'header_items_lvl1',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl2',
type: 'array',
dbName: 'header_items_lvl2',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl3',
type: 'array',
dbName: 'header_items_lvl3',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl4',
type: 'array',
dbName: 'header_items_lvl4',
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
],
},
],
},
],
},
{
slug: 'global',
dbName: 'customGlobal',
fields: [
{
name: 'text',
type: 'text',
},
],
versions: true,
},
{
slug: 'global-2',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'global-3',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'virtual-relation-global',
fields: [
{
type: 'text',
name: 'postTitle',
virtual: 'post.title',
},
{
type: 'relationship',
name: 'post',
relationTo: 'posts',
},
],
},
],
localization: {
defaultLocale: 'en',
locales: ['en', 'es'],
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
export const postDoc = {
title: 'test post',
}
export default buildConfigWithDefaults(getConfig())

942
test/database/getConfig.ts Normal file
View File

@@ -0,0 +1,942 @@
import type { Config, TextField } from 'payload'
import { randomUUID } from 'crypto'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { seed } from './seed.js'
import {
customIDsSlug,
customSchemaSlug,
defaultValuesSlug,
errorOnUnnamedFieldsSlug,
fakeCustomIDsSlug,
fieldsPersistanceSlug,
pgMigrationSlug,
placesSlug,
postsSlug,
relationASlug,
relationBSlug,
relationshipsMigrationSlug,
} from './shared.js'
const defaultValueField: TextField = {
name: 'defaultValue',
type: 'text',
defaultValue: 'default value from database',
}
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const getConfig: () => Partial<Config> = () => ({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'categories',
versions: { drafts: true },
fields: [
{
type: 'text',
name: 'title',
},
],
},
{
slug: 'simple',
fields: [
{
type: 'text',
name: 'text',
},
{
type: 'number',
name: 'number',
},
],
},
{
slug: 'categories-custom-id',
versions: { drafts: true },
fields: [
{
type: 'number',
name: 'id',
},
],
},
{
slug: postsSlug,
fields: [
{
name: 'title',
type: 'text',
required: true,
// access: { read: () => false },
},
{
type: 'relationship',
relationTo: 'categories',
name: 'category',
},
{
type: 'relationship',
relationTo: 'categories-custom-id',
name: 'categoryCustomID',
},
{
name: 'localized',
type: 'text',
localized: true,
},
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
type: 'blocks',
name: 'blocks',
blocks: [
{
slug: 'block-third',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [
{
slug: 'block-fourth',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [],
},
],
},
],
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'D1',
fields: [
{
name: 'D2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
type: 'tabs',
tabs: [
{
fields: [
{
name: 'D3',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
name: 'D4',
type: 'text',
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
{
name: 'hasTransaction',
type: 'checkbox',
hooks: {
beforeChange: [({ req }) => !!req.transactionID],
},
admin: {
readOnly: true,
},
},
{
name: 'throwAfterChange',
type: 'checkbox',
defaultValue: false,
hooks: {
afterChange: [
({ value }) => {
if (value) {
throw new Error('throw after change')
}
},
],
},
},
{
name: 'arrayWithIDs',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
name: 'blocksWithIDs',
type: 'blocks',
blocks: [
{
slug: 'block-first',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
{
type: 'group',
name: 'group',
fields: [{ name: 'text', type: 'text' }],
},
{
type: 'tabs',
tabs: [
{
name: 'tab',
fields: [{ name: 'text', type: 'text' }],
},
],
},
],
hooks: {
beforeOperation: [
({ args, operation, req }) => {
if (operation === 'update') {
const defaultIDType = req.payload.db.defaultIDType
if (defaultIDType === 'number' && typeof args.id === 'string') {
throw new Error('ID was not sanitized to a number properly')
}
}
return args
},
],
},
},
{
slug: errorOnUnnamedFieldsSlug,
fields: [
{
type: 'tabs',
tabs: [
{
label: 'UnnamedTab',
fields: [
{
name: 'groupWithinUnnamedTab',
type: 'group',
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
],
},
],
},
],
},
{
slug: defaultValuesSlug,
fields: [
{
name: 'title',
type: 'text',
},
defaultValueField,
{
name: 'array',
type: 'array',
// default array with one object to test subfield defaultValue properties for Mongoose
defaultValue: [{}],
fields: [defaultValueField],
},
{
name: 'group',
type: 'group',
// we need to have to use as default in order to have subfield defaultValue properties directly for Mongoose
defaultValue: {},
fields: [defaultValueField],
},
{
name: 'select',
type: 'select',
defaultValue: 'default',
options: [
{ value: 'option0', label: 'Option 0' },
{ value: 'option1', label: 'Option 1' },
{ value: 'default', label: 'Default' },
],
},
{
name: 'point',
type: 'point',
defaultValue: [10, 20],
},
{
name: 'escape',
type: 'text',
defaultValue: "Thanks, we're excited for you to join us.",
},
],
},
{
slug: relationASlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation As',
singular: 'Relation A',
},
},
{
slug: relationBSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'relationship',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation Bs',
singular: 'Relation B',
},
},
{
slug: pgMigrationSlug,
fields: [
{
name: 'relation1',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'myArray',
type: 'array',
fields: [
{
name: 'relation2',
type: 'relationship',
relationTo: 'relation-b',
},
{
name: 'mySubArray',
type: 'array',
fields: [
{
name: 'relation3',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
{
name: 'myGroup',
type: 'group',
fields: [
{
name: 'relation4',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
{
name: 'myBlocks',
type: 'blocks',
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'relation5',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'relation6',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
],
versions: true,
},
{
slug: customSchemaSlug,
dbName: 'customs',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'relationship',
type: 'relationship',
hasMany: true,
relationTo: 'relation-a',
},
{
name: 'select',
type: 'select',
dbName: ({ tableName }) => `${tableName}_customSelect`,
enumName: 'selectEnum',
hasMany: true,
options: ['a', 'b', 'c'],
},
{
name: 'radio',
type: 'select',
enumName: 'radioEnum',
options: ['a', 'b', 'c'],
},
{
name: 'array',
type: 'array',
dbName: 'customArrays',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block-second',
dbName: 'customBlocks',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
],
},
],
versions: {
drafts: true,
},
},
{
slug: placesSlug,
fields: [
{
name: 'country',
type: 'text',
},
{
name: 'city',
type: 'text',
},
],
},
{
slug: 'virtual-relations',
admin: { useAsTitle: 'postTitle' },
access: { read: () => true },
fields: [
{
name: 'postTitle',
type: 'text',
virtual: 'post.title',
},
{
name: 'postTitleHidden',
type: 'text',
virtual: 'post.title',
hidden: true,
},
{
name: 'postCategoryTitle',
type: 'text',
virtual: 'post.category.title',
},
{
name: 'postCategoryID',
type: 'json',
virtual: 'post.category.id',
},
{
name: 'postCategoryCustomID',
type: 'number',
virtual: 'post.categoryCustomID.id',
},
{
name: 'postID',
type: 'json',
virtual: 'post.id',
},
{
name: 'postLocalized',
type: 'text',
virtual: 'post.localized',
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
{
name: 'customID',
type: 'relationship',
relationTo: 'custom-ids',
},
{
name: 'customIDValue',
type: 'text',
virtual: 'customID.id',
},
],
versions: { drafts: true },
},
{
slug: fieldsPersistanceSlug,
fields: [
{
name: 'text',
type: 'text',
virtual: true,
},
{
name: 'textHooked',
type: 'text',
virtual: true,
hooks: { afterRead: [() => 'hooked'] },
},
{
name: 'array',
type: 'array',
virtual: true,
fields: [],
},
{
type: 'row',
fields: [
{
type: 'text',
name: 'textWithinRow',
virtual: true,
},
],
},
{
type: 'collapsible',
fields: [
{
type: 'text',
name: 'textWithinCollapsible',
virtual: true,
},
],
label: 'Colllapsible',
},
{
type: 'tabs',
tabs: [
{
label: 'tab',
fields: [
{
type: 'text',
name: 'textWithinTabs',
virtual: true,
},
],
},
],
},
],
},
{
slug: customIDsSlug,
fields: [
{
name: 'id',
type: 'text',
admin: {
readOnly: true,
},
hooks: {
beforeChange: [
({ value, operation }) => {
if (operation === 'create') {
return randomUUID()
}
return value
},
],
},
},
{
name: 'title',
type: 'text',
},
],
versions: { drafts: true },
},
{
slug: fakeCustomIDsSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'id',
type: 'text',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'myTab',
fields: [
{
name: 'id',
type: 'text',
},
],
},
],
},
],
},
{
slug: relationshipsMigrationSlug,
fields: [
{
type: 'relationship',
relationTo: 'default-values',
name: 'relationship',
},
{
type: 'relationship',
relationTo: ['default-values'],
name: 'relationship_2',
},
],
versions: true,
},
{
slug: 'compound-indexes',
fields: [
{
name: 'one',
type: 'text',
},
{
name: 'two',
type: 'text',
},
{
name: 'three',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'four',
type: 'text',
},
],
},
],
indexes: [
{
fields: ['one', 'two'],
unique: true,
},
{
fields: ['three', 'group.four'],
unique: true,
},
],
},
{
slug: 'aliases',
fields: [
{
name: 'thisIsALongFieldNameThatCanCauseAPostgresErrorEvenThoughWeSetAShorterDBName',
dbName: 'shortname',
type: 'array',
fields: [
{
name: 'nestedArray',
type: 'array',
dbName: 'short_nested_1',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
},
],
},
{
slug: 'blocks-docs',
fields: [
{
type: 'blocks',
localized: true,
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocksLocalized',
},
{
type: 'blocks',
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocks',
},
],
},
{
slug: 'unique-fields',
fields: [
{
name: 'slugField',
type: 'text',
unique: true,
},
],
},
],
globals: [
{
slug: 'header',
fields: [
{
name: 'itemsLvl1',
type: 'array',
dbName: 'header_items_lvl1',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl2',
type: 'array',
dbName: 'header_items_lvl2',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl3',
type: 'array',
dbName: 'header_items_lvl3',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl4',
type: 'array',
dbName: 'header_items_lvl4',
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
],
},
],
},
],
},
{
slug: 'global',
dbName: 'customGlobal',
fields: [
{
name: 'text',
type: 'text',
},
],
versions: true,
},
{
slug: 'global-2',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'global-3',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'virtual-relation-global',
fields: [
{
type: 'text',
name: 'postTitle',
virtual: 'post.title',
},
{
type: 'relationship',
name: 'post',
relationTo: 'posts',
},
],
},
],
localization: {
defaultLocale: 'en',
locales: ['en', 'es'],
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -1,7 +1,13 @@
import type { MongooseAdapter } from '@payloadcms/db-mongodb'
import type { PostgresAdapter } from '@payloadcms/db-postgres/types'
import type { NextRESTClient } from 'helpers/NextRESTClient.js'
import type { Payload, PayloadRequest, TypeWithID, ValidationError } from 'payload'
import type {
DataFromCollectionSlug,
Payload,
PayloadRequest,
TypeWithID,
ValidationError,
} from 'payload'
import {
migrateRelationshipsV2_V3,
@@ -379,6 +385,118 @@ describe('database', () => {
})
})
it('should find distinct field values of the collection', async () => {
await payload.delete({ collection: 'posts', where: {} })
const titles = [
'title-1',
'title-2',
'title-3',
'title-4',
'title-5',
'title-6',
'title-7',
'title-8',
'title-9',
].map((title) => ({ title }))
for (const { title } of titles) {
// eslint-disable-next-line jest/no-conditional-in-test
const docsCount = Math.random() > 0.5 ? 3 : Math.random() > 0.5 ? 2 : 1
for (let i = 0; i < docsCount; i++) {
await payload.create({ collection: 'posts', data: { title } })
}
}
const res = await payload.findDistinct({
collection: 'posts',
field: 'title',
})
expect(res.values).toStrictEqual(titles)
// const resREST = await restClient
// .GET('/posts/distinct', {
// headers: {
// Authorization: `Bearer ${token}`,
// },
// query: { sortOrder: 'asc', field: 'title' },
// })
// .then((res) => res.json())
// expect(resREST.values).toEqual(titles)
const resLimit = await payload.findDistinct({
collection: 'posts',
field: 'title',
limit: 3,
})
expect(resLimit.values).toStrictEqual(
['title-1', 'title-2', 'title-3'].map((title) => ({ title })),
)
// count is still 9
expect(resLimit.totalDocs).toBe(9)
const resDesc = await payload.findDistinct({
collection: 'posts',
sort: '-title',
field: 'title',
})
expect(resDesc.values).toStrictEqual(titles.toReversed())
const resAscDefault = await payload.findDistinct({
collection: 'posts',
field: 'title',
})
expect(resAscDefault.values).toStrictEqual(titles)
})
it('should populate distinct relationships when depth>0', async () => {
await payload.delete({ collection: 'posts', where: {} })
const categories = ['category-1', 'category-2', 'category-3', 'category-4'].map((title) => ({
title,
}))
const categoriesIDS: { category: string }[] = []
for (const { title } of categories) {
const doc = await payload.create({ collection: 'categories', data: { title } })
categoriesIDS.push({ category: doc.id })
}
for (const { category } of categoriesIDS) {
// eslint-disable-next-line jest/no-conditional-in-test
const docsCount = Math.random() > 0.5 ? 3 : Math.random() > 0.5 ? 2 : 1
for (let i = 0; i < docsCount; i++) {
await payload.create({ collection: 'posts', data: { title: randomUUID(), category } })
}
}
const resultDepth0 = await payload.findDistinct({
collection: 'posts',
sort: 'category.title',
field: 'category',
})
expect(resultDepth0.values).toStrictEqual(categoriesIDS)
const resultDepth1 = await payload.findDistinct({
depth: 1,
collection: 'posts',
field: 'category',
sort: 'category.title',
})
for (let i = 0; i < resultDepth1.values.length; i++) {
const fromRes = resultDepth1.values[i] as any
const id = categoriesIDS[i].category as any
const title = categories[i]?.title
expect(fromRes.category.title).toBe(title)
expect(fromRes.category.id).toBe(id)
}
})
describe('Compound Indexes', () => {
beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} })
@@ -2807,7 +2925,7 @@ describe('database', () => {
}
})
it('should update simple', async () => {
it('should use optimized updateOne', async () => {
const post = await payload.create({
collection: 'posts',
data: {
@@ -2818,7 +2936,7 @@ describe('database', () => {
arrayWithIDs: [{ text: 'some text' }],
},
})
const res = await payload.db.updateOne({
const res = (await payload.db.updateOne({
where: { id: { equals: post.id } },
data: {
title: 'hello updated',
@@ -2826,14 +2944,76 @@ describe('database', () => {
tab: { text: 'in tab updated' },
},
collection: 'posts',
})
})) as unknown as DataFromCollectionSlug<'posts'>
expect(res.title).toBe('hello updated')
expect(res.text).toBe('other text (should not be nuked)')
expect(res.group.text).toBe('in group updated')
expect(res.tab.text).toBe('in tab updated')
expect(res.group?.text).toBe('in group updated')
expect(res.tab?.text).toBe('in tab updated')
expect(res.arrayWithIDs).toHaveLength(1)
expect(res.arrayWithIDs[0].text).toBe('some text')
expect(res.arrayWithIDs?.[0]?.text).toBe('some text')
})
it('should use optimized updateMany', async () => {
const post1 = await payload.create({
collection: 'posts',
data: {
text: 'other text (should not be nuked)',
title: 'hello',
group: { text: 'in group' },
tab: { text: 'in tab' },
arrayWithIDs: [{ text: 'some text' }],
},
})
const post2 = await payload.create({
collection: 'posts',
data: {
text: 'other text 2 (should not be nuked)',
title: 'hello',
group: { text: 'in group' },
tab: { text: 'in tab' },
arrayWithIDs: [{ text: 'some text' }],
},
})
const res = (await payload.db.updateMany({
where: { id: { in: [post1.id, post2.id] } },
data: {
title: 'hello updated',
group: { text: 'in group updated' },
tab: { text: 'in tab updated' },
},
collection: 'posts',
})) as unknown as Array<DataFromCollectionSlug<'posts'>>
expect(res).toHaveLength(2)
const resPost1 = res?.find((r) => r.id === post1.id)
const resPost2 = res?.find((r) => r.id === post2.id)
expect(resPost1?.text).toBe('other text (should not be nuked)')
expect(resPost2?.text).toBe('other text 2 (should not be nuked)')
for (const post of res) {
expect(post.title).toBe('hello updated')
expect(post.group?.text).toBe('in group updated')
expect(post.tab?.text).toBe('in tab updated')
expect(post.arrayWithIDs).toHaveLength(1)
expect(post.arrayWithIDs?.[0]?.text).toBe('some text')
}
})
it('should allow to query like by ID with draft: true', async () => {
const category = await payload.create({
collection: 'categories',
data: { title: 'category123' },
})
const res = await payload.find({
collection: 'categories',
draft: true,
// eslint-disable-next-line jest/no-conditional-in-test
where: { id: { like: typeof category.id === 'number' ? `${category.id}` : category.id } },
})
expect(res.docs).toHaveLength(1)
expect(res.docs[0].id).toBe(category.id)
})
it('should allow incremental number update', async () => {

View File

@@ -68,6 +68,7 @@ export interface Config {
blocks: {};
collections: {
categories: Category;
simple: Simple;
'categories-custom-id': CategoriesCustomId;
posts: Post;
'error-on-unnamed-fields': ErrorOnUnnamedField;
@@ -94,6 +95,7 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
categories: CategoriesSelect<false> | CategoriesSelect<true>;
simple: SimpleSelect<false> | SimpleSelect<true>;
'categories-custom-id': CategoriesCustomIdSelect<false> | CategoriesCustomIdSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect<false> | ErrorOnUnnamedFieldsSelect<true>;
@@ -172,6 +174,17 @@ export interface Category {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "simple".
*/
export interface Simple {
id: string;
text?: string | null;
number?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories-custom-id".
@@ -608,6 +621,10 @@ export interface PayloadLockedDocument {
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'simple';
value: string | Simple;
} | null)
| ({
relationTo: 'categories-custom-id';
value: number | CategoriesCustomId;
@@ -736,6 +753,16 @@ export interface CategoriesSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "simple_select".
*/
export interface SimpleSelect<T extends boolean = true> {
text?: T;
number?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories-custom-id_select".

View File

@@ -0,0 +1,174 @@
import type { Payload } from 'payload'
/* eslint-disable jest/require-top-level-describe */
import assert from 'assert'
import path from 'path'
import { fileURLToPath } from 'url'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const describePostgres = process.env.PAYLOAD_DATABASE?.startsWith('postgres')
? describe
: describe.skip
let payload: Payload
describePostgres('database - postgres logs', () => {
beforeAll(async () => {
const initialized = await initPayloadInt(
dirname,
undefined,
undefined,
'config.postgreslogs.ts',
)
assert(initialized.payload)
assert(initialized.restClient)
;({ payload } = initialized)
})
afterAll(async () => {
await payload.destroy()
})
it('ensure simple update uses optimized upsertRow with returning()', async () => {
const doc = await payload.create({
collection: 'simple',
data: {
text: 'Some title',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
const result: any = await payload.db.updateOne({
collection: 'simple',
id: doc.id,
data: {
text: 'Updated Title',
number: 5,
},
})
expect(result.text).toEqual('Updated Title')
expect(result.number).toEqual(5) // Ensure the update did not reset the number field
expect(consoleCount).toHaveBeenCalledTimes(1) // Should be 1 single sql call if the optimization is used. If not, this would be 2 calls
consoleCount.mockRestore()
})
it('ensure simple update of complex collection uses optimized upsertRow without returning()', async () => {
const doc = await payload.create({
collection: 'posts',
data: {
title: 'Some title',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
const result: any = await payload.db.updateOne({
collection: 'posts',
id: doc.id,
data: {
title: 'Updated Title',
number: 5,
},
})
expect(result.title).toEqual('Updated Title')
expect(result.number).toEqual(5) // Ensure the update did not reset the number field
expect(consoleCount).toHaveBeenCalledTimes(2) // Should be 2 sql call if the optimization is used (update + find). If not, this would be 5 calls
consoleCount.mockRestore()
})
it('ensure deleteMany is done in single db query - no where query', async () => {
await payload.create({
collection: 'posts',
data: {
title: 'Some title',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
await payload.db.deleteMany({
collection: 'posts',
where: {},
})
expect(consoleCount).toHaveBeenCalledTimes(1)
consoleCount.mockRestore()
const allPosts = await payload.find({
collection: 'posts',
})
expect(allPosts.docs).toHaveLength(0)
})
it('ensure deleteMany is done in single db query while respecting where query', async () => {
const doc1 = await payload.create({
collection: 'posts',
data: {
title: 'Some title',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
await payload.db.deleteMany({
collection: 'posts',
where: {
title: { equals: 'Some title 2' },
},
})
expect(consoleCount).toHaveBeenCalledTimes(1)
consoleCount.mockRestore()
const allPosts = await payload.find({
collection: 'posts',
})
expect(allPosts.docs).toHaveLength(1)
expect(allPosts.docs[0].id).toEqual(doc1.id)
})
})

View File

@@ -12,11 +12,11 @@ import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const describeToUse = process.env.PAYLOAD_DATABASE?.startsWith('postgres')
const describePostgres = process.env.PAYLOAD_DATABASE?.startsWith('postgres')
? describe
: describe.skip
describeToUse('postgres vector custom column', () => {
describePostgres('postgres vector custom column', () => {
const vectorColumnQueryTest = async (vectorType: string) => {
const {
databaseAdapter,

View File

@@ -1,15 +1,6 @@
import type { Payload } from 'payload'
import path from 'path'
import { getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
import { devUser } from '../credentials.js'
import { seedDB } from '../helpers/seed.js'
import { collectionSlugs } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const _seed = async (_payload: Payload) => {
await _payload.create({

View File

@@ -20,18 +20,3 @@ export const customIDsSlug = 'custom-ids'
export const fakeCustomIDsSlug = 'fake-custom-ids'
export const relationshipsMigrationSlug = 'relationships-migration'
export const collectionSlugs = [
postsSlug,
errorOnUnnamedFieldsSlug,
defaultValuesSlug,
relationASlug,
relationBSlug,
pgMigrationSlug,
customSchemaSlug,
placesSlug,
fieldsPersistanceSlug,
customIDsSlug,
fakeCustomIDsSlug,
relationshipsMigrationSlug,
]

View File

@@ -1,5 +1,5 @@
{
"id": "bf183b76-944c-4e83-bd58-4aa993885106",
"id": "80e7a0d2-ffb3-4f22-8597-0442b3ab8102",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",

View File

@@ -1,4 +1,4 @@
import type { MigrateDownArgs, MigrateUpArgs} from '@payloadcms/db-postgres';
import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-postgres'
import { sql } from '@payloadcms/db-postgres'

View File

@@ -1,9 +1,9 @@
import * as migration_20250707_123508 from './20250707_123508.js'
import * as migration_20250714_201659 from './20250714_201659.js'
export const migrations = [
{
up: migration_20250707_123508.up,
down: migration_20250707_123508.down,
name: '20250707_123508',
up: migration_20250714_201659.up,
down: migration_20250714_201659.down,
name: '20250714_201659',
},
]

View File

@@ -74,6 +74,8 @@ export const testEslintConfig = [
'expectNoResultsAndCreateFolderButton',
'createFolder',
'createFolderFromDoc',
'assertURLParams',
'uploadImage',
],
},
],

View File

@@ -23,6 +23,7 @@ describe('Field Error States', () => {
let validateDraftsOnAutosave: AdminUrlUtil
let prevValue: AdminUrlUtil
let prevValueRelation: AdminUrlUtil
let errorFieldsURL: AdminUrlUtil
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -32,6 +33,7 @@ describe('Field Error States', () => {
validateDraftsOnAutosave = new AdminUrlUtil(serverURL, collectionSlugs.validateDraftsOnAutosave)
prevValue = new AdminUrlUtil(serverURL, collectionSlugs.prevValue)
prevValueRelation = new AdminUrlUtil(serverURL, collectionSlugs.prevValueRelation)
errorFieldsURL = new AdminUrlUtil(serverURL, collectionSlugs.errorFields)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -124,4 +126,46 @@ describe('Field Error States', () => {
await expect(page.locator('#field-title')).toHaveValue('original value 2')
})
})
describe('error field types', () => {
async function prefillBaseRequiredFields() {
const homeTabLocator = page.locator('.tabs-field__tab-button', {
hasText: 'Home',
})
const heroTabLocator = page.locator('.tabs-field__tab-button', {
hasText: 'Hero',
})
await homeTabLocator.click()
// fill out all required fields in the home tab
await page.locator('#field-home__text').fill('Home Collapsible Text')
await page.locator('#field-home__tabText').fill('Home Tab Text')
await page.locator('#field-group__text').fill('Home Group Text')
await heroTabLocator.click()
// fill out all required fields in the hero tab
await page.locator('#field-tabText').fill('Hero Tab Text')
await page.locator('#field-text').fill('Hero Tab Collapsible Text')
}
test('group errors', async () => {
await page.goto(errorFieldsURL.create)
await prefillBaseRequiredFields()
// clear group and save
await page.locator('#field-group__text').fill('')
await saveDocAndAssert(page, '#action-save', 'error')
// should show the error pill and count
const groupFieldErrorPill = page.locator('#field-group .group-field__header .error-pill', {
hasText: '1 error',
})
await expect(groupFieldErrorPill).toBeVisible()
// finish filling out the group
await page.locator('#field-group__text').fill('filled out')
await expect(page.locator('#field-group .group-field__header .error-pill')).toBeHidden()
await saveDocAndAssert(page, '#action-save')
})
})
})

View File

@@ -8,6 +8,7 @@ export const collectionSlugs: {
validateDraftsOnAutosave: 'validate-drafts-on-autosave',
prevValue: 'prev-value',
prevValueRelation: 'prev-value-relation',
errorFields: 'error-fields',
}
export const globalSlugs: {

View File

@@ -4,6 +4,7 @@ import type { CollectionSlug } from 'payload'
import { expect, test } from '@playwright/test'
import { assertToastErrors } from 'helpers/assertToastErrors.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToNextPage } from 'helpers/e2e/goToNextPage.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
@@ -739,9 +740,7 @@ describe('Relationship Field', () => {
const relationship = page.locator('.row-1 .cell-relationshipHasManyMultiple')
await expect(relationship).toHaveText(relationTwoDoc.id)
const paginator = page.locator('.clickable-arrow--right')
await paginator.click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
// check first doc on second page (should be different)
await expect(relationship).toContainText(relationOneDoc.id)

View File

@@ -131,13 +131,23 @@ describe('JSON', () => {
const jsonField = page.locator('.json-field:not(.read-only) #field-customJSON')
await expect(jsonField).toContainText('"default": "value"')
const originalHeight =
(await page.locator('.json-field:not(.read-only) #field-customJSON').boundingBox())?.height ||
0
await page.locator('#set-custom-json').click()
const newHeight =
(await page.locator('.json-field:not(.read-only) #field-customJSON').boundingBox())?.height ||
0
expect(newHeight).toBeGreaterThan(originalHeight)
const boundingBox = await page
.locator('.json-field:not(.read-only) #field-customJSON')
.boundingBox()
await expect(() => expect(boundingBox).not.toBeNull()).toPass()
const originalHeight = boundingBox!.height
// click the button to set custom JSON
await page.locator('#set-custom-json').click({ delay: 1000 })
const newBoundingBox = await page
.locator('.json-field:not(.read-only) #field-customJSON')
.boundingBox()
await expect(() => expect(newBoundingBox).not.toBeNull()).toPass()
const newHeight = newBoundingBox!.height
await expect(() => {
expect(newHeight).toBeGreaterThan(originalHeight)
}).toPass()
})
})

View File

@@ -23,7 +23,6 @@ import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { assertToastErrors } from '../../../helpers/assertToastErrors.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { relationshipFieldsSlug, textFieldsSlug } from '../../slugs.js'
const filename = fileURLToPath(import.meta.url)
@@ -33,7 +32,6 @@ const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
let payload: PayloadTestSDK<Config>
let client: RESTClient
let page: Page
let serverURL: string
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
@@ -59,12 +57,6 @@ describe('relationship', () => {
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
if (client) {
await client.logout()
}
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})
@@ -693,7 +685,7 @@ describe('relationship', () => {
await createRelationshipFieldDoc({ value: textDoc.id, relationTo: 'text-fields' })
await page.goto(url.list)
await wait(300)
await wait(1000) // wait for page to load
await addListFilter({
page,

View File

@@ -17,7 +17,6 @@ import {
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { uploadsSlug } from '../../slugs.js'
@@ -28,7 +27,6 @@ const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
let payload: PayloadTestSDK<Config>
let client: RESTClient
let page: Page
let serverURL: string
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
@@ -57,12 +55,6 @@ describe('Upload', () => {
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
if (client) {
await client.logout()
}
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})

View File

@@ -549,6 +549,14 @@ export interface BlockField {
}
)[]
| null;
readOnly?:
| {
title?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'readOnlyBlock';
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -2222,6 +2230,17 @@ export interface BlockFieldsSelect<T extends boolean = true> {
blockName?: T;
};
};
readOnly?:
| T
| {
readOnlyBlock?:
| T
| {
title?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T;
createdAt?: T;
}

View File

@@ -1,19 +1,26 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import {
getSelectInputOptions,
getSelectInputValue,
openSelectMenu,
} from '../helpers/e2e/selectInput.js'
import { applyBrowseByFolderTypeFilter } from '../helpers/folders/applyBrowseByFolderTypeFilter.js'
import { clickFolderCard } from '../helpers/folders/clickFolderCard.js'
import { createFolder } from '../helpers/folders/createFolder.js'
import { createFolderDoc } from '../helpers/folders/createFolderDoc.js'
import { createFolderFromDoc } from '../helpers/folders/createFolderFromDoc.js'
import { expectNoResultsAndCreateFolderButton } from '../helpers/folders/expectNoResultsAndCreateFolderButton.js'
import { selectFolderAndConfirmMove } from '../helpers/folders/selectFolderAndConfirmMove.js'
import { selectFolderAndConfirmMoveFromList } from '../helpers/folders/selectFolderAndConfirmMoveFromList.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { omittedFromBrowseBySlug, postSlug } from './shared.js'
@@ -93,16 +100,15 @@ test.describe('Folders', () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder({ folderName: 'Test Folder', page })
await clickFolderCard({ folderName: 'Test Folder', page })
const renameButton = page.locator('.list-selection__actions button', {
hasText: 'Rename',
const editFolderDocButton = page.locator('.list-selection__actions button', {
hasText: 'Edit',
})
await editFolderDocButton.click()
await createFolderDoc({
page,
folderName: 'Renamed Folder',
folderType: ['Posts'],
})
await renameButton.click()
const folderNameInput = page.locator('input[id="field-name"]')
await folderNameInput.fill('Renamed Folder')
const applyChangesButton = page.locator(
'dialog#rename-folder--list button[aria-label="Apply Changes"]',
)
await applyChangesButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
const renamedFolderCard = page
.locator('.folder-file-card__name', {
@@ -165,16 +171,12 @@ test.describe('Folders', () => {
hasText: 'Move',
})
await moveButton.click()
const destinationFolder = page
.locator('dialog#move-to-folder--list .folder-file-card')
.filter({
has: page.locator('.folder-file-card__name', { hasText: 'Move Into This Folder' }),
})
.first()
const destinationFolderButton = destinationFolder.locator(
'div[role="button"].folder-file-card__drag-handle',
)
await destinationFolderButton.click()
await clickFolderCard({
folderName: 'Move Into This Folder',
page,
doubleClick: true,
rootLocator: page.locator('dialog#move-to-folder--list'),
})
const selectButton = page.locator(
'dialog#move-to-folder--list button[aria-label="Apply Changes"]',
)
@@ -193,7 +195,11 @@ test.describe('Folders', () => {
// this test currently fails in postgres
test('should create new document from folder', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder({ folderName: 'Create New Here', page })
await createFolder({
folderName: 'Create New Here',
page,
folderType: ['Posts', 'Drafts'],
})
await clickFolderCard({ folderName: 'Create New Here', page, doubleClick: true })
const createDocButton = page.locator('.create-new-doc-in-folder__popup-button', {
hasText: 'Create document',
@@ -231,22 +237,12 @@ test.describe('Folders', () => {
await expect(createFolderButton).toBeVisible()
await createFolderButton.click()
const drawerHeader = page.locator(
'dialog#create-folder--no-results-new-folder-drawer h1.drawerHeader__title',
)
await expect(drawerHeader).toHaveText('New Folder')
await createFolderDoc({
page,
folderName: 'Nested Folder',
folderType: ['Posts'],
})
const titleField = page.locator(
'dialog#create-folder--no-results-new-folder-drawer input[id="field-name"]',
)
await titleField.fill('Nested Folder')
const createButton = page
.locator(
'dialog#create-folder--no-results-new-folder-drawer button[aria-label="Apply Changes"]',
)
.filter({ hasText: 'Create' })
.first()
await createButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await expect(page.locator('dialog#create-folder--no-results-new-folder-drawer')).toBeHidden()
})
@@ -296,12 +292,11 @@ test.describe('Folders', () => {
await createNewDropdown.click()
const createFolderButton = page.locator('.popup-button-list__button').first()
await createFolderButton.click()
const folderNameInput = page.locator('input[id="field-name"]')
await folderNameInput.fill('Nested Folder')
const createButton = page
.locator('.drawerHeader button[aria-label="Apply Changes"]')
.filter({ hasText: 'Create' })
await createButton.click()
await createFolderDoc({
page,
folderName: 'Nested Folder',
folderType: ['Posts'],
})
await expect(page.locator('.folder-file-card__name')).toHaveText('Nested Folder')
await createNewDropdown.click()
@@ -314,18 +309,28 @@ test.describe('Folders', () => {
await saveButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
const typeButton = page.locator('.popup-button', { hasText: 'Type' })
await typeButton.click()
const folderCheckbox = page.locator('.checkbox-popup__options .checkbox-input__input').first()
await folderCheckbox.click()
// should filter out folders and only show posts
await applyBrowseByFolderTypeFilter({
page,
type: { label: 'Folders', value: 'payload-folders' },
on: false,
})
const folderGroup = page.locator('.item-card-grid__title', { hasText: 'Folders' })
const postGroup = page.locator('.item-card-grid__title', { hasText: 'Documents' })
await expect(folderGroup).toBeHidden()
await expect(postGroup).toBeVisible()
await folderCheckbox.click()
const postCheckbox = page.locator('.checkbox-popup__options .checkbox-input__input').nth(1)
await postCheckbox.click()
// should filter out posts and only show folders
await applyBrowseByFolderTypeFilter({
page,
type: { label: 'Folders', value: 'payload-folders' },
on: true,
})
await applyBrowseByFolderTypeFilter({
page,
type: { label: 'Posts', value: 'posts' },
on: false,
})
await expect(folderGroup).toBeVisible()
await expect(postGroup).toBeHidden()
@@ -362,11 +367,11 @@ test.describe('Folders', () => {
})
test('should show By Folder button', async () => {
const folderButton = page.locator('.list-folder-pills__button', { hasText: 'By Folder' })
const folderButton = page.locator('.list-pills__button', { hasText: 'By Folder' })
await expect(folderButton).toBeVisible()
})
test('should navigate to By Folder view', async () => {
const folderButton = page.locator('.list-folder-pills__button', { hasText: 'By Folder' })
const folderButton = page.locator('.list-pills__button', { hasText: 'By Folder' })
await folderButton.click()
await expect(page).toHaveURL(`${serverURL}/admin/collections/posts/payload-folders`)
const foldersTitle = page.locator('.collection-folder-list', { hasText: 'Folders' })
@@ -389,7 +394,6 @@ test.describe('Folders', () => {
test('should resolve folder pills and not get stuck as Loading...', async () => {
await selectFolderAndConfirmMoveFromList({ folderName: 'Move Into This Folder', page })
const folderPill = page.locator('tbody .row-1 .move-doc-to-folder')
await page.reload()
await expect(folderPill).not.toHaveText('Loading...')
})
test('should show updated folder pill after folder change', async () => {
@@ -402,10 +406,16 @@ test.describe('Folders', () => {
const folderPill = page.locator('tbody .row-1 .move-doc-to-folder')
await selectFolderAndConfirmMoveFromList({ folderName: 'Move Into This Folder', page })
await expect(folderPill).toHaveText('Move Into This Folder')
await page.reload()
await folderPill.click()
const folderBreadcrumb = page.locator('.folderBreadcrumbs__crumb-item', { hasText: 'Folder' })
await folderBreadcrumb.click()
const drawerLocator = page.locator('dialog .move-folder-drawer')
await drawerLocator
.locator('.droppable-button.folderBreadcrumbs__crumb-item', {
hasText: 'Folder',
})
.click()
await expect(
drawerLocator.locator('.folder-file-card__name', { hasText: 'Move Into This Folder' }),
).toBeVisible()
await selectFolderAndConfirmMove({ page })
await expect(folderPill).toHaveText('No Folder')
})
@@ -418,14 +428,11 @@ test.describe('Folders', () => {
await createDropdown.click()
const createFolderButton = page.locator('.popup-button-list__button', { hasText: 'Folder' })
await createFolderButton.click()
const drawerHeader = page.locator('.drawerHeader__title', { hasText: 'New Folder' })
await expect(drawerHeader).toBeVisible()
const folderNameInput = page.locator('input[id="field-name"]')
await folderNameInput.fill('New Folder From Collection')
const createButton = page
.locator('.drawerHeader button[aria-label="Apply Changes"]')
.filter({ hasText: 'Create' })
await createButton.click()
await createFolderDoc({
page,
folderName: 'New Folder From Collection',
folderType: ['Posts'],
})
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
})
})
@@ -470,6 +477,58 @@ test.describe('Folders', () => {
})
})
test.describe('Collection with browse by folders disabled', () => {
test('should not show omitted collection documents in browse by folder view', async () => {
await page.goto(OmittedFromBrowseBy.byFolder)
const folderName = 'Folder without omitted Docs'
await page.goto(OmittedFromBrowseBy.byFolder)
await createFolder({
folderName,
page,
fromDropdown: true,
folderType: ['Omitted From Browse By', 'Posts'],
})
// create document
await page.goto(OmittedFromBrowseBy.create)
const titleInput = page.locator('input[name="title"]')
await titleInput.fill('Omitted Doc')
await saveDocAndAssert(page)
// assign to folder
const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' })
await folderPill.click()
await clickFolderCard({ folderName, page })
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
await selectButton.click()
await saveDocAndAssert(page)
// go to browse by folder view
await page.goto(`${serverURL}/admin/browse-by-folder`)
await clickFolderCard({ folderName, page, doubleClick: true })
// folder should be empty
await expectNoResultsAndCreateFolderButton({ page })
})
test('should not show collection type in browse by folder view', async () => {
const folderName = 'omitted collection pill test folder'
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder({ folderName, page })
await clickFolderCard({ folderName, page, doubleClick: true })
await page.locator('button:has(.collection-type__count)').click()
await expect(
page.locator('.checkbox-input .field-label', {
hasText: 'Omitted From Browse By',
}),
).toBeHidden()
})
})
test.describe('Multiple select options', () => {
test.beforeEach(async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
@@ -545,48 +604,140 @@ test.describe('Folders', () => {
})
})
test.describe('Collection with browse by folders disabled', () => {
const folderName = 'Folder without omitted Docs'
test('should not show omitted collection documents in browse by folder view', async () => {
await page.goto(OmittedFromBrowseBy.byFolder)
await createFolder({ folderName, page, fromDropdown: true })
// create document
await page.goto(OmittedFromBrowseBy.create)
const titleInput = page.locator('input[name="title"]')
await titleInput.fill('Omitted Doc')
await saveDocAndAssert(page)
// assign to folder
const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' })
await folderPill.click()
await clickFolderCard({ folderName, page })
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
await selectButton.click()
// go to browse by folder view
test.describe('should inherit folderType select values from parent folder', () => {
test('should scope folderType select options for: scoped > child folder', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await clickFolderCard({ folderName, page, doubleClick: true })
await createFolder({ folderName: 'Posts and Media', page, folderType: ['Posts', 'Media'] })
await clickFolderCard({ folderName: 'Posts and Media', page, doubleClick: true })
// folder should be empty
await expectNoResultsAndCreateFolderButton({ page })
const createNewDropdown = page.locator('.create-new-doc-in-folder__popup-button', {
hasText: 'Create New',
})
await createNewDropdown.click()
const createFolderButton = page.locator(
'.list-header__title-actions .popup-button-list__button',
{ hasText: 'Folder' },
)
await createFolderButton.click()
const drawer = page.locator('dialog .collection-edit--payload-folders')
const titleInput = drawer.locator('#field-name')
await titleInput.fill('Should only allow Posts and Media')
const selectLocator = drawer.locator('#field-folderType')
await expect(selectLocator).toBeVisible()
// should prefill with Posts and Media
await expect
.poll(async () => {
const options = await getSelectInputValue<true>({ selectLocator, multiSelect: true })
return options.sort()
})
.toEqual(['Posts', 'Media'].sort())
// should have no more select options available
await openSelectMenu({ selectLocator })
await expect(
selectLocator.locator('.rs__menu-notice', { hasText: 'No options' }),
).toBeVisible()
})
test('should not show collection type in browse by folder view', async () => {
const folderName = 'omitted collection pill test folder'
test('should scope folderType select options for: unscoped > scoped > child folder', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder({ folderName, page })
await clickFolderCard({ folderName, page, doubleClick: true })
await page.locator('button:has(.collection-type__count)').click()
// create an unscoped parent folder
await createFolder({ folderName: 'All collections', page, folderType: [] })
await clickFolderCard({ folderName: 'All collections', page, doubleClick: true })
// create a scoped child folder
await createFolder({
folderName: 'Posts and Media',
page,
folderType: ['Posts', 'Media'],
fromDropdown: true,
})
await clickFolderCard({ folderName: 'Posts and Media', page, doubleClick: true })
await expect(
page.locator('.checkbox-input .field-label', {
hasText: 'Omitted From Browse By',
page.locator('.step-nav', {
hasText: 'Posts and Media',
}),
).toBeHidden()
).toBeVisible()
const titleActionsLocator = page.locator('.list-header__title-actions')
await expect(titleActionsLocator).toBeVisible()
const folderDropdown = page.locator(
'.list-header__title-actions .create-new-doc-in-folder__action-popup',
{
hasText: 'Create',
},
)
await expect(folderDropdown).toBeVisible()
await folderDropdown.click()
const createFolderButton = page.locator(
'.list-header__title-actions .popup-button-list__button',
{
hasText: 'Folder',
},
)
await createFolderButton.click()
const drawer = page.locator('dialog .collection-edit--payload-folders')
const titleInput = drawer.locator('#field-name')
await titleInput.fill('Should only allow posts and media')
const selectLocator = drawer.locator('#field-folderType')
await expect(selectLocator).toBeVisible()
// should not prefill with any options
await expect
.poll(async () => {
const options = await getSelectInputValue<true>({ selectLocator, multiSelect: true })
return options.sort()
})
.toEqual(['Posts', 'Media'].sort())
// should have no more select options available
await openSelectMenu({ selectLocator })
await expect(
selectLocator.locator('.rs__menu-notice', { hasText: 'No options' }),
).toBeVisible()
})
test('should not scope child folder of an unscoped parent folder', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder({ folderName: 'All collections', page, folderType: [] })
await clickFolderCard({ folderName: 'All collections', page, doubleClick: true })
const createNewDropdown = page.locator('.create-new-doc-in-folder__popup-button', {
hasText: 'Create New',
})
await createNewDropdown.click()
const createFolderButton = page.locator(
'.list-header__title-actions .popup-button-list__button',
{ hasText: 'Folder' },
)
await createFolderButton.click()
const drawer = page.locator('dialog .collection-edit--payload-folders')
const titleInput = drawer.locator('#field-name')
await titleInput.fill('Should allow all collections')
const selectLocator = drawer.locator('#field-folderType')
await expect(selectLocator).toBeVisible()
// should not prefill with any options
await expect
.poll(async () => {
const options = await getSelectInputValue<true>({ selectLocator, multiSelect: true })
return options
})
.toEqual([])
// should have many options
await expect
.poll(async () => {
const options = await getSelectInputOptions({ selectLocator })
return options.length
})
.toBeGreaterThan(4)
})
})

View File

@@ -3,18 +3,15 @@ import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
let payload: Payload
let restClient: NextRESTClient
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('folders', () => {
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname))
;({ payload } = await initPayloadInt(dirname))
})
afterAll(async () => {
@@ -23,7 +20,7 @@ describe('folders', () => {
beforeEach(async () => {
await payload.delete({
collection: 'posts',
collection: 'payload-folders',
depth: 0,
where: {
id: {
@@ -48,6 +45,7 @@ describe('folders', () => {
collection: 'payload-folders',
data: {
name: 'Parent Folder',
folderType: ['posts'],
},
})
const folderIDFromParams = parentFolder.id
@@ -57,6 +55,7 @@ describe('folders', () => {
data: {
name: 'Nested 1',
folder: folderIDFromParams,
folderType: ['posts'],
},
})
@@ -65,6 +64,7 @@ describe('folders', () => {
data: {
name: 'Nested 2',
folder: folderIDFromParams,
folderType: ['posts'],
},
})
@@ -73,7 +73,7 @@ describe('folders', () => {
id: folderIDFromParams,
})
expect(parentFolderQuery.documentsAndFolders.docs).toHaveLength(2)
expect(parentFolderQuery.documentsAndFolders?.docs).toHaveLength(2)
})
})
@@ -82,6 +82,7 @@ describe('folders', () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
folderType: ['posts'],
name: 'Parent Folder',
},
})
@@ -108,7 +109,7 @@ describe('folders', () => {
id: folderIDFromParams,
})
expect(parentFolderQuery.documentsAndFolders.docs).toHaveLength(2)
expect(parentFolderQuery.documentsAndFolders?.docs).toHaveLength(2)
})
})
@@ -117,6 +118,7 @@ describe('folders', () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
folderType: ['posts'],
name: 'Parent Folder',
},
})
@@ -124,6 +126,7 @@ describe('folders', () => {
const childFolder = await payload.create({
collection: 'payload-folders',
data: {
folderType: ['posts'],
name: 'Child Folder',
folder: parentFolder,
},
@@ -153,6 +156,7 @@ describe('folders', () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
folderType: ['posts'],
name: 'Parent Folder',
},
})
@@ -168,6 +172,7 @@ describe('folders', () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
folderType: ['posts'],
name: 'Parent Folder',
},
})
@@ -176,6 +181,7 @@ describe('folders', () => {
data: {
name: 'Child Folder',
folder: parentFolder,
folderType: ['posts'],
},
})
@@ -189,5 +195,154 @@ describe('folders', () => {
}),
).resolves.toBeNull()
})
describe('ensureSafeCollectionsChange', () => {
it('should prevent narrowing scope of a folder if it contains documents of a removed type', async () => {
const sharedFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Posts and Drafts Folder',
folderType: ['posts', 'drafts'],
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Post 1',
folder: sharedFolder.id,
},
})
await payload.create({
collection: 'drafts',
data: {
title: 'Post 1',
folder: sharedFolder.id,
},
})
try {
const updatedFolder = await payload.update({
collection: 'payload-folders',
id: sharedFolder.id,
data: {
folderType: ['posts'],
},
})
expect(updatedFolder).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Posts and Drafts Folder" contains documents that still belong to the following collections: Drafts',
)
}
})
it('should prevent adding scope to a folder if it contains documents outside of the new scope', async () => {
const folderAcceptsAnything = await payload.create({
collection: 'payload-folders',
data: {
name: 'Anything Goes',
folderType: [],
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Post 1',
folder: folderAcceptsAnything.id,
},
})
try {
const scopedFolder = await payload.update({
collection: 'payload-folders',
id: folderAcceptsAnything.id,
data: {
folderType: ['posts'],
},
})
expect(scopedFolder).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Anything Goes" contains documents that still belong to the following collections: Posts',
)
}
})
it('should prevent narrowing scope of a folder if subfolders are assigned to any of the removed types', async () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
folderType: ['posts', 'drafts'],
},
})
await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
folderType: ['posts', 'drafts'],
folder: parentFolder.id,
},
})
try {
const updatedParent = await payload.update({
collection: 'payload-folders',
id: parentFolder.id,
data: {
folderType: ['posts'],
},
})
expect(updatedParent).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Parent Folder" contains folders that still belong to the following collections: Drafts',
)
}
})
it('should prevent widening scope on a scoped subfolder', async () => {
const unscopedFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
folderType: [],
},
})
const level1Folder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Level 1 Folder',
folderType: ['posts', 'drafts'],
folder: unscopedFolder.id,
},
})
try {
const level2UnscopedFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Level 2 Folder',
folder: level1Folder.id,
folderType: [],
},
})
expect(level2UnscopedFolder).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Level 2 Folder" must have folder-type set since its parent folder "Level 1 Folder" has a folder-type set.',
)
}
})
})
})
})

View File

@@ -201,6 +201,7 @@ export interface FolderInterface {
hasNextPage?: boolean;
totalDocs?: number;
};
folderType?: ('posts' | 'media' | 'drafts' | 'autosave' | 'omitted-from-browse-by')[] | null;
folderSlug?: string | null;
updatedAt: string;
createdAt: string;
@@ -255,6 +256,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -410,6 +418,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -419,6 +434,7 @@ export interface PayloadFoldersSelect<T extends boolean = true> {
name?: T;
folder?: T;
documentsAndFolders?: T;
folderType?: T;
folderSlug?: T;
updatedAt?: T;
createdAt?: T;

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

View File

@@ -309,6 +309,7 @@ describe('Form State', () => {
it('should merge array rows without losing rows added to local state', () => {
const currentState: FormState = {
array: {
errorPaths: [],
rows: [
{
id: '1',
@@ -358,6 +359,7 @@ describe('Form State', () => {
// Row 2 should still exist
expect(newState).toStrictEqual({
array: {
errorPaths: [],
passesCondition: true,
valid: true,
rows: [

View File

@@ -21,6 +21,25 @@ export const allDatabaseAdapters = {
strength: 1,
},
})`,
firestore: `
import { mongooseAdapter, compatabilityOptions } from '@payloadcms/db-mongodb'
export const databaseAdapter = mongooseAdapter({
...compatabilityOptions.firestore,
url:
process.env.DATABASE_URI ||
process.env.MONGODB_MEMORY_SERVER_URI ||
'mongodb://127.0.0.1/payloadtests',
collation: {
strength: 1,
},
// The following options prevent some tests from failing.
// More work needed to get tests succeeding without these options.
ensureIndexes: true,
transactionOptions: {},
disableIndexHints: false,
useAlternativeDropDatabase: false,
})`,
postgres: `
import { postgresAdapter } from '@payloadcms/db-postgres'

View File

@@ -13,7 +13,7 @@ const dirname = path.dirname(filename)
const writeDBAdapter = process.env.WRITE_DB_ADAPTER !== 'false'
process.env.PAYLOAD_DROP_DATABASE = process.env.PAYLOAD_DROP_DATABASE || 'true'
if (process.env.PAYLOAD_DATABASE === 'mongodb') {
if (process.env.PAYLOAD_DATABASE === 'mongodb' || process.env.PAYLOAD_DATABASE === 'firestore') {
throw new Error('Not supported')
}

2
test/group-by/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const categoriesSlug = 'categories'
export const CategoriesCollection: CollectionConfig = {
slug: categoriesSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload'
export const mediaSlug = 'media'
export const MediaCollection: CollectionConfig = {
slug: mediaSlug,
access: {
create: () => true,
read: () => true,
},
fields: [],
upload: {
crop: true,
focalPoint: true,
imageSizes: [
{
name: 'thumbnail',
height: 200,
width: 200,
},
{
name: 'medium',
height: 800,
width: 800,
},
{
name: 'large',
height: 1200,
width: 1200,
},
],
},
}

View File

@@ -0,0 +1,49 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { categoriesSlug } from '../Categories/index.js'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'title',
groupBy: true,
defaultColumns: ['title', 'category', 'createdAt', 'updatedAt'],
},
trash: true,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'category',
type: 'relationship',
relationTo: categoriesSlug,
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
{
type: 'tabs',
tabs: [
{
label: 'Tab 1',
fields: [
{
name: 'tab1Field',
type: 'text',
},
],
},
],
},
],
}

30
test/group-by/config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { CategoriesCollection } from './collections/Categories/index.js'
import { MediaCollection } from './collections/Media/index.js'
import { PostsCollection } from './collections/Posts/index.js'
import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection, CategoriesCollection, MediaCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
editor: lexicalEditor({}),
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

667
test/group-by/e2e.spec.ts Normal file
View File

@@ -0,0 +1,667 @@
import type { Page } from '@playwright/test'
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToNextPage } from 'helpers/e2e/goToNextPage.js'
import { addGroupBy, clearGroupBy, closeGroupBy, openGroupBy } from 'helpers/e2e/groupBy.js'
import { deletePreferences } from 'helpers/e2e/preferences.js'
import { sortColumn } from 'helpers/e2e/sortColumn.js'
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
import { openNav } from 'helpers/e2e/toggleNav.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { Config } from './payload-types.js'
import {
ensureCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
selectTableRow,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { postsSlug } from './collections/Posts/index.js'
const { beforeEach } = test
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
test.describe('Group By', () => {
let page: Page
let url: AdminUrlUtil
let serverURL: string
let payload: PayloadTestSDK<Config>
let user: any
let category1Id: number | string
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
url = new AdminUrlUtil(serverURL, 'posts')
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
user = await payload.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
})
beforeEach(async () => {
// await throttleTest({
// page,
// context,
// delay: 'Fast 4G',
// })
await reInitializeDB({
serverURL,
snapshotKey: 'groupByTests',
})
await ensureCompilationIsDone({ page, serverURL })
})
test('should display group-by button only when `admin.groupBy` is enabled', async () => {
await page.goto(url.list)
await expect(page.locator('#toggle-group-by')).toBeVisible()
await page.goto(new AdminUrlUtil(serverURL, 'users').list)
await expect(page.locator('#toggle-group-by')).toBeHidden()
})
test('should open and close group-by dropdown', async () => {
await page.goto(url.list)
await openGroupBy(page)
await expect(page.locator('#list-controls-group-by.rah-static--height-auto')).toBeVisible()
await closeGroupBy(page)
await expect(page.locator('#list-controls-group-by.rah-static--height-auto')).toBeHidden()
})
test('should display field options in group-by dropdown', async () => {
await page.goto(url.list)
const { groupByContainer } = await openGroupBy(page)
// TODO: expect no initial selection and for the sort control to be disabled
const field = groupByContainer.locator('#group-by--field-select')
await field.click()
await expect(
field.locator('.rs__option', {
hasText: exactText('Title'),
}),
).toBeVisible()
})
test('should omit unsupported fields from appearing as options in the group-by dropdown', async () => {
await page.goto(url.list)
await openGroupBy(page)
// certain fields are not allowed to be grouped by, for example rich text and the ID field itself
const forbiddenOptions = ['ID', 'Content']
const field = page.locator('#group-by--field-select')
await field.click()
for (const fieldOption of forbiddenOptions) {
const optionEl = page.locator('.rs__option', { hasText: exactText(fieldOption) })
await expect(optionEl).toHaveCount(0)
}
})
test('should properly group by field', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(2)
await expect(page.locator('.group-by-header')).toHaveCount(2)
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
).toBeVisible()
await expect(page.locator('.table-wrap').first().locator('tbody tr')).toHaveCount(10)
const table1CategoryCells = page
.locator('.table-wrap')
.first()
.locator('tbody tr td.cell-category')
// TODO: is there a way to iterate over all cells and check they all match? I could not get this to work.
await expect(table1CategoryCells.first()).toHaveText(/Category 1/)
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 2') }),
).toBeVisible()
const table2 = page.locator('.table-wrap').nth(1)
await expect(table2).toBeVisible()
await table2.scrollIntoViewIfNeeded()
await expect(page.locator('.table-wrap').nth(1).locator('tbody tr')).toHaveCount(10)
const table2CategoryCells = page
.locator('.table-wrap')
.nth(1)
.locator('tbody tr td.cell-category')
// TODO: is there a way to iterate over all cells and check they all match? I could not get this to work.
await expect(table2CategoryCells.first()).toHaveText(/Category 2/)
})
test('should load group-by from user preferences', async () => {
await deletePreferences({
payload,
key: `${postsSlug}.list`,
user,
})
await page.goto(url.list)
await expect(page).not.toHaveURL(/groupBy=/)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page).toHaveURL(/groupBy=category/)
await expect(page.locator('.table-wrap')).toHaveCount(2)
await page.goto(url.admin)
// click on the "Posts" link in the sidebar to invoke a soft navigation
await openNav(page)
await page.locator(`.nav a[href="/admin/collections/${postsSlug}"]`).click()
await expect(page).toHaveURL(/groupBy=category/)
await expect(page.locator('.table-wrap')).toHaveCount(2)
})
test('should reset group-by using the global "clear" button', async () => {
await page.goto(url.list)
const { groupByContainer } = await openGroupBy(page)
const field = groupByContainer.locator('#group-by--field-select')
await expect(field.locator('.react-select--single-value')).toHaveText('Select a value')
await expect(groupByContainer.locator('#group-by--reset')).toBeHidden()
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(2)
await expect(page.locator('.group-by-header')).toHaveCount(2)
await clearGroupBy(page)
})
test('should reset group-by using the select field\'s "x" button', async () => {
await page.goto(url.list)
const { field, groupByContainer } = await addGroupBy(page, {
fieldLabel: 'Category',
fieldPath: 'category',
})
await expect(page.locator('.table-wrap')).toHaveCount(2)
await expect(page.locator('.group-by-header')).toHaveCount(2)
// click the "x" button on the select field itself
await field.locator('.clear-indicator').click()
await expect(field.locator('.react-select--single-value')).toHaveText('Select a value')
await expect(page).not.toHaveURL(/&groupBy=/)
await expect(groupByContainer.locator('#field-direction input')).toBeDisabled()
await expect(page.locator('.table-wrap')).toHaveCount(1)
await expect(page.locator('.group-by-header')).toHaveCount(0)
})
test('should group by relationships even when their values are null', async () => {
await payload.create({
collection: postsSlug,
data: {
title: 'My Post',
category: null,
},
})
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(3)
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
).toBeVisible()
})
test('should sort the group-by field globally', async () => {
await page.goto(url.list)
const { groupByContainer } = await addGroupBy(page, {
fieldLabel: 'Category',
fieldPath: 'category',
})
const firstHeading = page.locator('.group-by-header__heading').first()
await expect(firstHeading).toHaveText(/Category 1/)
const secondHeading = page.locator('.group-by-header__heading').nth(1)
await expect(secondHeading).toHaveText(/Category 2/)
await groupByContainer.locator('#group-by--sort').click()
await groupByContainer.locator('.rs__option', { hasText: exactText('Descending') })?.click()
await expect(page.locator('.group-by-header__heading').first()).toHaveText(/Category 2/)
await expect(page.locator('.group-by-header__heading').nth(1)).toHaveText(/Category 1/)
})
test('should sort by columns within each table (will affect all tables)', async () => {
await page.goto(url.list)
await addGroupBy(page, {
fieldLabel: 'Category',
fieldPath: 'category',
})
const table1 = page.locator('.table-wrap').first()
await sortColumn(page, {
scope: table1,
fieldLabel: 'Title',
fieldPath: 'title',
targetState: 'asc',
})
const table1AscOrder = ['Find me', 'Post 1', 'Post 10', 'Post 11']
const table2AscOrder = ['Find me', 'Post 16', 'Post 17', 'Post 18']
const table1Titles = table1.locator('tbody tr td.cell-title')
const table2Titles = page.locator('.table-wrap').nth(1).locator('tbody tr td.cell-title')
await expect(table1Titles).toHaveCount(10)
await expect(table2Titles).toHaveCount(10)
// Note: it would be nice to put this in a loop, but this was flaky
await expect(table1Titles.nth(0)).toHaveText(table1AscOrder[0] || '')
await expect(table1Titles.nth(1)).toHaveText(table1AscOrder[1] || '')
await expect(table2Titles.nth(0)).toHaveText(table2AscOrder[0] || '')
await expect(table2Titles.nth(1)).toHaveText(table2AscOrder[1] || '')
await sortColumn(page, {
scope: table1,
fieldLabel: 'Title',
fieldPath: 'title',
targetState: 'desc',
})
const table1DescOrder = ['Post 9', 'Post 8', 'Post 7', 'Post 6']
const table2DescOrder = ['Post 30', 'Post 29', 'Post 28', 'Post 27']
// Note: it would be nice to put this in a loop, but this was flaky
await expect(table1Titles.nth(0)).toHaveText(table1DescOrder[0] || '')
await expect(table1Titles.nth(1)).toHaveText(table1DescOrder[1] || '')
await expect(table2Titles.nth(0)).toHaveText(table2DescOrder[0] || '')
await expect(table2Titles.nth(1)).toHaveText(table2DescOrder[1] || '')
})
test('should apply columns to all tables', async () => {
await page.goto(url.list)
await addGroupBy(page, {
fieldLabel: 'Category',
fieldPath: 'category',
})
const table1ColumnHeadings = page.locator('.table-wrap').nth(0).locator('thead tr th')
await expect(table1ColumnHeadings.nth(1)).toHaveText('Title')
await expect(table1ColumnHeadings.nth(2)).toHaveText('Category')
const table2ColumnHeadings = page.locator('.table-wrap').nth(1).locator('thead tr th')
await expect(table2ColumnHeadings.nth(1)).toHaveText('Title')
await expect(table2ColumnHeadings.nth(2)).toHaveText('Category')
await toggleColumn(page, { columnLabel: 'Title', targetState: 'off' })
await expect(table1ColumnHeadings.locator('text=Title')).toHaveCount(0)
await expect(table1ColumnHeadings.nth(1)).toHaveText('Category')
await expect(table2ColumnHeadings.locator('text=Title')).toHaveCount(0)
await expect(table2ColumnHeadings.nth(1)).toHaveText('Category')
})
test('should apply filters to all tables', async () => {
await page.goto(url.list)
await addGroupBy(page, {
fieldLabel: 'Category',
fieldPath: 'category',
})
await addListFilter({
page,
fieldLabel: 'Title',
operatorLabel: 'equals',
value: 'Find me',
})
const table1 = page.locator('.table-wrap').first()
await expect(table1).toBeVisible()
const table1Rows = table1.locator('tbody tr')
await expect(table1Rows).toHaveCount(1)
await expect(table1Rows.first().locator('td.cell-title')).toHaveText('Find me')
const table2 = page.locator('.table-wrap').nth(1)
await expect(table2).toBeVisible()
const table2Rows = table2.locator('tbody tr')
await expect(table2Rows).toHaveCount(1)
await expect(table2Rows.first().locator('td.cell-title')).toHaveText('Find me')
})
test('should apply filters to the distinct results of the group-by field', async () => {
// This ensures that no tables are rendered without docs
await page.goto(url.list)
await addGroupBy(page, {
fieldLabel: 'Category',
fieldPath: 'category',
})
await expect(page.locator('.table-wrap')).toHaveCount(2)
await addListFilter({
page,
fieldLabel: 'Category',
operatorLabel: 'equals',
value: 'Category 1',
})
await expect(page.locator('.table-wrap')).toHaveCount(1)
// Reset the filter by reloading the page without URL params
// TODO: There are no current test helpers for this
await page.goto(url.list)
await addListFilter({
page,
fieldLabel: 'Title',
operatorLabel: 'equals',
value: 'This title does not exist',
})
await expect(page.locator('.table-wrap')).toHaveCount(0)
await page.locator('.collection-list__no-results').isVisible()
})
test('should paginate globally (all tables)', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Title', fieldPath: 'title' })
await expect(page.locator('.sticky-toolbar')).toBeVisible()
})
test('should paginate per table', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
const table1 = page.locator('.table-wrap').first()
const table2 = page.locator('.table-wrap').nth(1)
await expect(table1.locator('.page-controls')).toBeVisible()
await expect(table2.locator('.page-controls')).toBeVisible()
await goToNextPage(page, {
scope: table1,
// TODO: this actually does affect the URL, but not in the same way as traditional pagination
// e.g. it manipulates the `?queryByGroup=` param instead of `?page=2`
affectsURL: false,
})
})
test('should reset ?queryByGroup= param when other params change', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
const table1 = page.locator('.table-wrap').first()
const table2 = page.locator('.table-wrap').nth(1)
await expect(table1.locator('.page-controls')).toBeVisible()
await expect(table2.locator('.page-controls')).toBeVisible()
await goToNextPage(page, {
scope: table1,
affectsURL: false,
})
await expect(page).toHaveURL(/queryByGroup=/)
await clearGroupBy(page)
await expect(page).not.toHaveURL(/queryByGroup=/)
})
test('should not render per table pagination controls when group-by is not active', async () => {
// delete user prefs to ensure that group-by isn't populated after loading the page
await deletePreferences({ payload, key: `${postsSlug}.list`, user })
await page.goto(url.list)
await expect(page.locator('.page-controls')).toHaveCount(1)
})
test('should render date fields in proper format when displayed as table headers', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Updated At', fieldPath: 'updatedAt' })
// the value of the updated at column in the table should match exactly the value in the table cell
const table1 = page.locator('.table-wrap').first()
const firstTableHeading = table1.locator('.group-by-header__heading')
const firstRowUpdatedAtCell = table1.locator('tbody tr td.cell-updatedAt').first()
const headingText = (await firstTableHeading.textContent())?.trim()
const cellText = (await firstRowUpdatedAtCell.textContent())?.trim()
expect(headingText).toBeTruthy()
expect(cellText).toBeTruthy()
expect(headingText).toEqual(cellText)
})
test.skip('should group by nested fields', async () => {
await page.goto(url.list)
expect(true).toBe(true)
})
test('can select all rows within a single table as expected', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
const firstTable = page.locator('.table-wrap').first()
const firstTableRows = firstTable.locator('tbody tr')
await expect(firstTableRows).toHaveCount(10)
await firstTable.locator('input#select-all').check()
await expect(page.locator('.list-header .list-selection')).toBeHidden()
await expect(firstTable.locator('button#select-all-across-pages')).toBeVisible()
await firstTable.locator('button#select-all-across-pages').click()
await expect(firstTable.locator('button#select-all-across-pages')).toBeHidden()
})
test('can bulk edit within a single table without affecting the others', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
const firstTable = page.locator('.table-wrap').first()
const secondTable = page.locator('.table-wrap').nth(1)
const firstTableRows = firstTable.locator('tbody tr')
const secondTableRows = secondTable.locator('tbody tr')
await sortColumn(page, {
scope: firstTable,
fieldLabel: 'Title',
fieldPath: 'title',
targetState: 'asc',
})
// select 'Find me' from both tables, only the first should get edited in the end
await selectTableRow(firstTable, 'Find me')
await selectTableRow(secondTable, 'Find me')
await firstTable.locator('.list-selection .edit-many__toggle').click()
const modal = page.locator('[id$="-edit-posts"]').first()
await expect(modal).toBeVisible()
await modal.locator('.field-select .rs__control').click()
await modal.locator('.field-select .rs__option', { hasText: exactText('Title') }).click()
const field = modal.locator(`#field-title`)
await expect(field).toBeVisible()
await field.fill('Find me (updated)')
await modal.locator('.form-submit button[type="submit"].edit-many__save').click()
await expect(
firstTableRows.locator('td.cell-title', { hasText: exactText('Find me (updated)') }),
).toHaveCount(0)
await expect(
secondTableRows.locator('td.cell-title', { hasText: exactText('Find me') }),
).toHaveCount(1)
})
test('can bulk delete within a single table without affecting the others', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
const firstTable = page.locator('.table-wrap').first()
const secondTable = page.locator('.table-wrap').nth(1)
const firstTableRows = firstTable.locator('tbody tr')
const secondTableRows = secondTable.locator('tbody tr')
await sortColumn(page, {
scope: firstTable,
fieldLabel: 'Title',
fieldPath: 'title',
targetState: 'asc',
})
// select 'Find me' from both tables, only the first should get deleted in the end
await selectTableRow(firstTable, 'Find me')
await selectTableRow(secondTable, 'Find me')
await firstTable.locator('.list-selection .delete-documents__toggle').click()
const modal = page.locator('[id$="-confirm-delete-many-docs"]').first()
await expect(modal).toBeVisible()
await modal.locator('#confirm-action').click()
await expect(
firstTableRows.locator('td.cell-title', { hasText: exactText('Find me') }),
).toHaveCount(0)
await expect(
secondTableRows.locator('td.cell-title', { hasText: exactText('Find me') }),
).toHaveCount(1)
})
test('can bulk edit across pages within a single table without affecting the others', async () => {
await page.goto(url.list)
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
const firstTable = page.locator('.table-wrap').first()
const secondTable = page.locator('.table-wrap').nth(1)
const firstTableRows = firstTable.locator('tbody tr')
const secondTableRows = secondTable.locator('tbody tr')
// click the select all checkbox, then the "select all across pages" button
await firstTable.locator('input#select-all').check()
await firstTable.locator('button#select-all-across-pages').click()
// now edit all titles and ensure that only the first table gets updated, not the second
await firstTable.locator('.list-selection .edit-many__toggle').click()
const modal = page.locator('[id$="-edit-posts"]').first()
await expect(modal).toBeVisible()
await modal.locator('.field-select .rs__control').click()
await modal.locator('.field-select .rs__option', { hasText: exactText('Title') }).click()
const field = modal.locator(`#field-title`)
await expect(field).toBeVisible()
await field.fill('Bulk edit across all pages')
await modal.locator('.form-submit button[type="submit"].edit-many__save').click()
await expect(
firstTableRows.locator('td.cell-title', { hasText: exactText('Bulk edit across all pages') }),
).toHaveCount(10)
await expect(
secondTableRows.locator('td.cell-title', {
hasText: exactText('Bulk edit across all pages'),
}),
).toHaveCount(0)
})
test('should show trashed docs in trash view when group-by is active', async () => {
await page.goto(url.list)
// Enable group-by on Category
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(2) // We expect 2 groups initially
// Trash the first document in the first group
const firstTable = page.locator('.table-wrap').first()
await firstTable.locator('.row-1 .cell-_select input').check()
await firstTable.locator('.list-selection__button[aria-label="Delete"]').click()
const firstGroupID = await firstTable
.locator('.group-by-header__heading')
.getAttribute('data-group-id')
const modalId = `[id^="${firstGroupID}-confirm-delete-many-docs"]`
await expect(page.locator(modalId)).toBeVisible()
// Confirm trash (skip permanent delete)
await page.locator(`${modalId} #confirm-action`).click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Go to the trash view
await page.locator('#trash-view-pill').click()
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
// Re-enable group-by on Category in trash view
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(1) // Should only have Category 1 (or the trashed doc's category)
// Ensure the trashed doc is visible
await expect(
page.locator('.table-wrap tbody tr td.cell-title', { hasText: 'Find me' }),
).toBeVisible()
})
})

View File

@@ -0,0 +1,430 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
posts: Post;
categories: Category;
media: Media;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title?: string | null;
category?: (string | null) | Category;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
tab1Field?: string | null;
updatedAt: string;
createdAt: string;
deletedAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
*/
export interface Category {
id: string;
title?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
medium?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
large?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
category?: T;
content?: T;
tab1Field?: T;
updatedAt?: T;
createdAt?: T;
deletedAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories_select".
*/
export interface CategoriesSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
thumbnail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
medium?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
large?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

4271
test/group-by/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

79
test/group-by/seed.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { Payload } from 'payload'
import { devUser } from '../credentials.js'
import { executePromises } from '../helpers/executePromises.js'
import { seedDB } from '../helpers/seed.js'
import { categoriesSlug } from './collections/Categories/index.js'
import { postsSlug } from './collections/Posts/index.js'
export const seed = async (_payload: Payload) => {
await executePromises(
[
() =>
_payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
depth: 0,
overrideAccess: true,
}),
async () => {
const [category1, category2] = await Promise.all([
_payload.create({
collection: categoriesSlug,
data: {
title: 'Category 1',
},
}),
_payload.create({
collection: categoriesSlug,
data: {
title: 'Category 2',
},
}),
])
await Promise.all(
Array.from({ length: 30 }).map(async (_, index) =>
_payload.create({
collection: postsSlug,
data: {
title: `Post ${index + 1}`,
category: index < 15 ? category1.id : category2.id,
},
}),
),
)
await _payload.create({
collection: 'posts',
data: {
title: 'Find me',
category: category1.id,
},
})
await _payload.create({
collection: 'posts',
data: {
title: 'Find me',
category: category2.id,
},
})
},
],
false,
)
}
export async function clearAndSeedEverything(_payload: Payload) {
return await seedDB({
_payload,
collectionSlugs: [postsSlug, categoriesSlug, 'users', 'media'],
seedFunction: seed,
snapshotKey: 'groupByTests',
// uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
}

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

9
test/group-by/types.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { RequestContext as OriginalRequestContext } from 'payload'
declare module 'payload' {
// Create a new interface that merges your additional fields with the original one
export interface RequestContext extends OriginalRequestContext {
myObject?: string
// ...
}
}

View File

@@ -98,10 +98,26 @@ export async function ensureCompilationIsDone({
await page.goto(adminURL)
await page.waitForURL(
readyURL ??
(noAutoLogin ? `${adminURL + (adminURL.endsWith('/') ? '' : '/')}login` : adminURL),
)
if (readyURL) {
await page.waitForURL(readyURL)
} else {
await expect
.poll(
() => {
if (noAutoLogin) {
const baseAdminURL = adminURL + (adminURL.endsWith('/') ? '' : '/')
return (
page.url() === `${baseAdminURL}create-first-user` ||
page.url() === `${baseAdminURL}login`
)
} else {
return page.url() === adminURL
}
},
{ timeout: POLL_TOPASS_TIMEOUT },
)
.toBe(true)
}
console.log('Successfully compiled')
return
@@ -279,7 +295,12 @@ export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
export async function saveDocAndAssert(
page: Page,
selector: '#action-publish' | '#action-save' | '#action-save-draft' | string = '#action-save',
selector:
| '#action-publish'
| '#action-save'
| '#action-save-draft'
| '#publish-locale'
| string = '#action-save',
expectation: 'error' | 'success' = 'success',
): Promise<void> {
await wait(500) // TODO: Fix this
@@ -386,10 +407,10 @@ export const checkBreadcrumb = async (page: Page, text: string) => {
.toBe(text)
}
export const selectTableRow = async (page: Page, title: string): Promise<void> => {
export const selectTableRow = async (scope: Locator | Page, title: string): Promise<void> => {
const selector = `tbody tr:has-text("${title}") .select-row__checkbox input[type=checkbox]`
await page.locator(selector).check()
await expect(page.locator(selector)).toBeChecked()
await scope.locator(selector).check()
await expect(scope.locator(selector)).toBeChecked()
}
export const findTableCell = async (

View File

@@ -16,7 +16,7 @@ import { devUser } from '../credentials.js'
type ValidPath = `/${string}`
type RequestOptions = {
auth?: boolean
query?: {
query?: { [key: string]: unknown } & {
depth?: number
fallbackLocale?: string
joins?: JoinQuery

View File

@@ -25,6 +25,8 @@ export class AdminUrlUtil {
serverURL: string
trash: string
constructor(serverURL: string, slug: string, routes?: Config['routes']) {
this.routes = {
admin: routes?.admin || '/admin',
@@ -75,6 +77,12 @@ export class AdminUrlUtil {
path: `/collections/${this.entitySlug}/payload-folders`,
serverURL: this.serverURL,
})
this.trash = formatAdminURL({
adminRoute: this.routes.admin,
path: `/collections/${this.entitySlug}/trash`,
serverURL: this.serverURL,
})
}
collection(slug: string): string {
@@ -96,4 +104,8 @@ export class AdminUrlUtil {
serverURL: this.serverURL,
})
}
trashEdit(id: number | string): string {
return `${this.trash}/${id}`
}
}

View File

@@ -11,19 +11,24 @@ export async function assertToastErrors({
errors: string[]
page: Page
}): Promise<void> {
const message =
errors.length === 1
? 'The following field is invalid:'
: `The following fields are invalid (${errors.length}):`
await expect(
page.locator('.payload-toast-container li').filter({ hasText: message }),
).toBeVisible()
for (let i = 0; i < errors.length; i++) {
const error = errors[i]
if (error) {
await expect(
page.locator('.payload-toast-container [data-testid="field-errors"] li').nth(i),
).toHaveText(error)
const isSingleError = errors.length === 1
const message = isSingleError
? 'The following field is invalid:'
: `The following fields are invalid (${errors.length}):`
// Check the intro message text
await expect(page.locator('.payload-toast-container')).toContainText(message)
// Check single error
if (isSingleError) {
await expect(page.locator('.payload-toast-container [data-testid="field-error"]')).toHaveText(
errors[0]!,
)
} else {
// Check multiple errors
const errorItems = page.locator('.payload-toast-container [data-testid="field-errors"] li')
for (let i = 0; i < errors.length; i++) {
await expect(errorItems.nth(i)).toHaveText(errors[i]!)
}
}

View File

@@ -0,0 +1,49 @@
import type { Locator, Page } from '@playwright/test'
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
export const goToNextPage = async (
page: Page,
options: {
affectsURL?: boolean // if false, won't wait for URL change (useful for when pagination doesn't affect URL)
/**
* Scope the pagination to a specific selector. If not provided, will search the whole page for the controls.
*/
scope?: Locator
// defaults to 2, assuming you're on page 1
targetPage?: number
} = { targetPage: 2, affectsURL: true },
) => {
const pageControls = (options.scope || page).locator('.paginator')
await pageControls.locator('button').nth(1).click()
if (options.affectsURL) {
const regex = new RegExp(`page=${options.targetPage}(?:&|$)`)
await page.waitForURL(regex, { timeout: POLL_TOPASS_TIMEOUT })
}
}
export const goToPreviousPage = async (
page: Page,
options: {
affectsURL?: boolean // if false, won't wait for URL change (useful for when pagination doesn't affect URL)
/**
* Scope the pagination to a specific selector. If not provided, will search the whole page for the controls.
* This is useful when multiple pagination controls are displayed on the same page (e.g. group-by)
*/
scope?: Locator
// defaults to 1, assuming you're on page 2
targetPage?: number
} = {
targetPage: 1,
affectsURL: true,
},
) => {
const pageControls = (options.scope || page).locator('.paginator')
await pageControls.locator('button').nth(0).click()
if (options.affectsURL) {
const regex = new RegExp(`page=${options.targetPage}(?:&|$)`)
await page.waitForURL(regex, { timeout: POLL_TOPASS_TIMEOUT })
}
}

View File

@@ -0,0 +1,90 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from 'helpers.js'
type ToggleOptions = {
groupByContainerSelector: string
targetState: 'closed' | 'open'
togglerSelector: string
}
/**
* Toggles the group-by drawer in the list view based on the targetState option.
*/
export const toggleGroupBy = async (
page: Page,
{
targetState = 'open',
togglerSelector = '#toggle-group-by',
groupByContainerSelector = '#list-controls-group-by',
}: ToggleOptions,
) => {
const groupByContainer = page.locator(groupByContainerSelector).first()
const isAlreadyOpen = await groupByContainer.isVisible()
if (!isAlreadyOpen && targetState === 'open') {
await page.locator(togglerSelector).first().click()
await expect(page.locator(`${groupByContainerSelector}.rah-static--height-auto`)).toBeVisible()
}
if (isAlreadyOpen && targetState === 'closed') {
await page.locator(togglerSelector).first().click()
await expect(page.locator(`${groupByContainerSelector}.rah-static--height-auto`)).toBeHidden()
}
return { groupByContainer }
}
/**
* Closes the group-by drawer in the list view. If it's already closed, does nothing.
*/
export const closeGroupBy = async (
page: Page,
options?: Omit<ToggleOptions, 'targetState'>,
): Promise<{
groupByContainer: Locator
}> => toggleGroupBy(page, { ...(options || ({} as ToggleOptions)), targetState: 'closed' })
/**
* Opens the group-by drawer in the list view. If it's already open, does nothing.
*/
export const openGroupBy = async (
page: Page,
options?: Omit<ToggleOptions, 'targetState'>,
): Promise<{
groupByContainer: Locator
}> => toggleGroupBy(page, { ...(options || ({} as ToggleOptions)), targetState: 'open' })
export const addGroupBy = async (
page: Page,
{ fieldLabel, fieldPath }: { fieldLabel: string; fieldPath: string },
): Promise<{ field: Locator; groupByContainer: Locator }> => {
const { groupByContainer } = await openGroupBy(page)
const field = groupByContainer.locator('#group-by--field-select')
await field.click()
await field.locator('.rs__option', { hasText: exactText(fieldLabel) })?.click()
await expect(field.locator('.react-select--single-value')).toHaveText(fieldLabel)
await expect(page).toHaveURL(new RegExp(`&groupBy=${fieldPath}`))
return { groupByContainer, field }
}
export const clearGroupBy = async (page: Page): Promise<{ groupByContainer: Locator }> => {
const { groupByContainer } = await openGroupBy(page)
await groupByContainer.locator('#group-by--reset').click()
const field = groupByContainer.locator('#group-by--field-select')
await expect(field.locator('.react-select--single-value')).toHaveText('Select a value')
await expect(groupByContainer.locator('#group-by--reset')).toBeHidden()
await expect(page).not.toHaveURL(/&groupBy=/)
await expect(groupByContainer.locator('#field-direction input')).toBeDisabled()
await expect(page.locator('.table-wrap')).toHaveCount(1)
await expect(page.locator('.group-by-header')).toHaveCount(0)
return { groupByContainer }
}

View File

@@ -17,3 +17,11 @@ export const navigateToDoc = async (page: Page, urlUtil: AdminUrlUtil) => {
await page.waitForURL(regex)
await goToFirstCell(page, urlUtil)
}
export const navigateToTrashedDoc = async (page: Page, urlUtil: AdminUrlUtil) => {
await page.goto(urlUtil.trash)
// wait for query params to arrive
const regex = new RegExp(`^${urlUtil.trash}(?:\\?.*)?$`)
await page.waitForURL(regex)
await goToFirstCell(page, urlUtil)
}

View File

@@ -2,11 +2,15 @@ import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
/**
* Opens the list filters drawer in the list view. If it's already open, does nothing.
* Return the filter container locator for further interactions.
*/
export const openListFilters = async (
page: Page,
{
togglerSelector = '.list-controls__toggle-where',
filterContainerSelector = '.list-controls__where',
togglerSelector = '#toggle-list-filters',
filterContainerSelector = '#list-controls-where',
}: {
filterContainerSelector?: string
togglerSelector?: string
@@ -14,6 +18,7 @@ export const openListFilters = async (
): Promise<{
filterContainer: Locator
}> => {
await expect(page.locator(togglerSelector)).toBeVisible()
const filterContainer = page.locator(filterContainerSelector).first()
const isAlreadyOpen = await filterContainer.isVisible()

View File

@@ -0,0 +1,36 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
/**
* Sort by columns within the list view.
* Will search for that field's heading in the selector, and click the appropriate sort button.
*/
export const sortColumn = async (
page: Page,
options: {
fieldLabel: string
fieldPath: string
/**
* Scope the sorting to a specific scope. If not provided, will search the whole page for the column heading.
*/
scope?: Locator
targetState: 'asc' | 'desc'
},
) => {
const pathAsClassName = options.fieldPath.replace(/\./g, '__')
const field = (options.scope || page).locator(`#heading-${pathAsClassName}`)
const upChevron = field.locator('button.sort-column__asc')
const downChevron = field.locator('button.sort-column__desc')
if (options.targetState === 'asc') {
await upChevron.click()
await expect(field.locator('button.sort-column__asc.sort-column--active')).toBeVisible()
await page.waitForURL(() => page.url().includes(`sort=${options.fieldPath}`))
} else if (options.targetState === 'desc') {
await downChevron.click()
await expect(field.locator('button.sort-column__desc.sort-column--active')).toBeVisible()
await page.waitForURL(() => page.url().includes(`sort=-${options.fieldPath}`))
}
}

View File

@@ -2,6 +2,9 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
/**
* Closes the list drawer by clicking the close button in the header.
*/
export const closeListDrawer = async ({
page,
drawerSelector = '[id^=list-drawer_1_]',

View File

@@ -1,11 +1,11 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { wait } from 'payload/shared'
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
export async function waitForAutoSaveToRunAndComplete(
page: Page,
page: Locator | Page,
expectation: 'error' | 'success' = 'success',
) {
await expect(async () => {

View File

@@ -0,0 +1,41 @@
import type { Page } from '@playwright/test'
export const applyBrowseByFolderTypeFilter = async ({
page,
type,
on,
}: {
on: boolean
page: Page
type: {
label: string
value: string
}
}) => {
// Check if the popup is already active
let typePill = page.locator('.search-bar__actions .checkbox-popup.popup--active', {
hasText: 'Type',
})
const isActive = (await typePill.count()) > 0
if (!isActive) {
typePill = page.locator('.search-bar__actions .checkbox-popup', { hasText: 'Type' })
await typePill.locator('.popup-button', { hasText: 'Type' }).click()
}
await typePill.locator('.field-label', { hasText: type.label }).click()
await page.waitForURL((urlStr) => {
try {
const url = new URL(urlStr)
const relationTo = url.searchParams.get('relationTo')
if (on) {
return Boolean(relationTo?.includes(`"${type.value}"`))
} else {
return Boolean(!relationTo?.includes(`"${type.value}"`))
}
} catch {
return false
}
})
}

View File

@@ -1,27 +1,37 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
type Args = {
doubleClick?: boolean
folderName: string
page: Page
rootLocator?: Locator
}
export async function clickFolderCard({
page,
folderName,
doubleClick = false,
rootLocator,
}: Args): Promise<void> {
const folderCard = page
.locator('.folder-file-card')
const folderCard = (rootLocator || page)
.locator('div[role="button"].draggable-with-click')
.filter({
has: page.locator('.folder-file-card__name', { hasText: folderName }),
})
.first()
const dragHandleButton = folderCard.locator('div[role="button"].folder-file-card__drag-handle')
await folderCard.waitFor({ state: 'visible' })
if (doubleClick) {
await dragHandleButton.dblclick()
// Release any modifier keys that might be held down from previous tests
await page.keyboard.up('Shift')
await page.keyboard.up('Control')
await page.keyboard.up('Alt')
await page.keyboard.up('Meta')
await folderCard.dblclick()
await expect(folderCard).toBeHidden()
} else {
await dragHandleButton.click()
await folderCard.click()
}
}

View File

@@ -1,7 +1,10 @@
import { expect, type Page } from '@playwright/test'
import { createFolderDoc } from './createFolderDoc.js'
type Args = {
folderName: string
folderType?: string[]
fromDropdown?: boolean
page: Page
}
@@ -9,13 +12,15 @@ export async function createFolder({
folderName,
fromDropdown = false,
page,
folderType = ['Posts'],
}: Args): Promise<void> {
if (fromDropdown) {
const folderDropdown = page.locator('.create-new-doc-in-folder__popup-button', {
const titleActionsLocator = page.locator('.list-header__title-actions')
const folderDropdown = titleActionsLocator.locator('.create-new-doc-in-folder__action-popup', {
hasText: 'Create',
})
await folderDropdown.click()
const createFolderButton = page.locator('.popup-button-list__button', {
const createFolderButton = titleActionsLocator.locator('.popup-button-list__button', {
hasText: 'Folder',
})
await createFolderButton.click()
@@ -26,16 +31,11 @@ export async function createFolder({
await createFolderButton.click()
}
const folderNameInput = page.locator(
'dialog#create-document--header-pill-new-folder-drawer div.drawer-content-container input#field-name',
)
await folderNameInput.fill(folderName)
const createButton = page.getByRole('button', { name: 'Apply Changes' })
await createButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await createFolderDoc({
page,
folderName,
folderType,
})
const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first()
await expect(folderCard).toBeVisible()

View File

@@ -0,0 +1,26 @@
import { expect, type Page } from '@playwright/test'
import { selectInput } from '../../helpers/e2e/selectInput.js'
export const createFolderDoc = async ({
folderName,
page,
folderType,
}: {
folderName: string
folderType: string[]
page: Page
}) => {
const drawer = page.locator('dialog .collection-edit--payload-folders')
await drawer.locator('input#field-name').fill(folderName)
await selectInput({
multiSelect: true,
options: folderType,
selectLocator: drawer.locator('#field-folderType'),
})
const createButton = drawer.getByRole('button', { name: 'Save' })
await createButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
}

View File

@@ -1,26 +1,29 @@
import { expect, type Page } from '@playwright/test'
import { createFolder } from './createFolder.js'
import { createFolderDoc } from './createFolderDoc.js'
type Args = {
folderName: string
folderType?: string[]
page: Page
}
export async function createFolderFromDoc({ folderName, page }: Args): Promise<void> {
export async function createFolderFromDoc({
folderName,
page,
folderType = ['Posts'],
}: Args): Promise<void> {
const addFolderButton = page.locator('.create-new-doc-in-folder__button', {
hasText: 'Create folder',
})
await addFolderButton.click()
const folderNameInput = page.locator('div.drawer-content-container input#field-name')
await folderNameInput.fill(folderName)
const createButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Create' })
await createButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await createFolderDoc({
page,
folderName,
folderType,
})
const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first()
await expect(folderCard).toBeVisible()

View File

@@ -13,15 +13,16 @@ export async function initPayloadInt<TInitializePayload extends boolean | undefi
dirname: string,
testSuiteNameOverride?: string,
initializePayload?: TInitializePayload,
configFile?: string,
): Promise<
TInitializePayload extends false
? { config: SanitizedConfig }
: { config: SanitizedConfig; payload: Payload; restClient: NextRESTClient }
> {
const testSuiteName = testSuiteNameOverride ?? path.basename(dirname)
await runInit(testSuiteName, false, true)
console.log('importing config', path.resolve(dirname, 'config.ts'))
const { default: config } = await import(path.resolve(dirname, 'config.ts'))
await runInit(testSuiteName, false, true, configFile)
console.log('importing config', path.resolve(dirname, configFile ?? 'config.ts'))
const { default: config } = await import(path.resolve(dirname, configFile ?? 'config.ts'))
if (initializePayload === false) {
return { config: await config } as any

View File

@@ -1,5 +1,8 @@
import type { Payload } from 'payload'
export function isMongoose(_payload?: Payload) {
return _payload?.db?.name === 'mongoose' || ['mongodb'].includes(process.env.PAYLOAD_DATABASE)
return (
_payload?.db?.name === 'mongoose' ||
['firestore', 'mongodb'].includes(process.env.PAYLOAD_DATABASE)
)
}

View File

@@ -15,7 +15,7 @@ const handler: PayloadHandler = async (req) => {
}
const query: {
deleteOnly?: boolean
deleteOnly?: string
snapshotKey?: string
uploadsDir?: string | string[]
} = qs.parse(req.url.split('?')[1] ?? '', {
@@ -31,7 +31,8 @@ const handler: PayloadHandler = async (req) => {
snapshotKey: String(query.snapshotKey),
// uploadsDir can be string or stringlist
uploadsDir: query.uploadsDir as string | string[],
deleteOnly: query.deleteOnly,
// query value will be a string of 'true' or 'false'
deleteOnly: query.deleteOnly === 'true',
})
return Response.json(

View File

@@ -126,6 +126,7 @@ export type FindArgs<
pagination?: boolean
showHiddenFields?: boolean
sort?: string
trash?: boolean
user?: TypeWithID
where?: Where
} & BaseArgs
@@ -148,5 +149,6 @@ export type DeleteArgs<
collection: TSlug
id?: string
overrideAccess?: boolean
trash?: boolean
where?: Where
} & BaseArgs

View File

@@ -14,13 +14,17 @@ declare global {
*/
// eslint-disable-next-line no-restricted-exports
export default async () => {
if (process.env.DATABASE_URI) {
return
}
process.env.NODE_ENV = 'test'
process.env.PAYLOAD_DROP_DATABASE = 'true'
process.env.NODE_OPTIONS = '--no-deprecation'
process.env.DISABLE_PAYLOAD_HMR = 'true'
if (
(!process.env.PAYLOAD_DATABASE || process.env.PAYLOAD_DATABASE === 'mongodb') &&
(!process.env.PAYLOAD_DATABASE ||
['firestore', 'mongodb'].includes(process.env.PAYLOAD_DATABASE)) &&
!global._mongoMemoryServer
) {
console.log('Starting memory db...')

View File

@@ -44,8 +44,8 @@ describe('i18n', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {

View File

@@ -17,6 +17,7 @@ export async function initDevAndTest(
testSuiteArg: string,
writeDBAdapter: string,
skipGenImportMap: string,
configFile?: string,
): Promise<void> {
const importMapPath: string = path.resolve(
getNextRootDir(testSuiteArg).rootDir,
@@ -44,7 +45,7 @@ export async function initDevAndTest(
const testDir = path.resolve(dirname, testSuiteArg)
console.log('Generating import map for config:', testDir)
const configUrl = pathToFileURL(path.resolve(testDir, 'config.ts')).href
const configUrl = pathToFileURL(path.resolve(testDir, configFile ?? 'config.ts')).href
const config: SanitizedConfig = await (await import(configUrl)).default
process.env.ROOT_DIR = getNextRootDir(testSuiteArg).rootDir

View File

@@ -4,6 +4,10 @@ import { categoriesVersionsSlug, versionsSlug } from '../shared.js'
export const CategoriesVersions: CollectionConfig = {
slug: categoriesVersionsSlug,
labels: {
singular: 'Category With Versions',
plural: 'Categories With Versions',
},
fields: [
{
name: 'title',

View File

@@ -4,6 +4,10 @@ import { versionsSlug } from '../shared.js'
export const Versions: CollectionConfig = {
slug: versionsSlug,
labels: {
singular: 'Post With Versions',
plural: 'Posts With Versions',
},
fields: [
{
name: 'title',
@@ -19,15 +23,19 @@ export const Versions: CollectionConfig = {
name: 'categoryVersion',
relationTo: 'categories-versions',
type: 'relationship',
label: 'Category With Versions',
},
{
name: 'categoryVersions',
relationTo: 'categories-versions',
type: 'relationship',
hasMany: true,
label: 'Categories With Versions (Has Many)',
},
],
versions: {
drafts: true,
drafts: {
autosave: true,
},
},
}

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
@@ -13,7 +14,7 @@ import {
exactText,
initPageConsoleErrorCatch,
saveDocAndAssert,
throttleTest,
// throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
@@ -134,9 +135,11 @@ describe('Join Field', () => {
limit: 1,
})
const category = result.docs[0]
if (!category) {
throw new Error('No category found')
}
// seed additional posts to test defaultLimit (5)
await payload.create({
collection: postsSlug,
@@ -145,6 +148,7 @@ describe('Join Field', () => {
category: category.id,
},
})
await payload.create({
collection: postsSlug,
data: {
@@ -152,6 +156,7 @@ describe('Join Field', () => {
category: category.id,
},
})
await payload.create({
collection: postsSlug,
data: {
@@ -159,6 +164,7 @@ describe('Join Field', () => {
category: category.id,
},
})
await navigateToDoc(page, categoriesURL)
const joinField = page.locator('#field-relatedPosts.field-type.join')
await expect(joinField.locator('.row-1 > .cell-title')).toContainText('z')
@@ -177,12 +183,15 @@ describe('Join Field', () => {
const button = joinField.locator('button.doc-drawer__toggler.relationship-table__add-new')
await expect(button).toBeVisible()
await button.click()
const drawer = page.locator('[id^=doc-drawer_hidden-posts_1_]')
await expect(drawer).toBeVisible()
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
await titleField.fill('Test Hidden Post')
await drawer.locator('button[id="action-save"]').click()
await saveDocAndAssert(page, '[id^=doc-drawer_hidden-posts_1_] button#action-save')
await expect(joinField.locator('.relationship-table tbody tr.row-2')).toBeVisible()
})
@@ -233,7 +242,7 @@ describe('Join Field', () => {
const joinField = page.locator('#field-relatedPosts.field-type.join')
await expect(joinField).toBeVisible()
const actionColumn = joinField.locator('tbody tr td:nth-child(2)').first()
const toggler = actionColumn.locator('button.doc-drawer__toggler')
const toggler = actionColumn.locator('button.drawer-link__doc-drawer-toggler')
await expect(toggler).toBeVisible()
const link = actionColumn.locator('a')
await expect(link).toBeHidden()
@@ -246,7 +255,7 @@ describe('Join Field', () => {
})
const newActionColumn = joinField.locator('tbody tr td:nth-child(2)').first()
const newToggler = newActionColumn.locator('button.doc-drawer__toggler')
const newToggler = newActionColumn.locator('button.drawer-link__doc-drawer-toggler')
await expect(newToggler).toBeVisible()
const newLink = newActionColumn.locator('a')
await expect(newLink).toBeHidden()
@@ -316,8 +325,10 @@ describe('Join Field', () => {
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
await titleField.fill('Test Post 4')
await drawer.locator('button[id="action-save"]').click()
await saveDocAndAssert(page, '[id^=doc-drawer_posts_1_] button#action-save')
await expect(drawer).toBeHidden()
await expect(
joinField.locator('tbody tr td:nth-child(2)', {
hasText: exactText('Test Post 4'),
@@ -325,23 +336,77 @@ describe('Join Field', () => {
).toBeVisible()
})
test('should update relationship table when document is updated', async () => {
test('should edit joined document and update relationship table', async () => {
await page.goto(categoriesURL.edit(categoryID))
const joinField = page.locator('#field-group__relatedPosts.field-type.join')
await expect(joinField).toBeVisible()
const editButton = joinField.locator(
'tbody tr:first-child td:nth-child(2) button.doc-drawer__toggler',
'tbody tr:first-child td:nth-child(2) button.drawer-link__doc-drawer-toggler',
)
await expect(editButton).toBeVisible()
await editButton.click()
const drawer = page.locator('[id^=doc-drawer_posts_1_]')
await expect(drawer).toBeVisible()
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
await titleField.fill('Test Post 1 Updated')
await drawer.locator('button[id="action-save"]').click()
const updatedTitle = 'Test Post 1 (Updated)'
await titleField.fill(updatedTitle)
await saveDocAndAssert(page, '[id^=doc-drawer_posts_1_] button#action-save')
await drawer.locator('.doc-drawer__header-close').click()
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test Post 1 Updated')
await expect(joinField.locator('tbody .row-1')).toContainText(updatedTitle)
})
test('should edit joined document and update relationship table when autosave is enabled', async () => {
const categoryVersionsDoc = await payload.create({
collection: categoriesVersionsSlug,
data: {
title: 'Test Category (With Versions)',
},
})
await payload.create({
collection: versionsSlug,
data: {
title: 'Test Post',
categoryVersion: categoryVersionsDoc.id,
},
})
await page.goto(categoriesVersionsURL.edit(categoryVersionsDoc.id))
const joinField = page.locator('#field-relatedVersions.field-type.join')
await expect(joinField).toBeVisible()
const editButton = joinField.locator(
'tbody tr:first-child td:nth-child(2) button.drawer-link__doc-drawer-toggler',
)
await expect(editButton).toBeVisible()
await editButton.click()
const drawer = page.locator('[id^=doc-drawer_versions_1_]')
await expect(drawer).toBeVisible()
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
const updatedTitle = 'Test Post (Updated)'
await titleField.fill(updatedTitle)
await waitForAutoSaveToRunAndComplete(drawer)
// drawer should remain open after autosave
await expect(drawer).toBeVisible()
await drawer.locator('.doc-drawer__header-close').click()
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText(updatedTitle)
})
test('should update relationship table when document is deleted', async () => {
@@ -354,7 +419,7 @@ describe('Join Field', () => {
await expect(rows).toHaveCount(expectedRows)
const editButton = joinField.locator(
'tbody tr:first-child td:nth-child(2) button.doc-drawer__toggler',
'tbody tr:first-child td:nth-child(2) button.drawer-link__doc-drawer-toggler',
)
await expect(editButton).toBeVisible()
await editButton.click()
@@ -388,8 +453,10 @@ describe('Join Field', () => {
await expect(titleField).toBeVisible()
await titleField.fill('Test polymorphic Post')
await expect(drawer.locator('#field-polymorphic')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await saveDocAndAssert(page, '[id^=doc-drawer_posts_1_] button#action-save')
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
test('should create join collection from polymorphic, hasMany relationships', async () => {
@@ -403,8 +470,10 @@ describe('Join Field', () => {
await expect(titleField).toBeVisible()
await titleField.fill('Test polymorphic Post')
await expect(drawer.locator('#field-polymorphics')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await saveDocAndAssert(page, '[id^=doc-drawer_posts_1_] button#action-save')
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
test('should create join collection from polymorphic localized relationships', async () => {
@@ -418,8 +487,10 @@ describe('Join Field', () => {
await expect(titleField).toBeVisible()
await titleField.fill('Test polymorphic Post')
await expect(drawer.locator('#field-localizedPolymorphic')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await saveDocAndAssert(page, '[id^=doc-drawer_posts_1_] button#action-save')
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
test('should create join collection from polymorphic, hasMany, localized relationships', async () => {
@@ -433,8 +504,10 @@ describe('Join Field', () => {
await expect(titleField).toBeVisible()
await titleField.fill('Test polymorphic Post')
await expect(drawer.locator('#field-localizedPolymorphics')).toContainText('example')
await drawer.locator('button[id="action-save"]').click()
await saveDocAndAssert(page, '[id^=doc-drawer_posts_1_] button#action-save')
await expect(drawer).toBeHidden()
await expect(joinField.locator('tbody .row-1')).toContainText('Test polymorphic Post')
})
@@ -469,6 +542,7 @@ describe('Join Field', () => {
await titleField.fill('Edited title for upload')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(
joinField.locator('tbody tr td:nth-child(2)', {
hasText: exactText('Edited title for upload'),
@@ -490,12 +564,15 @@ describe('Join Field', () => {
await page.goto(foldersURL.edit(rootFolderID))
const joinField = page.locator('#field-children.field-type.join')
await expect(joinField).toBeVisible()
await expect(
joinField.locator('.relationship-table tbody .row-1 .cell-collection .pill__label'),
).toHaveText('Folder')
await expect(
joinField.locator('.relationship-table tbody .row-3 .cell-collection .pill__label'),
).toHaveText('Example Post')
await expect(
joinField.locator('.relationship-table tbody .row-5 .cell-collection .pill__label'),
).toHaveText('Example Page')
@@ -518,6 +595,7 @@ describe('Join Field', () => {
await expect(
joinField.locator('.relationship-table tbody .row-1 .cell-collection .pill__label'),
).toHaveText('Example Page')
await expect(
joinField.locator('.relationship-table tbody .row-1 .cell-title .drawer-link__cell'),
).toHaveText('Some new page')
@@ -556,7 +634,6 @@ describe('Join Field', () => {
})
test('should fetch draft documents in joins', async () => {
// create category-versions document
const categoryVersionsDoc = await payload.create({
collection: categoriesVersionsSlug,
data: {
@@ -564,7 +641,6 @@ describe('Join Field', () => {
},
})
// create versions document
const versionDoc = await payload.create({
collection: versionsSlug,
data: {
@@ -573,7 +649,6 @@ describe('Join Field', () => {
},
})
// update versions document with draft data
await payload.update({
id: versionDoc.id,
collection: versionsSlug,

View File

@@ -356,6 +356,56 @@ describe('Joins Field', () => {
expect(result.docs[0]?.documentsAndFolders.docs).toHaveLength(1)
})
it('should allow join where query on hasMany select fields', async () => {
const folderDoc = await payload.create({
collection: 'payload-folders',
data: {
name: 'scopedFolder',
folderType: ['folderPoly1', 'folderPoly2'],
},
})
await payload.create({
collection: 'payload-folders',
data: {
name: 'childFolder',
folderType: ['folderPoly1'],
folder: folderDoc.id,
},
})
const findFolder = await payload.find({
collection: 'payload-folders',
where: {
id: {
equals: folderDoc.id,
},
},
joins: {
documentsAndFolders: {
limit: 100_000,
sort: 'name',
where: {
and: [
{
relationTo: {
equals: 'payload-folders',
},
},
{
folderType: {
in: ['folderPoly1'],
},
},
],
},
},
},
})
expect(findFolder?.docs[0]?.documentsAndFolders?.docs).toHaveLength(1)
})
it('should filter joins using where query', async () => {
const categoryWithPosts = await payload.findByID({
id: category.id,
@@ -919,6 +969,37 @@ describe('Joins Field', () => {
expect(unlimited.data.Categories.docs[0].relatedPosts.hasNextPage).toStrictEqual(false)
})
it('should return totalDocs with count: true', async () => {
const queryWithLimit = `query {
Categories(where: {
name: { equals: "paginate example" }
}) {
docs {
relatedPosts(
sort: "createdAt",
limit: 4,
count: true
) {
docs {
title
}
hasNextPage
totalDocs
}
}
}
}`
const pageWithLimit = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) })
.then((res) => res.json())
expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs).toHaveLength(4)
expect(pageWithLimit.data.Categories.docs[0].relatedPosts.docs[0].title).toStrictEqual(
'test 0',
)
expect(pageWithLimit.data.Categories.docs[0].relatedPosts.hasNextPage).toStrictEqual(true)
expect(pageWithLimit.data.Categories.docs[0].relatedPosts.totalDocs).toStrictEqual(15)
})
it('should have simple paginate with page for joins', async () => {
let queryWithLimit = `query {
Categories(where: {

View File

@@ -805,6 +805,7 @@ export interface FolderInterface {
hasNextPage?: boolean;
totalDocs?: number;
};
folderType?: ('folderPoly1' | 'folderPoly2')[] | null;
updatedAt: string;
createdAt: string;
}
@@ -1351,6 +1352,7 @@ export interface PayloadFoldersSelect<T extends boolean = true> {
name?: T;
folder?: T;
documentsAndFolders?: T;
folderType?: T;
updatedAt?: T;
createdAt?: T;
}

View File

@@ -19,11 +19,14 @@ export const categoriesJoinRestrictedSlug = 'categories-join-restricted'
export const collectionRestrictedSlug = 'collection-restricted'
export const restrictedCategoriesSlug = 'restricted-categories'
export const categoriesVersionsSlug = 'categories-versions'
export const versionsSlug = 'versions'
export const collectionSlugs = [
categoriesSlug,
categoriesVersionsSlug,
postsSlug,
localizedPostsSlug,
localizedCategoriesSlug,

View File

@@ -11,6 +11,7 @@ import {
} from './collections/Lexical/index.js'
import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js'
import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
import { LexicalJSXConverter } from './collections/LexicalJSXConverter/index.js'
import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js'
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
@@ -30,6 +31,7 @@ export const baseConfig: Partial<Config> = {
collections: [
LexicalFullyFeatured,
LexicalLinkFeature,
LexicalJSXConverter,
getLexicalFieldsCollection({
blocks: lexicalBlocks,
inlineBlocks: lexicalInlineBlocks,

View File

@@ -240,7 +240,7 @@ describe('lexicalBlocks', () => {
)
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await expect(newBlock.locator('.rs__menu')).toHaveText('invalid')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
@@ -281,7 +281,7 @@ describe('lexicalBlocks', () => {
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await expect(newBlock.locator('.rs__menu')).toHaveText('invalid')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
@@ -322,7 +322,7 @@ describe('lexicalBlocks', () => {
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await expect(newBlock.locator('.rs__menu')).toHaveText('invalid')
await dependsOnBlockData.locator('.rs__control').click()
await saveDocAndAssert(page)

View File

@@ -1296,24 +1296,23 @@ describe('lexicalMain', () => {
await page.getByLabel('Title*').fill('Indent and Text-align')
await page.getByRole('paragraph').nth(1).click()
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
const getHTMLContent: (indentToSize: (indent: number) => string) => string = (indentToSize) =>
`<p style='text-align: center;'>paragraph centered</p><h1 style='text-align: right;'>Heading right</h1><p>paragraph without indent</p><p style='padding-inline-start: ${indentToSize(1)};'>paragraph indent 1</p><h2 style='padding-inline-start: ${indentToSize(2)};'>heading indent 2</h2><blockquote style='padding-inline-start: ${indentToSize(3)};'>quote indent 3</blockquote>`
const htmlContent = `<p style='text-align: center;'>paragraph centered</p><h1 style='text-align: right;'>Heading right</h1><p>paragraph without indent</p><p style='padding-inline-start: 40px;'>paragraph indent 1</p><h2 style='padding-inline-start: 80px;'>heading indent 2</h2><blockquote style='padding-inline-start: 120px;'>quote indent 3</blockquote>`
await page.evaluate(
async ([htmlContent]) => {
const blob = new Blob([htmlContent as string], { type: 'text/html' })
const blob = new Blob([htmlContent], { type: 'text/html' })
const clipboardItem = new ClipboardItem({ 'text/html': blob })
await navigator.clipboard.write([clipboardItem])
},
[getHTMLContent((indent: number) => `${indent * 40}px`)],
[htmlContent],
)
// eslint-disable-next-line playwright/no-conditional-in-test
const pasteKey = process.platform === 'darwin' ? 'Meta' : 'Control'
await page.keyboard.press(`${pasteKey}+v`)
await page.locator('#field-richText').click()
await page.locator('#field-richText').fill('asd')
await saveDocAndAssert(page)
await page.getByRole('button', { name: 'Save' }).click()
await page.getByRole('link', { name: 'API' }).click()
const htmlOutput = page.getByText(getHTMLContent((indent: number) => `${indent * 2}rem`))
const htmlOutput = page.getByText(htmlContent)
await expect(htmlOutput).toBeVisible()
})
@@ -1552,7 +1551,7 @@ describe('lexicalMain', () => {
await closeTagInMultiSelect.click()
await expect(decoratorLocator).toBeHidden()
const labelInsideCollapsableBody = page.locator('label').getByText('Sub Blocks')
const labelInsideCollapsableBody = page.locator('h3>span').getByText('Sub Blocks')
await labelInsideCollapsableBody.click()
await expectInsideSelectedDecorator(labelInsideCollapsableBody)

View File

@@ -0,0 +1,117 @@
import type { Locator, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import { lexicalJSXConverterSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone } from '../../../helpers.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from '../utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
// Unlike other suites, this one runs in parallel, as they run on the `/create` URL and are "pure" tests
test.describe.configure({ mode: 'parallel' })
const { serverURL } = await initPayloadE2ENoConfig({
dirname,
})
describe('Lexical JSX Converter', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()
})
beforeEach(async ({ page }) => {
await reInitializeDB({
serverURL,
snapshotKey: 'lexicalTest',
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
})
const url = new AdminUrlUtil(serverURL, lexicalJSXConverterSlug)
const lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
// See rationale in https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085
test('indents should be 40px in the editor and in the jsx converter', async ({ page }) => {
const lexical = new LexicalHelpers(page)
// 40px
await lexical.addLine('ordered', 'HelloA0', 1, false)
await lexical.addLine('paragraph', 'HelloA1', 1)
await lexical.addLine('unordered', 'HelloA2', 1)
await lexical.addLine('h1', 'HelloA3', 1)
await lexical.addLine('check', 'HelloA4', 1)
// 80px
await lexical.addLine('ordered', 'HelloB0', 2)
await lexical.addLine('paragraph', 'HelloB1', 2)
await lexical.addLine('unordered', 'HelloB2', 2)
await lexical.addLine('h1', 'HelloB3', 2)
await lexical.addLine('check', 'HelloB4', 2)
const [offsetA0_ed, offsetA0_jsx] = await getIndentOffset(page, 'HelloA0')
const [offsetA1_ed, offsetA1_jsx] = await getIndentOffset(page, 'HelloA1')
const [offsetA2_ed, offsetA2_jsx] = await getIndentOffset(page, 'HelloA2')
const [offsetA3_ed, offsetA3_jsx] = await getIndentOffset(page, 'HelloA3')
const [offsetA4_ed, offsetA4_jsx] = await getIndentOffset(page, 'HelloA4')
const [offsetB0_ed, offsetB0_jsx] = await getIndentOffset(page, 'HelloB0')
const [offsetB1_ed, offsetB1_jsx] = await getIndentOffset(page, 'HelloB1')
const [offsetB2_ed, offsetB2_jsx] = await getIndentOffset(page, 'HelloB2')
const [offsetB3_ed, offsetB3_jsx] = await getIndentOffset(page, 'HelloB3')
const [offsetB4_ed, offsetB4_jsx] = await getIndentOffset(page, 'HelloB4')
await expect(() => {
expect(offsetA0_ed).toBe(offsetB0_ed - 40)
expect(offsetA1_ed).toBe(offsetB1_ed - 40)
expect(offsetA2_ed).toBe(offsetB2_ed - 40)
expect(offsetA3_ed).toBe(offsetB3_ed - 40)
expect(offsetA4_ed).toBe(offsetB4_ed - 40)
expect(offsetA0_jsx).toBe(offsetB0_jsx - 40)
expect(offsetA1_jsx).toBe(offsetB1_jsx - 40)
expect(offsetA2_jsx).toBe(offsetB2_jsx - 40)
expect(offsetA3_jsx).toBe(offsetB3_jsx - 40)
// TODO: Checklist item in JSX needs more thought
// expect(offsetA4_jsx).toBe(offsetB4_jsx - 40)
}).toPass()
// HTML in JSX converter should contain as few inline styles as possible
await expect(async () => {
const innerHTML = await page.locator('.payload-richtext').innerHTML()
const normalized = normalizeCheckboxUUIDs(innerHTML)
expect(normalized).toBe(
`<ol class="list-number"><li class="" value="1">HelloA0</li></ol><p style="padding-inline-start: 40px;">HelloA1</p><ul class="list-bullet"><li class="" value="1">HelloA2</li></ul><h1 style="padding-inline-start: 40px;">HelloA3</h1><ol class="list-number"><li class="" value="1">HelloA4</li><li class="nestedListItem" value="2" style="list-style-type: none;"><ol class="list-number"><li class="" value="1">HelloB0</li></ol></li></ol><p style="padding-inline-start: 80px;">HelloB1</p><ul class="list-bullet"><li class="nestedListItem" value="1" style="list-style-type: none;"><ul class="list-bullet"><li class="" value="1">HelloB2</li></ul></li></ul><h1 style="padding-inline-start: 80px;">HelloB3</h1><ul class="list-check"><li aria-checked="false" class="list-item-checkbox list-item-checkbox-unchecked nestedListItem" role="checkbox" tabindex="-1" value="1" style="list-style-type: none;"><ul class="list-check"><li aria-checked="false" class="list-item-checkbox list-item-checkbox-unchecked" role="checkbox" tabindex="-1" value="1" style="list-style-type: none;"><input id="DETERMINISTIC_UUID" readonly="" type="checkbox"><label for="DETERMINISTIC_UUID">HelloB4</label><br></li></ul></li></ul>`,
)
}).toPass()
})
})
async function getIndentOffset(page: Page, text: string): Promise<[number, number]> {
const textElement = page.getByText(text)
await expect(textElement).toHaveCount(2)
const startLeft = (locator: Locator) =>
locator.evaluate((el) => {
const leftRect = el.getBoundingClientRect().left
const paddingLeft = getComputedStyle(el).paddingLeft
return leftRect + parseFloat(paddingLeft)
})
return [await startLeft(textElement.first()), await startLeft(textElement.last())]
}
function normalizeCheckboxUUIDs(html: string): string {
return html
.replace(/id="[a-f0-9-]{36}"/g, 'id="DETERMINISTIC_UUID"')
.replace(/for="[a-f0-9-]{36}"/g, 'for="DETERMINISTIC_UUID"')
}

View File

@@ -0,0 +1,18 @@
import type { CollectionConfig } from 'payload'
import { DebugJsxConverterFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { lexicalJSXConverterSlug } from '../../slugs.js'
export const LexicalJSXConverter: CollectionConfig = {
slug: lexicalJSXConverterSlug,
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, DebugJsxConverterFeature()],
}),
},
],
}

View File

@@ -13,7 +13,6 @@ import {
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
@@ -22,7 +21,6 @@ const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
let client: RESTClient
let page: Page
let serverURL: string
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
@@ -48,12 +46,6 @@ describe('Rich Text', () => {
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
})
if (client) {
await client.logout()
}
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})
@@ -75,24 +67,32 @@ describe('Rich Text', () => {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields')
await page.goto(url.list) // Navigate to rich-text list view
const table = page.locator('.list-controls ~ .table')
const table = page.locator('.table-wrap .table')
await expect(table).toBeVisible()
const lexicalCell = table.locator('.cell-lexicalCustomFields').first()
await expect(lexicalCell).toBeVisible()
const lexicalHtmlCell = table.locator('.cell-lexicalCustomFields_html').first()
await expect(lexicalHtmlCell).toBeVisible()
const entireRow = table.locator('.row-1').first()
// Make sure each of the 3 above are no larger than 300px in height:
await expect
.poll(async () => (await lexicalCell.boundingBox()).height, {
.poll(async () => (await lexicalCell.boundingBox())?.height, {
timeout: POLL_TOPASS_TIMEOUT,
})
.toBeLessThanOrEqual(300)
await expect
.poll(async () => (await lexicalHtmlCell.boundingBox()).height, {
.poll(async () => (await lexicalHtmlCell.boundingBox())?.height, {
timeout: POLL_TOPASS_TIMEOUT,
})
.toBeLessThanOrEqual(300)
await expect
.poll(async () => (await entireRow.boundingBox()).height, { timeout: POLL_TOPASS_TIMEOUT })
.poll(async () => (await entireRow.boundingBox())?.height, { timeout: POLL_TOPASS_TIMEOUT })
.toBeLessThanOrEqual(300)
})
})

View File

@@ -73,7 +73,16 @@ export function generateSlateRichText() {
{
children: [
{
text: "It's built with SlateJS",
// This node is untyped, because I want to test this scenario:
// https://github.com/payloadcms/payload/pull/13202
children: [
{
text: 'This editor is built ',
},
{
text: 'with SlateJS',
},
],
},
],
type: 'li',

View File

@@ -8,7 +8,7 @@ import { fileURLToPath } from 'url'
import { ensureCompilationIsDone } from '../../../helpers.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from './utils.js'
import { LexicalHelpers } from '../utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')

View File

@@ -8,6 +8,27 @@ export class LexicalHelpers {
this.page = page
}
async addLine(
type: 'check' | 'h1' | 'h2' | 'ordered' | 'paragraph' | 'unordered',
text: string,
indent: number,
startWithEnter = true,
) {
if (startWithEnter) {
await this.page.keyboard.press('Enter')
}
await this.slashCommand(type)
// Outdent 10 times to be sure we are at the beginning of the line
for (let i = 0; i < 10; i++) {
await this.page.keyboard.press('Shift+Tab')
}
const adjustedIndent = ['check', 'ordered', 'unordered'].includes(type) ? indent - 1 : indent
for (let i = 0; i < adjustedIndent; i++) {
await this.page.keyboard.press('Tab')
}
await this.page.keyboard.type(text)
}
async save(container: 'document' | 'drawer') {
if (container === 'drawer') {
await this.drawer.getByText('Save').click()
@@ -19,7 +40,7 @@ export class LexicalHelpers {
async slashCommand(
// prettier-ignore
command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline'
command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline'
| 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'unordered' | 'upload',
) {
await this.page.keyboard.press(`/`)

View File

@@ -85,6 +85,7 @@ export interface Config {
collections: {
'lexical-fully-featured': LexicalFullyFeatured;
'lexical-link-feature': LexicalLinkFeature;
'lexical-jsx-converter': LexicalJsxConverter;
'lexical-fields': LexicalField;
'lexical-migrate-fields': LexicalMigrateField;
'lexical-localized-fields': LexicalLocalizedField;
@@ -105,6 +106,7 @@ export interface Config {
collectionsSelect: {
'lexical-fully-featured': LexicalFullyFeaturedSelect<false> | LexicalFullyFeaturedSelect<true>;
'lexical-link-feature': LexicalLinkFeatureSelect<false> | LexicalLinkFeatureSelect<true>;
'lexical-jsx-converter': LexicalJsxConverterSelect<false> | LexicalJsxConverterSelect<true>;
'lexical-fields': LexicalFieldsSelect<false> | LexicalFieldsSelect<true>;
'lexical-migrate-fields': LexicalMigrateFieldsSelect<false> | LexicalMigrateFieldsSelect<true>;
'lexical-localized-fields': LexicalLocalizedFieldsSelect<false> | LexicalLocalizedFieldsSelect<true>;
@@ -205,6 +207,30 @@ export interface LexicalLinkFeature {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-jsx-converter".
*/
export interface LexicalJsxConverter {
id: string;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields".
@@ -817,6 +843,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -834,6 +867,10 @@ export interface PayloadLockedDocument {
relationTo: 'lexical-link-feature';
value: string | LexicalLinkFeature;
} | null)
| ({
relationTo: 'lexical-jsx-converter';
value: string | LexicalJsxConverter;
} | null)
| ({
relationTo: 'lexical-fields';
value: string | LexicalField;
@@ -942,6 +979,15 @@ export interface LexicalLinkFeatureSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-jsx-converter_select".
*/
export interface LexicalJsxConverterSelect<T extends boolean = true> {
richText?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields_select".
@@ -1254,6 +1300,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -2,6 +2,7 @@ export const usersSlug = 'users'
export const lexicalFullyFeaturedSlug = 'lexical-fully-featured'
export const lexicalFieldsSlug = 'lexical-fields'
export const lexicalJSXConverterSlug = 'lexical-jsx-converter'
export const lexicalLinkFeatureSlug = 'lexical-link-feature'
export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'

View File

@@ -26,12 +26,13 @@ export const getDoc = async <T>(args: {
depth,
where,
draft,
trash: true, // Include trashed documents
})
if (docs[0]) {
return docs[0] as T
}
} catch (err) {
} catch (err: Error | any) {
throw new Error(`Error getting doc: ${err.message}`)
}

View File

@@ -15,6 +15,7 @@ export const Posts: CollectionConfig = {
update: () => true,
delete: () => true,
},
trash: true,
admin: {
useAsTitle: 'title',
defaultColumns: ['id', 'title', 'slug', 'createdAt'],

View File

@@ -11,17 +11,18 @@ import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import { devUser } from '../credentials.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js'
import { deletePreferences } from '../helpers/e2e/preferences.js'
import { waitForAutoSaveToRunAndComplete } from '../helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { waitForAutoSaveToRunAndComplete } from '../helpers/waitForAutoSaveToRunAndComplete.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
ensureDeviceIsCentered,
ensureDeviceIsLeftAligned,
goToCollectionLivePreview,
goToGlobalLivePreview,
goToTrashedLivePreview,
selectLivePreviewBreakpoint,
selectLivePreviewZoom,
toggleLivePreview,
@@ -31,6 +32,7 @@ import {
desktopBreakpoint,
mobileBreakpoint,
pagesSlug,
postsSlug,
renderedPageTitleID,
ssrAutosavePagesSlug,
ssrPagesSlug,
@@ -46,6 +48,7 @@ describe('Live Preview', () => {
let serverURL: string
let pagesURLUtil: AdminUrlUtil
let postsURLUtil: AdminUrlUtil
let ssrPagesURLUtil: AdminUrlUtil
let ssrAutosavePostsURLUtil: AdminUrlUtil
let payload: PayloadTestSDK<Config>
@@ -56,12 +59,16 @@ describe('Live Preview', () => {
;({ serverURL, payload } = await initPayloadE2ENoConfig<Config>({ dirname }))
pagesURLUtil = new AdminUrlUtil(serverURL, pagesSlug)
postsURLUtil = new AdminUrlUtil(serverURL, postsSlug)
ssrPagesURLUtil = new AdminUrlUtil(serverURL, ssrPagesSlug)
ssrAutosavePostsURLUtil = new AdminUrlUtil(serverURL, ssrAutosavePagesSlug)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
user = await payload
.login({
collection: 'users',
@@ -71,10 +78,6 @@ describe('Live Preview', () => {
},
})
?.then((res) => res.user) // TODO: this type is wrong
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
@@ -361,6 +364,29 @@ describe('Live Preview', () => {
await saveDocAndAssert(page)
})
test('trash — has live-preview toggle', async () => {
await navigateToTrashedDoc(page, postsURLUtil)
const livePreviewToggler = page.locator('button#live-preview-toggler')
await expect(() => expect(livePreviewToggler).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('trash - renders iframe', async () => {
await goToTrashedLivePreview(page, postsURLUtil)
const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible()
})
test('trash - fields should stay read-only', async () => {
await goToTrashedLivePreview(page, postsURLUtil)
const titleField = page.locator('#field-title')
await expect(titleField).toBeDisabled()
})
test('global — renders toggler', async () => {
const global = new AdminUrlUtil(serverURL, 'header')
await page.goto(global.global('header'))

View File

@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import { exactText } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
import { navigateToDoc, navigateToTrashedDoc } from '../helpers/e2e/navigateToDoc.js'
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
export const toggleLivePreview = async (
@@ -44,6 +44,14 @@ export const goToCollectionLivePreview = async (
})
}
export const goToTrashedLivePreview = async (page: Page, urlUtil: AdminUrlUtil): Promise<void> => {
await navigateToTrashedDoc(page, urlUtil)
await toggleLivePreview(page, {
targetState: 'on',
})
}
export const goToGlobalLivePreview = async (
page: Page,
slug: string,

View File

@@ -545,6 +545,7 @@ export interface Post {
};
updatedAt: string;
createdAt: string;
deletedAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -1188,6 +1189,7 @@ export interface PostsSelect<T extends boolean = true> {
};
updatedAt?: T;
createdAt?: T;
deletedAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -26,12 +26,13 @@ export const getDoc = async <T>(args: {
depth,
where,
draft,
trash: true, // Include trashed documents
})
if (docs[0]) {
return docs[0] as T
}
} catch (err) {
} catch (err: Error | any) {
throw new Error(`Error getting doc: ${err.message}`)
}

View File

@@ -15,6 +15,7 @@ import { post3 } from './post-3.js'
import { postsPage } from './posts-page.js'
import { tenant1 } from './tenant-1.js'
import { tenant2 } from './tenant-2.js'
import { trashedPost } from './trashed-post.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -78,6 +79,15 @@ export const seed: Config['onInit'] = async (payload) => {
),
})
await payload.create({
collection: postsSlug,
data: JSON.parse(
JSON.stringify(trashedPost)
.replace(/"\{\{IMAGE\}\}"/g, mediaID)
.replace(/"\{\{TENANT_1_ID\}\}"/g, tenantID),
),
})
const postsPageDoc = await payload.create({
collection: pagesSlug,
data: JSON.parse(JSON.stringify(postsPage).replace(/"\{\{IMAGE\}\}"/g, mediaID)),

Some files were not shown because too many files have changed in this diff Show More