chore: merge conflicts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
60
test/admin/collections/CustomListDrawer/Component.tsx
Normal file
60
test/admin/collections/CustomListDrawer/Component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
test/admin/collections/CustomListDrawer/index.ts
Normal file
16
test/admin/collections/CustomListDrawer/index.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -293,6 +293,13 @@ export const Posts: CollectionConfig = {
|
||||
name: 'file',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'noReadAccessField',
|
||||
type: 'text',
|
||||
access: {
|
||||
read: () => false,
|
||||
},
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
plural: slugPluralLabel,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
34
test/auth/seed.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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/)
|
||||
|
||||
19
test/database/config.postgreslogs.ts
Normal file
19
test/database/config.postgreslogs.ts
Normal 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,
|
||||
})
|
||||
@@ -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
942
test/database/getConfig.ts
Normal 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'),
|
||||
},
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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".
|
||||
|
||||
174
test/database/postgres-logs.int.spec.ts
Normal file
174
test/database/postgres-logs.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -74,6 +74,8 @@ export const testEslintConfig = [
|
||||
'expectNoResultsAndCreateFolderButton',
|
||||
'createFolder',
|
||||
'createFolderFromDoc',
|
||||
'assertURLParams',
|
||||
'uploadImage',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
test/folders/tsconfig.json
Normal file
3
test/folders/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
2
test/group-by/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
16
test/group-by/collections/Categories/index.ts
Normal file
16
test/group-by/collections/Categories/index.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
33
test/group-by/collections/Media/index.ts
Normal file
33
test/group-by/collections/Media/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
49
test/group-by/collections/Posts/index.ts
Normal file
49
test/group-by/collections/Posts/index.ts
Normal 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
30
test/group-by/config.ts
Normal 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
667
test/group-by/e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
430
test/group-by/payload-types.ts
Normal file
430
test/group-by/payload-types.ts
Normal 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
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
79
test/group-by/seed.ts
Normal 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'),
|
||||
})
|
||||
}
|
||||
13
test/group-by/tsconfig.eslint.json
Normal file
13
test/group-by/tsconfig.eslint.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
test/group-by/tsconfig.json
Normal file
3
test/group-by/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
||||
9
test/group-by/types.d.ts
vendored
Normal file
9
test/group-by/types.d.ts
vendored
Normal 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
|
||||
// ...
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]!)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
test/helpers/e2e/goToNextPage.ts
Normal file
49
test/helpers/e2e/goToNextPage.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
90
test/helpers/e2e/groupBy.ts
Normal file
90
test/helpers/e2e/groupBy.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
36
test/helpers/e2e/sortColumn.ts
Normal file
36
test/helpers/e2e/sortColumn.ts
Normal 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}`))
|
||||
}
|
||||
}
|
||||
@@ -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_]',
|
||||
|
||||
@@ -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 () => {
|
||||
41
test/helpers/folders/applyBrowseByFolderTypeFilter.ts
Normal file
41
test/helpers/folders/applyBrowseByFolderTypeFilter.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
26
test/helpers/folders/createFolderDoc.ts
Normal file
26
test/helpers/folders/createFolderDoc.ts
Normal 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')
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
117
test/lexical/collections/LexicalJSXConverter/e2e.spec.ts
Normal file
117
test/lexical/collections/LexicalJSXConverter/e2e.spec.ts
Normal 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"')
|
||||
}
|
||||
18
test/lexical/collections/LexicalJSXConverter/index.ts
Normal file
18
test/lexical/collections/LexicalJSXConverter/index.ts
Normal 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()],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, '../../')
|
||||
|
||||
@@ -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(`/`)
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export const Posts: CollectionConfig = {
|
||||
update: () => true,
|
||||
delete: () => true,
|
||||
},
|
||||
trash: true,
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['id', 'title', 'slug', 'createdAt'],
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user