Merge branch 'main' into feat/folders

This commit is contained in:
Jarrod Flesch
2025-03-24 10:05:53 -04:00
1185 changed files with 44794 additions and 28044 deletions

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'

View File

@@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
import { hooksSlug } from '../../shared.js'
export const Hooks: CollectionConfig = {
slug: hooksSlug,
access: {
update: () => true,
},
fields: [
{
name: 'cannotMutateRequired',
type: 'text',
access: {
update: () => false,
},
required: true,
},
{
name: 'cannotMutateNotRequired',
type: 'text',
access: {
update: () => false,
},
hooks: {
beforeChange: [
({ value }) => {
if (!value) {
return 'no value found'
}
return value
},
],
},
},
{
name: 'canMutate',
type: 'text',
access: {
update: () => true,
},
},
],
}

View File

@@ -10,6 +10,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { textToLexicalJSON } from '../fields/collections/LexicalLocalized/textToLexicalJSON.js'
import { Disabled } from './collections/Disabled/index.js'
import { Hooks } from './collections/hooks/index.js'
import { Regression1 } from './collections/Regression-1/index.js'
import { Regression2 } from './collections/Regression-2/index.js'
import { RichText } from './collections/RichText/index.js'
@@ -567,6 +568,7 @@ export default buildConfigWithDefaults(
RichText,
Regression1,
Regression2,
Hooks,
],
globals: [
{

View File

@@ -8,7 +8,7 @@ import type {
} from 'payload'
import path from 'path'
import { Forbidden } from 'payload'
import { Forbidden, ValidationError } from 'payload'
import { fileURLToPath } from 'url'
import type { FullyRestricted, Post } from './payload-types.js'
@@ -21,6 +21,7 @@ import {
hiddenAccessCountSlug,
hiddenAccessSlug,
hiddenFieldsSlug,
hooksSlug,
relyOnRequestHeadersSlug,
restrictedVersionsSlug,
secondArrayText,
@@ -58,115 +59,181 @@ describe('Access Control', () => {
}
})
it('should not affect hidden fields when patching data', async () => {
const doc = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
describe('Fields', () => {
it('should not affect hidden fields when patching data', async () => {
const doc = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
},
})
await payload.update({
id: doc.id,
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
})
const updatedDoc = await payload.findByID({
id: doc.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
})
expect(updatedDoc.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedDoc.partiallyHiddenArray[0].value).toStrictEqual('private_value')
})
await payload.update({
id: doc.id,
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
})
const updatedDoc = await payload.findByID({
id: doc.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
})
expect(updatedDoc.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedDoc.partiallyHiddenArray[0].value).toStrictEqual('private_value')
})
it('should not affect hidden fields when patching data - update many', async () => {
const docsMany = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
it('should not affect hidden fields when patching data - update many', async () => {
const docsMany = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
},
})
await payload.update({
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
where: {
id: { equals: docsMany.id },
},
})
const updatedMany = await payload.findByID({
id: docsMany.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
})
expect(updatedMany.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedMany.partiallyHiddenArray[0].value).toStrictEqual('private_value')
})
await payload.update({
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
where: {
id: { equals: docsMany.id },
},
it('should be able to restrict access based upon siblingData', async () => {
const { id } = await payload.create({
collection: siblingDataSlug,
data: {
array: [
{
allowPublicReadability: true,
text: firstArrayText,
},
{
allowPublicReadability: false,
text: secondArrayText,
},
],
},
})
const doc = await payload.findByID({
id,
collection: siblingDataSlug,
overrideAccess: false,
})
expect(doc.array?.[0].text).toBe(firstArrayText)
// Should respect PublicReadabilityAccess function and not be sent
expect(doc.array?.[1].text).toBeUndefined()
// Retrieve with default of overriding access
const docOverride = await payload.findByID({
id,
collection: siblingDataSlug,
})
expect(docOverride.array?.[0].text).toBe(firstArrayText)
expect(docOverride.array?.[1].text).toBe(secondArrayText)
})
const updatedMany = await payload.findByID({
id: docsMany.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
it('should use fallback value when trying to update a field without permission', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'original',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
cannotMutateRequired: 'new',
canMutate: 'canMutate',
},
})
expect(updatedDoc.cannotMutateRequired).toBe('original')
})
expect(updatedMany.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedMany.partiallyHiddenArray[0].value).toStrictEqual('private_value')
it('should use fallback value when required data is missing', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'original',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
canMutate: 'canMutate',
},
})
// should fallback to original data and not throw validation error
expect(updatedDoc.cannotMutateRequired).toBe('original')
})
it('should pass fallback value through to beforeChange hook when access returns false', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'cannotMutateRequired',
cannotMutateNotRequired: 'cannotMutateNotRequired',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
cannotMutateNotRequired: 'updated',
},
})
// should fallback to original data and not throw validation error
expect(updatedDoc.cannotMutateRequired).toBe('cannotMutateRequired')
expect(updatedDoc.cannotMutateNotRequired).toBe('cannotMutateNotRequired')
})
})
it('should be able to restrict access based upon siblingData', async () => {
const { id } = await payload.create({
collection: siblingDataSlug,
data: {
array: [
{
allowPublicReadability: true,
text: firstArrayText,
},
{
allowPublicReadability: false,
text: secondArrayText,
},
],
},
})
const doc = await payload.findByID({
id,
collection: siblingDataSlug,
overrideAccess: false,
})
expect(doc.array?.[0].text).toBe(firstArrayText)
// Should respect PublicReadabilityAccess function and not be sent
expect(doc.array?.[1].text).toBeUndefined()
// Retrieve with default of overriding access
const docOverride = await payload.findByID({
id,
collection: siblingDataSlug,
})
expect(docOverride.array?.[0].text).toBe(firstArrayText)
expect(docOverride.array?.[1].text).toBe(secondArrayText)
})
describe('Collections', () => {
describe('restricted collection', () => {
it('field without read access should not show', async () => {

View File

@@ -89,6 +89,7 @@ export interface Config {
'rich-text': RichText;
regression1: Regression1;
regression2: Regression2;
hooks: Hook;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -117,6 +118,7 @@ export interface Config {
'rich-text': RichTextSelect<false> | RichTextSelect<true>;
regression1: Regression1Select<false> | Regression1Select<true>;
regression2: Regression2Select<false> | Regression2Select<true>;
hooks: HooksSelect<false> | HooksSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -680,6 +682,18 @@ export interface Regression2 {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks".
*/
export interface Hook {
id: string;
cannotMutateRequired: string;
cannotMutateNotRequired?: string | null;
canMutate?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -774,6 +788,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'regression2';
value: string | Regression2;
} | null)
| ({
relationTo: 'hooks';
value: string | Hook;
} | null);
globalSlug?: string | null;
user:
@@ -1168,6 +1186,17 @@ export interface Regression2Select<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks_select".
*/
export interface HooksSelect<T extends boolean = true> {
cannotMutateRequired?: T;
cannotMutateNotRequired?: T;
canMutate?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -5,6 +5,7 @@ export const slug = 'posts'
export const unrestrictedSlug = 'unrestricted'
export const readOnlySlug = 'read-only-collection'
export const readOnlyGlobalSlug = 'read-only-global'
export const hooksSlug = 'hooks'
export const userRestrictedCollectionSlug = 'user-restricted-collection'
export const fullyRestrictedSlug = 'fully-restricted'

2
test/admin-bar/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })
export default NotFound

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })
export default Page

View File

@@ -0,0 +1 @@
export const importMap = {}

View File

@@ -0,0 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes/index.js'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,7 @@
#custom-css {
font-family: monospace;
}
#custom-css::after {
content: 'custom-css';
}

View File

@@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { ServerFunctionClient } from 'payload'
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

View File

@@ -0,0 +1,22 @@
:root {
--font-body: system-ui;
}
* {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-body);
margin: 0;
}

View File

@@ -0,0 +1,29 @@
import type { Metadata } from 'next'
import { PayloadAdminBar } from '@payloadcms/admin-bar'
import React from 'react'
import './app.scss'
export const metadata: Metadata = {
description: 'Payload Admin Bar',
title: 'Payload Admin Bar',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<PayloadAdminBar
adminPath="/admin"
apiPath="/api"
cmsURL="http://localhost:3000"
collection="pages"
devMode
id="1"
/>
{children}
</body>
</html>
)
}

View File

@@ -0,0 +1,12 @@
import { Fragment } from 'react'
const PageTemplate = () => {
return (
<Fragment>
<br />
<h1>Payload Admin Bar</h1>
</Fragment>
)
}
export default PageTemplate

View File

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

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
],
versions: {
drafts: true,
},
}

39
test/admin-bar/config.ts Normal file
View File

@@ -0,0 +1,39 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { MediaCollection } from './collections/Media/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
// ...extend config here
collections: [PostsCollection, MediaCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'example post',
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,37 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
test.describe('Admin Bar', () => {
let page: Page
let url: AdminUrlUtil
let serverURL: string
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
const { payload, serverURL: incomingServerURL } = await initPayloadE2ENoConfig({ dirname })
url = new AdminUrlUtil(incomingServerURL, 'posts')
serverURL = incomingServerURL
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL: incomingServerURL })
})
test('should render admin bar', async () => {
await page.goto(`${serverURL}/admin-bar`)
await expect(page.locator('#payload-admin-bar')).toBeVisible()
})
})

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

5
test/admin-bar/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,15 @@
import nextConfig from '../../next.config.mjs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
export default {
...nextConfig,
env: {
PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname),
},
}

View File

@@ -0,0 +1,370 @@
/* 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/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
posts: Post;
media: Media;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<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;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* 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;
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: '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;
updatedAt?: T;
createdAt?: T;
_status?: 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;
}
/**
* 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 {}
}

View File

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

View File

@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

9
test/admin-bar/types.d.ts vendored Normal file
View File

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

View File

@@ -80,6 +80,15 @@ test.describe('Admin Panel (Root)', () => {
await expect(firstRow).toBeVisible()
})
test('collection - should hide Copy To Locale button when localization is false', async () => {
await page.goto(url.create)
const textField = page.locator('#field-text')
await textField.fill('test')
await saveDocAndAssert(page)
await page.locator('.doc-controls__popup >> .popup-button').click()
await expect(page.locator('#copy-locale-data__button')).toBeHidden()
})
test('global — navigates to edit view', async () => {
await page.goto(url.global('menu'))
const pageURL = page.url()

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
import { disableCopyToLocale } from '../slugs.js'
export const DisableCopyToLocale: CollectionConfig = {
slug: disableCopyToLocale,
admin: {
disableCopyToLocale: true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -137,64 +137,6 @@ export const Posts: CollectionConfig = {
},
],
},
{
name: 'arrayOfFields',
type: 'array',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'optional',
type: 'text',
},
{
name: 'innerArrayOfFields',
type: 'array',
fields: [
{
name: 'innerOptional',
type: 'text',
},
],
},
],
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'testing',
},
{
name: 'title',
type: 'text',
},
],
},
{
name: 'someBlock',
type: 'blocks',
blocks: [
{
slug: 'textBlock',
fields: [
{
name: 'textFieldForBlock',
type: 'text',
},
],
},
],
},
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'testing',
},
{
name: 'relationship',
type: 'relationship',
@@ -263,25 +205,6 @@ export const Posts: CollectionConfig = {
position: 'sidebar',
},
},
{
name: 'validateUsingEvent',
type: 'text',
admin: {
description:
'This field should only validate on submit. Try typing "Not allowed" and submitting the form.',
},
validate: (value, { event }) => {
if (event === 'onChange') {
return true
}
if (value === 'Not allowed') {
return 'This field has been validated only on submit'
}
return true
},
},
],
labels: {
plural: slugPluralLabel,

View File

@@ -0,0 +1,14 @@
import type { CollectionConfig } from 'payload'
import { uploadTwoCollectionSlug } from '../slugs.js'
export const UploadTwoCollection: CollectionConfig = {
slug: uploadTwoCollectionSlug,
fields: [
{
name: 'title',
type: 'text',
},
],
upload: true,
}

View File

@@ -1,6 +1,6 @@
'use client'
import React, { createContext, useContext, useState } from 'react'
import React, { createContext, use, useState } from 'react'
type CustomContext = {
getCustom
@@ -18,13 +18,13 @@ export const CustomProvider: React.FC<{ children: React.ReactNode }> = ({ childr
}
return (
<Context.Provider value={value}>
<Context value={value}>
<div className="custom-provider" style={{ display: 'none' }}>
This is a custom provider.
</div>
{children}
</Context.Provider>
</Context>
)
}
export const useCustom = () => useContext(Context)
export const useCustom = () => use(Context)

View File

@@ -7,6 +7,7 @@ import { BaseListFilter } from './collections/BaseListFilter.js'
import { CustomFields } from './collections/CustomFields/index.js'
import { CustomViews1 } from './collections/CustomViews1.js'
import { CustomViews2 } from './collections/CustomViews2.js'
import { DisableCopyToLocale } from './collections/DisableCopyToLocale.js'
import { DisableDuplicate } from './collections/DisableDuplicate.js'
import { Geo } from './collections/Geo.js'
import { CollectionGroup1A } from './collections/Group1A.js'
@@ -19,6 +20,7 @@ import { CollectionNoApiView } from './collections/NoApiView.js'
import { CollectionNotInView } from './collections/NotInView.js'
import { Posts } from './collections/Posts.js'
import { UploadCollection } from './collections/Upload.js'
import { UploadTwoCollection } from './collections/UploadTwo.js'
import { Users } from './collections/Users.js'
import { with300Documents } from './collections/With300Documents.js'
import { CustomGlobalViews1 } from './globals/CustomViews1.js'
@@ -40,6 +42,7 @@ import {
protectedCustomNestedViewPath,
publicCustomViewPath,
} from './shared.js'
export default buildConfigWithDefaults({
admin: {
importMap: {
@@ -141,6 +144,7 @@ export default buildConfigWithDefaults({
},
collections: [
UploadCollection,
UploadTwoCollection,
Posts,
Users,
CollectionHidden,
@@ -155,6 +159,7 @@ export default buildConfigWithDefaults({
CollectionGroup2B,
Geo,
DisableDuplicate,
DisableCopyToLocale,
BaseListFilter,
with300Documents,
ListDrawer,

View File

@@ -174,36 +174,12 @@ describe('Document View', () => {
})
})
describe('form state', () => {
test('collection — should re-enable fields after save', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('global — should re-enable fields after save', async () => {
await page.goto(globalURL.global(globalSlug))
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('should thread proper event argument to validation functions', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await page.locator('#field-validateUsingEvent').fill('Not allowed')
await saveDocAndAssert(page, '#action-save', 'error')
})
})
describe('document titles', () => {
test('collection — should render fallback titles when creating new', async () => {
await page.goto(postsUrl.create)
await checkPageTitle(page, '[Untitled]')
await checkBreadcrumb(page, 'Create New')
await saveDocAndAssert(page)
expect(true).toBe(true)
})
test('collection — should render `useAsTitle` field', async () => {
@@ -213,7 +189,6 @@ describe('Document View', () => {
await wait(500)
await checkPageTitle(page, title)
await checkBreadcrumb(page, title)
expect(true).toBe(true)
})
test('collection — should render `id` as `useAsTitle` fallback', async () => {

View File

@@ -6,7 +6,6 @@ import type { Config, Geo, Post } from '../../payload-types.js'
import {
ensureCompilationIsDone,
exactText,
getRoutes,
initPageConsoleErrorCatch,
saveDocAndAssert,
@@ -33,12 +32,14 @@ import {
} from '../../shared.js'
import {
customViews2CollectionSlug,
disableCopyToLocale as disableCopyToLocaleSlug,
disableDuplicateSlug,
geoCollectionSlug,
globalSlug,
notInViewCollectionSlug,
postsCollectionSlug,
settingsGlobalSlug,
uploadTwoCollectionSlug,
} from '../../slugs.js'
const { beforeAll, beforeEach, describe } = test
@@ -70,9 +71,11 @@ describe('General', () => {
let notInViewUrl: AdminUrlUtil
let globalURL: AdminUrlUtil
let customViewsURL: AdminUrlUtil
let disableCopyToLocale: AdminUrlUtil
let disableDuplicateURL: AdminUrlUtil
let serverURL: string
let adminRoutes: ReturnType<typeof getRoutes>
let uploadsTwo: AdminUrlUtil
beforeAll(async ({ browser }, testInfo) => {
const prebuild = false // Boolean(process.env.CI)
@@ -89,7 +92,9 @@ describe('General', () => {
notInViewUrl = new AdminUrlUtil(serverURL, notInViewCollectionSlug)
globalURL = new AdminUrlUtil(serverURL, globalSlug)
customViewsURL = new AdminUrlUtil(serverURL, customViews2CollectionSlug)
disableCopyToLocale = new AdminUrlUtil(serverURL, disableCopyToLocaleSlug)
disableDuplicateURL = new AdminUrlUtil(serverURL, disableDuplicateSlug)
uploadsTwo = new AdminUrlUtil(serverURL, uploadTwoCollectionSlug)
context = await browser.newContext()
page = await context.newPage()
@@ -152,6 +157,16 @@ describe('General', () => {
})
})
describe('robots', () => {
test('should apply default robots meta tag', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
/noindex, nofollow/,
)
})
})
describe('favicons', () => {
test('should render custom favicons', async () => {
await page.goto(postsUrl.admin)
@@ -409,12 +424,42 @@ describe('General', () => {
test('should disable active nav item', async () => {
await page.goto(postsUrl.list)
await openNav(page)
const activeItem = page.locator('.nav .nav__link.active')
const activeItem = page.locator('.nav .nav__link:has(.nav__link-indicator)')
await expect(activeItem).toBeVisible()
const tagName = await activeItem.evaluate((el) => el.tagName.toLowerCase())
expect(tagName).toBe('div')
})
test('should keep active nav item enabled in the edit view', async () => {
await page.goto(postsUrl.create)
await openNav(page)
const activeItem = page.locator('.nav .nav__link:has(.nav__link-indicator)')
await expect(activeItem).toBeVisible()
const tagName = await activeItem.evaluate((el) => el.tagName.toLowerCase())
expect(tagName).toBe('a')
})
test('should only have one nav item active at a time', async () => {
await page.goto(uploadsTwo.list)
await openNav(page)
// Locate "uploads" and "uploads-two" nav items
const uploadsNavItem = page.locator('.nav-group__content #nav-uploads')
const uploadsTwoNavItem = page.locator('.nav-group__content #nav-uploads-two')
// Ensure both exist before continuing
await expect(uploadsNavItem).toBeVisible()
await expect(uploadsTwoNavItem).toBeVisible()
// Locate all nav items containing the nav__link-indicator
const activeNavItems = page.locator(
'.nav-group__content .nav__link:has(.nav__link-indicator), .nav-group__content div.nav__link:has(.nav__link-indicator)',
)
// Expect exactly one nav item to have the indicator
await expect(activeNavItems).toHaveCount(1)
})
test('breadcrumbs — should navigate from list to dashboard', async () => {
await page.goto(postsUrl.list)
await page.locator(`.step-nav a[href="${adminRoutes.routes.admin}"]`).click()
@@ -482,6 +527,14 @@ describe('General', () => {
await page.goto(notInViewUrl.global('not-in-view-global'))
await expect(page.locator('.render-title')).toContainText('Not In View Global')
})
test('should hide Copy To Locale button when disableCopyToLocale: true', async () => {
await page.goto(disableCopyToLocale.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await page.locator('.doc-controls__popup >> .popup-button').click()
await expect(page.locator('#copy-locale-data__button')).toBeHidden()
})
})
describe('custom CSS', () => {
@@ -727,205 +780,6 @@ describe('General', () => {
expect(page.url()).toContain(postsUrl.list)
})
test('should bulk delete all on page', async () => {
await deleteAllPosts()
await Promise.all([createPost(), createPost(), createPost()])
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.delete-documents__toggle').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 3 Posts successfully.',
)
// Poll until router has refreshed
await expect.poll(() => page.locator('.collection-list__no-results').isVisible()).toBeTruthy()
})
test('should bulk delete with filters and across pages', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
await page.locator('.delete-documents__toggle').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 6 Posts successfully.',
)
// Poll until router has refreshed
await expect.poll(() => page.locator('.table table > tbody > tr').count()).toBe(0)
})
test('should bulk update', async () => {
// First, delete all posts created by the seed
await deleteAllPosts()
const post1Title = 'Post'
const updatedPostTitle = `${post1Title} (Updated)`
await Promise.all([createPost({ title: post1Title }), createPost(), createPost()])
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
await titleInput.fill(updatedPostTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 3 Posts successfully.',
)
await expect(page.locator('.row-1 .cell-title')).toContainText(updatedPostTitle)
await expect(page.locator('.row-2 .cell-title')).toContainText(updatedPostTitle)
await expect(page.locator('.row-3 .cell-title')).toContainText(updatedPostTitle)
})
test('should not override un-edited values in bulk edit if it has a defaultValue', async () => {
await deleteAllPosts()
const post1Title = 'Post'
const postData = {
title: 'Post',
arrayOfFields: [
{
optional: 'some optional array field',
innerArrayOfFields: [
{
innerOptional: 'some inner optional array field',
},
],
},
],
group: {
defaultValueField: 'not the group default value',
title: 'some title',
},
someBlock: [
{
textFieldForBlock: 'some text for block text',
blockType: 'textBlock',
},
],
defaultValueField: 'not the default value',
}
const updatedPostTitle = `${post1Title} (Updated)`
await createPost(postData)
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
await titleInput.fill(updatedPostTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.',
)
const updatedPost = await payload.find({
collection: 'posts',
limit: 1,
})
expect(updatedPost.docs[0].title).toBe(updatedPostTitle)
expect(updatedPost.docs[0].arrayOfFields.length).toBe(1)
expect(updatedPost.docs[0].arrayOfFields[0].optional).toBe('some optional array field')
expect(updatedPost.docs[0].arrayOfFields[0].innerArrayOfFields.length).toBe(1)
expect(updatedPost.docs[0].someBlock[0].textFieldForBlock).toBe('some text for block text')
expect(updatedPost.docs[0].defaultValueField).toBe('not the default value')
})
test('should not show "select all across pages" button if already selected all', async () => {
await deleteAllPosts()
await createPost({ title: `Post 1` })
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await expect(page.locator('button#select-all-across-pages')).toBeHidden()
})
test('should bulk update with filters and across pages', async () => {
// First, delete all posts created by the seed
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
const updatedTitle = `Post (Updated)`
await titleInput.fill(updatedTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 6 Posts successfully.',
)
// Poll until router has refreshed
await expect.poll(() => page.locator('.table table > tbody > tr').count()).toBe(5)
await expect(page.locator('.row-1 .cell-title')).toContainText(updatedTitle)
})
test('should update selection state after deselecting item following select all', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
// Deselect the first row
await page.locator('.row-1 input').click()
// eslint-disable-next-line jest-dom/prefer-checked
await expect(page.locator('input#select-all')).not.toHaveAttribute('checked', '')
})
test('should save globals', async () => {
await page.goto(postsUrl.global(globalSlug))
@@ -954,7 +808,7 @@ describe('General', () => {
const newTitle = 'new title'
await page.locator('#field-title').fill(newTitle)
await page.locator('header.app-header a[href="/admin/collections/posts"]').click()
await page.locator(`header.app-header a[href="/admin/collections/posts"]`).click()
// Locate the modal container
const modalContainer = page.locator('.payload__modal-container')
@@ -970,6 +824,36 @@ describe('General', () => {
})
})
test('should not open leave-without-saving modal if opening a new tab', async () => {
const title = 'title'
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await expect(page.locator('#field-title')).toHaveValue(title)
const newTitle = 'new title'
await page.locator('#field-title').fill(newTitle)
// Open link in a new tab by holding down the Meta or Control key
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
page
.locator(`header.app-header a[href="/admin/collections/posts"]`)
.click({ modifiers: ['ControlOrMeta'] }),
])
// Wait for navigation to complete in the new tab and ensure correct URL
await expect(newPage.locator('.list-header')).toBeVisible()
// using contain here, because after load the lists view will add query params like "?limit=10"
expect(newPage.url()).toContain(postsUrl.list)
// Locate the modal container and ensure it is not visible
const modalContainer = page.locator('.payload__modal-container')
await expect(modalContainer).toBeHidden()
// Ensure the original page is the correct URL
expect(page.url()).toBe(postsUrl.create)
})
describe('preferences', () => {
test('should successfully reset prefs after clicking reset button', async () => {
await page.goto(`${serverURL}/admin/account`)
@@ -995,10 +879,6 @@ describe('General', () => {
})
})
async function deleteAllPosts() {
await payload.delete({ collection: postsCollectionSlug, where: { id: { exists: true } } })
}
async function createPost(overrides?: Partial<Post>): Promise<Post> {
return payload.create({
collection: postsCollectionSlug,

View File

@@ -1,17 +1,17 @@
import type { Page } from '@playwright/test'
import type { User as PayloadUser } from 'payload'
import { expect, test } from '@playwright/test'
import { mapAsync } from 'payload'
import * as qs from 'qs-esm'
import type { Config, Geo, Post } from '../../payload-types.js'
import type { Config, Geo, Post, User } from '../../payload-types.js'
import {
ensureCompilationIsDone,
exactText,
getRoutes,
initPageConsoleErrorCatch,
openDocDrawer,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
@@ -31,11 +31,15 @@ const description = 'Description'
let payload: PayloadTestSDK<Config>
import { devUser } from 'credentials.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
import { deletePreferences } from 'helpers/e2e/preferences.js'
import { toggleColumn, waitForColumnInURL } from 'helpers/e2e/toggleColumn.js'
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import { closeListDrawer } from 'helpers/e2e/toggleListDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -58,6 +62,7 @@ describe('List View', () => {
let customViewsUrl: AdminUrlUtil
let with300DocumentsUrl: AdminUrlUtil
let withListViewUrl: AdminUrlUtil
let user: any
let serverURL: string
let adminRoutes: ReturnType<typeof getRoutes>
@@ -87,6 +92,14 @@ describe('List View', () => {
await ensureCompilationIsDone({ customAdminRoutes, page, serverURL })
adminRoutes = getRoutes({ customAdminRoutes })
user = await payload.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
})
beforeEach(async () => {
@@ -831,49 +844,91 @@ describe('List View', () => {
).toBeVisible()
})
test('should toggle columns', async () => {
const columnCountLocator = 'table > thead > tr > th'
await createPost()
test('should toggle columns and effect table', async () => {
const tableHeaders = 'table > thead > tr > th'
await openListColumns(page, {})
const numberOfColumns = await page.locator(columnCountLocator).count()
const numberOfColumns = await page.locator(tableHeaders).count()
await expect(page.locator('.column-selector')).toBeVisible()
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID')
await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' })
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
await page.locator('#heading-id').waitFor({ state: 'detached' })
await page.locator('.cell-id').first().waitFor({ state: 'detached' })
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1)
await expect(page.locator(tableHeaders)).toHaveCount(numberOfColumns - 1)
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number')
await toggleColumn(page, { columnLabel: 'ID', targetState: 'on' })
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'on' })
await expect(page.locator('.cell-id').first()).toBeVisible()
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns)
await expect(page.locator(tableHeaders)).toHaveCount(numberOfColumns)
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID')
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
})
test('should toggle columns and save to preferences', async () => {
const tableHeaders = 'table > thead > tr > th'
const numberOfColumns = await page.locator(tableHeaders).count()
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
await page.reload()
await expect(page.locator('#heading-id')).toBeHidden()
await expect(page.locator('.cell-id').first()).toBeHidden()
await expect(page.locator(tableHeaders)).toHaveCount(numberOfColumns - 1)
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number')
})
test('should inject preferred columns into URL search params on load', async () => {
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
// reload to ensure the columns were stored and loaded from preferences
await page.reload()
// The `columns` search params _should_ contain "-id"
await waitForColumnInURL({ page, columnName: 'id', state: 'off' })
expect(true).toBe(true)
})
test('should not inject default columns into URL search params on load', async () => {
// clear preferences first, ensure that they don't automatically populate in the URL on load
await deletePreferences({
payload,
key: `${postsCollectionSlug}.list`,
user,
})
// wait for the URL search params to populate
await page.waitForURL(/posts\?/)
// The `columns` search params should _not_ appear in the URL
expect(page.url()).not.toMatch(/columns=/)
})
test('should drag to reorder columns and save to preferences', async () => {
await createPost()
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
// reload to ensure the preferred order was stored in the database
// reload to ensure the columns were stored and loaded from preferences
await page.reload()
await expect(
page.locator('.list-controls .column-selector .column-selector__column').first(),
).toHaveText('Number')
await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number')
})
test('should render drawer columns in order', async () => {
// Re-order columns like done in the previous test
await createPost()
test('should render list drawer columns in proper order', async () => {
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
await page.reload()
await createPost()
await page.goto(postsUrl.create)
await openDocDrawer(page, '.rich-text .list-drawer__toggler')
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
const listDrawer = page.locator('[id^=list-drawer_1_]')
await expect(listDrawer).toBeVisible()
@@ -883,17 +938,17 @@ describe('List View', () => {
// select the "Post" collection
await collectionSelector.click()
await page
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
hasText: exactText('Post'),
})
.click()
// open the column controls
const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns')
await columnSelector.click()
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible()
await openListColumns(page, {
columnContainerSelector: '.list-controls__columns',
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
})
// ensure that the columns are in the correct order
await expect(
@@ -903,48 +958,94 @@ describe('List View', () => {
).toHaveText('Number')
})
test('should toggle columns in list drawer', async () => {
await page.goto(postsUrl.create)
// Open the drawer
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
const listDrawer = page.locator('[id^=list-drawer_1_]')
await expect(listDrawer).toBeVisible()
await openListColumns(page, {
columnContainerSelector: '.list-controls__columns',
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
})
await toggleColumn(page, {
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
columnContainerSelector: '.list-controls__columns',
columnLabel: 'ID',
targetState: 'off',
expectURLChange: false,
})
await closeListDrawer({ page })
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
await openListColumns(page, {
columnContainerSelector: '.list-controls__columns',
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
})
const columnContainer = page.locator('.list-controls__columns').first()
const column = columnContainer.locator(`.column-selector .column-selector__column`, {
hasText: exactText('ID'),
})
await expect(column).not.toHaveClass(/column-selector__column--active/)
})
test('should retain preferences when changing drawer collections', async () => {
await page.goto(postsUrl.create)
// Open the drawer
await openDocDrawer(page, '.rich-text .list-drawer__toggler')
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
const listDrawer = page.locator('[id^=list-drawer_1_]')
await expect(listDrawer).toBeVisible()
await openListColumns(page, {
columnContainerSelector: '.list-controls__columns',
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
})
const collectionSelector = page.locator(
'[id^=list-drawer_1_] .list-header__select-collection.react-select',
)
const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns')
// open the column controls
await columnSelector.click()
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible()
// deselect the "id" column
await page
.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', {
hasText: exactText('ID'),
})
.click()
await toggleColumn(page, {
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
columnContainerSelector: '.list-controls__columns',
columnLabel: 'ID',
targetState: 'off',
expectURLChange: false,
})
// select the "Post" collection
await collectionSelector.click()
await page
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
hasText: exactText('Post'),
})
.click()
// deselect the "number" column
await page
.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', {
hasText: exactText('Number'),
})
.click()
await toggleColumn(page, {
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
columnContainerSelector: '.list-controls__columns',
columnLabel: 'Number',
targetState: 'off',
expectURLChange: false,
})
// select the "User" collection again
await collectionSelector.click()
await page
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
hasText: exactText('User'),
@@ -1139,7 +1240,9 @@ describe('List View', () => {
test('should sort with existing filters', async () => {
await page.goto(postsUrl.list)
await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' })
await toggleColumn(page, { columnLabel: 'ID', targetState: 'off', columnName: 'id' })
await page.locator('#heading-id').waitFor({ state: 'detached' })
await page.locator('#heading-title button.sort-column__asc').click()
await page.waitForURL(/sort=title/)
@@ -1157,13 +1260,10 @@ describe('List View', () => {
})
test('should sort without resetting column preferences', async () => {
await payload.delete({
collection: 'payload-preferences',
where: {
key: {
equals: `${postsCollectionSlug}.list`,
},
},
await deletePreferences({
key: `${postsCollectionSlug}.list`,
payload,
user,
})
await page.goto(postsUrl.list)
@@ -1173,7 +1273,8 @@ describe('List View', () => {
await page.waitForURL(/sort=title/)
// enable a column that is _not_ part of this collection's default columns
await toggleColumn(page, { columnLabel: 'Status', targetState: 'on' })
await toggleColumn(page, { columnLabel: 'Status', targetState: 'on', columnName: '_status' })
await page.locator('#heading-_status').waitFor({ state: 'visible' })
const columnAfterSort = page.locator(

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -67,6 +68,7 @@ export interface Config {
blocks: {};
collections: {
uploads: Upload;
'uploads-two': UploadsTwo;
posts: Post;
users: User;
'hidden-collection': HiddenCollection;
@@ -81,6 +83,7 @@ export interface Config {
'group-two-collection-twos': GroupTwoCollectionTwo;
geo: Geo;
'disable-duplicate': DisableDuplicate;
'disable-copy-to-locale': DisableCopyToLocale;
'base-list-filters': BaseListFilter;
with300documents: With300Document;
'with-list-drawer': WithListDrawer;
@@ -91,6 +94,7 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
uploads: UploadsSelect<false> | UploadsSelect<true>;
'uploads-two': UploadsTwoSelect<false> | UploadsTwoSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'hidden-collection': HiddenCollectionSelect<false> | HiddenCollectionSelect<true>;
@@ -105,6 +109,7 @@ export interface Config {
'group-two-collection-twos': GroupTwoCollectionTwosSelect<false> | GroupTwoCollectionTwosSelect<true>;
geo: GeoSelect<false> | GeoSelect<true>;
'disable-duplicate': DisableDuplicateSelect<false> | DisableDuplicateSelect<true>;
'disable-copy-to-locale': DisableCopyToLocaleSelect<false> | DisableCopyToLocaleSelect<true>;
'base-list-filters': BaseListFiltersSelect<false> | BaseListFiltersSelect<true>;
with300documents: With300DocumentsSelect<false> | With300DocumentsSelect<true>;
'with-list-drawer': WithListDrawerSelect<false> | WithListDrawerSelect<true>;
@@ -193,6 +198,25 @@ export interface Upload {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads-two".
*/
export interface UploadsTwo {
id: string;
title?: string | null;
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;
}
/**
* This is a custom collection description.
*
@@ -209,31 +233,6 @@ export interface Post {
[k: string]: unknown;
}[]
| null;
arrayOfFields?:
| {
optional?: string | null;
innerArrayOfFields?:
| {
innerOptional?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
group?: {
defaultValueField?: string | null;
title?: string | null;
};
someBlock?:
| {
textFieldForBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'textBlock';
}[]
| null;
defaultValueField?: string | null;
relationship?: (string | null) | Post;
users?: (string | null) | User;
customCell?: string | null;
@@ -246,10 +245,6 @@ export interface Post {
* This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates.
*/
sidebarField?: string | null;
/**
* This field should only validate on submit. Try typing "Not allowed" and submitting the form.
*/
validateUsingEvent?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -426,6 +421,16 @@ export interface DisableDuplicate {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "disable-copy-to-locale".
*/
export interface DisableCopyToLocale {
id: string;
title?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "base-list-filters".
@@ -470,6 +475,10 @@ export interface PayloadLockedDocument {
relationTo: 'uploads';
value: string | Upload;
} | null)
| ({
relationTo: 'uploads-two';
value: string | UploadsTwo;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
@@ -526,6 +535,10 @@ export interface PayloadLockedDocument {
relationTo: 'disable-duplicate';
value: string | DisableDuplicate;
} | null)
| ({
relationTo: 'disable-copy-to-locale';
value: string | DisableCopyToLocale;
} | null)
| ({
relationTo: 'base-list-filters';
value: string | BaseListFilter;
@@ -612,6 +625,24 @@ export interface UploadsSelect<T extends boolean = true> {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads-two_select".
*/
export interface UploadsTwoSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
@@ -621,36 +652,6 @@ export interface PostsSelect<T extends boolean = true> {
description?: T;
number?: T;
richText?: T;
arrayOfFields?:
| T
| {
optional?: T;
innerArrayOfFields?:
| T
| {
innerOptional?: T;
id?: T;
};
id?: T;
};
group?:
| T
| {
defaultValueField?: T;
title?: T;
};
someBlock?:
| T
| {
textBlock?:
| T
| {
textFieldForBlock?: T;
id?: T;
blockName?: T;
};
};
defaultValueField?: T;
relationship?: T;
users?: T;
customCell?: T;
@@ -660,7 +661,6 @@ export interface PostsSelect<T extends boolean = true> {
disableListColumnText?: T;
disableListFilterText?: T;
sidebarField?: T;
validateUsingEvent?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -821,6 +821,15 @@ export interface DisableDuplicateSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "disable-copy-to-locale_select".
*/
export interface DisableCopyToLocaleSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "base-list-filters_select".

View File

@@ -11,7 +11,10 @@ export const hiddenCollectionSlug = 'hidden-collection'
export const notInViewCollectionSlug = 'not-in-view-collection'
export const noApiViewCollectionSlug = 'collection-no-api-view'
export const disableDuplicateSlug = 'disable-duplicate'
export const disableCopyToLocale = 'disable-copy-to-locale'
export const uploadCollectionSlug = 'uploads'
export const uploadTwoCollectionSlug = 'uploads-two'
export const customFieldsSlug = 'custom-fields'
export const listDrawerSlug = 'with-list-drawer'

View File

@@ -1,7 +1,15 @@
import type { FieldAffectingData, Payload, User } from 'payload'
import type {
BasePayload,
EmailFieldValidation,
FieldAffectingData,
Payload,
SanitizedConfig,
User,
} from 'payload'
import { jwtDecode } from 'jwt-decode'
import path from 'path'
import { email as emailValidation } from 'payload/shared'
import { fileURLToPath } from 'url'
import { v4 as uuid } from 'uuid'
@@ -969,4 +977,59 @@ describe('Auth', () => {
).rejects.toThrow('Token is either invalid or has expired.')
})
})
describe('Email - format validation', () => {
const mockT = jest.fn((key) => key) // Mocks translation function
const mockContext: Parameters<EmailFieldValidation>[1] = {
// @ts-expect-error: Mocking context for email validation
req: {
payload: {
collections: {} as Record<string, never>,
config: {} as SanitizedConfig,
} as unknown as BasePayload,
t: mockT,
},
required: true,
siblingData: {},
blockData: {},
data: {},
path: ['email'],
preferences: { fields: {} },
}
it('should allow standard formatted emails', () => {
expect(emailValidation('user@example.com', mockContext)).toBe(true)
expect(emailValidation('user.name+alias@example.co.uk', mockContext)).toBe(true)
expect(emailValidation('user-name@example.org', mockContext)).toBe(true)
expect(emailValidation('user@ex--ample.com', mockContext)).toBe(true)
})
it('should not allow emails with double quotes', () => {
expect(emailValidation('"user"@example.com', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user@"example.com"', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('"user@example.com"', mockContext)).toBe('validation:emailAddress')
})
it('should not allow emails with spaces', () => {
expect(emailValidation('user @example.com', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user@ example.com', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user name@example.com', mockContext)).toBe('validation:emailAddress')
})
it('should not allow emails with consecutive dots', () => {
expect(emailValidation('user..name@example.com', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user@example..com', mockContext)).toBe('validation:emailAddress')
})
it('should not allow emails with invalid domains', () => {
expect(emailValidation('user@example', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user@example..com', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user@example.c', mockContext)).toBe('validation:emailAddress')
})
it('should not allow domains starting or ending with a hyphen', () => {
expect(emailValidation('user@-example.com', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user@example-.com', mockContext)).toBe('validation:emailAddress')
})
})
})

2
test/bulk-edit/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,114 @@
import type { CollectionConfig } from 'payload'
import { postsSlug } from '../../shared.js'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
versions: {
drafts: true,
},
admin: {
useAsTitle: 'title',
defaultColumns: ['id', 'title', 'description', '_status'],
pagination: {
defaultLimit: 5,
limits: [5, 10, 15],
},
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'This is a default value',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'defaultValueField',
type: 'text',
defaultValue: 'This is a default value',
},
{
name: 'title',
type: 'text',
},
],
},
{
name: 'array',
type: 'array',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'optional',
type: 'text',
},
{
name: 'innerArrayOfFields',
type: 'array',
fields: [
{
name: 'innerOptional',
type: 'text',
},
],
},
{
name: 'noRead',
type: 'text',
access: {
read: () => false,
},
},
{
name: 'noUpdate',
type: 'text',
access: {
update: () => false,
},
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'textBlock',
fields: [
{
name: 'textFieldForBlock',
type: 'text',
},
],
},
],
},
{
name: 'noRead',
type: 'text',
access: {
read: () => false,
},
},
{
name: 'noUpdate',
type: 'text',
access: {
update: () => false,
},
},
],
}

38
test/bulk-edit/config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { PostsCollection } from './collections/Posts/index.js'
import { postsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'example post',
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

546
test/bulk-edit/e2e.spec.ts Normal file
View File

@@ -0,0 +1,546 @@
import type { BrowserContext, Locator, Page } from '@playwright/test'
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
import { expect, test } from '@playwright/test'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { Config, Post } from './payload-types.js'
import {
ensureCompilationIsDone,
exactText,
findTableCell,
initPageConsoleErrorCatch,
selectTableRow,
// throttleTest,
} 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 './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
let context: BrowserContext
let payload: PayloadTestSDK<Config>
let serverURL: string
test.describe('Bulk Edit', () => {
let page: Page
let postsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, postsSlug)
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test.beforeEach(async () => {
// await throttleTest({ page, context, delay: 'Fast 3G' })
})
test('should not show "select all across pages" button if already selected all', async () => {
await deleteAllPosts()
await createPost({ title: 'Post 1' })
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await expect(page.locator('button#select-all-across-pages')).toBeHidden()
})
test('should update selection state after deselecting item following select all', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
// Deselect the first row
await page.locator('.row-1 input').click()
// eslint-disable-next-line jest-dom/prefer-checked
await expect(page.locator('input#select-all')).not.toHaveAttribute('checked', '')
})
test('should delete many', async () => {
await deleteAllPosts()
const titleOfPostToDelete1 = 'Post to delete (published)'
const titleOfPostToDelete2 = 'Post to delete (draft)'
await Promise.all([
createPost({ title: titleOfPostToDelete1 }),
createPost({ title: titleOfPostToDelete2 }, { draft: true }),
])
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToDelete1)
await selectTableRow(page, titleOfPostToDelete2)
await page.locator('.delete-documents__toggle').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Deleted 2 Posts successfully.',
)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete1}")`)).toBeHidden()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToDelete2}")`)).toBeHidden()
})
test('should publish many', async () => {
await deleteAllPosts()
const titleOfPostToPublish1 = 'Post to publish (already published)'
const titleOfPostToPublish2 = 'Post to publish (draft)'
await Promise.all([
createPost({ title: titleOfPostToPublish1 }),
createPost({ title: titleOfPostToPublish2 }, { draft: true }),
])
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToPublish1)
await selectTableRow(page, titleOfPostToPublish2)
await page.locator('.publish-many__toggle').click()
await page.locator('#publish-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(findTableCell(page, '_status', titleOfPostToPublish1)).toContainText('Published')
await expect(findTableCell(page, '_status', titleOfPostToPublish2)).toContainText('Published')
})
test('should unpublish many', async () => {
await deleteAllPosts()
const titleOfPostToUnpublish1 = 'Post to unpublish (published)'
const titleOfPostToUnpublish2 = 'Post to unpublish (already draft)'
await Promise.all([
createPost({ title: titleOfPostToUnpublish1 }),
createPost({ title: titleOfPostToUnpublish2 }, { draft: true }),
])
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToUnpublish1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToUnpublish2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToUnpublish1)
await selectTableRow(page, titleOfPostToUnpublish2)
await page.locator('.unpublish-many__toggle').click()
await page.locator('#unpublish-posts #confirm-action').click()
await expect(findTableCell(page, '_status', titleOfPostToUnpublish1)).toContainText('Draft')
await expect(findTableCell(page, '_status', titleOfPostToUnpublish2)).toContainText('Draft')
})
test('should update many', async () => {
await deleteAllPosts()
const updatedPostTitle = 'Post (Updated)'
Array.from({ length: 3 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
for (let i = 1; i <= 3; i++) {
const invertedIndex = 4 - i
await expect(page.locator(`.row-${invertedIndex} .cell-title`)).toContainText(`Post ${i}`)
}
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
const { field, modal } = await selectFieldToEdit(page, {
fieldLabel: 'Title',
fieldID: 'title',
})
await field.fill(updatedPostTitle)
await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 3 Posts successfully.',
)
for (let i = 1; i <= 3; i++) {
const invertedIndex = 4 - i
await expect(page.locator(`.row-${invertedIndex} .cell-title`)).toContainText(
updatedPostTitle,
)
}
})
test('should publish many from drawer', async () => {
await deleteAllPosts()
const titleOfPostToPublish1 = 'Post to unpublish (published)'
const titleOfPostToPublish2 = 'Post to publish (already draft)'
await Promise.all([
createPost({ title: titleOfPostToPublish1 }),
createPost({ title: titleOfPostToPublish2 }, { draft: true }),
])
const description = 'published document'
await page.goto(postsUrl.list)
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish1}")`)).toBeVisible()
await expect(page.locator(`tbody tr:has-text("${titleOfPostToPublish2}")`)).toBeVisible()
await selectTableRow(page, titleOfPostToPublish1)
await selectTableRow(page, titleOfPostToPublish2)
await page.locator('.edit-many__toggle').click()
const { field, modal } = await selectFieldToEdit(page, {
fieldLabel: 'Description',
fieldID: 'description',
})
await field.fill(description)
// Bulk edit the selected rows to `published` status
await modal.locator('.form-submit .edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(findTableCell(page, '_status', titleOfPostToPublish1)).toContainText('Published')
await expect(findTableCell(page, '_status', titleOfPostToPublish2)).toContainText('Published')
})
test('should draft many from drawer', async () => {
await deleteAllPosts()
const titleOfPostToDraft1 = 'Post to draft (published)'
const titleOfPostToDraft2 = 'Post to draft (draft)'
await Promise.all([
createPost({ title: titleOfPostToDraft1 }),
createPost({ title: titleOfPostToDraft2 }, { draft: true }),
])
const description = 'draft document'
await page.goto(postsUrl.list)
await selectTableRow(page, titleOfPostToDraft1)
await selectTableRow(page, titleOfPostToDraft2)
await page.locator('.edit-many__toggle').click()
const { field, modal } = await selectFieldToEdit(page, {
fieldLabel: 'Description',
fieldID: 'description',
})
await field.fill(description)
// Bulk edit the selected rows to `draft` status
await modal.locator('.form-submit .edit-many__draft').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.',
)
await expect(findTableCell(page, '_status', titleOfPostToDraft1)).toContainText('Draft')
await expect(findTableCell(page, '_status', titleOfPostToDraft2)).toContainText('Draft')
})
test('should delete all on page', async () => {
await deleteAllPosts()
Array.from({ length: 3 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(3)
await page.locator('input#select-all').check()
await page.locator('.delete-documents__toggle').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 3 Posts successfully.',
)
await page.locator('.collection-list__no-results').isVisible()
})
test('should delete all with filters and across pages', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await expect(page.locator('.collection-list__page-info')).toContainText('1-5 of 6')
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
await page.locator('.delete-documents__toggle').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 6 Posts successfully.',
)
await page.locator('.collection-list__no-results').isVisible()
})
test('should update all with filters and across pages', async () => {
await deleteAllPosts()
Array.from({ length: 6 }).forEach(async (_, i) => {
await createPost({ title: `Post ${i + 1}` })
})
await page.goto(postsUrl.list)
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await page.locator('input#select-all').check()
await page.locator('button#select-all-across-pages').click()
await page.locator('.edit-many__toggle').click()
const { field } = await selectFieldToEdit(page, {
fieldLabel: 'Title',
fieldID: 'title',
})
const updatedTitle = 'Post (Updated)'
await field.fill(updatedTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 6 Posts successfully.',
)
await expect(page.locator('.table table > tbody > tr')).toHaveCount(5)
await expect(page.locator('.row-1 .cell-title')).toContainText(updatedTitle)
})
test('should not override un-edited values if it has a defaultValue', async () => {
await deleteAllPosts()
const postData = {
title: 'Post 1',
array: [
{
optional: 'some optional array field',
innerArrayOfFields: [
{
innerOptional: 'some inner optional array field',
},
],
},
],
group: {
defaultValueField: 'This is NOT the default value',
title: 'some title',
},
blocks: [
{
textFieldForBlock: 'some text for block text',
blockType: 'textBlock',
},
],
defaultValueField: 'This is NOT the default value',
}
const updatedPostTitle = 'Post 1 (Updated)'
const { id: postID } = await createPost(postData)
await page.goto(postsUrl.list)
const { modal } = await selectAllAndEditMany(page)
const { field } = await selectFieldToEdit(page, {
fieldLabel: 'Title',
fieldID: 'title',
})
await field.fill(updatedPostTitle)
await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.',
)
const updatedPost = await payload.find({
collection: postsSlug,
limit: 1,
depth: 0,
where: {
id: {
equals: postID,
},
},
})
expect(updatedPost.docs[0]).toMatchObject({
...postData,
title: updatedPostTitle,
})
})
test('should bulk edit fields with subfields', async () => {
await deleteAllPosts()
await createPost()
await page.goto(postsUrl.list)
await selectAllAndEditMany(page)
const { modal, field } = await selectFieldToEdit(page, {
fieldLabel: 'Group > Title',
fieldID: 'group__title',
})
await field.fill('New Group Title')
await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.',
)
const updatedPost = await payload
.find({
collection: 'posts',
limit: 1,
})
?.then((res) => res.docs[0])
expect(updatedPost?.group?.title).toBe('New Group Title')
})
test('should not display fields options lacking read and update permissions', async () => {
await deleteAllPosts()
await createPost()
await page.goto(postsUrl.list)
const { modal } = await selectAllAndEditMany(page)
await expect(
modal.locator('.field-select .rs__option', { hasText: exactText('No Read') }),
).toBeHidden()
await expect(
modal.locator('.field-select .rs__option', { hasText: exactText('No Update') }),
).toBeHidden()
})
test('should thread field permissions through subfields', async () => {
await deleteAllPosts()
await createPost()
await page.goto(postsUrl.list)
await selectAllAndEditMany(page)
const { field } = await selectFieldToEdit(page, { fieldLabel: 'Array', fieldID: 'array' })
await field.locator('button.array-field__add-row').click()
await expect(field.locator('#field-array__0__optional')).toBeVisible()
await expect(field.locator('#field-array__0__noRead')).toBeHidden()
await expect(field.locator('#field-array__0__noUpdate')).toBeDisabled()
})
})
async function selectFieldToEdit(
page: Page,
{
fieldLabel,
fieldID,
}: {
fieldID: string
fieldLabel: string
},
): Promise<{ field: Locator; modal: Locator }> {
// ensure modal is open, open if needed
const isModalOpen = await page.locator('#edit-posts').isVisible()
if (!isModalOpen) {
await page.locator('.edit-many__toggle').click()
}
const modal = page.locator('#edit-posts')
await expect(modal).toBeVisible()
await modal.locator('.field-select .rs__control').click()
await modal.locator('.field-select .rs__option', { hasText: exactText(fieldLabel) }).click()
const field = modal.locator(`#field-${fieldID}`)
await expect(field).toBeVisible()
return { modal, field }
}
async function selectAllAndEditMany(page: Page): Promise<{ modal: Locator }> {
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
const modal = page.locator('#edit-posts')
await expect(modal).toBeVisible()
return { modal }
}
async function deleteAllPosts() {
await payload.delete({ collection: postsSlug, where: { id: { exists: true } } })
}
async function createPost(
dataOverrides?: Partial<Post>,
overrides?: Record<string, unknown>,
): Promise<Post> {
return payload.create({
collection: postsSlug,
...(overrides || {}),
data: {
title: 'Post Title',
...(dataOverrides || {}),
},
}) as unknown as Promise<Post>
}

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -0,0 +1,335 @@
/* 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;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<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;
description?: string | null;
defaultValueField?: string | null;
group?: {
defaultValueField?: string | null;
title?: string | null;
};
array?:
| {
optional?: string | null;
innerArrayOfFields?:
| {
innerOptional?: string | null;
id?: string | null;
}[]
| null;
noRead?: string | null;
noUpdate?: string | null;
id?: string | null;
}[]
| null;
blocks?:
| {
textFieldForBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'textBlock';
}[]
| null;
noRead?: string | null;
noUpdate?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | 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;
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: '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;
description?: T;
defaultValueField?: T;
group?:
| T
| {
defaultValueField?: T;
title?: T;
};
array?:
| T
| {
optional?: T;
innerArrayOfFields?:
| T
| {
innerOptional?: T;
id?: T;
};
noRead?: T;
noUpdate?: T;
id?: T;
};
blocks?:
| T
| {
textBlock?:
| T
| {
textFieldForBlock?: T;
id?: T;
blockName?: T;
};
};
noRead?: T;
noUpdate?: T;
updatedAt?: T;
createdAt?: T;
_status?: 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;
}
/**
* 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 {}
}

File diff suppressed because it is too large Load Diff

1
test/bulk-edit/shared.ts Normal file
View File

@@ -0,0 +1 @@
export const postsSlug = 'posts'

View File

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

View File

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

9
test/bulk-edit/types.d.ts vendored Normal file
View File

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

View File

@@ -129,7 +129,7 @@ describe('Config', () => {
}
}
it('should execute a custom script', () => {
it.skip('should execute a custom script', () => {
deleteTestFile()
executeCLI('start-server')
expect(JSON.parse(readFileSync(testFilePath, 'utf-8')).docs).toHaveLength(1)

View File

@@ -4,11 +4,24 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { TextField } from 'payload'
import { v4 as uuid } from 'uuid'
import { randomUUID } from 'crypto'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { errorOnUnnamedFieldsSlug, postsSlug } from './shared.js'
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',
@@ -31,6 +44,10 @@ export default buildConfigWithDefaults({
type: 'text',
required: true,
},
{
name: 'number',
type: 'number',
},
{
type: 'tabs',
tabs: [
@@ -183,7 +200,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'default-values',
slug: defaultValuesSlug,
fields: [
{
name: 'title',
@@ -222,7 +239,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'relation-a',
slug: relationASlug,
fields: [
{
name: 'title',
@@ -239,7 +256,7 @@ export default buildConfigWithDefaults({
},
},
{
slug: 'relation-b',
slug: relationBSlug,
fields: [
{
name: 'title',
@@ -261,7 +278,7 @@ export default buildConfigWithDefaults({
},
},
{
slug: 'pg-migrations',
slug: pgMigrationSlug,
fields: [
{
name: 'relation1',
@@ -329,7 +346,7 @@ export default buildConfigWithDefaults({
versions: true,
},
{
slug: 'custom-schema',
slug: customSchemaSlug,
dbName: 'customs',
fields: [
{
@@ -404,7 +421,7 @@ export default buildConfigWithDefaults({
},
},
{
slug: 'places',
slug: placesSlug,
fields: [
{
name: 'country',
@@ -417,7 +434,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'fields-persistance',
slug: fieldsPersistanceSlug,
fields: [
{
name: 'text',
@@ -475,7 +492,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'custom-ids',
slug: customIDsSlug,
fields: [
{
name: 'id',
@@ -487,7 +504,7 @@ export default buildConfigWithDefaults({
beforeChange: [
({ value, operation }) => {
if (operation === 'create') {
return uuid()
return randomUUID()
}
return value
},
@@ -502,7 +519,7 @@ export default buildConfigWithDefaults({
versions: { drafts: true },
},
{
slug: 'fake-custom-ids',
slug: fakeCustomIDsSlug,
fields: [
{
name: 'title',
@@ -535,7 +552,7 @@ export default buildConfigWithDefaults({
],
},
{
slug: 'relationships-migration',
slug: relationshipsMigrationSlug,
fields: [
{
type: 'relationship',
@@ -550,6 +567,43 @@ export default buildConfigWithDefaults({
],
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,
},
],
},
],
globals: [
{
@@ -587,13 +641,9 @@ export default buildConfigWithDefaults({
locales: ['en', 'es'],
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),

View File

@@ -7,11 +7,12 @@ import {
migrateRelationshipsV2_V3,
migrateVersionsV1_V2,
} from '@payloadcms/db-mongodb/migration-utils'
import { randomUUID } from 'crypto'
import { type Table } from 'drizzle-orm'
import * as drizzlePg from 'drizzle-orm/pg-core'
import * as drizzleSqlite from 'drizzle-orm/sqlite-core'
import fs from 'fs'
import { Types } from 'mongoose'
import mongoose, { Types } from 'mongoose'
import path from 'path'
import {
commitTransaction,
@@ -28,6 +29,7 @@ import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { isMongoose } from '../helpers/isMongoose.js'
import removeFiles from '../helpers/removeFiles.js'
import { seed } from './seed.js'
import { errorOnUnnamedFieldsSlug, postsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
@@ -43,9 +45,17 @@ process.env.PAYLOAD_CONFIG_PATH = path.join(dirname, 'config.ts')
describe('database', () => {
beforeAll(async () => {
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
;({ payload, restClient } = await initPayloadInt(dirname))
payload.db.migrationDir = path.join(dirname, './migrations')
await seed(payload)
await restClient.login({
slug: 'users',
credentials: devUser,
})
const loginResult = await payload.login({
collection: 'users',
data: {
@@ -296,6 +306,143 @@ describe('database', () => {
})
})
describe('allow ID on create', () => {
beforeAll(() => {
payload.db.allowIDOnCreate = true
payload.config.db.allowIDOnCreate = true
})
afterAll(() => {
payload.db.allowIDOnCreate = false
payload.config.db.allowIDOnCreate = false
})
it('local API - accepts ID on create', async () => {
let id: any = null
if (payload.db.name === 'mongoose') {
id = new mongoose.Types.ObjectId().toHexString()
} else if (payload.db.idType === 'uuid') {
id = randomUUID()
} else {
id = 9999
}
const post = await payload.create({ collection: 'posts', data: { id, title: 'created' } })
expect(post.id).toBe(id)
})
it('rEST API - accepts ID on create', async () => {
let id: any = null
if (payload.db.name === 'mongoose') {
id = new mongoose.Types.ObjectId().toHexString()
} else if (payload.db.idType === 'uuid') {
id = randomUUID()
} else {
id = 99999
}
const response = await restClient.POST(`/posts`, {
body: JSON.stringify({
id,
title: 'created',
}),
})
const post = await response.json()
expect(post.doc.id).toBe(id)
})
it('graphQL - accepts ID on create', async () => {
let id: any = null
if (payload.db.name === 'mongoose') {
id = new mongoose.Types.ObjectId().toHexString()
} else if (payload.db.idType === 'uuid') {
id = randomUUID()
} else {
id = 999999
}
const query = `mutation {
createPost(data: {title: "created", id: ${typeof id === 'string' ? `"${id}"` : id}}) {
id
title
}
}`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((res) => res.json())
const doc = res.data.createPost
expect(doc).toMatchObject({ title: 'created', id })
expect(doc.id).toBe(id)
})
})
describe('Compound Indexes', () => {
beforeEach(async () => {
await payload.delete({ collection: 'compound-indexes', where: {} })
})
it('top level: should throw a unique error', async () => {
await payload.create({
collection: 'compound-indexes',
data: { three: randomUUID(), one: '1', two: '2' },
})
// does not fail
await payload.create({
collection: 'compound-indexes',
data: { three: randomUUID(), one: '1', two: '3' },
})
// does not fail
await payload.create({
collection: 'compound-indexes',
data: { three: randomUUID(), one: '-1', two: '2' },
})
// fails
await expect(
payload.create({
collection: 'compound-indexes',
data: { three: randomUUID(), one: '1', two: '2' },
}),
).rejects.toBeTruthy()
})
it('combine group and top level: should throw a unique error', async () => {
await payload.create({
collection: 'compound-indexes',
data: {
one: randomUUID(),
three: '3',
group: { four: '4' },
},
})
// does not fail
await payload.create({
collection: 'compound-indexes',
data: { one: randomUUID(), three: '3', group: { four: '5' } },
})
// does not fail
await payload.create({
collection: 'compound-indexes',
data: { one: randomUUID(), three: '4', group: { four: '4' } },
})
// fails
await expect(
payload.create({
collection: 'compound-indexes',
data: { one: randomUUID(), three: '3', group: { four: '4' } },
}),
).rejects.toBeTruthy()
})
})
describe('migrations', () => {
let ranFreshTest = false
@@ -794,6 +941,7 @@ describe('database', () => {
data: {
title,
},
depth: 0,
disableTransaction: true,
})
})
@@ -876,6 +1024,525 @@ describe('database', () => {
expect(result.point).toEqual([5, 10])
})
it('ensure updateMany updates all docs and respects where query', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'notupdated',
},
})
// Create 5 posts
for (let i = 0; i < 5; i++) {
await payload.create({
collection: postsSlug,
data: {
title: `v1 ${i}`,
},
})
}
const result = await payload.db.updateMany({
collection: postsSlug,
data: {
title: 'updated',
},
where: {
title: {
not_equals: 'notupdated',
},
},
})
expect(result?.length).toBe(5)
expect(result?.[0]?.title).toBe('updated')
expect(result?.[4]?.title).toBe('updated')
// Ensure all posts minus the one we don't want updated are updated
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
expect(docs?.[0]?.title).toBe('updated')
expect(docs?.[4]?.title).toBe('updated')
const { docs: notUpdatedDocs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
not_equals: 'updated',
},
},
})
expect(notUpdatedDocs).toHaveLength(1)
expect(notUpdatedDocs?.[0]?.title).toBe('notupdated')
})
it('ensure updateMany respects limit', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
// Create 11 posts
for (let i = 0; i < 11; i++) {
await payload.create({
collection: postsSlug,
data: {
title: 'not updated',
},
})
}
const result = await payload.db.updateMany({
collection: postsSlug,
data: {
title: 'updated',
},
limit: 5,
where: {
id: {
exists: true,
},
},
})
expect(result?.length).toBe(5)
expect(result?.[0]?.title).toBe('updated')
expect(result?.[4]?.title).toBe('updated')
// Ensure all posts minus the one we don't want updated are updated
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
expect(docs?.[0]?.title).toBe('updated')
expect(docs?.[4]?.title).toBe('updated')
const { docs: notUpdatedDocs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
equals: 'not updated',
},
},
})
expect(notUpdatedDocs).toHaveLength(6)
expect(notUpdatedDocs?.[0]?.title).toBe('not updated')
expect(notUpdatedDocs?.[5]?.title).toBe('not updated')
})
it('ensure updateMany respects limit and sort', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
const numbers = Array.from({ length: 11 }, (_, i) => i)
// shuffle the numbers
numbers.sort(() => Math.random() - 0.5)
// create 11 documents numbered 0-10, but in random order
for (const i of numbers) {
await payload.create({
collection: postsSlug,
data: {
title: 'not updated',
number: i,
},
})
}
const result = await payload.db.updateMany({
collection: postsSlug,
data: {
title: 'updated',
},
limit: 5,
sort: 'number',
where: {
id: {
exists: true,
},
},
})
expect(result?.length).toBe(5)
for (let i = 0; i < 5; i++) {
expect(result?.[i]?.number).toBe(i)
expect(result?.[i]?.title).toBe('updated')
}
// Ensure all posts minus the one we don't want updated are updated
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
sort: 'number',
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
for (let i = 0; i < 5; i++) {
expect(docs?.[i]?.number).toBe(i)
expect(docs?.[i]?.title).toBe('updated')
}
})
it('ensure payload.update operation respects limit and sort', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
const numbers = Array.from({ length: 11 }, (_, i) => i)
// shuffle the numbers
numbers.sort(() => Math.random() - 0.5)
// create 11 documents numbered 0-10, but in random order
for (const i of numbers) {
await payload.create({
collection: postsSlug,
data: {
title: 'not updated',
number: i,
},
})
}
const result = await payload.update({
collection: postsSlug,
data: {
title: 'updated',
},
limit: 5,
sort: 'number',
where: {
id: {
exists: true,
},
},
})
expect(result?.docs.length).toBe(5)
for (let i = 0; i < 5; i++) {
expect(result?.docs?.[i]?.number).toBe(i)
expect(result?.docs?.[i]?.title).toBe('updated')
}
// Ensure all posts minus the one we don't want updated are updated
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
sort: 'number',
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
for (let i = 0; i < 5; i++) {
expect(docs?.[i]?.number).toBe(i)
expect(docs?.[i]?.title).toBe('updated')
}
})
it('ensure updateMany respects limit and negative sort', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
const numbers = Array.from({ length: 11 }, (_, i) => i)
// shuffle the numbers
numbers.sort(() => Math.random() - 0.5)
// create 11 documents numbered 0-10, but in random order
for (const i of numbers) {
await payload.create({
collection: postsSlug,
data: {
title: 'not updated',
number: i,
},
})
}
const result = await payload.db.updateMany({
collection: postsSlug,
data: {
title: 'updated',
},
limit: 5,
sort: '-number',
where: {
id: {
exists: true,
},
},
})
expect(result?.length).toBe(5)
for (let i = 10; i > 5; i--) {
expect(result?.[-i + 10]?.number).toBe(i)
expect(result?.[-i + 10]?.title).toBe('updated')
}
// Ensure all posts minus the one we don't want updated are updated
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
sort: '-number',
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
for (let i = 10; i > 5; i--) {
expect(docs?.[-i + 10]?.number).toBe(i)
expect(docs?.[-i + 10]?.title).toBe('updated')
}
})
it('ensure payload.update operation respects limit and negative sort', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
const numbers = Array.from({ length: 11 }, (_, i) => i)
// shuffle the numbers
numbers.sort(() => Math.random() - 0.5)
// create 11 documents numbered 0-10, but in random order
for (const i of numbers) {
await payload.create({
collection: postsSlug,
data: {
title: 'not updated',
number: i,
},
})
}
const result = await payload.update({
collection: postsSlug,
data: {
title: 'updated',
},
limit: 5,
sort: '-number',
where: {
id: {
exists: true,
},
},
})
expect(result?.docs?.length).toBe(5)
for (let i = 10; i > 5; i--) {
expect(result?.docs?.[-i + 10]?.number).toBe(i)
expect(result?.docs?.[-i + 10]?.title).toBe('updated')
}
// Ensure all posts minus the one we don't want updated are updated
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
sort: '-number',
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
for (let i = 10; i > 5; i--) {
expect(docs?.[-i + 10]?.number).toBe(i)
expect(docs?.[-i + 10]?.title).toBe('updated')
}
})
it('ensure updateMany correctly handles 0 limit', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
// Create 5 posts
for (let i = 0; i < 5; i++) {
await payload.create({
collection: postsSlug,
data: {
title: 'not updated',
},
})
}
const result = await payload.db.updateMany({
collection: postsSlug,
data: {
title: 'updated',
},
limit: 0,
where: {
id: {
exists: true,
},
},
})
expect(result?.length).toBe(5)
expect(result?.[0]?.title).toBe('updated')
expect(result?.[4]?.title).toBe('updated')
// Ensure all posts are updated. limit: 0 should mean unlimited
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
expect(docs?.[0]?.title).toBe('updated')
expect(docs?.[4]?.title).toBe('updated')
})
it('ensure updateMany correctly handles -1 limit', async () => {
await payload.db.deleteMany({
collection: postsSlug,
where: {
id: {
exists: true,
},
},
})
// Create 5 posts
for (let i = 0; i < 5; i++) {
await payload.create({
collection: postsSlug,
data: {
title: 'not updated',
},
})
}
const result = await payload.db.updateMany({
collection: postsSlug,
data: {
title: 'updated',
},
limit: -1,
where: {
id: {
exists: true,
},
},
})
expect(result?.length).toBe(5)
expect(result?.[0]?.title).toBe('updated')
expect(result?.[4]?.title).toBe('updated')
// Ensure all posts are updated. limit: -1 should mean unlimited
const { docs } = await payload.find({
collection: postsSlug,
depth: 0,
pagination: false,
where: {
title: {
equals: 'updated',
},
},
})
expect(docs).toHaveLength(5)
expect(docs?.[0]?.title).toBe('updated')
expect(docs?.[4]?.title).toBe('updated')
})
})
describe('Error Handler', () => {
@@ -1466,4 +2133,49 @@ describe('database', () => {
expect(query2.totalDocs).toEqual(1)
expect(query3.totalDocs).toEqual(1)
})
it('mongodb additional keys stripping', async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name !== 'mongoose') {
return
}
const arrItemID = randomUUID()
const res = await payload.db.collections[postsSlug]?.collection.insertOne({
SECRET_FIELD: 'secret data',
arrayWithIDs: [
{
id: arrItemID,
additionalKeyInArray: 'true',
text: 'existing key',
},
],
})
let payloadRes: any = await payload.findByID({
collection: postsSlug,
id: res!.insertedId.toHexString(),
})
expect(payloadRes.id).toBe(res!.insertedId.toHexString())
expect(payloadRes['SECRET_FIELD']).toBeUndefined()
expect(payloadRes.arrayWithIDs).toBeDefined()
expect(payloadRes.arrayWithIDs[0].id).toBe(arrItemID)
expect(payloadRes.arrayWithIDs[0].text).toBe('existing key')
expect(payloadRes.arrayWithIDs[0].additionalKeyInArray).toBeUndefined()
// But allows when allowAdditionaKeys is true
payload.db.allowAdditionalKeys = true
payloadRes = await payload.findByID({
collection: postsSlug,
id: res!.insertedId.toHexString(),
})
expect(payloadRes.id).toBe(res!.insertedId.toHexString())
expect(payloadRes['SECRET_FIELD']).toBe('secret data')
expect(payloadRes.arrayWithIDs[0].additionalKeyInArray).toBe('true')
payload.db.allowAdditionalKeys = false
})
})

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -78,6 +79,7 @@ export interface Config {
'custom-ids': CustomId;
'fake-custom-ids': FakeCustomId;
'relationships-migration': RelationshipsMigration;
'compound-indexes': CompoundIndex;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -97,6 +99,7 @@ export interface Config {
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
'fake-custom-ids': FakeCustomIdsSelect<false> | FakeCustomIdsSelect<true>;
'relationships-migration': RelationshipsMigrationSelect<false> | RelationshipsMigrationSelect<true>;
'compound-indexes': CompoundIndexesSelect<false> | CompoundIndexesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -149,6 +152,7 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title: string;
number?: number | null;
D1?: {
D2?: {
D3?: {
@@ -400,6 +404,21 @@ export interface RelationshipsMigration {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "compound-indexes".
*/
export interface CompoundIndex {
id: string;
one?: string | null;
two?: string | null;
three?: string | null;
group?: {
four?: string | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -472,6 +491,10 @@ export interface PayloadLockedDocument {
relationTo: 'relationships-migration';
value: string | RelationshipsMigration;
} | null)
| ({
relationTo: 'compound-indexes';
value: string | CompoundIndex;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -524,6 +547,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
number?: T;
D1?:
| T
| {
@@ -755,6 +779,22 @@ export interface RelationshipsMigrationSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "compound-indexes_select".
*/
export interface CompoundIndexesSelect<T extends boolean = true> {
one?: T;
two?: T;
three?: T;
group?:
| T
| {
four?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -0,0 +1,122 @@
/* eslint-disable jest/require-top-level-describe */
import type { PostgresAdapter } from '@payloadcms/db-postgres/types'
import { cosineDistance, desc, gt, sql } from 'drizzle-orm'
import path from 'path'
import { buildConfig, getPayload } from 'payload'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// skip on ci as there db does not have the vector extension
const describeToUse =
process.env.PAYLOAD_DATABASE.startsWith('postgres') && process.env.CI !== 'true'
? describe
: describe.skip
describeToUse('postgres vector custom column', () => {
it('should add a vector column and query it', async () => {
const { databaseAdapter } = await import(path.resolve(dirname, '../databaseAdapter.js'))
const init = databaseAdapter.init
// set options
databaseAdapter.init = ({ payload }) => {
const adapter = init({ payload })
adapter.extensions = {
vector: true,
}
adapter.beforeSchemaInit = [
({ schema, adapter }) => {
;(adapter as PostgresAdapter).rawTables.posts.columns.embedding = {
type: 'vector',
dimensions: 5,
name: 'embedding',
}
return schema
},
]
return adapter
}
const config = await buildConfig({
db: databaseAdapter,
secret: 'secret',
collections: [
{
slug: 'users',
auth: true,
fields: [],
},
{
slug: 'posts',
fields: [
{
type: 'json',
name: 'embedding',
},
{
name: 'title',
type: 'text',
},
],
},
],
})
const payload = await getPayload({ config })
const catEmbedding = [1.5, -0.4, 7.2, 19.6, 20.2]
await payload.create({
collection: 'posts',
data: {
embedding: [-5.2, 3.1, 0.2, 8.1, 3.5],
title: 'apple',
},
})
await payload.create({
collection: 'posts',
data: {
embedding: catEmbedding,
title: 'cat',
},
})
await payload.create({
collection: 'posts',
data: {
embedding: [-5.1, 2.9, 0.8, 7.9, 3.1],
title: 'fruit',
},
})
await payload.create({
collection: 'posts',
data: {
embedding: [1.7, -0.3, 6.9, 19.1, 21.1],
title: 'dog',
},
})
const similarity = sql<number>`1 - (${cosineDistance(payload.db.tables.posts.embedding, catEmbedding)})`
const res = await payload.db.drizzle
.select()
.from(payload.db.tables.posts)
.where(gt(similarity, 0.9))
.orderBy(desc(similarity))
// Only cat and dog
expect(res).toHaveLength(2)
// similarity sort
expect(res[0].title).toBe('cat')
expect(res[1].title).toBe('dog')
payload.logger.info(res)
})
})

26
test/database/seed.ts Normal file
View File

@@ -0,0 +1,26 @@
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({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
}
export async function seed(_payload: Payload) {
return await _seed(_payload)
}

View File

@@ -1,2 +1,37 @@
export const postsSlug = 'posts'
export const errorOnUnnamedFieldsSlug = 'error-on-unnamed-fields'
export const defaultValuesSlug = 'default-values'
export const relationASlug = 'relation-a'
export const relationBSlug = 'relation-b'
export const pgMigrationSlug = 'pg-migrations'
export const customSchemaSlug = 'custom-schema'
export const placesSlug = 'places'
export const fieldsPersistanceSlug = 'fields-persistance'
export const customIDsSlug = 'custom-ids'
export const fakeCustomIDsSlug = 'fake-custom-ids'
export const relationshipsMigrationSlug = 'relationships-migration'
export const collectionSlugs = [
postsSlug,
errorOnUnnamedFieldsSlug,
defaultValuesSlug,
relationASlug,
relationBSlug,
pgMigrationSlug,
customSchemaSlug,
placesSlug,
fieldsPersistanceSlug,
customIDsSlug,
fakeCustomIDsSlug,
relationshipsMigrationSlug,
]

View File

@@ -1,6 +1,7 @@
import type { Payload } from 'payload'
import type { CollectionSlug, Payload } from 'payload'
import path from 'path'
import { createLocalReq } from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
@@ -28,7 +29,9 @@ describe('dataloader', () => {
},
})
if (loginResult.token) token = loginResult.token
if (loginResult.token) {
token = loginResult.token
}
})
afterAll(async () => {
@@ -187,4 +190,26 @@ describe('dataloader', () => {
expect(innerMostRelationship).toStrictEqual(relationB.id)
})
})
describe('find', () => {
it('should call the same query only once in a request', async () => {
const req = await createLocalReq({}, payload)
const spy = jest.spyOn(payload, 'find')
const findArgs = {
collection: 'items' as CollectionSlug,
req,
depth: 0,
where: {
name: { exists: true },
},
}
void req.payloadDataLoader.find(findArgs)
void req.payloadDataLoader.find(findArgs)
await req.payloadDataLoader.find(findArgs)
expect(spy).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -62,6 +62,17 @@ export const testEslintConfig = [
'payload/no-wait-function': 'warn',
// Enable the no-non-retryable-assertions rule ONLY for hunting for flakes
// 'payload/no-non-retryable-assertions': 'error',
'playwright/expect-expect': [
'error',
{
assertFunctionNames: [
'assertToastErrors',
'saveDocAndAssert',
'runFilterOptionsTest',
'assertNetworkRequests',
],
},
],
},
},
{

View File

@@ -1,8 +1,10 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { assertToastErrors } from 'helpers/assertToastErrors.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -19,15 +21,9 @@ import type {
VersionedRelationshipField,
} from './payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
openCreateDocDrawer,
openDocDrawer,
saveDocAndAssert,
} from '../helpers.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
import { assertNetworkRequests } from '../helpers/e2e/assertNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
@@ -180,7 +176,7 @@ describe('Relationship Field', () => {
await expect(options).toHaveCount(2) // two docs
await options.nth(0).click()
await expect(field).toContainText(relationOneDoc.id)
await trackNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
await assertNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
await saveDocAndAssert(page)
await wait(200)
})
@@ -293,9 +289,10 @@ describe('Relationship Field', () => {
await expect(field).toContainText(anotherRelationOneDoc.id)
await wait(2000) // Need to wait form state to come back before clicking save
await page.locator('#action-save').click()
await expect(page.locator('.payload-toast-container')).toContainText(
`is invalid: ${fieldLabel}`,
)
await assertToastErrors({
page,
errors: [fieldLabel],
})
filteredField = page.locator(`#field-${fieldName} .react-select`)
await filteredField.click({ delay: 100 })
filteredOptions = filteredField.locator('.rs__option')
@@ -308,7 +305,7 @@ describe('Relationship Field', () => {
describe('filterOptions', () => {
// TODO: Flaky test. Fix this! (This is an actual issue not just an e2e flake)
test('should allow dynamic filterOptions', async () => {
await runFilterOptionsTest('relationshipFilteredByID', 'Relationship Filtered')
await runFilterOptionsTest('relationshipFilteredByID', 'Relationship Filtered By ID')
})
// TODO: Flaky test. Fix this! (This is an actual issue not just an e2e flake)
@@ -495,10 +492,11 @@ describe('Relationship Field', () => {
const editURL = url.edit(docWithExistingRelations.id)
await page.goto(editURL)
await openDocDrawer(
await openDocDrawer({
page,
'#field-relationshipReadOnly button.relationship--single-value__drawer-toggler.doc-drawer__toggler',
)
selector:
'#field-relationshipReadOnly button.relationship--single-value__drawer-toggler.doc-drawer__toggler',
})
const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]')
await expect(documentDrawer).toBeVisible()
@@ -506,7 +504,7 @@ describe('Relationship Field', () => {
test('should open document drawer and append newly created docs onto the parent field', async () => {
await page.goto(url.edit(docWithExistingRelations.id))
await openCreateDocDrawer(page, '#field-relationshipHasMany')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationshipHasMany' })
const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]')
await expect(documentDrawer).toBeVisible()
const drawerField = documentDrawer.locator('#field-name')
@@ -532,10 +530,10 @@ describe('Relationship Field', () => {
const saveButton = page.locator('#action-save')
await expect(saveButton).toBeDisabled()
await openDocDrawer(
await openDocDrawer({
page,
'#field-relationship button.relationship--single-value__drawer-toggler ',
)
selector: '#field-relationship button.relationship--single-value__drawer-toggler',
})
const field = page.locator('#field-name')
await field.fill('Updated')

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { assertToastErrors } from 'helpers/assertToastErrors.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -113,7 +114,7 @@ describe('Array', () => {
await expect(page.locator('#field-customArrayField__0__text')).toBeVisible()
})
// eslint-disable-next-line playwright/expect-expect
test('should bypass min rows validation when no rows present and field is not required', async () => {
await page.goto(url.create)
await saveDocAndAssert(page)
@@ -124,9 +125,10 @@ describe('Array', () => {
await page.locator('#field-arrayWithMinRows >> .array-field__add-row').click()
await page.click('#action-save', { delay: 100 })
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid: Array With Min Rows',
)
await assertToastErrors({
page,
errors: ['Array With Min Rows'],
})
})
test('should show singular label for array rows', async () => {

View File

@@ -1,43 +1,4 @@
@mixin btn-reset {
border: 0;
background: none;
box-shadow: none;
border-radius: 0;
padding: 0;
color: currentColor;
cursor: pointer;
}
#field-customBlocks {
margin-bottom: var(--base);
.blocks-field__drawer-toggler {
display: none;
}
}
.custom-blocks-field-management {
&__blocks-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: calc(var(--base) * 2);
}
&__block-button {
@include btn-reset;
border: 1px solid var(--theme-border-color);
width: 100%;
padding: 25px 10px;
&:hover {
border-color: var(--theme-elevation-400);
}
}
&__replace-block-button {
margin-top: calc(var(--base) * 1.5);
color: var(--theme-bg);
background: var(--theme-text);
}
display: flex;
gap: var(--base);
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useField, useForm } from '@payloadcms/ui'
import { Button, useField, useForm } from '@payloadcms/ui'
import * as React from 'react'
import './index.scss'
@@ -10,7 +10,7 @@ const baseClass = 'custom-blocks-field-management'
const blocksPath = 'customBlocks'
export const AddCustomBlocks: React.FC<any> = (props) => {
const { addFieldRow, replaceFieldRow } = useForm()
const { addFieldRow, initializing, replaceFieldRow } = useForm()
const field = useField<number>({ path: blocksPath })
const { value } = field
@@ -18,73 +18,67 @@ export const AddCustomBlocks: React.FC<any> = (props) => {
return (
<div className={baseClass}>
<div className={`${baseClass}__blocks-grid`}>
<button
className={`${baseClass}__block-button`}
onClick={() => {
addFieldRow({
blockType: 'block-1',
path: blocksPath,
schemaPath,
subFieldState: {
block1Title: {
initialValue: 'Block 1: Prefilled Title',
valid: true,
value: 'Block 1: Prefilled Title',
},
<Button
disabled={initializing}
onClick={() => {
addFieldRow({
blockType: 'block-1',
path: blocksPath,
schemaPath,
subFieldState: {
block1Title: {
initialValue: 'Block 1: Prefilled Title',
valid: true,
value: 'Block 1: Prefilled Title',
},
})
}}
type="button"
>
Add Block 1
</button>
<button
className={`${baseClass}__block-button`}
onClick={() => {
addFieldRow({
blockType: 'block-2',
path: blocksPath,
schemaPath,
subFieldState: {
block2Title: {
initialValue: 'Block 2: Prefilled Title',
valid: true,
value: 'Block 2: Prefilled Title',
},
},
})
}}
type="button"
>
Add Block 1
</Button>
<Button
disabled={initializing}
onClick={() => {
addFieldRow({
blockType: 'block-2',
path: blocksPath,
schemaPath,
subFieldState: {
block2Title: {
initialValue: 'Block 2: Prefilled Title',
valid: true,
value: 'Block 2: Prefilled Title',
},
})
}}
type="button"
>
Add Block 2
</button>
</div>
<div>
<button
className={`${baseClass}__block-button ${baseClass}__replace-block-button`}
onClick={() =>
replaceFieldRow({
blockType: 'block-1',
path: blocksPath,
rowIndex: value,
schemaPath,
subFieldState: {
block1Title: {
initialValue: 'REPLACED BLOCK',
valid: true,
value: 'REPLACED BLOCK',
},
},
})
}}
type="button"
>
Add Block 2
</Button>
<Button
disabled={initializing}
onClick={() =>
replaceFieldRow({
blockType: 'block-1',
path: blocksPath,
rowIndex: value,
schemaPath,
subFieldState: {
block1Title: {
initialValue: 'REPLACED BLOCK',
valid: true,
value: 'REPLACED BLOCK',
},
})
}
type="button"
>
Replace Block {value}
</button>
</div>
},
})
}
type="button"
>
Replace Block {value}
</Button>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test'
import { addBlock } from 'helpers/e2e/addBlock.js'
import { openBlocksDrawer } from 'helpers/e2e/openBlocksDrawer.js'
import { reorderBlocks } from 'helpers/e2e/reorderBlocks.js'
import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -13,10 +14,11 @@ import {
saveDocAndAssert,
} from '../../../helpers.js'
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 { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -82,7 +84,7 @@ describe('Block fields', () => {
const addedRow = page.locator('#field-blocks .blocks-field__row').last()
await expect(addedRow).toBeVisible()
await expect(addedRow.locator('.blocks-field__block-header')).toHaveText(
'Custom Block Label: Content 04',
'Custom Block Label: Content 05',
)
})
@@ -156,7 +158,7 @@ describe('Block fields', () => {
await duplicateButton.click()
const blocks = page.locator('#field-blocks > .blocks-field__rows > div')
expect(await blocks.count()).toEqual(4)
expect(await blocks.count()).toEqual(5)
})
test('should save when duplicating subblocks', async () => {
@@ -171,7 +173,7 @@ describe('Block fields', () => {
await duplicateButton.click()
const blocks = page.locator('#field-blocks > .blocks-field__rows > div')
expect(await blocks.count()).toEqual(4)
expect(await blocks.count()).toEqual(5)
await page.click('#action-save')
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
@@ -274,9 +276,10 @@ describe('Block fields', () => {
await expect(firstRow).toHaveValue('first row')
await page.click('#action-save', { delay: 100 })
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid: Blocks With Min Rows',
)
await assertToastErrors({
page,
errors: ['Blocks With Min Rows'],
})
})
test('ensure functions passed to blocks field labels property are respected', async () => {
@@ -327,20 +330,16 @@ describe('Block fields', () => {
test('should add 2 new block rows', async () => {
await page.goto(url.create)
await scrollEntirePage(page)
await page
.locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Add Block 1' })
.click()
const customBlocks = page.locator(
'#field-customBlocks input[name="customBlocks.0.block1Title"]',
)
await page.mouse.wheel(0, 1750)
await customBlocks.scrollIntoViewIfNeeded()
await expect(customBlocks).toHaveValue('Block 1: Prefilled Title')
await expect(
page.locator('#field-customBlocks input[name="customBlocks.0.block1Title"]'),
).toHaveValue('Block 1: Prefilled Title')
await page
.locator('.custom-blocks-field-management')
@@ -379,6 +378,33 @@ describe('Block fields', () => {
})
})
describe('blockNames', () => {
test('should show blockName field', async () => {
await page.goto(url.create)
const blockWithBlockname = page.locator('#field-blocks .blocks-field__rows #blocks-row-1')
const blocknameField = blockWithBlockname.locator('.section-title')
await expect(async () => await expect(blocknameField).toBeVisible()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await expect(blocknameField).toHaveAttribute('data-value', 'Second block')
})
test("should not show blockName field when it's disabled", async () => {
await page.goto(url.create)
const blockWithBlockname = page.locator('#field-blocks .blocks-field__rows #blocks-row-3')
await expect(
async () => await expect(blockWithBlockname.locator('.section-title')).toBeHidden(),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
})
describe('block groups', () => {
test('should render group labels', async () => {
await page.goto(url.create)

View File

@@ -11,6 +11,7 @@ export const getBlocksField = (prefix?: string): BlocksField => ({
blocks: [
{
slug: prefix ? `${prefix}Content` : 'content',
imageURL: '/api/uploads/file/payload480x320.jpg',
interfaceName: prefix ? `${prefix}ContentBlock` : 'ContentBlock',
admin: {
components: {
@@ -30,6 +31,19 @@ export const getBlocksField = (prefix?: string): BlocksField => ({
},
],
},
{
slug: prefix ? `${prefix}NoBlockname` : 'noBlockname',
interfaceName: prefix ? `${prefix}NoBlockname` : 'NoBlockname',
admin: {
disableBlockName: true,
},
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: prefix ? `${prefix}Number` : 'number',
interfaceName: prefix ? `${prefix}NumberBlock` : 'NumberBlock',

View File

@@ -32,6 +32,10 @@ export const getBlocksFieldSeedData = (prefix?: string): any => [
},
],
},
{
blockType: prefix ? `${prefix}NoBlockname` : 'noBlockname',
text: 'Hello world',
},
]
export const blocksDoc: Partial<BlockField> = {

View File

@@ -40,6 +40,13 @@ const Code: CollectionConfig = {
language: 'css',
},
},
{
name: 'codeWithPadding',
type: 'code',
admin: {
editorOptions: { padding: { bottom: 25, top: 25 } },
},
},
],
}

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import path from 'path'
@@ -7,7 +7,11 @@ import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
import type { Config } from '../../payload-types.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../../../helpers.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
// throttleTest,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
@@ -26,6 +30,7 @@ let client: RESTClient
let page: Page
let serverURL: string
let url: AdminUrlUtil
let context: BrowserContext
const toggleConditionAndCheckField = async (toggleLocator: string, fieldLocator: string) => {
const toggle = page.locator(toggleLocator)
@@ -52,7 +57,7 @@ describe('Conditional Logic', () => {
url = new AdminUrlUtil(serverURL, conditionalLogicSlug)
const context = await browser.newContext()
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -60,14 +65,22 @@ describe('Conditional Logic', () => {
})
beforeEach(async () => {
// await throttleTest({
// page,
// context,
// delay: 'Fast 4G',
// })
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
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 })
@@ -161,20 +174,55 @@ describe('Conditional Logic', () => {
test('should not render fields when adding array or blocks rows until form state returns', async () => {
await page.goto(url.create)
const addRowButton = page.locator('.array-field__add-row')
const fieldWithConditionSelector = 'input#field-arrayWithConditionalField__0__textWithCondition'
await addRowButton.click()
await page.locator('#field-arrayWithConditionalField .array-field__add-row').click()
const shimmer = '#field-arrayWithConditionalField .collapsible__content > .shimmer-effect'
await expect(page.locator(shimmer)).toBeVisible()
await expect(page.locator(shimmer)).toBeHidden()
// Do not use `waitForSelector` here, as it will wait for the selector to appear, not disappear
// eslint-disable-next-line playwright/no-wait-for-selector
const wasFieldAttached = await page
.waitForSelector(fieldWithConditionSelector, {
.waitForSelector('input#field-arrayWithConditionalField__0__textWithCondition', {
state: 'attached',
timeout: 100, // A small timeout to catch any transient rendering
})
.catch(() => false) // If it doesn't appear, this resolves to `false`
expect(wasFieldAttached).toBeFalsy()
const fieldToToggle = page.locator('input#field-enableConditionalFields')
await fieldToToggle.click()
await expect(page.locator(fieldWithConditionSelector)).toBeVisible()
await expect(
page.locator('input#field-arrayWithConditionalField__0__textWithCondition'),
).toBeVisible()
})
test('should render field based on path argument', async () => {
await page.goto(url.create)
const arrayOneButton = page.locator('#field-arrayOne .array-field__add-row')
await arrayOneButton.click()
const arrayTwoButton = page.locator('#arrayOne-row-0 .array-field__add-row')
await arrayTwoButton.click()
const arrayThreeButton = page.locator('#arrayOne-0-arrayTwo-row-0 .array-field__add-row')
await arrayThreeButton.click()
const numberField = page.locator('#field-arrayOne__0__arrayTwo__0__arrayThree__0__numberField')
await expect(numberField).toBeHidden()
const selectField = page.locator('#field-arrayOne__0__arrayTwo__0__selectOptions')
await selectField.click({ delay: 100 })
const options = page.locator('.rs__option')
await options.locator('text=Option Two').click()
await expect(numberField).toBeVisible()
})
})

View File

@@ -29,7 +29,7 @@ const ConditionalLogic: CollectionConfig = {
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomFieldWithField',
Field: '/collections/ConditionalLogic/CustomFieldWithField.js',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
@@ -40,7 +40,7 @@ const ConditionalLogic: CollectionConfig = {
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomFieldWithHOC',
Field: '/collections/ConditionalLogic/CustomFieldWithHOC.js',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
@@ -50,7 +50,7 @@ const ConditionalLogic: CollectionConfig = {
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomClientField',
Field: '/collections/ConditionalLogic/CustomClientField.js',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
@@ -60,7 +60,7 @@ const ConditionalLogic: CollectionConfig = {
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomServerField',
Field: '/collections/ConditionalLogic/CustomServerField.js',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
@@ -182,6 +182,63 @@ const ConditionalLogic: CollectionConfig = {
},
],
},
{
name: 'arrayOne',
type: 'array',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'arrayTwo',
type: 'array',
fields: [
{
name: 'selectOptions',
type: 'select',
defaultValue: 'optionOne',
options: [
{
label: 'Option One',
value: 'optionOne',
},
{
label: 'Option Two',
value: 'optionTwo',
},
],
},
{
name: 'arrayThree',
type: 'array',
fields: [
{
name: 'numberField',
type: 'number',
admin: {
condition: (data, siblingData, { path, user }) => {
// Ensure path has enough depth
if (path.length < 5) {
return false
}
const arrayOneIndex = parseInt(String(path[1]), 10)
const arrayTwoIndex = parseInt(String(path[3]), 10)
const arrayOneItem = data.arrayOne?.[arrayOneIndex]
const arrayTwoItem = arrayOneItem?.arrayTwo?.[arrayTwoIndex]
return arrayTwoItem?.selectOptions === 'optionTwo'
},
},
},
],
},
],
},
],
},
],
}

View File

@@ -10,6 +10,7 @@ import type { Config } from '../../payload-types.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../../../helpers.js'
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'
@@ -96,9 +97,10 @@ describe('Radio', () => {
await page.click('#action-save', { delay: 200 })
// toast error
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid: uniqueText',
)
await assertToastErrors({
page,
errors: ['uniqueText'],
})
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('create')
@@ -117,9 +119,10 @@ describe('Radio', () => {
await page.locator('#action-save').click()
// toast error
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid: group.unique',
)
await assertToastErrors({
page,
errors: ['group.unique'],
})
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('create')

View File

@@ -72,7 +72,7 @@ const JSON: CollectionConfig = {
type: 'json',
admin: {
components: {
afterInput: ['./collections/JSON/AfterField#AfterField'],
afterInput: ['./collections/JSON/AfterField.js#AfterField'],
},
},
label: 'Custom Json',

View File

@@ -1,11 +1,23 @@
'use client'
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
import {
convertLexicalToHTML,
type HTMLConvertersFunction,
} from '@payloadcms/richtext-lexical/html'
import {
convertLexicalToHTMLAsync,
type HTMLConvertersFunctionAsync,
} from '@payloadcms/richtext-lexical/html-async'
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
import { useConfig, useDocumentInfo, usePayloadAPI } from '@payloadcms/ui'
import React from 'react'
import React, { useEffect, useMemo, useState } from 'react'
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
const jsxConverters: JSXConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
@@ -15,6 +27,30 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
},
})
const htmlConverters: HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
relationshipBlock: () => {
return `<p>Test</p>`
},
},
})
const htmlConvertersAsync: HTMLConvertersFunctionAsync<
DefaultNodeTypes | SerializedBlockNode<any>
> = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
relationshipBlock: () => {
return `<p>Test</p>`
},
},
})
export const LexicalRendered: React.FC = () => {
const { id, collectionSlug } = useDocumentInfo()
@@ -31,14 +67,54 @@ export const LexicalRendered: React.FC = () => {
},
})
const [{ data: unpopulatedData }] = usePayloadAPI(`${serverURL}${api}/${collectionSlug}/${id}`, {
initialParams: {
depth: 0,
},
})
const html: null | string = useMemo(() => {
if (!data.lexicalWithBlocks) {
return null
}
return convertLexicalToHTML({
converters: htmlConverters,
data: data.lexicalWithBlocks as SerializedEditorState,
})
}, [data.lexicalWithBlocks])
const [htmlFromUnpopulatedData, setHtmlFromUnpopulatedData] = useState<null | string>(null)
useEffect(() => {
async function convert() {
const html = await convertLexicalToHTMLAsync({
converters: htmlConvertersAsync,
data: unpopulatedData.lexicalWithBlocks as SerializedEditorState,
populate: getRestPopulateFn({
apiURL: `${serverURL}${api}`,
}),
})
setHtmlFromUnpopulatedData(html)
}
void convert()
}, [unpopulatedData.lexicalWithBlocks, api, serverURL])
if (!data.lexicalWithBlocks) {
return null
}
return (
<div>
<h1>Rendered:</h1>
<h1>Rendered JSX:</h1>
<RichText converters={jsxConverters} data={data.lexicalWithBlocks as SerializedEditorState} />
<h1>Rendered HTML:</h1>
{html && <div dangerouslySetInnerHTML={{ __html: html }} />}
<h1>Rendered HTML 2:</h1>
{htmlFromUnpopulatedData && (
<div dangerouslySetInnerHTML={{ __html: htmlFromUnpopulatedData }} />
)}
<h1>Raw JSON:</h1>
<pre>{JSON.stringify(data.lexicalWithBlocks, null, 2)}</pre>
</div>

View File

@@ -24,7 +24,8 @@ import {
saveDocAndAssert,
} from '../../../../../helpers.js'
import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js'
import { trackNetworkRequests } from '../../../../../helpers/e2e/trackNetworkRequests.js'
import { assertToastErrors } from '../../../../../helpers/assertToastErrors.js'
import { assertNetworkRequests } from '../../../../../helpers/e2e/assertNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../../../helpers/rest.js'
@@ -399,11 +400,12 @@ describe('lexicalBlocks', () => {
await dependsOnBlockData.locator('.rs__control').click()
// Fill and wait for form state to come back
await trackNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
await assertNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
await topLevelDocTextField.fill('invalid')
})
// Ensure block form state is updated and comes back (=> filter options are updated)
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
@@ -441,7 +443,7 @@ describe('lexicalBlocks', () => {
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
@@ -477,7 +479,7 @@ describe('lexicalBlocks', () => {
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
@@ -570,18 +572,21 @@ describe('lexicalBlocks', () => {
await topLevelDocTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Doc Data',
)
await assertToastErrors({
page,
errors: ['Lexical With Blocks', 'Lexical With Blocks → Group → Text Depends On Doc Data'],
})
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await topLevelDocTextField.fill('Rich Text') // Default value
},
{ allowedNumberOfRequests: 2 },
{ allowedNumberOfRequests: 1 },
)
await saveDocAndAssert(page)
@@ -593,18 +598,22 @@ describe('lexicalBlocks', () => {
await blockGroupTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Sibling Data',
)
await assertToastErrors({
page,
errors: [
'Lexical With Blocks',
'Lexical With Blocks → Group → Text Depends On Sibling Data',
],
})
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockGroupTextField.fill('')
},
{ allowedNumberOfRequests: 3 },
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
@@ -616,19 +625,19 @@ describe('lexicalBlocks', () => {
await blockTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Block Data',
)
await assertToastErrors({
page,
errors: ['Lexical With Blocks', 'Lexical With Blocks → Group → Text Depends On Block Data'],
})
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('')
},
{ allowedNumberOfRequests: 3 },
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
@@ -1190,7 +1199,7 @@ describe('lexicalBlocks', () => {
// Ensure radio button option1 of radioButtonBlock2 (the default option) is still selected
await expect(
radioButtonBlock2.locator('.radio-input:has-text("Option 1")').first(),
).toBeChecked()
).toHaveClass(/radio-input--is-selected/)
// Click radio button option3 of radioButtonBlock2
await radioButtonBlock2
@@ -1201,7 +1210,7 @@ describe('lexicalBlocks', () => {
// Ensure previously clicked option2 of radioButtonBlock1 is still selected
await expect(
radioButtonBlock1.locator('.radio-input:has-text("Option 2")').first(),
).toBeChecked()
).toHaveClass(/radio-input--is-selected/)
/**
* Now save and check the actual data. radio button block 1 should have option2 selected and radio button block 2 should have option3 selected

View File

@@ -1,9 +1,8 @@
import type { CollectionConfig } from 'payload'
import {
HTMLConverterFeature,
lexicalEditor,
lexicalHTML,
lexicalHTMLField,
LinkFeature,
TreeViewFeature,
UploadFeature,
@@ -39,7 +38,6 @@ export const LexicalMigrateFields: CollectionConfig = {
...defaultFeatures,
LexicalPluginToLexicalFeature({ quiet: true }),
TreeViewFeature(),
HTMLConverterFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
@@ -80,7 +78,6 @@ export const LexicalMigrateFields: CollectionConfig = {
...defaultFeatures,
SlateToLexicalFeature(),
TreeViewFeature(),
HTMLConverterFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
@@ -117,11 +114,11 @@ export const LexicalMigrateFields: CollectionConfig = {
name: 'lexicalSimple',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
defaultValue: getSimpleLexicalData('simple'),
},
lexicalHTML('lexicalSimple', { name: 'lexicalSimple_html' }),
lexicalHTMLField({ htmlFieldName: 'lexicalSimple_html', lexicalFieldName: 'lexicalSimple' }),
{
name: 'groupWithLexicalField',
type: 'group',
@@ -130,11 +127,14 @@ export const LexicalMigrateFields: CollectionConfig = {
name: 'lexicalInGroupField',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
defaultValue: getSimpleLexicalData('group'),
},
lexicalHTML('lexicalInGroupField', { name: 'lexicalInGroupField_html' }),
lexicalHTMLField({
htmlFieldName: 'lexicalInGroupField_html',
lexicalFieldName: 'lexicalInGroupField',
}),
],
},
{
@@ -145,10 +145,13 @@ export const LexicalMigrateFields: CollectionConfig = {
name: 'lexicalInArrayField',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
lexicalHTML('lexicalInArrayField', { name: 'lexicalInArrayField_html' }),
lexicalHTMLField({
htmlFieldName: 'lexicalInArrayField_html',
lexicalFieldName: 'lexicalInArrayField',
}),
],
},
],

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import path from 'path'
import { wait } from 'payload/shared'
@@ -15,12 +16,12 @@ import {
saveDocAndAssert,
} from '../../../helpers.js'
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 { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { numberDoc } from './shared.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -110,7 +111,6 @@ describe('Number', () => {
test('should bypass min rows validation when no rows present and field is not required', async () => {
await page.goto(url.create)
await saveDocAndAssert(page)
expect(true).toBe(true) // the above fn contains the assertion
})
test('should fail min rows validation when rows are present', async () => {
@@ -120,9 +120,10 @@ describe('Number', () => {
await page.keyboard.type(String(input))
await page.keyboard.press('Enter')
await page.click('#action-save', { delay: 100 })
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid: With Min Rows',
)
await assertToastErrors({
page,
errors: ['With Min Rows'],
})
})
test('should keep data removed on save if deleted', async () => {

View File

@@ -0,0 +1,21 @@
export const CustomJSXLabel = () => {
return (
<svg
className="graphic-icon"
height="20px"
id="payload-logo"
viewBox="0 0 25 25"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.8673 21.2336L4.40922 16.9845C4.31871 16.9309 4.25837 16.8355 4.25837 16.7282V10.1609C4.25837 10.0477 4.38508 9.97616 4.48162 10.0298L13.1404 14.9642C13.2611 15.0358 13.412 14.9464 13.412 14.8093V11.6091C13.412 11.4839 13.3456 11.3647 13.2309 11.2992L2.81624 5.36353C2.72573 5.30989 2.60505 5.30989 2.51454 5.36353L1.15085 6.14422C1.06034 6.19786 1 6.29321 1 6.40048V18.5995C1 18.7068 1.06034 18.8021 1.15085 18.8558L11.8491 24.9583C11.9397 25.0119 12.0603 25.0119 12.1509 24.9583L21.1355 19.8331C21.2562 19.7616 21.2562 19.5948 21.1355 19.5232L18.3357 17.9261C18.2211 17.8605 18.0883 17.8605 17.9737 17.9261L12.175 21.2336C12.0845 21.2872 11.9638 21.2872 11.8733 21.2336H11.8673Z"
fill="var(--theme-elevation-1000)"
/>
<path
d="M22.8491 6.13827L12.1508 0.0417218C12.0603 -0.0119135 11.9397 -0.0119135 11.8491 0.0417218L6.19528 3.2658C6.0746 3.33731 6.0746 3.50418 6.19528 3.57569L8.97092 5.16091C9.08557 5.22647 9.21832 5.22647 9.33296 5.16091L11.8672 3.71872C11.9578 3.66508 12.0784 3.66508 12.1689 3.71872L19.627 7.96782C19.7175 8.02146 19.7778 8.11681 19.7778 8.22408V14.8212C19.7778 14.9464 19.8442 15.0656 19.9589 15.1311L22.7345 16.7104C22.8552 16.7819 23.006 16.6925 23.006 16.5554V6.40048C23.006 6.29321 22.9457 6.19786 22.8552 6.14423L22.8491 6.13827Z"
fill="var(--theme-elevation-1000)"
/>
</svg>
)
}

View File

@@ -75,4 +75,16 @@ describe('Radio', () => {
'Value One',
)
})
test('should show custom JSX label in list', async () => {
await page.goto(url.list)
await expect(page.locator('.cell-radioWithJsxLabelOption svg#payload-logo')).toBeVisible()
})
test('should show custom JSX label while editing', async () => {
await page.goto(url.create)
await expect(
page.locator('label[for="field-radioWithJsxLabelOption-three"] svg#payload-logo'),
).toBeVisible()
})
})

View File

@@ -1,6 +1,7 @@
import type { CollectionConfig } from 'payload'
import { radioFieldsSlug } from '../../slugs.js'
import { CustomJSXLabel } from './CustomJSXLabel.js'
const RadioFields: CollectionConfig = {
slug: radioFieldsSlug,
@@ -27,6 +28,26 @@ const RadioFields: CollectionConfig = {
},
],
},
{
name: 'radioWithJsxLabelOption',
label: 'Radio with JSX label option',
type: 'radio',
defaultValue: 'three',
options: [
{
label: 'Value One',
value: 'one',
},
{
label: 'Value Two',
value: 'two',
},
{
label: CustomJSXLabel,
value: 'three',
},
],
},
],
}

View File

@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -16,12 +16,11 @@ import {
ensureCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
openCreateDocDrawer,
openDocDrawer,
saveDocAndAssert,
saveDocHotkeyAndAssert,
} from '../../../helpers.js'
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'
@@ -78,7 +77,7 @@ describe('relationship', () => {
test('should create inline relationship within field with many relations', async () => {
await page.goto(url.create)
await openCreateDocDrawer(page, '#field-relationship')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
await page
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
.click()
@@ -100,7 +99,7 @@ describe('relationship', () => {
await page.goto(url.create)
// Open first modal
await openCreateDocDrawer(page, '#field-relationToSelf')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationToSelf' })
// Fill first modal's required relationship field
await page.locator('[id^=doc-drawer_relationship-fields_1_] #field-relationship').click()
@@ -298,7 +297,7 @@ describe('relationship', () => {
await page.goto(url.create)
// First fill out the relationship field, as it's required
await openCreateDocDrawer(page, '#field-relationship')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
await page
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
.click()
@@ -313,7 +312,7 @@ describe('relationship', () => {
// Create a new doc for the `relationshipHasMany` field
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
await openCreateDocDrawer(page, '#field-relationshipHasMany')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationshipHasMany' })
const value = 'Hello, world!'
await page.locator('.drawer__content #field-text').fill(value)
@@ -326,10 +325,10 @@ describe('relationship', () => {
// Mimic real user behavior by typing into the field with spaces and backspaces
// Explicitly use both `down` and `type` to cover edge cases
await openDocDrawer(
await openDocDrawer({
page,
'#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler',
)
selector: '#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler',
})
await page.locator('[id^=doc-drawer_text-fields_1_] #field-text').click()
await page.keyboard.down('1')
@@ -365,7 +364,7 @@ describe('relationship', () => {
test('should save using hotkey in document drawer', async () => {
await page.goto(url.create)
// First fill out the relationship field, as it's required
await openCreateDocDrawer(page, '#field-relationship')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
await page.locator('#field-relationship .value-container').click()
await wait(500)
// Select "Seeded text document" relationship
@@ -413,7 +412,10 @@ describe('relationship', () => {
.locator('#field-relationship .relationship--single-value')
.textContent()
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
await openDocDrawer({
page,
selector: '#field-relationship .relationship--single-value__drawer-toggler',
})
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
const originalDrawerID = await drawer1Content.locator('.id-label').textContent()
await openDocControls(drawer1Content)
@@ -447,8 +449,6 @@ describe('relationship', () => {
}),
).toBeVisible()
})
test.skip('has many', async () => {})
})
describe('should duplicate document within document drawer', () => {
@@ -469,7 +469,10 @@ describe('relationship', () => {
}),
).toBeVisible()
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
await openDocDrawer({
page,
selector: '#field-relationship .relationship--single-value__drawer-toggler',
})
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
const originalID = await drawer1Content.locator('.id-label').textContent()
const originalText = 'Text'
@@ -505,8 +508,6 @@ describe('relationship', () => {
}),
).toBeVisible()
})
test.skip('has many', async () => {})
})
describe('should delete document within document drawer', () => {
@@ -527,10 +528,10 @@ describe('relationship', () => {
}),
).toBeVisible()
await openDocDrawer(
await openDocDrawer({
page,
'#field-relationship button.relationship--single-value__drawer-toggler',
)
selector: '#field-relationship button.relationship--single-value__drawer-toggler',
})
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
const originalID = await drawer1Content.locator('.id-label').textContent()
@@ -565,15 +566,13 @@ describe('relationship', () => {
}),
).toBeHidden()
})
test.skip('has many', async () => {})
})
// TODO: Fix this. This test flakes due to react select
test.skip('should bypass min rows validation when no rows present and field is not required', async () => {
await page.goto(url.create)
// First fill out the relationship field, as it's required
await openCreateDocDrawer(page, '#field-relationship')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
await page.locator('#field-relationship .value-container').click()
await page.getByText('Seeded text document', { exact: true }).click()
@@ -585,7 +584,7 @@ describe('relationship', () => {
await page.goto(url.create)
// First fill out the relationship field, as it's required
await openCreateDocDrawer(page, '#field-relationship')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
await page.locator('#field-relationship .value-container').click()
await page.getByText('Seeded text document', { exact: true }).click()
@@ -599,9 +598,10 @@ describe('relationship', () => {
.click()
await page.click('#action-save', { delay: 100 })
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid: Relationship With Min Rows',
)
await assertToastErrors({
page,
errors: ['Relationship With Min Rows'],
})
})
test('should sort relationship options by sortOptions property (ID in ascending order)', async () => {

View File

@@ -0,0 +1,21 @@
export const CustomJSXLabel = () => {
return (
<svg
className="graphic-icon"
height="20px"
id="payload-logo"
viewBox="0 0 25 25"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.8673 21.2336L4.40922 16.9845C4.31871 16.9309 4.25837 16.8355 4.25837 16.7282V10.1609C4.25837 10.0477 4.38508 9.97616 4.48162 10.0298L13.1404 14.9642C13.2611 15.0358 13.412 14.9464 13.412 14.8093V11.6091C13.412 11.4839 13.3456 11.3647 13.2309 11.2992L2.81624 5.36353C2.72573 5.30989 2.60505 5.30989 2.51454 5.36353L1.15085 6.14422C1.06034 6.19786 1 6.29321 1 6.40048V18.5995C1 18.7068 1.06034 18.8021 1.15085 18.8558L11.8491 24.9583C11.9397 25.0119 12.0603 25.0119 12.1509 24.9583L21.1355 19.8331C21.2562 19.7616 21.2562 19.5948 21.1355 19.5232L18.3357 17.9261C18.2211 17.8605 18.0883 17.8605 17.9737 17.9261L12.175 21.2336C12.0845 21.2872 11.9638 21.2872 11.8733 21.2336H11.8673Z"
fill="var(--theme-elevation-1000)"
/>
<path
d="M22.8491 6.13827L12.1508 0.0417218C12.0603 -0.0119135 11.9397 -0.0119135 11.8491 0.0417218L6.19528 3.2658C6.0746 3.33731 6.0746 3.50418 6.19528 3.57569L8.97092 5.16091C9.08557 5.22647 9.21832 5.22647 9.33296 5.16091L11.8672 3.71872C11.9578 3.66508 12.0784 3.66508 12.1689 3.71872L19.627 7.96782C19.7175 8.02146 19.7778 8.11681 19.7778 8.22408V14.8212C19.7778 14.9464 19.8442 15.0656 19.9589 15.1311L22.7345 16.7104C22.8552 16.7819 23.006 16.6925 23.006 16.5554V6.40048C23.006 6.29321 22.9457 6.19786 22.8552 6.14423L22.8491 6.13827Z"
fill="var(--theme-elevation-1000)"
/>
</svg>
)
}

View File

@@ -31,7 +31,7 @@ let page: Page
let serverURL: string
let url: AdminUrlUtil
describe('Radio', () => {
describe('Select', () => {
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
@@ -75,4 +75,24 @@ describe('Radio', () => {
await saveDocAndAssert(page)
await expect(field.locator('.rs__value-container')).toContainText('One')
})
test('should show custom JSX option label in edit', async () => {
await page.goto(url.create)
const svgLocator = page.locator('#field-selectWithJsxLabelOption svg#payload-logo')
await expect(svgLocator).toBeVisible()
})
test('should show custom JSX option label in list', async () => {
await page.goto(url.list)
const columnsButton = page.locator('button:has-text("Columns")')
await columnsButton.click()
await page.locator('text=Select with JSX label option').click()
await expect(page.locator('.cell-selectWithJsxLabelOption svg#payload-logo')).toBeVisible()
})
})

View File

@@ -1,9 +1,11 @@
import type { CollectionConfig } from 'payload'
import { selectFieldsSlug } from '../../slugs.js'
import { CustomJSXLabel } from './CustomJSXLabel.js'
const SelectFields: CollectionConfig = {
slug: selectFieldsSlug,
versions: true,
fields: [
{
name: 'select',
@@ -221,6 +223,26 @@ const SelectFields: CollectionConfig = {
},
],
},
{
name: 'selectWithJsxLabelOption',
label: 'Select with JSX label option',
type: 'select',
defaultValue: 'three',
options: [
{
label: 'Value One',
value: 'one',
},
{
label: 'Value Two',
value: 'two',
},
{
label: CustomJSXLabel,
value: 'three',
},
],
},
],
}

View File

@@ -126,12 +126,69 @@ describe('Tabs', () => {
test('should render array data within named tabs', async () => {
await navigateToDoc(page, url)
await switchTab(page, '.tabs-field__tab-button:nth-child(5)')
await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Name")')
await expect(page.locator('#field-tab__array__0__text')).toHaveValue(
"Hello, I'm the first row, in a named tab",
)
})
test('should render conditional tab when checkbox is toggled', async () => {
await navigateToDoc(page, url)
const conditionalTabSelector = '.tabs-field__tab-button:text-is("Conditional Tab")'
const button = page.locator(conditionalTabSelector)
await expect(
async () => await expect(page.locator(conditionalTabSelector)).toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
const checkboxSelector = `input#field-conditionalTabVisible`
await page.locator(checkboxSelector).check()
await expect(page.locator(checkboxSelector)).toBeChecked()
await expect(
async () => await expect(page.locator(conditionalTabSelector)).not.toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await switchTab(page, conditionalTabSelector)
await expect(
page.locator('label[for="field-conditionalTab__conditionalTabField"]'),
).toHaveCount(1)
})
test('should hide nested conditional tab when checkbox is toggled', async () => {
await navigateToDoc(page, url)
// Show the conditional tab
const conditionalTabSelector = '.tabs-field__tab-button:text-is("Conditional Tab")'
const checkboxSelector = `input#field-conditionalTabVisible`
await page.locator(checkboxSelector).check()
await switchTab(page, conditionalTabSelector)
// Now assert on the nested conditional tab
const nestedConditionalTabSelector = '.tabs-field__tab-button:text-is("Nested Conditional Tab")'
await expect(
async () =>
await expect(page.locator(nestedConditionalTabSelector)).not.toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
const nestedCheckboxSelector = `input#field-conditionalTab__nestedConditionalTabVisible`
await page.locator(nestedCheckboxSelector).uncheck()
await expect(
async () => await expect(page.locator(nestedConditionalTabSelector)).toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('should save preferences for tab order', async () => {
await page.goto(url.list)
@@ -139,7 +196,7 @@ describe('Tabs', () => {
const href = await firstItem.getAttribute('href')
await firstItem.click()
const regex = new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const regex = new RegExp(href!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
await page.waitForURL(regex)

View File

@@ -21,9 +21,103 @@ const TabsFields: CollectionConfig = {
'This should not collapse despite there being many tabs pushing the main fields open.',
},
},
{
name: 'conditionalTabVisible',
type: 'checkbox',
label: 'Toggle Conditional Tab',
admin: {
position: 'sidebar',
description:
'When active, the conditional tab should be visible. When inactive, it should be hidden.',
},
},
{
type: 'tabs',
tabs: [
{
name: 'conditionalTab',
label: 'Conditional Tab',
description: 'This tab should only be visible when the conditional field is checked.',
fields: [
{
name: 'conditionalTabField',
type: 'text',
label: 'Conditional Tab Field',
defaultValue:
'This field should only be visible when the conditional tab is visible.',
},
{
name: 'nestedConditionalTabVisible',
type: 'checkbox',
label: 'Toggle Nested Conditional Tab',
defaultValue: true,
admin: {
description:
'When active, the nested conditional tab should be visible. When inactive, it should be hidden.',
},
},
{
type: 'group',
name: 'conditionalTabGroup',
fields: [
{
type: 'text',
name: 'conditionalTabGroupTitle',
},
{
type: 'tabs',
tabs: [
{
// duplicate name as above, should not conflict with tab IDs in form-state, if it does then tests will fail
name: 'conditionalTab',
label: 'Duplicate conditional tab',
fields: [],
admin: {
condition: ({ conditionalTab }) =>
!!conditionalTab?.nestedConditionalTabVisible,
},
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
label: 'Nested Unconditional Tab',
description: 'Description for a nested unconditional tab',
fields: [
{
name: 'nestedUnconditionalTabInput',
type: 'text',
},
],
},
{
label: 'Nested Conditional Tab',
description: 'Here is a description for a nested conditional tab',
fields: [
{
name: 'nestedConditionalTabInput',
type: 'textarea',
defaultValue:
'This field should only be visible when the nested conditional tab is visible.',
},
],
admin: {
condition: ({ conditionalTab }) =>
!!conditionalTab?.nestedConditionalTabVisible,
},
},
],
},
],
admin: {
condition: ({ conditionalTabVisible }) => !!conditionalTabVisible,
},
},
{
label: 'Tab with Array',
description: 'This tab has an array.',

View File

@@ -3,8 +3,8 @@ import type { GeneratedTypes } from 'helpers/sdk/types.js'
import { expect, test } from '@playwright/test'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { upsertPreferences } from 'helpers/e2e/preferences.js'
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -165,7 +165,7 @@ describe('Text', () => {
})
test('should respect admin.disableListColumn despite preferences', async () => {
await upsertPrefs<Config, GeneratedTypes<any>>({
await upsertPreferences<Config, GeneratedTypes<any>>({
payload,
user: client.user,
key: 'text-fields-list',
@@ -198,6 +198,7 @@ describe('Text', () => {
await toggleColumn(page, {
targetState: 'on',
columnLabel: 'Text en',
columnName: 'localizedText',
})
const textCell = page.locator('.row-1 .cell-i18nText')

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -11,7 +12,6 @@ import type { Config } from '../../payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
openDocDrawer,
saveDocAndAssert,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
@@ -155,14 +155,16 @@ describe('Upload', () => {
await wait(1000)
// Open the media drawer and create a png upload
await openDocDrawer(page, '#field-media .upload__createNewToggler')
await openDocDrawer({ page, selector: '#field-media .upload__createNewToggler' })
await page
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
.setInputFiles(path.resolve(dirname, './uploads/payload.png'))
await expect(
page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'),
).toHaveValue('payload.png')
await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
@@ -170,9 +172,11 @@ describe('Upload', () => {
await expect(
page.locator('.field-type.upload .upload-relationship-details__filename a'),
).toHaveAttribute('href', '/api/uploads/file/payload-1.png')
await expect(
page.locator('.field-type.upload .upload-relationship-details__filename a'),
).toContainText('payload-1.png')
await expect(
page.locator('.field-type.upload .upload-relationship-details img'),
).toHaveAttribute('src', '/api/uploads/file/payload-1.png')
@@ -184,7 +188,7 @@ describe('Upload', () => {
await wait(1000)
// Open the media drawer and create a png upload
await openDocDrawer(page, '#field-media .upload__createNewToggler')
await openDocDrawer({ page, selector: '#field-media .upload__createNewToggler' })
await page
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
@@ -222,7 +226,7 @@ describe('Upload', () => {
await uploadImage()
await wait(1000) // TODO: Fix this. Need to wait a bit until the form in the drawer mounted, otherwise values sometimes disappear. This is an issue for all drawers
await openDocDrawer(page, '#field-media .upload__createNewToggler')
await openDocDrawer({ page, selector: '#field-media .upload__createNewToggler' })
await wait(1000)
@@ -240,7 +244,7 @@ describe('Upload', () => {
test('should select using the list drawer and restrict mimetype based on filterOptions', async () => {
await uploadImage()
await openDocDrawer(page, '.field-type.upload .upload__listToggler')
await openDocDrawer({ page, selector: '.field-type.upload .upload__listToggler' })
const jpgImages = page.locator('[id^=list-drawer_1_] .upload-gallery img[src$=".jpg"]')
await expect
@@ -262,7 +266,7 @@ describe('Upload', () => {
await wait(200)
// open drawer
await openDocDrawer(page, '.field-type.upload .list-drawer__toggler')
await openDocDrawer({ page, selector: '.field-type.upload .list-drawer__toggler' })
// check title
await expect(page.locator('.list-drawer__header-text')).toContainText('Uploads 3')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1815,7 +1815,7 @@ describe('Fields', () => {
],
},
}),
).rejects.toThrow('The following field is invalid: Items 1 > SubArray 1 > Second text field')
).rejects.toThrow('The following field is invalid: Items 1 > Sub Array 1 > Second text field')
})
it('should show proper validation error on text field in row field in nested array', async () => {
@@ -1835,7 +1835,7 @@ describe('Fields', () => {
],
},
}),
).rejects.toThrow('The following field is invalid: Items 1 > SubArray 1 > Text In Row')
).rejects.toThrow('The following field is invalid: Items 1 > Sub Array 1 > Text In Row')
})
})
@@ -2363,7 +2363,7 @@ describe('Fields', () => {
],
},
}),
).rejects.toThrow('The following field is invalid: Array 3 > Text')
).rejects.toThrow('The following field is invalid: Tab with Array > Array 3 > Text')
})
})
@@ -2667,7 +2667,7 @@ describe('Fields', () => {
},
}),
).rejects.toThrow(
'The following field is invalid: Group > SubGroup > Required Text Within Sub Group',
'The following field is invalid: Collapsible Field > Group > Sub Group > Required Text Within Sub Group',
)
})
})

View File

@@ -304,8 +304,8 @@ describe('Lexical', () => {
})
).docs[0] as never
const htmlField: string = lexicalDoc?.lexicalSimple_html
expect(htmlField).toStrictEqual('<p>simple</p>')
const htmlField = lexicalDoc?.lexicalSimple_html
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>simple</p></div>')
})
it('htmlConverter: should output correct HTML for lexical field nested in group', async () => {
const lexicalDoc: LexicalMigrateField = (
@@ -320,8 +320,8 @@ describe('Lexical', () => {
})
).docs[0] as never
const htmlField: string = lexicalDoc?.groupWithLexicalField?.lexicalInGroupField_html
expect(htmlField).toStrictEqual('<p>group</p>')
const htmlField = lexicalDoc?.groupWithLexicalField?.lexicalInGroupField_html
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>group</p></div>')
})
it('htmlConverter: should output correct HTML for lexical field nested in array', async () => {
const lexicalDoc: LexicalMigrateField = (
@@ -336,11 +336,11 @@ describe('Lexical', () => {
})
).docs[0] as never
const htmlField1: string = lexicalDoc?.arrayWithLexicalField[0].lexicalInArrayField_html
const htmlField2: string = lexicalDoc?.arrayWithLexicalField[1].lexicalInArrayField_html
const htmlField1 = lexicalDoc?.arrayWithLexicalField?.[0]?.lexicalInArrayField_html
const htmlField2 = lexicalDoc?.arrayWithLexicalField?.[1]?.lexicalInArrayField_html
expect(htmlField1).toStrictEqual('<p>array 1</p>')
expect(htmlField2).toStrictEqual('<p>array 2</p>')
expect(htmlField1).toStrictEqual('<div class="payload-richtext"><p>array 1</p></div>')
expect(htmlField2).toStrictEqual('<div class="payload-richtext"><p>array 2</p></div>')
})
})
describe('advanced - blocks', () => {
@@ -654,7 +654,7 @@ describe('Lexical', () => {
locale: 'en',
data: {
title: 'Localized Lexical hooks',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }) as any,
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',

View File

@@ -699,16 +699,29 @@ export interface ArrayField {
*/
export interface BlockField {
id: string;
blocks: (ContentBlock | NumberBlock | SubBlocksBlock | TabsBlock)[];
duplicate: (ContentBlock | NumberBlock | SubBlocksBlock | TabsBlock)[];
blocks: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[];
duplicate: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[];
collapsedByDefaultBlocks: (
| LocalizedContentBlock
| LocalizedNoBlockname
| LocalizedNumberBlock
| LocalizedSubBlocksBlock
| LocalizedTabsBlock
)[];
disableSort: (
| LocalizedContentBlock
| LocalizedNoBlockname
| LocalizedNumberBlock
| LocalizedSubBlocksBlock
| LocalizedTabsBlock
)[];
localizedBlocks: (
| LocalizedContentBlock
| LocalizedNoBlockname
| LocalizedNumberBlock
| LocalizedSubBlocksBlock
| LocalizedTabsBlock
)[];
disableSort: (LocalizedContentBlock | LocalizedNumberBlock | LocalizedSubBlocksBlock | LocalizedTabsBlock)[];
localizedBlocks: (LocalizedContentBlock | LocalizedNumberBlock | LocalizedSubBlocksBlock | LocalizedTabsBlock)[];
i18nBlocks?:
| {
text?: string | null;
@@ -883,6 +896,16 @@ export interface ContentBlock {
blockName?: string | null;
blockType: 'content';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "NoBlockname".
*/
export interface NoBlockname {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'noBlockname';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "NumberBlock".
@@ -939,6 +962,16 @@ export interface LocalizedContentBlock {
blockName?: string | null;
blockType: 'localizedContent';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedNoBlockname".
*/
export interface LocalizedNoBlockname {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'localizedNoBlockname';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedNumberBlock".
@@ -1053,6 +1086,7 @@ export interface CodeField {
json?: string | null;
html?: string | null;
css?: string | null;
codeWithPadding?: string | null;
updatedAt: string;
createdAt: string;
}
@@ -1153,6 +1187,24 @@ export interface ConditionalLogic {
blockType: 'blockWithConditionalField';
}[]
| null;
arrayOne?:
| {
title?: string | null;
arrayTwo?:
| {
selectOptions?: ('optionOne' | 'optionTwo') | null;
arrayThree?:
| {
numberField?: number | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -1253,6 +1305,7 @@ export interface EmailField {
export interface RadioField {
id: string;
radio?: ('one' | 'two' | 'three') | null;
radioWithJsxLabelOption?: ('one' | 'two' | 'three') | null;
updatedAt: string;
createdAt: string;
}
@@ -1748,6 +1801,7 @@ export interface SelectField {
settings?: {
category?: ('a' | 'b')[] | null;
};
selectWithJsxLabelOption?: ('one' | 'two' | 'three') | null;
updatedAt: string;
createdAt: string;
}
@@ -1779,11 +1833,28 @@ export interface TabsField {
* This should not collapse despite there being many tabs pushing the main fields open.
*/
sidebarField?: string | null;
/**
* When active, the conditional tab should be visible. When inactive, it should be hidden.
*/
conditionalTabVisible?: boolean | null;
conditionalTab?: {
conditionalTabField?: string | null;
/**
* When active, the nested conditional tab should be visible. When inactive, it should be hidden.
*/
nestedConditionalTabVisible?: boolean | null;
conditionalTabGroup?: {
conditionalTabGroupTitle?: string | null;
conditionalTab?: {};
};
nestedUnconditionalTabInput?: string | null;
nestedConditionalTabInput?: string | null;
};
array: {
text: string;
id?: string | null;
}[];
blocks: (ContentBlock | NumberBlock | SubBlocksBlock | TabsBlock)[];
blocks: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[];
group: {
number: number;
};
@@ -2443,6 +2514,7 @@ export interface BlockFieldsSelect<T extends boolean = true> {
| T
| {
content?: T | ContentBlockSelect<T>;
noBlockname?: T | NoBlocknameSelect<T>;
number?: T | NumberBlockSelect<T>;
subBlocks?: T | SubBlocksBlockSelect<T>;
tabs?: T | TabsBlockSelect<T>;
@@ -2451,6 +2523,7 @@ export interface BlockFieldsSelect<T extends boolean = true> {
| T
| {
content?: T | ContentBlockSelect<T>;
noBlockname?: T | NoBlocknameSelect<T>;
number?: T | NumberBlockSelect<T>;
subBlocks?: T | SubBlocksBlockSelect<T>;
tabs?: T | TabsBlockSelect<T>;
@@ -2459,6 +2532,7 @@ export interface BlockFieldsSelect<T extends boolean = true> {
| T
| {
localizedContent?: T | LocalizedContentBlockSelect<T>;
localizedNoBlockname?: T | LocalizedNoBlocknameSelect<T>;
localizedNumber?: T | LocalizedNumberBlockSelect<T>;
localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
localizedTabs?: T | LocalizedTabsBlockSelect<T>;
@@ -2467,6 +2541,7 @@ export interface BlockFieldsSelect<T extends boolean = true> {
| T
| {
localizedContent?: T | LocalizedContentBlockSelect<T>;
localizedNoBlockname?: T | LocalizedNoBlocknameSelect<T>;
localizedNumber?: T | LocalizedNumberBlockSelect<T>;
localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
localizedTabs?: T | LocalizedTabsBlockSelect<T>;
@@ -2475,6 +2550,7 @@ export interface BlockFieldsSelect<T extends boolean = true> {
| T
| {
localizedContent?: T | LocalizedContentBlockSelect<T>;
localizedNoBlockname?: T | LocalizedNoBlocknameSelect<T>;
localizedNumber?: T | LocalizedNumberBlockSelect<T>;
localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
localizedTabs?: T | LocalizedTabsBlockSelect<T>;
@@ -2672,6 +2748,15 @@ export interface ContentBlockSelect<T extends boolean = true> {
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "NoBlockname_select".
*/
export interface NoBlocknameSelect<T extends boolean = true> {
text?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "NumberBlock_select".
@@ -2721,6 +2806,15 @@ export interface LocalizedContentBlockSelect<T extends boolean = true> {
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedNoBlockname_select".
*/
export interface LocalizedNoBlocknameSelect<T extends boolean = true> {
text?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedNumberBlock_select".
@@ -2779,6 +2873,7 @@ export interface CodeFieldsSelect<T extends boolean = true> {
json?: T;
html?: T;
css?: T;
codeWithPadding?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -2874,6 +2969,24 @@ export interface ConditionalLogicSelect<T extends boolean = true> {
blockName?: T;
};
};
arrayOne?:
| T
| {
title?: T;
arrayTwo?:
| T
| {
selectOptions?: T;
arrayThree?:
| T
| {
numberField?: T;
id?: T;
};
id?: T;
};
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
@@ -2968,6 +3081,7 @@ export interface EmailFieldsSelect<T extends boolean = true> {
*/
export interface RadioFieldsSelect<T extends boolean = true> {
radio?: T;
radioWithJsxLabelOption?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -3330,6 +3444,7 @@ export interface SelectFieldsSelect<T extends boolean = true> {
| {
category?: T;
};
selectWithJsxLabelOption?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -3358,6 +3473,21 @@ export interface TabsFields2Select<T extends boolean = true> {
*/
export interface TabsFieldsSelect<T extends boolean = true> {
sidebarField?: T;
conditionalTabVisible?: T;
conditionalTab?:
| T
| {
conditionalTabField?: T;
nestedConditionalTabVisible?: T;
conditionalTabGroup?:
| T
| {
conditionalTabGroupTitle?: T;
conditionalTab?: T | {};
};
nestedUnconditionalTabInput?: T;
nestedConditionalTabInput?: T;
};
array?:
| T
| {
@@ -3368,6 +3498,7 @@ export interface TabsFieldsSelect<T extends boolean = true> {
| T
| {
content?: T | ContentBlockSelect<T>;
noBlockname?: T | NoBlocknameSelect<T>;
number?: T | NumberBlockSelect<T>;
subBlocks?: T | SubBlocksBlockSelect<T>;
tabs?: T | TabsBlockSelect<T>;

View File

@@ -69,10 +69,15 @@ const dirname = path.dirname(filename)
export const seed = async (_payload: Payload) => {
const jpgPath = path.resolve(dirname, './collections/Upload/payload.jpg')
const jpg480x320Path = path.resolve(dirname, './collections/Upload/payload480x320.jpg')
const pngPath = path.resolve(dirname, './uploads/payload.png')
// Get both files in parallel
const [jpgFile, pngFile] = await Promise.all([getFileByPath(jpgPath), getFileByPath(pngPath)])
const [jpgFile, jpg480x320File, pngFile] = await Promise.all([
getFileByPath(jpgPath),
getFileByPath(jpg480x320Path),
getFileByPath(pngPath),
])
const createdArrayDoc = await _payload.create({
collection: arrayFieldsSlug,
@@ -121,6 +126,14 @@ export const seed = async (_payload: Payload) => {
overrideAccess: true,
})
await _payload.create({
collection: uploadsSlug,
data: {},
file: jpg480x320File,
depth: 0,
overrideAccess: true,
})
// const createdJPGDocSlug2 = await _payload.create({
// collection: uploads2Slug,
// data: {

2
test/form-state/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,10 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { useField } from '@payloadcms/ui'
export const RenderTracker: TextFieldClientComponent = ({ path }) => {
useField({ path })
console.count('Renders') // eslint-disable-line no-console
return null
}

View File

@@ -0,0 +1,68 @@
import type { CollectionConfig } from 'payload'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'renderTracker',
type: 'text',
admin: {
components: {
Field: './collections/Posts/RenderTracker.js#RenderTracker',
},
},
},
{
name: 'validateUsingEvent',
type: 'text',
admin: {
description:
'This field should only validate on submit. Try typing "Not allowed" and submitting the form.',
},
validate: (value, { event }) => {
if (event === 'onChange') {
return true
}
if (value === 'Not allowed') {
return 'This field has been validated only on submit'
}
return true
},
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'text',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'number',
fields: [
{
name: 'number',
type: 'number',
},
],
},
],
},
],
}

37
test/form-state/config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'example post',
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

192
test/form-state/e2e.spec.ts Normal file
View File

@@ -0,0 +1,192 @@
import type { BrowserContext, Page } from '@playwright/test'
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
import { expect, test } from '@playwright/test'
import { addBlock } from 'helpers/e2e/addBlock.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { Config, Post } from './payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const title = 'Title'
let context: BrowserContext
let payload: PayloadTestSDK<Config>
let serverURL: string
test.describe('Form State', () => {
let page: Page
let postsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, 'posts')
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test.beforeEach(async () => {
// await throttleTest({ page, context, delay: 'Fast 3G' })
})
test('should disable fields during initialization', async () => {
await page.goto(postsUrl.create, { waitUntil: 'commit' })
await expect(page.locator('#field-title')).toBeDisabled()
})
test('should disable fields while processing', async () => {
const doc = await createPost()
await page.goto(postsUrl.edit(doc.id))
await page.locator('#field-title').fill(title)
await page.click('#action-save', { delay: 100 })
await expect(page.locator('#field-title')).toBeDisabled()
})
test('should re-enable fields after save', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('should only validate on submit via the `event` argument', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await page.locator('#field-validateUsingEvent').fill('Not allowed')
await saveDocAndAssert(page, '#action-save', 'error')
})
test('should fire a single network request for onChange events when manipulating blocks', async () => {
await page.goto(postsUrl.create)
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await addBlock({
page,
blockLabel: 'Text',
fieldName: 'blocks',
})
},
{
allowedNumberOfRequests: 1,
},
)
})
test('should not throw fields into an infinite rendering loop', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
let numberOfRenders = 0
page.on('console', (msg) => {
if (msg.type() === 'count' && msg.text().includes('Renders')) {
numberOfRenders++
}
})
const allowedNumberOfRenders = 25
const pollInterval = 200
const maxTime = 5000
let elapsedTime = 0
const intervalId = setInterval(() => {
if (numberOfRenders > allowedNumberOfRenders) {
clearInterval(intervalId)
throw new Error(`Render count exceeded the threshold of ${allowedNumberOfRenders}`)
}
elapsedTime += pollInterval
if (elapsedTime >= maxTime) {
clearInterval(intervalId)
}
}, pollInterval)
await page.waitForTimeout(maxTime)
expect(numberOfRenders).toBeLessThanOrEqual(allowedNumberOfRenders)
})
test('should debounce onChange events', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
// Need to type _faster_ than the debounce rate (250ms)
await field.pressSequentially('Some text to type', { delay: 50 })
},
{
allowedNumberOfRequests: 1,
},
)
})
test('should queue onChange functions', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
// only throttle test after initial load to avoid timeouts
const cdpSession = await throttleTest({
page,
context,
delay: 'Slow 3G',
})
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await field.fill('')
// Need to type into a _slower_ than the debounce rate (250ms), but _faster_ than the network request
await field.pressSequentially('Some text to type', { delay: 275 })
},
{
allowedNumberOfRequests: 2,
timeout: 10000, // watch network for 10 seconds to allow requests to build up
},
)
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await cdpSession.detach()
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {
return payload.create({
collection: 'posts',
data: {
title: 'Post Title',
...overrides,
},
}) as unknown as Promise<Post>
}

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

142
test/form-state/int.spec.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { Payload, User } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import path from 'path'
import { createLocalReq } from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { postsSlug } from './collections/Posts/index.js'
let payload: Payload
let token: string
let restClient: NextRESTClient
let user: User
const { email, password } = devUser
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Form State', () => {
// --__--__--__--__--__--__--__--__--__
// Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname))
const data = await restClient
.POST('/users/login', {
body: JSON.stringify({
email,
password,
}),
})
.then((res) => res.json())
token = data.token
user = data.user
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
it('should build entire form state', async () => {
const req = await createLocalReq({ user }, payload)
const postData = await payload.create({
collection: postsSlug,
data: {
title: 'Test Post',
},
})
const { state } = await buildFormState({
id: postData.id,
collectionSlug: postsSlug,
data: postData,
docPermissions: {
create: true,
delete: true,
fields: true,
read: true,
readVersions: true,
update: true,
},
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
expect(state).toMatchObject({
title: {
value: postData.title,
initialValue: postData.title,
},
updatedAt: {
value: postData.updatedAt,
initialValue: postData.updatedAt,
},
createdAt: {
value: postData.createdAt,
initialValue: postData.createdAt,
},
renderTracker: {},
validateUsingEvent: {},
blocks: {
initialValue: 0,
requiresRender: false,
rows: [],
value: 0,
},
})
})
it('should use `select` to build partial form state with only specified fields', async () => {
const req = await createLocalReq({ user }, payload)
const postData = await payload.create({
collection: postsSlug,
data: {
title: 'Test Post',
},
})
const { state } = await buildFormState({
id: postData.id,
collectionSlug: postsSlug,
data: postData,
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
select: {
title: true,
},
})
expect(state).toStrictEqual({
title: {
value: postData.title,
initialValue: postData.title,
},
})
})
it.todo('should skip validation if specified')
})

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