Merge branch 'main' into feat/folders
This commit is contained in:
@@ -54,6 +54,7 @@ export type SupportedTimezones =
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
|
||||
44
test/access-control/collections/hooks/index.ts
Normal file
44
test/access-control/collections/hooks/index.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
2
test/admin-bar/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
@@ -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
|
||||
25
test/admin-bar/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
test/admin-bar/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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
|
||||
1
test/admin-bar/app/(payload)/admin/importMap.js
Normal file
1
test/admin-bar/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1 @@
|
||||
export const importMap = {}
|
||||
10
test/admin-bar/app/(payload)/api/[...slug]/route.ts
Normal file
10
test/admin-bar/app/(payload)/api/[...slug]/route.ts
Normal 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)
|
||||
@@ -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)
|
||||
8
test/admin-bar/app/(payload)/api/graphql/route.ts
Normal file
8
test/admin-bar/app/(payload)/api/graphql/route.ts
Normal 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)
|
||||
7
test/admin-bar/app/(payload)/custom.scss
Normal file
7
test/admin-bar/app/(payload)/custom.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
#custom-css {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#custom-css::after {
|
||||
content: 'custom-css';
|
||||
}
|
||||
31
test/admin-bar/app/(payload)/layout.tsx
Normal file
31
test/admin-bar/app/(payload)/layout.tsx
Normal 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
|
||||
22
test/admin-bar/app/admin-bar/app.scss
Normal file
22
test/admin-bar/app/admin-bar/app.scss
Normal 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;
|
||||
}
|
||||
29
test/admin-bar/app/admin-bar/layout.tsx
Normal file
29
test/admin-bar/app/admin-bar/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
test/admin-bar/app/admin-bar/page.tsx
Normal file
12
test/admin-bar/app/admin-bar/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Fragment } from 'react'
|
||||
|
||||
const PageTemplate = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<br />
|
||||
<h1>Payload Admin Bar</h1>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageTemplate
|
||||
33
test/admin-bar/collections/Media/index.ts
Normal file
33
test/admin-bar/collections/Media/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const mediaSlug = 'media'
|
||||
|
||||
export const MediaCollection: CollectionConfig = {
|
||||
slug: mediaSlug,
|
||||
access: {
|
||||
create: () => true,
|
||||
read: () => true,
|
||||
},
|
||||
fields: [],
|
||||
upload: {
|
||||
crop: true,
|
||||
focalPoint: true,
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'thumbnail',
|
||||
height: 200,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
name: 'medium',
|
||||
height: 800,
|
||||
width: 800,
|
||||
},
|
||||
{
|
||||
name: 'large',
|
||||
height: 1200,
|
||||
width: 1200,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
19
test/admin-bar/collections/Posts/index.ts
Normal file
19
test/admin-bar/collections/Posts/index.ts
Normal 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
39
test/admin-bar/config.ts
Normal 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'),
|
||||
},
|
||||
})
|
||||
37
test/admin-bar/e2e.spec.ts
Normal file
37
test/admin-bar/e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
19
test/admin-bar/eslint.config.js
Normal file
19
test/admin-bar/eslint.config.js
Normal 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
5
test/admin-bar/next-env.d.ts
vendored
Normal 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.
|
||||
15
test/admin-bar/next.config.mjs
Normal file
15
test/admin-bar/next.config.mjs
Normal 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),
|
||||
},
|
||||
}
|
||||
370
test/admin-bar/payload-types.ts
Normal file
370
test/admin-bar/payload-types.ts
Normal 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 {}
|
||||
}
|
||||
13
test/admin-bar/tsconfig.eslint.json
Normal file
13
test/admin-bar/tsconfig.eslint.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
// extend your base config to share compilerOptions, etc
|
||||
//"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// ensure that nobody can accidentally use this config for a build
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
// whatever paths you intend to lint
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
]
|
||||
}
|
||||
5
test/admin-bar/tsconfig.json
Normal file
5
test/admin-bar/tsconfig.json
Normal 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
9
test/admin-bar/types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RequestContext as OriginalRequestContext } from 'payload'
|
||||
|
||||
declare module 'payload' {
|
||||
// Create a new interface that merges your additional fields with the original one
|
||||
export interface RequestContext extends OriginalRequestContext {
|
||||
myObject?: string
|
||||
// ...
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
16
test/admin/collections/DisableCopyToLocale.ts
Normal file
16
test/admin/collections/DisableCopyToLocale.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
14
test/admin/collections/UploadTwo.ts
Normal file
14
test/admin/collections/UploadTwo.ts
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
2
test/bulk-edit/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
114
test/bulk-edit/collections/Posts/index.ts
Normal file
114
test/bulk-edit/collections/Posts/index.ts
Normal 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
38
test/bulk-edit/config.ts
Normal 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
546
test/bulk-edit/e2e.spec.ts
Normal 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>
|
||||
}
|
||||
19
test/bulk-edit/eslint.config.js
Normal file
19
test/bulk-edit/eslint.config.js
Normal 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
|
||||
335
test/bulk-edit/payload-types.ts
Normal file
335
test/bulk-edit/payload-types.ts
Normal 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 {}
|
||||
}
|
||||
1902
test/bulk-edit/schema.graphql
Normal file
1902
test/bulk-edit/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
1
test/bulk-edit/shared.ts
Normal file
1
test/bulk-edit/shared.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const postsSlug = 'posts'
|
||||
13
test/bulk-edit/tsconfig.eslint.json
Normal file
13
test/bulk-edit/tsconfig.eslint.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
// extend your base config to share compilerOptions, etc
|
||||
//"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// ensure that nobody can accidentally use this config for a build
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
// whatever paths you intend to lint
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
]
|
||||
}
|
||||
3
test/bulk-edit/tsconfig.json
Normal file
3
test/bulk-edit/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
||||
9
test/bulk-edit/types.d.ts
vendored
Normal file
9
test/bulk-edit/types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RequestContext as OriginalRequestContext } from 'payload'
|
||||
|
||||
declare module 'payload' {
|
||||
// Create a new interface that merges your additional fields with the original one
|
||||
export interface RequestContext extends OriginalRequestContext {
|
||||
myObject?: string
|
||||
// ...
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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".
|
||||
|
||||
122
test/database/postgres-vector.int.spec.ts
Normal file
122
test/database/postgres-vector.int.spec.ts
Normal 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
26
test/database/seed.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -32,6 +32,10 @@ export const getBlocksFieldSeedData = (prefix?: string): any => [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blockType: prefix ? `${prefix}NoBlockname` : 'noBlockname',
|
||||
text: 'Hello world',
|
||||
},
|
||||
]
|
||||
|
||||
export const blocksDoc: Partial<BlockField> = {
|
||||
|
||||
@@ -40,6 +40,13 @@ const Code: CollectionConfig = {
|
||||
language: 'css',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'codeWithPadding',
|
||||
type: 'code',
|
||||
admin: {
|
||||
editorOptions: { padding: { bottom: 25, top: 25 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
21
test/fields/collections/Radio/CustomJSXLabel.tsx
Normal file
21
test/fields/collections/Radio/CustomJSXLabel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
21
test/fields/collections/Select/CustomJSXLabel.tsx
Normal file
21
test/fields/collections/Select/CustomJSXLabel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
BIN
test/fields/collections/Upload/payload480x320.jpg
Normal file
BIN
test/fields/collections/Upload/payload480x320.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
2
test/form-state/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
10
test/form-state/collections/Posts/RenderTracker.tsx
Normal file
10
test/form-state/collections/Posts/RenderTracker.tsx
Normal 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
|
||||
}
|
||||
68
test/form-state/collections/Posts/index.ts
Normal file
68
test/form-state/collections/Posts/index.ts
Normal 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
37
test/form-state/config.ts
Normal 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
192
test/form-state/e2e.spec.ts
Normal 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>
|
||||
}
|
||||
19
test/form-state/eslint.config.js
Normal file
19
test/form-state/eslint.config.js
Normal 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
142
test/form-state/int.spec.ts
Normal 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
Reference in New Issue
Block a user