Merge branch 'main' into feat/folders

This commit is contained in:
Jarrod Flesch
2025-05-02 14:37:46 -04:00
145 changed files with 2723 additions and 418 deletions

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'
import { arrayCollectionSlug } from '../slugs.js'
export const Array: CollectionConfig = {
slug: arrayCollectionSlug,
fields: [
{
name: 'array',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}

View File

@@ -0,0 +1,41 @@
import type { CollectionConfig } from 'payload'
import { placeholderCollectionSlug } from '../slugs.js'
export const Placeholder: CollectionConfig = {
slug: placeholderCollectionSlug,
fields: [
{
name: 'defaultSelect',
type: 'select',
options: [
{
label: 'Option 1',
value: 'option1',
},
],
},
{
name: 'placeholderSelect',
type: 'select',
options: [{ label: 'Option 1', value: 'option1' }],
admin: {
placeholder: 'Custom placeholder',
},
},
{
name: 'defaultRelationship',
type: 'relationship',
relationTo: 'posts',
},
{
name: 'placeholderRelationship',
type: 'relationship',
relationTo: 'posts',
admin: {
placeholder: 'Custom placeholder',
},
},
],
versions: true,
}

View File

@@ -1,8 +1,8 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Array } from './collections/Array.js'
import { BaseListFilter } from './collections/BaseListFilter.js'
import { CustomFields } from './collections/CustomFields/index.js'
import { CustomViews1 } from './collections/CustomViews1.js'
@@ -18,6 +18,7 @@ import { CollectionHidden } from './collections/Hidden.js'
import { ListDrawer } from './collections/ListDrawer.js'
import { CollectionNoApiView } from './collections/NoApiView.js'
import { CollectionNotInView } from './collections/NotInView.js'
import { Placeholder } from './collections/Placeholder.js'
import { Posts } from './collections/Posts.js'
import { UploadCollection } from './collections/Upload.js'
import { UploadTwoCollection } from './collections/UploadTwo.js'
@@ -42,7 +43,8 @@ import {
protectedCustomNestedViewPath,
publicCustomViewPath,
} from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
admin: {
importMap: {
@@ -158,11 +160,13 @@ export default buildConfigWithDefaults({
CollectionGroup2A,
CollectionGroup2B,
Geo,
Array,
DisableDuplicate,
DisableCopyToLocale,
BaseListFilter,
with300Documents,
ListDrawer,
Placeholder,
],
globals: [
GlobalHidden,

View File

@@ -1,11 +1,10 @@
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, User } from '../../payload-types.js'
import type { Config, Geo, Post } from '../../payload-types.js'
import {
ensureCompilationIsDone,
@@ -17,9 +16,11 @@ import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { customAdminRoutes } from '../../shared.js'
import {
arrayCollectionSlug,
customViews1CollectionSlug,
geoCollectionSlug,
listDrawerSlug,
placeholderCollectionSlug,
postsCollectionSlug,
with300DocumentsSlug,
} from '../../slugs.js'
@@ -57,11 +58,13 @@ const dirname = path.resolve(currentFolder, '../../')
describe('List View', () => {
let page: Page
let geoUrl: AdminUrlUtil
let arrayUrl: AdminUrlUtil
let postsUrl: AdminUrlUtil
let baseListFiltersUrl: AdminUrlUtil
let customViewsUrl: AdminUrlUtil
let with300DocumentsUrl: AdminUrlUtil
let withListViewUrl: AdminUrlUtil
let placeholderUrl: AdminUrlUtil
let user: any
let serverURL: string
@@ -79,12 +82,13 @@ describe('List View', () => {
}))
geoUrl = new AdminUrlUtil(serverURL, geoCollectionSlug)
arrayUrl = new AdminUrlUtil(serverURL, arrayCollectionSlug)
postsUrl = new AdminUrlUtil(serverURL, postsCollectionSlug)
with300DocumentsUrl = new AdminUrlUtil(serverURL, with300DocumentsSlug)
baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters')
customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug)
withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug)
placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -389,6 +393,32 @@ describe('List View', () => {
await expect(page.locator(tableRowLocator)).toHaveCount(2)
})
test('should allow to filter in array field', async () => {
await createArray()
await page.goto(arrayUrl.list)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
await addListFilter({
page,
fieldLabel: 'Array > Text',
operatorLabel: 'equals',
value: 'test',
})
await expect(page.locator(tableRowLocator)).toHaveCount(1)
await page.locator('.condition__actions .btn.condition__actions-remove').click()
await addListFilter({
page,
fieldLabel: 'Array > Text',
operatorLabel: 'equals',
value: 'not-matching',
})
await expect(page.locator(tableRowLocator)).toHaveCount(0)
})
test('should reset filter value when a different field is selected', async () => {
const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '')
@@ -1379,6 +1409,66 @@ describe('List View', () => {
).toHaveText('Title')
})
})
describe('placeholder', () => {
test('should display placeholder in filter options', async () => {
await page.goto(
`${placeholderUrl.list}${qs.stringify(
{
where: {
or: [
{
and: [
{
defaultSelect: {
equals: '',
},
},
{
placeholderSelect: {
equals: '',
},
},
{
defaultRelationship: {
equals: '',
},
},
{
placeholderRelationship: {
equals: '',
},
},
],
},
],
},
},
{ addQueryPrefix: true },
)}`,
)
const conditionValueSelects = page.locator('#list-controls-where .condition__value')
await expect(conditionValueSelects.nth(0)).toHaveText('Select a value')
await expect(conditionValueSelects.nth(1)).toHaveText('Custom placeholder')
await expect(conditionValueSelects.nth(2)).toHaveText('Select a value')
await expect(conditionValueSelects.nth(3)).toHaveText('Custom placeholder')
})
})
test('should display placeholder in edit view', async () => {
await page.goto(placeholderUrl.create)
await expect(page.locator('#field-defaultSelect .rs__placeholder')).toHaveText('Select a value')
await expect(page.locator('#field-placeholderSelect .rs__placeholder')).toHaveText(
'Custom placeholder',
)
await expect(page.locator('#field-defaultRelationship .rs__placeholder')).toHaveText(
'Select a value',
)
await expect(page.locator('#field-placeholderRelationship .rs__placeholder')).toHaveText(
'Custom placeholder',
)
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {
@@ -1405,3 +1495,12 @@ async function createGeo(overrides?: Partial<Geo>): Promise<Geo> {
},
}) as unknown as Promise<Geo>
}
async function createArray() {
return payload.create({
collection: arrayCollectionSlug,
data: {
array: [{ text: 'test' }],
},
})
}

View File

@@ -82,6 +82,7 @@ export interface Config {
'group-two-collection-ones': GroupTwoCollectionOne;
'group-two-collection-twos': GroupTwoCollectionTwo;
geo: Geo;
array: Array;
'disable-duplicate': DisableDuplicate;
'disable-copy-to-locale': DisableCopyToLocale;
'base-list-filters': BaseListFilter;
@@ -108,6 +109,7 @@ export interface Config {
'group-two-collection-ones': GroupTwoCollectionOnesSelect<false> | GroupTwoCollectionOnesSelect<true>;
'group-two-collection-twos': GroupTwoCollectionTwosSelect<false> | GroupTwoCollectionTwosSelect<true>;
geo: GeoSelect<false> | GeoSelect<true>;
array: ArraySelect<false> | ArraySelect<true>;
'disable-duplicate': DisableDuplicateSelect<false> | DisableDuplicateSelect<true>;
'disable-copy-to-locale': DisableCopyToLocaleSelect<false> | DisableCopyToLocaleSelect<true>;
'base-list-filters': BaseListFiltersSelect<false> | BaseListFiltersSelect<true>;
@@ -414,6 +416,21 @@ export interface Geo {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array".
*/
export interface Array {
id: string;
array?:
| {
text?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "disable-duplicate".
@@ -534,6 +551,10 @@ export interface PayloadLockedDocument {
relationTo: 'geo';
value: string | Geo;
} | null)
| ({
relationTo: 'array';
value: string | Array;
} | null)
| ({
relationTo: 'disable-duplicate';
value: string | DisableDuplicate;
@@ -818,6 +839,20 @@ export interface GeoSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array_select".
*/
export interface ArraySelect<T extends boolean = true> {
array?:
| T
| {
text?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "disable-duplicate_select".

View File

@@ -2,6 +2,7 @@ export const usersCollectionSlug = 'users'
export const customViews1CollectionSlug = 'custom-views-one'
export const customViews2CollectionSlug = 'custom-views-two'
export const geoCollectionSlug = 'geo'
export const arrayCollectionSlug = 'array'
export const postsCollectionSlug = 'posts'
export const group1Collection1Slug = 'group-one-collection-ones'
export const group1Collection2Slug = 'group-one-collection-twos'
@@ -13,6 +14,7 @@ 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 placeholderCollectionSlug = 'placeholder'
export const uploadTwoCollectionSlug = 'uploads-two'
export const customFieldsSlug = 'custom-fields'
@@ -23,6 +25,7 @@ export const collectionSlugs = [
customViews1CollectionSlug,
customViews2CollectionSlug,
geoCollectionSlug,
arrayCollectionSlug,
postsCollectionSlug,
group1Collection1Slug,
group1Collection2Slug,

View File

@@ -0,0 +1,51 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../../buildConfigWithDefaults.js'
export const collectionSlug = 'users'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
admin: {
user: collectionSlug,
importMap: {
baseDir: path.resolve(dirname),
},
},
localization: {
locales: ['en', 'pl'],
defaultLocale: 'en',
},
collections: [
{
slug: collectionSlug,
auth: {
forgotPassword: {
// Default options
},
},
fields: [
{
name: 'localizedField',
type: 'text',
localized: true, // This field is localized and will require locale during validation
required: true,
},
{
name: 'roles',
type: 'select',
defaultValue: ['user'],
hasMany: true,
label: 'Role',
options: ['admin', 'editor', 'moderator', 'user', 'viewer'],
required: true,
saveToJWT: true,
},
],
},
],
debug: true,
})

View File

@@ -0,0 +1,78 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../../helpers/NextRESTClient.js'
import { devUser } from '../../credentials.js'
import { initPayloadInt } from '../../helpers/initPayloadInt.js'
import { collectionSlug } from './config.js'
let restClient: NextRESTClient | undefined
let payload: Payload | undefined
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Forgot password operation with localized fields', () => {
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname, 'auth/forgot-password-localized'))
// Register a user with additional localized field
const res = await restClient?.POST(`/${collectionSlug}/first-register?locale=en`, {
body: JSON.stringify({
...devUser,
'confirm-password': devUser.password,
localizedField: 'English content',
}),
})
if (!res) {
throw new Error('Failed to register user')
}
const { user } = await res.json()
// @ts-expect-error - Localized field is not in the general Payload type, but it is in mocked collection in this case.
await payload?.update({
collection: collectionSlug,
id: user.id as string,
locale: 'pl',
data: {
localizedField: 'Polish content',
},
})
})
afterAll(async () => {
if (typeof payload?.db.destroy === 'function') {
await payload?.db.destroy()
}
})
it('should successfully process forgotPassword operation with localized fields', async () => {
// Attempt to trigger forgotPassword operation
const token = await payload?.forgotPassword({
collection: collectionSlug,
data: { email: devUser.email },
disableEmail: true,
})
// Verify token was generated successfully
expect(token).toBeDefined()
expect(typeof token).toBe('string')
expect(token?.length).toBeGreaterThan(0)
})
it('should not throw validation errors for localized fields', async () => {
// We expect this not to throw an error
await expect(
payload?.forgotPassword({
collection: collectionSlug,
data: { email: devUser.email },
disableEmail: true,
}),
).resolves.not.toThrow()
})
})

View File

@@ -0,0 +1,246 @@
/* 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: {
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
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: 'en' | 'pl';
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` "users".
*/
export interface User {
id: string;
localizedField: string;
roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[];
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: '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` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
localizedField?: T;
roles?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

View File

@@ -1016,6 +1016,7 @@ describe('Auth', () => {
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)
expect(emailValidation("user'payload@example.org", mockContext)).toBe(true)
})
it('should not allow emails with double quotes', () => {
@@ -1045,5 +1046,11 @@ describe('Auth', () => {
expect(emailValidation('user@-example.com', mockContext)).toBe('validation:emailAddress')
expect(emailValidation('user@example-.com', mockContext)).toBe('validation:emailAddress')
})
it('should not allow emails that start with dot', () => {
expect(emailValidation('.user@example.com', mockContext)).toBe('validation:emailAddress')
})
it('should not allow emails that have a comma', () => {
expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress')
})
})
})

View File

@@ -1997,6 +1997,23 @@ describe('database', () => {
expect(draft.docs[0]?.postTitle).toBe('my-title')
})
it('should not break when using select', async () => {
const post = await payload.create({ collection: 'posts', data: { title: 'my-title-10' } })
const { id } = await payload.create({
collection: 'virtual-relations',
depth: 0,
data: { post: post.id },
})
const doc = await payload.findByID({
collection: 'virtual-relations',
depth: 0,
id,
select: { postTitle: true },
})
expect(doc.postTitle).toBe('my-title-10')
})
it('should allow virtual field as reference to ID', async () => {
const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } })
const { id } = await payload.create({
@@ -2129,6 +2146,26 @@ describe('database', () => {
expect(doc.postCategoryTitle).toBe('1-category')
})
it('should not break when using select 2x deep', async () => {
const category = await payload.create({
collection: 'categories',
data: { title: '3-category' },
})
const post = await payload.create({
collection: 'posts',
data: { title: '3-post', category: category.id },
})
const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } })
const docWithSelect = await payload.findByID({
collection: 'virtual-relations',
depth: 0,
id: doc.id,
select: { postCategoryTitle: true },
})
expect(docWithSelect.postCategoryTitle).toBe('3-category')
})
it('should allow to query by virtual field 2x deep', async () => {
const category = await payload.create({
collection: 'categories',
@@ -2414,4 +2451,37 @@ describe('database', () => {
expect(res.docs[0].id).toBe(customID.id)
})
it('should count with a query that contains subqueries', async () => {
const category = await payload.create({
collection: 'categories',
data: { title: 'new-category' },
})
const post = await payload.create({
collection: 'posts',
data: { title: 'new-post', category: category.id },
})
const result_1 = await payload.count({
collection: 'posts',
where: {
'category.title': {
equals: 'new-category',
},
},
})
expect(result_1.totalDocs).toBe(1)
const result_2 = await payload.count({
collection: 'posts',
where: {
'category.title': {
equals: 'non-existing-category',
},
},
})
expect(result_2.totalDocs).toBe(0)
})
})

View File

@@ -358,6 +358,46 @@ describe('relationship', () => {
).toHaveText(`${value}123456`)
})
test('should open related document in a new tab when meta key is applied', async () => {
await page.goto(url.create)
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
await openDocDrawer({
page,
selector:
'#field-relationWithAllowCreateToFalse .relationship--single-value__drawer-toggler',
withMetaKey: true,
}),
])
// Wait for navigation to complete in the new tab and ensure the edit view is open
await expect(newPage.locator('.collection-edit')).toBeVisible()
})
test('multi value relationship should open document in a new tab', async () => {
await page.goto(url.create)
// Select "Seeded text document" relationship
await page.locator('#field-relationshipHasMany .rs__control').click()
await page.locator('.rs__option:has-text("Seeded text document")').click()
await expect(
page.locator('#field-relationshipHasMany .relationship--multi-value-label__drawer-toggler'),
).toBeVisible()
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
await openDocDrawer({
page,
selector: '#field-relationshipHasMany .relationship--multi-value-label__drawer-toggler',
withMetaKey: true,
}),
])
// Wait for navigation to complete in the new tab and ensure the edit view is open
await expect(newPage.locator('.collection-edit')).toBeVisible()
})
// Drawers opened through the edit button are prone to issues due to the use of stopPropagation for certain
// events - specifically for drawers opened through the edit button. This test is to ensure that drawers
// opened through the edit button can be saved using the hotkey.
@@ -742,7 +782,15 @@ describe('relationship', () => {
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--multi-value-label__text')
await expect(selectedValue).toBeVisible()
await expect(selectedValue).toHaveCount(1)
await relationshipField.click()
await expect(listDrawerContent).toBeVisible()
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValues = relationshipField.locator('.relationship--multi-value-label__text')
await expect(selectedValues).toHaveCount(2)
})
test('should handle `hasMany` polymorphic relationship when `appearance: "drawer"`', async () => {
@@ -807,6 +855,42 @@ describe('relationship', () => {
await expect(newRows).toHaveCount(1)
await expect(listDrawerContent.getByText('Seeded text document')).toHaveCount(0)
})
test('should filter out existing values from polymorphic relationship list drawer', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-polymorphicRelationshipDrawer')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const relationToSelector = page.locator('.list-header__select-collection')
await expect(relationToSelector).toBeVisible()
await relationToSelector.locator('.rs__control').click()
const option = relationToSelector.locator('.rs__option').nth(1)
await option.click()
const rows = listDrawerContent.locator('table tbody tr')
await expect(rows).toHaveCount(2)
const firstRow = rows.first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--single-value__text')
await expect(selectedValue).toBeVisible()
await relationshipField.click()
await expect(listDrawerContent).toBeVisible()
await expect(relationToSelector).toBeVisible()
await relationToSelector.locator('.rs__control').click()
await option.click()
const newRows = listDrawerContent.locator('table tbody tr')
await expect(newRows).toHaveCount(1)
const newFirstRow = newRows.first()
const newButton = newFirstRow.locator('button')
await newButton.click()
await expect(listDrawerContent).toBeHidden()
})
})
async function createTextFieldDoc(overrides?: Partial<TextField>): Promise<TextField> {

View File

@@ -158,7 +158,7 @@ const RelationshipFields: CollectionConfig = {
},
{
name: 'relationshipDrawerHasManyPolymorphic',
relationTo: ['text-fields'],
relationTo: ['text-fields', 'array-fields'],
admin: {
appearance: 'drawer',
},

View File

@@ -228,6 +228,12 @@ describe('Form State', () => {
collection: postsSlug,
data: {
title: 'Test Post',
blocks: [
{
blockType: 'text',
text: 'Test block',
},
],
},
})
@@ -248,6 +254,7 @@ describe('Form State', () => {
})
expect(state.title?.addedByServer).toBe(true)
expect(state['blocks.0.blockType']?.addedByServer).toBe(true)
// Ensure that `addedByServer` is removed after being received by the client
const newState = mergeServerFormState({

View File

@@ -6,12 +6,18 @@ import { wait } from 'payload/shared'
export async function openDocDrawer({
page,
selector,
withMetaKey = false,
}: {
page: Page
selector: string
withMetaKey?: boolean
}): Promise<void> {
let clickProperties = {}
if (withMetaKey) {
clickProperties = { modifiers: ['ControlOrMeta'] }
}
await wait(500) // wait for parent form state to initialize
await page.locator(selector).click()
await page.locator(selector).click(clickProperties)
await wait(500) // wait for drawer form state to initialize
}

View File

@@ -940,6 +940,94 @@ describe('Joins Field', () => {
)
})
it('should have simple paginate with page for joins polymorphic', async () => {
let queryWithLimit = `query {
Categories(where: {
name: { equals: "paginate example" }
}) {
docs {
polymorphic(
sort: "createdAt",
limit: 2
) {
docs {
title
}
hasNextPage
}
}
}
}`
let pageWithLimit = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) })
.then((res) => res.json())
const queryUnlimited = `query {
Categories(
where: {
name: { equals: "paginate example" }
}
) {
docs {
polymorphic(
sort: "createdAt",
limit: 0
) {
docs {
title
createdAt
}
hasNextPage
}
}
}
}`
const unlimited = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: queryUnlimited }) })
.then((res) => res.json())
expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs).toHaveLength(2)
expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[0].id).toStrictEqual(
unlimited.data.Categories.docs[0].polymorphic.docs[0].id,
)
expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[1].id).toStrictEqual(
unlimited.data.Categories.docs[0].polymorphic.docs[1].id,
)
expect(pageWithLimit.data.Categories.docs[0].polymorphic.hasNextPage).toStrictEqual(true)
queryWithLimit = `query {
Categories(where: {
name: { equals: "paginate example" }
}) {
docs {
polymorphic(
sort: "createdAt",
limit: 2,
page: 2,
) {
docs {
title
}
hasNextPage
}
}
}
}`
pageWithLimit = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) })
.then((res) => res.json())
expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[0].id).toStrictEqual(
unlimited.data.Categories.docs[0].polymorphic.docs[2].id,
)
expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[1].id).toStrictEqual(
unlimited.data.Categories.docs[0].polymorphic.docs[3].id,
)
})
it('should populate joins with hasMany when on both sides documents are in draft', async () => {
const category = await payload.create({
collection: 'categories-versions',

View File

@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
import { type Config } from 'payload'
import { LexicalFullyFeatured } from './collections/_LexicalFullyFeatured/index.js'
import ArrayFields from './collections/Array/index.js'
import {
getLexicalFieldsCollection,
@@ -26,6 +27,7 @@ const dirname = path.dirname(filename)
export const baseConfig: Partial<Config> = {
// ...extend config here
collections: [
LexicalFullyFeatured,
getLexicalFieldsCollection({
blocks: lexicalBlocks,
inlineBlocks: lexicalInlineBlocks,
@@ -42,10 +44,18 @@ export const baseConfig: Partial<Config> = {
ArrayFields,
],
globals: [TabsWithRichText],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
components: {
beforeDashboard: [
{
path: './components/CollectionsExplained.tsx#CollectionsExplained',
},
],
},
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {

View File

@@ -302,7 +302,7 @@ describe('lexicalBlocks', () => {
await assertLexicalDoc({
fn: ({ lexicalWithBlocks }) => {
const rscBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[14] as SerializedBlockNode
.children[13] as SerializedBlockNode
const paragraphNode: SerializedParagraphNode = lexicalWithBlocks.root
.children[12] as SerializedParagraphNode
@@ -1133,9 +1133,9 @@ describe('lexicalBlocks', () => {
).docs[0] as never
const richTextBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[13] as SerializedBlockNode
.children[12] as SerializedBlockNode
const subRichTextBlock: SerializedBlockNode = richTextBlock.fields.richTextField.root
.children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
.children[0] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
const subSubRichTextField = subRichTextBlock.fields.subRichTextField
const subSubUploadField = subRichTextBlock.fields.subUploadField
@@ -1163,9 +1163,9 @@ describe('lexicalBlocks', () => {
).docs[0] as never
const richTextBlock2: SerializedBlockNode = lexicalWithBlocks.root
.children[13] as SerializedBlockNode
.children[12] as SerializedBlockNode
const subRichTextBlock2: SerializedBlockNode = richTextBlock2.fields.richTextField.root
.children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
.children[0] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
const subSubRichTextField2 = subRichTextBlock2.fields.subRichTextField
const subSubUploadField2 = subRichTextBlock2.fields.subUploadField
@@ -1666,5 +1666,55 @@ describe('lexicalBlocks', () => {
},
})
})
test('ensure inline blocks restore their state after undoing a removal', async () => {
await page.goto('http://localhost:3000/admin/collections/LexicalInBlock?limit=10')
await page.locator('.cell-id a').first().click()
await page.waitForURL(`**/collections/LexicalInBlock/**`)
// Wait for the page to be fully loaded and elements to be stable
await page.waitForLoadState('domcontentloaded')
// Wait for the specific row to be visible and have its content loaded
const row2 = page.locator('#blocks-row-2')
await expect(row2).toBeVisible()
// Get initial count and ensure it's stable
const inlineBlocks = page.locator('#blocks-row-2 .inline-block-container')
const inlineBlockCount = await inlineBlocks.count()
await expect(() => {
expect(inlineBlockCount).toBeGreaterThan(0)
}).toPass()
const inlineBlockElement = inlineBlocks.first()
await inlineBlockElement.locator('.inline-block__editButton').first().click()
await page.locator('.drawer--is-open #field-text').fill('value1')
await page.locator('.drawer--is-open button[type="submit"]').first().click()
// remove inline block
await inlineBlockElement.click()
await page.keyboard.press('Backspace')
// Check both that this specific element is removed and the total count decreased
await expect(inlineBlocks).toHaveCount(inlineBlockCount - 1)
await page.keyboard.press('Escape')
await inlineBlockElement.click()
// Undo the removal using keyboard shortcut
await page.keyboard.press('ControlOrMeta+Z')
// Wait for the block to be restored
await expect(inlineBlocks).toHaveCount(inlineBlockCount)
// Open the drawer again
await inlineBlockElement.locator('.inline-block__editButton').first().click()
// Check if the text field still contains 'value1'
await expect(page.locator('.drawer--is-open #field-text')).toHaveValue('value1')
})
})
})

View File

@@ -728,7 +728,8 @@ describe('lexicalMain', () => {
await expect(relationshipListDrawer).toBeVisible()
await wait(500)
await expect(relationshipListDrawer.locator('.rs__single-value')).toHaveText('Lexical Field')
await relationshipListDrawer.locator('.rs__input').first().click()
await relationshipListDrawer.locator('.rs__menu').getByText('Lexical Field').click()
await relationshipListDrawer.locator('button').getByText('Rich Text').first().click()
await expect(relationshipListDrawer).toBeHidden()
@@ -1203,10 +1204,11 @@ describe('lexicalMain', () => {
await expect(newUploadNode.locator('.lexical-upload__bottomRow')).toContainText('payload.png')
await page.keyboard.press('Enter') // floating toolbar needs to appear with enough distance to the upload node, otherwise clicking may fail
await page.keyboard.press('Enter')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowLeft')
// Select "there" by pressing shift + arrow left
for (let i = 0; i < 4; i++) {
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Shift+ArrowLeft')
}
@@ -1258,10 +1260,10 @@ describe('lexicalMain', () => {
const firstParagraph: SerializedParagraphNode = lexicalField.root
.children[0] as SerializedParagraphNode
const secondParagraph: SerializedParagraphNode = lexicalField.root
.children[1] as SerializedParagraphNode
const thirdParagraph: SerializedParagraphNode = lexicalField.root
.children[2] as SerializedParagraphNode
const uploadNode: SerializedUploadNode = lexicalField.root.children[3] as SerializedUploadNode
const thirdParagraph: SerializedParagraphNode = lexicalField.root
.children[3] as SerializedParagraphNode
const uploadNode: SerializedUploadNode = lexicalField.root.children[1] as SerializedUploadNode
expect(firstParagraph.children).toHaveLength(2)
expect((firstParagraph.children[0] as SerializedTextNode).text).toBe('Some ')
@@ -1391,7 +1393,7 @@ describe('lexicalMain', () => {
const lexicalField: SerializedEditorState = lexicalDoc.lexicalRootEditor
// @ts-expect-error no need to type this
expect(lexicalField?.root?.children[1].fields.someTextRequired).toEqual('test')
expect(lexicalField?.root?.children[0].fields.someTextRequired).toEqual('test')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})

View File

@@ -0,0 +1,68 @@
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone } from '../../../helpers.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from './utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
test.describe.configure({ mode: 'parallel' })
const { serverURL } = await initPayloadE2ENoConfig({
dirname,
})
describe('Lexical Fully Featured', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()
})
beforeEach(async ({ page }) => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: [
path.resolve(dirname, './collections/Upload/uploads'),
path.resolve(dirname, './collections/Upload2/uploads2'),
],
})
const url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug)
const lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async ({
page,
}) => {
const lexical = new LexicalHelpers(page)
await lexical.slashCommand('block')
await lexical.slashCommand('relationship')
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
await lexical.save('drawer')
await expect(lexical.decorator).toHaveCount(2)
await lexical.slashCommand('upload')
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
await lexical.drawer.getByText('Paste URL').click()
await lexical.drawer
.locator('.file-field__remote-file')
.fill('https://payloadcms.com/images/universal-truth.jpg')
await lexical.drawer.getByText('Add file').click()
await lexical.save('drawer')
await expect(lexical.decorator).toHaveCount(3)
const paragraph = lexical.editor.locator('> p')
await expect(paragraph).toHaveText('')
})
})

View File

@@ -0,0 +1,57 @@
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
lexicalEditor,
TreeViewFeature,
} from '@payloadcms/richtext-lexical'
import { lexicalFullyFeaturedSlug } from '../../slugs.js'
export const LexicalFullyFeatured: CollectionConfig = {
slug: lexicalFullyFeaturedSlug,
labels: {
singular: 'Lexical Fully Featured',
plural: 'Lexical Fully Featured',
},
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
FixedToolbarFeature(),
EXPERIMENTAL_TableFeature(),
BlocksFeature({
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'someText',
type: 'text',
},
],
},
],
inlineBlocks: [
{
slug: 'myInlineBlock',
fields: [
{
name: 'someText',
type: 'text',
},
],
},
],
}),
],
}),
},
],
}

View File

@@ -0,0 +1,49 @@
import type { Page } from 'playwright'
import { expect } from '@playwright/test'
export class LexicalHelpers {
page: Page
constructor(page: Page) {
this.page = page
}
async save(container: 'document' | 'drawer') {
if (container === 'drawer') {
await this.drawer.getByText('Save').click()
} else {
throw new Error('Not implemented')
}
await this.page.waitForTimeout(1000)
}
async slashCommand(
// prettier-ignore
command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline'
| 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'unordered' | 'upload',
) {
await this.page.keyboard.press(`/`)
const slashMenuPopover = this.page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
await this.page.keyboard.type(command)
await this.page.keyboard.press(`Enter`)
await expect(slashMenuPopover).toBeHidden()
}
get decorator() {
return this.editor.locator('[data-lexical-decorator="true"]')
}
get drawer() {
return this.page.locator('.drawer__content')
}
get editor() {
return this.page.locator('[data-lexical-editor="true"]')
}
get paragraph() {
return this.editor.locator('p')
}
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
export function CollectionsExplained() {
return (
<div>
<h1>Which collection should I use for my tests?</h1>
<p>
By default and as a rule of thumb: "Lexical Fully Featured". This collection has all our
features, but it does NOT have (and will never have):
</p>
<ul>
<li>Relationships or dependencies to other collections</li>
<li>Seeded documents</li>
<li>Features with custom props (except for a block and an inline block included)</li>
<li>Multiple richtext fields or other fields</li>
</ul>
<p>If you need any of these features, use another collection or create a new one.</p>
</div>
)
}

View File

@@ -83,6 +83,7 @@ export interface Config {
};
blocks: {};
collections: {
'lexical-fully-featured': LexicalFullyFeatured;
'lexical-fields': LexicalField;
'lexical-migrate-fields': LexicalMigrateField;
'lexical-localized-fields': LexicalLocalizedField;
@@ -101,6 +102,7 @@ export interface Config {
};
collectionsJoins: {};
collectionsSelect: {
'lexical-fully-featured': LexicalFullyFeaturedSelect<false> | LexicalFullyFeaturedSelect<true>;
'lexical-fields': LexicalFieldsSelect<false> | LexicalFieldsSelect<true>;
'lexical-migrate-fields': LexicalMigrateFieldsSelect<false> | LexicalMigrateFieldsSelect<true>;
'lexical-localized-fields': LexicalLocalizedFieldsSelect<false> | LexicalLocalizedFieldsSelect<true>;
@@ -153,6 +155,30 @@ export interface UserAuthOperations {
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fully-featured".
*/
export interface LexicalFullyFeatured {
id: string;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields".
@@ -774,6 +800,10 @@ export interface User {
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'lexical-fully-featured';
value: string | LexicalFullyFeatured;
} | null)
| ({
relationTo: 'lexical-fields';
value: string | LexicalField;
@@ -864,6 +894,15 @@ export interface PayloadMigration {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fully-featured_select".
*/
export interface LexicalFullyFeaturedSelect<T extends boolean = true> {
richText?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields_select".

View File

@@ -1,5 +1,6 @@
export const usersSlug = 'users'
export const lexicalFullyFeaturedSlug = 'lexical-fully-featured'
export const lexicalFieldsSlug = 'lexical-fields'
export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'
export const lexicalMigrateFieldsSlug = 'lexical-migrate-fields'

View File

@@ -13,9 +13,9 @@ import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Media, Page, Post, Tenant } from './payload-types.js'
import config from './config.js'
import { Pages } from './collections/Pages.js'
import config from './config.js'
import { postsSlug, tenantsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
@@ -28,7 +28,7 @@ let restClient: NextRESTClient
import { initPayloadInt } from '../helpers/initPayloadInt.js'
function collectionPopulationRequestHandler({ endpoint }: { endpoint: string }) {
function requestHandler({ endpoint }: { endpoint: string }) {
return restClient.GET(`/${endpoint}`)
}
@@ -170,7 +170,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(mergedData.title).toEqual('Test Page (Changed)')
@@ -198,7 +198,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(mergedData.arrayOfRelationships).toEqual([])
@@ -217,7 +217,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(mergedData2.arrayOfRelationships).toEqual([])
@@ -243,7 +243,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(mergedData.hero.media).toMatchObject(media)
@@ -262,7 +262,7 @@ describe('Collections - Live Preview', () => {
},
initialData: mergedData,
serverURL,
collectionPopulationRequestHandler,
requestHandler,
})
expect(mergedDataWithoutUpload.hero.media).toBeFalsy()
@@ -290,7 +290,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1.richTextSlate).toHaveLength(1)
@@ -317,7 +317,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge2.richTextSlate).toHaveLength(1)
@@ -377,7 +377,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1.richTextLexical.root.children).toHaveLength(2)
@@ -423,7 +423,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge2.richTextLexical.root.children).toHaveLength(1)
@@ -446,7 +446,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(1)
@@ -468,7 +468,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(1)
@@ -490,7 +490,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(1)
@@ -515,7 +515,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(1)
@@ -545,7 +545,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge2._numberOfRequests).toEqual(0)
@@ -571,7 +571,7 @@ describe('Collections - Live Preview', () => {
},
initialData,
serverURL,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1.tab.relationshipInTab).toMatchObject(testPost)
@@ -608,7 +608,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(1)
@@ -665,7 +665,7 @@ describe('Collections - Live Preview', () => {
initialData: merge1,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge2._numberOfRequests).toEqual(1)
@@ -741,7 +741,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(2)
@@ -804,7 +804,7 @@ describe('Collections - Live Preview', () => {
initialData: merge1,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge2._numberOfRequests).toEqual(1)
@@ -870,7 +870,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(2)
@@ -937,7 +937,7 @@ describe('Collections - Live Preview', () => {
initialData: merge1,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge2._numberOfRequests).toEqual(1)
@@ -991,7 +991,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(0)
@@ -1051,7 +1051,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
// Check that the relationship on the first has been removed
@@ -1080,7 +1080,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge1._numberOfRequests).toEqual(1)
@@ -1126,7 +1126,7 @@ describe('Collections - Live Preview', () => {
externallyUpdatedRelationship,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
expect(merge2._numberOfRequests).toEqual(1)
@@ -1183,7 +1183,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
locale: 'es',
})
@@ -1332,7 +1332,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
// Check that the blocks have been reordered
@@ -1365,7 +1365,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
// Check that the block has been removed
@@ -1385,7 +1385,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler,
requestHandler,
})
// Check that the block has been removed
@@ -1445,7 +1445,7 @@ describe('Collections - Live Preview', () => {
initialData,
serverURL,
returnNumberOfRequests: true,
collectionPopulationRequestHandler: customRequestHandler,
requestHandler: customRequestHandler,
})
expect(mergedData.relationshipPolyHasMany).toMatchObject([

View File

@@ -26,6 +26,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { blocksCollectionSlug } from './collections/Blocks/index.js'
import { nestedToArrayAndBlockCollectionSlug } from './collections/NestedToArrayAndBlock/index.js'
import { richTextSlug } from './collections/RichText/index.js'
import {
@@ -427,6 +428,30 @@ describe('Localization', () => {
await expect(arrayField).toHaveValue(sampleText)
})
test('should copy block to locale', async () => {
const sampleText = 'Copy this text'
const blocksCollection = new AdminUrlUtil(serverURL, blocksCollectionSlug)
await page.goto(blocksCollection.create)
await changeLocale(page, 'pt')
const addBlock = page.locator('.blocks-field__drawer-toggler')
await addBlock.click()
const selectBlock = page.locator('.blocks-drawer__block button')
await selectBlock.click()
const addContentButton = page.locator('#field-content__0__content button')
await addContentButton.click()
await selectBlock.click()
const textField = page.locator('#field-content__0__content__0__text')
await expect(textField).toBeVisible()
await textField.fill(sampleText)
await saveDocAndAssert(page)
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'English')
await runCopy(page)
await expect(textField).toHaveValue(sampleText)
})
test('should default source locale to current locale', async () => {
await changeLocale(page, spanishLocale)
await createAndSaveDoc(page, url, { title })

View File

@@ -98,6 +98,19 @@ export const Pages: CollectionConfig = {
type: 'relationship',
relationTo: 'users',
},
{
name: 'virtualRelationship',
type: 'text',
virtual: 'author.name',
},
{
name: 'virtual',
type: 'text',
virtual: true,
hooks: {
afterRead: [() => 'virtual value'],
},
},
{
name: 'hasManyNumber',
type: 'number',

View File

@@ -10,6 +10,10 @@ export const Users: CollectionConfig = {
read: () => true,
},
fields: [
{
name: 'name',
type: 'text',
},
// Email added by default
// Add more fields as needed
],

View File

@@ -1,5 +1,6 @@
import type { CollectionSlug, Payload } from 'payload'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -221,6 +222,68 @@ describe('@payloadcms/plugin-import-export', () => {
expect(data[0].array_1_field2).toStrictEqual('baz')
})
it('should create a CSV file with columns matching the order of the fields array', async () => {
const fields = ['id', 'group.value', 'group.array.field1', 'title', 'createdAt', 'updatedAt']
const doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields,
format: 'csv',
where: {
title: { contains: 'Title ' },
},
},
})
const exportDoc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(exportDoc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', exportDoc.filename as string)
const buffer = fs.readFileSync(expectedPath)
const str = buffer.toString()
// Assert that the header row matches the fields array
expect(str.indexOf('id')).toBeLessThan(str.indexOf('title'))
expect(str.indexOf('group_value')).toBeLessThan(str.indexOf('title'))
expect(str.indexOf('group_value')).toBeLessThan(str.indexOf('group_array'))
expect(str.indexOf('title')).toBeLessThan(str.indexOf('createdAt'))
expect(str.indexOf('createdAt')).toBeLessThan(str.indexOf('updatedAt'))
})
it('should create a CSV file with virtual fields', async () => {
const fields = ['id', 'virtual', 'virtualRelationship']
const doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields,
format: 'csv',
where: {
title: { contains: 'Virtual ' },
},
},
})
const exportDoc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(exportDoc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', exportDoc.filename as string)
const data = await readCSV(expectedPath)
// Assert that the csv file contains the expected virtual fields
expect(data[0].virtual).toStrictEqual('virtual value')
expect(data[0].virtualRelationship).toStrictEqual('name value')
})
it('should create a file for collection csv from array.subfield', async () => {
let doc = await payload.create({
collection: 'exports',

View File

@@ -131,6 +131,7 @@ export interface UserAuthOperations {
*/
export interface User {
id: string;
name?: string | null;
updatedAt: string;
createdAt: string;
email: string;
@@ -199,6 +200,8 @@ export interface Page {
)[]
| null;
author?: (string | null) | User;
virtualRelationship?: string | null;
virtual?: string | null;
hasManyNumber?: number[] | null;
relationship?: (string | null) | User;
excerpt?: string | null;
@@ -444,6 +447,7 @@ export interface PayloadMigration {
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
@@ -500,6 +504,8 @@ export interface PagesSelect<T extends boolean = true> {
};
};
author?: T;
virtualRelationship?: T;
virtual?: T;
hasManyNumber?: T;
relationship?: T;
excerpt?: T;

View File

@@ -6,11 +6,12 @@ import { richTextData } from './richTextData.js'
export const seed = async (payload: Payload): Promise<boolean> => {
payload.logger.info('Seeding data...')
try {
await payload.create({
const user = await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
name: 'name value',
},
})
// create pages
@@ -80,6 +81,16 @@ export const seed = async (payload: Payload): Promise<boolean> => {
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
author: user.id,
title: `Virtual ${i}`,
},
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',

View File

@@ -444,6 +444,43 @@ describe('Sort', () => {
parseInt(ordered.docs[1]._order, 16),
)
})
it('should allow to duplicate with reordable', async () => {
const doc = await payload.create({
collection: 'orderable',
data: { title: 'new document' },
})
const docDuplicated = await payload.create({
duplicateFromID: doc.id,
collection: 'orderable',
data: {},
})
expect(docDuplicated.title).toBe('new document')
expect(parseInt(doc._order!, 16)).toBeLessThan(parseInt(docDuplicated._order!, 16))
await restClient.POST('/reorder', {
body: JSON.stringify({
collectionSlug: orderableSlug,
docsToMove: [doc.id],
newKeyWillBe: 'greater',
orderableFieldName: '_order',
target: {
id: docDuplicated.id,
key: docDuplicated._order,
},
}),
})
const docAfterReorder = await payload.findByID({ collection: 'orderable', id: doc.id })
const docDuplicatedAfterReorder = await payload.findByID({
collection: 'orderable',
id: docDuplicated.id,
})
expect(parseInt(docAfterReorder._order!, 16)).toBeGreaterThan(
parseInt(docDuplicatedAfterReorder._order!, 16),
)
})
})
describe('Orderable join', () => {

View File

@@ -205,6 +205,116 @@ describe('Versions', () => {
await expect(page.locator('#field-title')).toHaveValue('v1')
})
test('should show currently published version status in versions view', async () => {
const publishedDoc = await payload.create({
collection: draftCollectionSlug,
data: {
_status: 'published',
title: 'title',
description: 'description',
},
overrideAccess: true,
})
await page.goto(`${url.edit(publishedDoc.id)}/versions`)
await expect(page.locator('main.versions')).toContainText('Current Published Version')
})
test('should show unpublished version status in versions view', async () => {
const publishedDoc = await payload.create({
collection: draftCollectionSlug,
data: {
_status: 'published',
title: 'title',
description: 'description',
},
overrideAccess: true,
})
// Unpublish the document
await payload.update({
collection: draftCollectionSlug,
id: publishedDoc.id,
data: {
_status: 'draft',
},
draft: false,
})
await page.goto(`${url.edit(publishedDoc.id)}/versions`)
await expect(page.locator('main.versions')).toContainText('Previously Published')
})
test('should show global versions view level action in globals versions view', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(`${global.global(draftGlobalSlug)}/versions`)
await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1)
})
test('global — has versions tab', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(global.global(draftGlobalSlug))
const docURL = page.url()
const pathname = new URL(docURL).pathname
const versionsTab = page.locator('.doc-tab', {
hasText: 'Versions',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const href = versionsTab.locator('a').first()
await expect(href).toHaveAttribute('href', `${pathname}/versions`)
})
test('global — respects max number of versions', async () => {
await payload.updateGlobal({
slug: draftWithMaxGlobalSlug,
data: {
title: 'initial title',
},
})
const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug)
await page.goto(global.global(draftWithMaxGlobalSlug))
const titleFieldInitial = page.locator('#field-title')
await titleFieldInitial.fill('updated title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldInitial).toHaveValue('updated title')
const versionsTab = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const titleFieldUpdated = page.locator('#field-title')
await titleFieldUpdated.fill('latest title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldUpdated).toHaveValue('latest title')
const versionsTabUpdated = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTabUpdated.waitFor({ state: 'visible' })
expect(versionsTabUpdated).toBeTruthy()
})
test('global — has versions route', async () => {
const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
await page.goto(versionsURL)
await expect(() => {
expect(page.url()).toMatch(/\/versions/)
}).toPass({ timeout: 10000, intervals: [100] })
})
test('collection - should autosave', async () => {
await page.goto(autosaveURL.create)
await page.locator('#field-title').fill('autosave title')