Merge branch 'main' into feat/folders

This commit is contained in:
Jarrod Flesch
2025-04-04 16:22:57 -04:00
524 changed files with 28161 additions and 5724 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -205,6 +205,37 @@ export const Posts: CollectionConfig = {
position: 'sidebar',
},
},
{
type: 'radio',
name: 'wavelengths',
defaultValue: 'fm',
options: [
{
label: 'FM',
value: 'fm',
},
{
label: 'AM',
value: 'am',
},
],
},
{
type: 'select',
name: 'selectField',
hasMany: true,
defaultValue: ['option1', 'option2'],
options: [
{
label: 'Option 1',
value: 'option1',
},
{
label: 'Option 2',
value: 'option2',
},
],
},
],
labels: {
plural: slugPluralLabel,

View File

@@ -392,7 +392,7 @@ describe('List View', () => {
test('should reset filter value when a different field is selected', async () => {
const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '')
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'ID',
operatorLabel: 'equals',
@@ -413,10 +413,11 @@ describe('List View', () => {
await expect(whereBuilder.locator('.condition__value input')).toHaveValue('')
})
test('should remove condition from URL when value is cleared', async () => {
await page.goto(postsUrl.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Relationship',
operatorLabel: 'equals',
@@ -431,16 +432,13 @@ describe('List View', () => {
await whereBuilder.locator('.condition__value .clear-indicator').click()
await page.waitForURL(new RegExp(encodedQueryString))
})
test.skip('should remove condition from URL when a different field is selected', async () => {
// TODO: fix this bug and write this test
expect(true).toBe(true)
})
test('should refresh relationship values when a different field is selected', async () => {
await page.goto(postsUrl.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Relationship',
operatorLabel: 'equals',
@@ -600,7 +598,7 @@ describe('List View', () => {
test('should reset filter values for every additional filter', async () => {
await page.goto(postsUrl.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Tab 1 > Title',
operatorLabel: 'equals',
@@ -622,7 +620,7 @@ describe('List View', () => {
test('should not re-render page upon typing in a value in the filter value field', async () => {
await page.goto(postsUrl.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Tab 1 > Title',
operatorLabel: 'equals',
@@ -645,7 +643,7 @@ describe('List View', () => {
test('should still show second filter if two filters exist and first filter is removed', async () => {
await page.goto(postsUrl.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Tab 1 > Title',
operatorLabel: 'equals',
@@ -739,7 +737,7 @@ describe('List View', () => {
test('should properly paginate many documents', async () => {
await page.goto(with300DocumentsUrl.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Self Relation',
operatorLabel: 'equals',
@@ -1288,6 +1286,31 @@ describe('List View', () => {
await expect(page.locator('#heading-_status')).toBeVisible()
await expect(page.locator('.cell-_status').first()).toBeVisible()
await toggleColumn(page, {
columnLabel: 'Wavelengths',
targetState: 'on',
columnName: 'wavelengths',
})
await toggleColumn(page, {
columnLabel: 'Select Field',
targetState: 'on',
columnName: 'selectField',
})
// check that the cells have the classes added per value selected
await expect(
page.locator('.cell-_status').first().locator("[class*='selected--']"),
).toBeVisible()
await expect(
page.locator('.cell-wavelengths').first().locator("[class*='selected--']"),
).toBeVisible()
await expect(
page.locator('.cell-selectField').first().locator("[class*='selected--']"),
).toBeVisible()
// sort by title again in descending order
await page.locator('#heading-title button.sort-column__desc').click()
await page.waitForURL(/sort=-title/)

View File

@@ -203,6 +203,17 @@ export default buildConfigWithDefaults({
// lock_until
],
},
{
slug: 'disable-local-strategy-password',
auth: { disableLocalStrategy: true },
fields: [
{
name: 'password',
type: 'text',
required: true,
},
],
},
{
slug: apiKeysSlug,
access: {

View File

@@ -786,6 +786,20 @@ describe('Auth', () => {
expect(response.status).toBe(403)
})
it('should allow to use password field', async () => {
const doc = await payload.create({
collection: 'disable-local-strategy-password',
data: { password: '123' },
})
expect(doc.password).toBe('123')
const updated = await payload.update({
collection: 'disable-local-strategy-password',
data: { password: '1234' },
id: doc.id,
})
expect(updated.password).toBe('1234')
})
})
describe('API Key', () => {

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -64,6 +65,7 @@ export interface Config {
auth: {
users: UserAuthOperations;
'partial-disable-local-strategies': PartialDisableLocalStrategyAuthOperations;
'disable-local-strategy-password': DisableLocalStrategyPasswordAuthOperations;
'api-keys': ApiKeyAuthOperations;
'public-users': PublicUserAuthOperations;
};
@@ -71,6 +73,7 @@ export interface Config {
collections: {
users: User;
'partial-disable-local-strategies': PartialDisableLocalStrategy;
'disable-local-strategy-password': DisableLocalStrategyPassword;
'api-keys': ApiKey;
'public-users': PublicUser;
relationsCollection: RelationsCollection;
@@ -82,6 +85,7 @@ export interface Config {
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
'partial-disable-local-strategies': PartialDisableLocalStrategiesSelect<false> | PartialDisableLocalStrategiesSelect<true>;
'disable-local-strategy-password': DisableLocalStrategyPasswordSelect<false> | DisableLocalStrategyPasswordSelect<true>;
'api-keys': ApiKeysSelect<false> | ApiKeysSelect<true>;
'public-users': PublicUsersSelect<false> | PublicUsersSelect<true>;
relationsCollection: RelationsCollectionSelect<false> | RelationsCollectionSelect<true>;
@@ -102,6 +106,9 @@ export interface Config {
| (PartialDisableLocalStrategy & {
collection: 'partial-disable-local-strategies';
})
| (DisableLocalStrategyPassword & {
collection: 'disable-local-strategy-password';
})
| (ApiKey & {
collection: 'api-keys';
})
@@ -149,6 +156,24 @@ export interface PartialDisableLocalStrategyAuthOperations {
password: string;
};
}
export interface DisableLocalStrategyPasswordAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
export interface ApiKeyAuthOperations {
forgotPassword: {
email: string;
@@ -242,6 +267,16 @@ export interface PartialDisableLocalStrategy {
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "disable-local-strategy-password".
*/
export interface DisableLocalStrategyPassword {
id: string;
password: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "api-keys".
@@ -299,6 +334,10 @@ export interface PayloadLockedDocument {
relationTo: 'partial-disable-local-strategies';
value: string | PartialDisableLocalStrategy;
} | null)
| ({
relationTo: 'disable-local-strategy-password';
value: string | DisableLocalStrategyPassword;
} | null)
| ({
relationTo: 'api-keys';
value: string | ApiKey;
@@ -321,6 +360,10 @@ export interface PayloadLockedDocument {
relationTo: 'partial-disable-local-strategies';
value: string | PartialDisableLocalStrategy;
}
| {
relationTo: 'disable-local-strategy-password';
value: string | DisableLocalStrategyPassword;
}
| {
relationTo: 'api-keys';
value: string | ApiKey;
@@ -347,6 +390,10 @@ export interface PayloadPreference {
relationTo: 'partial-disable-local-strategies';
value: string | PartialDisableLocalStrategy;
}
| {
relationTo: 'disable-local-strategy-password';
value: string | DisableLocalStrategyPassword;
}
| {
relationTo: 'api-keys';
value: string | ApiKey;
@@ -440,6 +487,15 @@ export interface PartialDisableLocalStrategiesSelect<T extends boolean = true> {
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "disable-local-strategy-password_select".
*/
export interface DisableLocalStrategyPasswordSelect<T extends boolean = true> {
password?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "api-keys_select".

View File

@@ -2134,6 +2134,16 @@ describe('database', () => {
expect(query3.totalDocs).toEqual(1)
})
it('db.deleteOne should not fail if query does not resolve to any document', async () => {
await expect(
payload.db.deleteOne({
collection: 'posts',
returning: false,
where: { title: { equals: 'some random title' } },
}),
).resolves.toBeNull()
})
it('mongodb additional keys stripping', async () => {
// eslint-disable-next-line jest/no-conditional-in-test
if (payload.db.name !== 'mongoose') {

View File

@@ -0,0 +1,58 @@
/* eslint-disable jest/require-top-level-describe */
import { existsSync, rmdirSync, rmSync } from 'fs'
import path from 'path'
import { buildConfig, getPayload } from 'payload'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const describe =
process.env.PAYLOAD_DATABASE === 'postgres' ? global.describe : global.describe.skip
const clearMigrations = () => {
if (existsSync(path.resolve(dirname, 'migrations'))) {
rmSync(path.resolve(dirname, 'migrations'), { force: true, recursive: true })
}
}
describe('SQL migrations', () => {
// If something fails - an error will be thrown.
// eslint-disable-next-line jest/expect-expect
it('should up and down migration successfully', async () => {
clearMigrations()
const { databaseAdapter } = await import(path.resolve(dirname, '../../databaseAdapter.js'))
const init = databaseAdapter.init
// set options
databaseAdapter.init = ({ payload }) => {
const adapter = init({ payload })
adapter.migrationDir = path.resolve(dirname, 'migrations')
adapter.push = false
return adapter
}
const config = await buildConfig({
db: databaseAdapter,
secret: 'secret',
collections: [
{
slug: 'users',
auth: true,
fields: [],
},
],
})
const payload = await getPayload({ config })
await payload.db.createMigration({ payload })
await payload.db.migrate()
await payload.db.migrateDown()
await payload.db.dropDatabase({ adapter: payload.db as any })
await payload.db.destroy?.()
})
})

View File

@@ -0,0 +1,463 @@
{
"version": "6",
"dialect": "sqlite",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reset_password_token": {
"name": "reset_password_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reset_password_expiration": {
"name": "reset_password_expiration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"salt": {
"name": "salt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"login_attempts": {
"name": "login_attempts",
"type": "numeric",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"lock_until": {
"name": "lock_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_updated_at_idx": {
"name": "users_updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
},
"users_created_at_idx": {
"name": "users_created_at_idx",
"columns": ["created_at"],
"isUnique": false
},
"users_email_idx": {
"name": "users_email_idx",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"payload_locked_documents": {
"name": "payload_locked_documents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"global_slug": {
"name": "global_slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
}
},
"indexes": {
"payload_locked_documents_global_slug_idx": {
"name": "payload_locked_documents_global_slug_idx",
"columns": ["global_slug"],
"isUnique": false
},
"payload_locked_documents_updated_at_idx": {
"name": "payload_locked_documents_updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
},
"payload_locked_documents_created_at_idx": {
"name": "payload_locked_documents_created_at_idx",
"columns": ["created_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"payload_locked_documents_rels": {
"name": "payload_locked_documents_rels",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"users_id": {
"name": "users_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"payload_locked_documents_rels_order_idx": {
"name": "payload_locked_documents_rels_order_idx",
"columns": ["order"],
"isUnique": false
},
"payload_locked_documents_rels_parent_idx": {
"name": "payload_locked_documents_rels_parent_idx",
"columns": ["parent_id"],
"isUnique": false
},
"payload_locked_documents_rels_path_idx": {
"name": "payload_locked_documents_rels_path_idx",
"columns": ["path"],
"isUnique": false
},
"payload_locked_documents_rels_users_id_idx": {
"name": "payload_locked_documents_rels_users_id_idx",
"columns": ["users_id"],
"isUnique": false
}
},
"foreignKeys": {
"payload_locked_documents_rels_parent_fk": {
"name": "payload_locked_documents_rels_parent_fk",
"tableFrom": "payload_locked_documents_rels",
"tableTo": "payload_locked_documents",
"columnsFrom": ["parent_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"payload_locked_documents_rels_users_fk": {
"name": "payload_locked_documents_rels_users_fk",
"tableFrom": "payload_locked_documents_rels",
"tableTo": "users",
"columnsFrom": ["users_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"payload_preferences": {
"name": "payload_preferences",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
}
},
"indexes": {
"payload_preferences_key_idx": {
"name": "payload_preferences_key_idx",
"columns": ["key"],
"isUnique": false
},
"payload_preferences_updated_at_idx": {
"name": "payload_preferences_updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
},
"payload_preferences_created_at_idx": {
"name": "payload_preferences_created_at_idx",
"columns": ["created_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"payload_preferences_rels": {
"name": "payload_preferences_rels",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"users_id": {
"name": "users_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"payload_preferences_rels_order_idx": {
"name": "payload_preferences_rels_order_idx",
"columns": ["order"],
"isUnique": false
},
"payload_preferences_rels_parent_idx": {
"name": "payload_preferences_rels_parent_idx",
"columns": ["parent_id"],
"isUnique": false
},
"payload_preferences_rels_path_idx": {
"name": "payload_preferences_rels_path_idx",
"columns": ["path"],
"isUnique": false
},
"payload_preferences_rels_users_id_idx": {
"name": "payload_preferences_rels_users_id_idx",
"columns": ["users_id"],
"isUnique": false
}
},
"foreignKeys": {
"payload_preferences_rels_parent_fk": {
"name": "payload_preferences_rels_parent_fk",
"tableFrom": "payload_preferences_rels",
"tableTo": "payload_preferences",
"columnsFrom": ["parent_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"payload_preferences_rels_users_fk": {
"name": "payload_preferences_rels_users_fk",
"tableFrom": "payload_preferences_rels",
"tableTo": "users",
"columnsFrom": ["users_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"payload_migrations": {
"name": "payload_migrations",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"batch": {
"name": "batch",
"type": "numeric",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
}
},
"indexes": {
"payload_migrations_updated_at_idx": {
"name": "payload_migrations_updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
},
"payload_migrations_created_at_idx": {
"name": "payload_migrations_created_at_idx",
"columns": ["created_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
},
"id": "2b2f5008-c761-40d0-a858-67d6b4233615",
"prevId": "00000000-0000-0000-0000-000000000000"
}

View File

@@ -0,0 +1,122 @@
import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-sqlite'
import { sql } from '@payloadcms/db-sqlite'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.run(sql`CREATE TABLE \`users\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`email\` text NOT NULL,
\`reset_password_token\` text,
\`reset_password_expiration\` text,
\`salt\` text,
\`hash\` text,
\`login_attempts\` numeric DEFAULT 0,
\`lock_until\` text
);
`)
await db.run(sql`CREATE INDEX \`users_updated_at_idx\` ON \`users\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`users_created_at_idx\` ON \`users\` (\`created_at\`);`)
await db.run(sql`CREATE UNIQUE INDEX \`users_email_idx\` ON \`users\` (\`email\`);`)
await db.run(sql`CREATE TABLE \`payload_locked_documents\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`global_slug\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_global_slug_idx\` ON \`payload_locked_documents\` (\`global_slug\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_updated_at_idx\` ON \`payload_locked_documents\` (\`updated_at\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_created_at_idx\` ON \`payload_locked_documents\` (\`created_at\`);`,
)
await db.run(sql`CREATE TABLE \`payload_locked_documents_rels\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`order\` integer,
\`parent_id\` integer NOT NULL,
\`path\` text NOT NULL,
\`users_id\` integer,
FOREIGN KEY (\`parent_id\`) REFERENCES \`payload_locked_documents\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`users_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_rels_order_idx\` ON \`payload_locked_documents_rels\` (\`order\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_rels_parent_idx\` ON \`payload_locked_documents_rels\` (\`parent_id\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_rels_path_idx\` ON \`payload_locked_documents_rels\` (\`path\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_rels_users_id_idx\` ON \`payload_locked_documents_rels\` (\`users_id\`);`,
)
await db.run(sql`CREATE TABLE \`payload_preferences\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`key\` text,
\`value\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(
sql`CREATE INDEX \`payload_preferences_key_idx\` ON \`payload_preferences\` (\`key\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_preferences_updated_at_idx\` ON \`payload_preferences\` (\`updated_at\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_preferences_created_at_idx\` ON \`payload_preferences\` (\`created_at\`);`,
)
await db.run(sql`CREATE TABLE \`payload_preferences_rels\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`order\` integer,
\`parent_id\` integer NOT NULL,
\`path\` text NOT NULL,
\`users_id\` integer,
FOREIGN KEY (\`parent_id\`) REFERENCES \`payload_preferences\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`users_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(
sql`CREATE INDEX \`payload_preferences_rels_order_idx\` ON \`payload_preferences_rels\` (\`order\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_preferences_rels_parent_idx\` ON \`payload_preferences_rels\` (\`parent_id\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_preferences_rels_path_idx\` ON \`payload_preferences_rels\` (\`path\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_preferences_rels_users_id_idx\` ON \`payload_preferences_rels\` (\`users_id\`);`,
)
await db.run(sql`CREATE TABLE \`payload_migrations\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`name\` text,
\`batch\` numeric,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(
sql`CREATE INDEX \`payload_migrations_updated_at_idx\` ON \`payload_migrations\` (\`updated_at\`);`,
)
await db.run(
sql`CREATE INDEX \`payload_migrations_created_at_idx\` ON \`payload_migrations\` (\`created_at\`);`,
)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.run(sql`DROP TABLE \`users\`;`)
await db.run(sql`DROP TABLE \`payload_locked_documents\`;`)
await db.run(sql`DROP TABLE \`payload_locked_documents_rels\`;`)
await db.run(sql`DROP TABLE \`payload_preferences\`;`)
await db.run(sql`DROP TABLE \`payload_preferences_rels\`;`)
await db.run(sql`DROP TABLE \`payload_migrations\`;`)
}

View File

@@ -0,0 +1,9 @@
import * as migration_20250328_185055 from './20250328_185055.js'
export const migrations = [
{
up: migration_20250328_185055.up,
down: migration_20250328_185055.down,
name: '20250328_185055',
},
]

View File

@@ -70,6 +70,7 @@ export const testEslintConfig = [
'saveDocAndAssert',
'runFilterOptionsTest',
'assertNetworkRequests',
'assertRequestBody',
],
},
],

View File

@@ -332,7 +332,7 @@ describe('Relationship Field', () => {
// now ensure that the same filter options are applied in the list view
await page.goto(url.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Relationship Filtered By Field',
operatorLabel: 'equals',
@@ -367,7 +367,7 @@ describe('Relationship Field', () => {
// now ensure that the same filter options are applied in the list view
await page.goto(url.list)
const whereBuilder = await addListFilter({
const { whereBuilder } = await addListFilter({
page,
fieldLabel: 'Collapsible > Nested Relationship Filtered By Field',
operatorLabel: 'equals',

View File

@@ -114,7 +114,6 @@ describe('Array', () => {
await expect(page.locator('#field-customArrayField__0__text')).toBeVisible()
})
test('should bypass min rows validation when no rows present and field is not required', async () => {
await page.goto(url.create)
await saveDocAndAssert(page)

View File

@@ -6,6 +6,7 @@ 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 { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import {
@@ -312,6 +313,8 @@ describe('Block fields', () => {
'Second block',
)
await wait(1000)
await reorderBlocks({
page,
fieldName: 'blocks',

View File

@@ -100,6 +100,7 @@ describe('Radio', () => {
await assertToastErrors({
page,
errors: ['uniqueText'],
dismissAfterAssertion: true,
})
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('create')
@@ -113,7 +114,9 @@ describe('Radio', () => {
// nested in a group error
await page.locator('#field-group__unique').fill(uniqueText)
await wait(1000)
// TODO: used because otherwise the toast locator resolves to 2 items
// at the same time. Instead we should uniquely identify each toast.
await wait(2000)
// attempt to save
await page.locator('#action-save').click()

View File

@@ -1507,6 +1507,24 @@ describe('lexicalBlocks', () => {
),
).toHaveText('Some Description')
})
test('ensure individual inline blocks in lexical editor within a block have initial state on initial load', async () => {
await page.goto('http://localhost:3000/admin/collections/LexicalInBlock?limit=10')
await assertNetworkRequests(
page,
'/collections/LexicalInBlock/',
async () => {
await page.locator('.cell-id a').first().click()
await page.waitForURL(`**/collections/LexicalInBlock/**`)
await expect(
page.locator('.inline-block:has-text("Inline Block In Lexical")'),
).toHaveCount(20)
},
{ allowedNumberOfRequests: 1 },
)
})
})
describe('inline blocks', () => {

View File

@@ -1135,6 +1135,33 @@ describe('lexicalMain', () => {
await expect(urlInput).toBeVisible()
})
test('ensure link drawer displays nested block fields if document does not have `create` permission', async () => {
await navigateToLexicalFields(true, 'lexical-access-control')
const richTextField = page.locator('.rich-text-lexical').first()
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
const link = richTextField.locator('.LexicalEditorTheme__link').first()
await link.scrollIntoViewIfNeeded()
await expect(link).toBeVisible()
await link.click({
// eslint-disable-next-line playwright/no-force-option
force: true,
button: 'left',
})
await expect(page.locator('.link-edit')).toBeVisible()
await page.locator('.link-edit').click()
const linkDrawer = page.locator('dialog[id^=drawer_1_lexical-rich-text-link-]').first()
await expect(linkDrawer).toBeVisible()
const blockTextInput = linkDrawer.locator('#field-blocks__0__text').first()
await expect(blockTextInput).toBeVisible()
await expect(blockTextInput).toBeEditable()
})
test('lexical cursor / selection should be preserved when swapping upload field and clicking within with its list drawer', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').first()
@@ -1494,6 +1521,7 @@ describe('lexicalMain', () => {
expect(htmlContent).not.toContain('Cargando...')
expect(htmlContent).toContain('Start typing, or press')
})
// eslint-disable-next-line playwright/expect-expect, playwright/no-skipped-test
test.skip('ensure simple localized lexical field works', async () => {
await navigateToLexicalFields(true, 'lexical-localized-fields')
})

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
import { defaultEditorFeatures, lexicalEditor } from '@payloadcms/richtext-lexical'
import { defaultEditorFeatures, lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical'
import { lexicalAccessControlSlug } from '../../slugs.js'
@@ -22,7 +22,29 @@ export const LexicalAccessControl: CollectionConfig = {
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: [...defaultEditorFeatures],
features: [
...defaultEditorFeatures,
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
],
}),
],
}),
},
],

View File

@@ -42,6 +42,23 @@ export const LexicalInBlock: CollectionConfig = {
{
name: 'lexical',
type: 'richText',
editor: lexicalEditor({
features: [
BlocksFeature({
inlineBlocks: [
{
slug: 'inlineBlockInLexical',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}),
],
}),
},
],
},

View File

@@ -79,7 +79,7 @@ describe('Text', () => {
await expect(page.locator('.cell-hiddenTextField')).toBeHidden()
await expect(page.locator('#heading-hiddenTextField')).toBeHidden()
const columnContainer = await openListColumns(page, {})
const { columnContainer } = await openListColumns(page, {})
await expect(
columnContainer.locator('.column-selector__column', {
@@ -105,7 +105,7 @@ describe('Text', () => {
await expect(page.locator('.cell-disabledTextField')).toBeHidden()
await expect(page.locator('#heading-disabledTextField')).toBeHidden()
const columnContainer = await openListColumns(page, {})
const { columnContainer } = await openListColumns(page, {})
await expect(
columnContainer.locator('.column-selector__column', {
@@ -133,7 +133,7 @@ describe('Text', () => {
await expect(page.locator('.cell-adminHiddenTextField').first()).toBeVisible()
await expect(page.locator('#heading-adminHiddenTextField')).toBeVisible()
const columnContainer = await openListColumns(page, {})
const { columnContainer } = await openListColumns(page, {})
await expect(
columnContainer.locator('.column-selector__column', {

View File

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

View File

@@ -511,6 +511,16 @@ export const seed = async (_payload: Payload) => {
depth: 0,
})
const getInlineBlock = () => ({
type: 'inlineBlock',
fields: {
id: Math.random().toString(36).substring(2, 15),
text: 'text',
blockType: 'inlineBlockInLexical',
},
version: 1,
})
await _payload.create({
collection: 'LexicalInBlock',
depth: 0,
@@ -548,6 +558,32 @@ export const seed = async (_payload: Payload) => {
blockName: '2',
lexical: textToLexicalJSON({ text: '2' }),
},
{
blockType: 'lexicalInBlock2',
lexical: {
root: {
children: [
{
children: [...Array.from({ length: 20 }, () => getInlineBlock())],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
id: '67e1af0b78de3228e23ef1d5',
blockName: '1',
},
],
},
})
@@ -555,7 +591,67 @@ export const seed = async (_payload: Payload) => {
await _payload.create({
collection: 'lexical-access-control',
data: {
richText: textToLexicalJSON({ text: 'text' }),
richText: {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'text ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://',
newTab: false,
linkType: 'custom',
blocks: [
{
id: '67e45673cbd5181ca8cbeef7',
blockType: 'block',
},
],
},
id: '67e4566fcbd5181ca8cbeef5',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
title: 'title',
},
depth: 0,

View File

@@ -0,0 +1,5 @@
import React from 'react'
export const ArrayRowLabel = () => {
return <p>This is a custom component</p>
}

View File

@@ -1,5 +1,7 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
@@ -64,5 +66,21 @@ export const PostsCollection: CollectionConfig = {
},
],
},
{
name: 'array',
type: 'array',
admin: {
components: {
RowLabel: './collections/Posts/ArrayRowLabel.js#ArrayRowLabel',
},
},
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
},
],
},
],
}

View File

@@ -1,9 +1,11 @@
import type { BrowserContext, Page } from '@playwright/test'
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
import type { FormState } from 'payload'
import { expect, test } from '@playwright/test'
import { addBlock } from 'helpers/e2e/addBlock.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
@@ -17,7 +19,8 @@ import {
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -34,7 +37,7 @@ test.describe('Form State', () => {
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, 'posts')
postsUrl = new AdminUrlUtil(serverURL, postsSlug)
context = await browser.newContext()
page = await context.newPage()
@@ -144,6 +147,64 @@ test.describe('Form State', () => {
)
})
test('should send `lastRenderedPath` only when necessary', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
// The `array` itself SHOULD have a `lastRenderedPath` because it was rendered on initial load
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: await page.locator('#field-array .array-field__add-row').click(),
url: postsUrl.create,
expect: (body) =>
Boolean(
body?.[0]?.args?.formState?.['array'] && body[0].args.formState['array'].lastRenderedPath,
),
})
await page.waitForResponse(
(response) =>
response.url() === postsUrl.create &&
response.status() === 200 &&
response.headers()['content-type'] === 'text/x-component',
)
// The `array` itself SHOULD still have a `lastRenderedPath`
// The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: await page.locator('#field-array .array-field__add-row').click(),
url: postsUrl.create,
expect: (body) =>
Boolean(
body?.[0]?.args?.formState?.['array'] &&
body[0].args.formState['array'].lastRenderedPath &&
body[0].args.formState['array.0.richText']?.lastRenderedPath,
),
})
await page.waitForResponse(
(response) =>
response.url() === postsUrl.create &&
response.status() === 200 &&
response.headers()['content-type'] === 'text/x-component',
)
// The `array` itself SHOULD still have a `lastRenderedPath`
// The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request
// The rich text field in the second row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the second request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: await page.locator('#field-array .array-field__add-row').click(),
url: postsUrl.create,
expect: (body) =>
Boolean(
body?.[0]?.args?.formState?.['array'] &&
body[0].args.formState['array'].lastRenderedPath &&
body[0].args.formState['array.0.richText']?.lastRenderedPath &&
body[0].args.formState['array.1.richText']?.lastRenderedPath,
),
})
})
test('should queue onChange functions', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
@@ -179,6 +240,106 @@ test.describe('Form State', () => {
await cdpSession.detach()
})
test('should not cause nested custom components to disappear when adding a row then editing a field', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
const cdpSession = await throttleTest({
page,
context,
delay: 'Slow 3G',
})
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-title').fill('Test 2')
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// eslint-disable-next-line playwright/no-wait-for-selector
await page.waitForSelector('#field-array #array-row-0 .field-type.rich-text-lexical', {
timeout: TEST_TIMEOUT,
})
await expect(
page.locator('#field-array #array-row-0 .field-type.rich-text-lexical'),
).toBeVisible()
},
{
allowedNumberOfRequests: 2,
timeout: 10000,
},
)
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await cdpSession.detach()
})
test('should not cause nested custom components to disappear when adding rows back-to-back', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
const cdpSession = await throttleTest({
page,
context,
delay: 'Slow 3G',
})
// Add two rows quickly
// Test that the rich text fields within the rows do not disappear
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-array .array-field__add-row').click()
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// eslint-disable-next-line playwright/no-wait-for-selector
await page.waitForSelector('#field-array #array-row-0 .field-type.rich-text-lexical', {
timeout: TEST_TIMEOUT,
})
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// eslint-disable-next-line playwright/no-wait-for-selector
await page.waitForSelector('#field-array #array-row-1 .field-type.rich-text-lexical', {
timeout: TEST_TIMEOUT,
})
await expect(
page.locator('#field-array #array-row-0 .field-type.rich-text-lexical'),
).toBeVisible()
await expect(
page.locator('#field-array #array-row-1 .field-type.rich-text-lexical'),
).toBeVisible()
},
{
allowedNumberOfRequests: 2,
timeout: 10000,
},
)
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await cdpSession.detach()
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {

View File

@@ -21,11 +21,8 @@ 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))
;({ payload, restClient } = await initPayloadInt(dirname, undefined, true))
const data = await restClient
.POST('/users/login', {
@@ -57,6 +54,7 @@ describe('Form State', () => {
})
const { state } = await buildFormState({
mockRSCs: true,
id: postData.id,
collectionSlug: postsSlug,
data: postData,
@@ -95,7 +93,6 @@ describe('Form State', () => {
validateUsingEvent: {},
blocks: {
initialValue: 0,
requiresRender: false,
rows: [],
value: 0,
},
@@ -113,6 +110,7 @@ describe('Form State', () => {
})
const { state } = await buildFormState({
mockRSCs: true,
id: postData.id,
collectionSlug: postsSlug,
data: postData,
@@ -134,9 +132,89 @@ describe('Form State', () => {
title: {
value: postData.title,
initialValue: postData.title,
lastRenderedPath: 'title',
},
})
})
it.todo('should skip validation if specified')
it('should not render custom components when `lastRenderedPath` exists', async () => {
const req = await createLocalReq({ user }, payload)
const { state: stateWithRow } = await buildFormState({
mockRSCs: true,
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
// Ensure that row 1 _DOES_ return with rendered components
expect(stateWithRow?.['array.0.richText']?.lastRenderedPath).toStrictEqual('array.0.richText')
expect(stateWithRow?.['array.0.richText']?.customComponents?.Field).toBeDefined()
const { state: stateWithTitle } = await buildFormState({
mockRSCs: true,
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
{
id: '456',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
'array.0.richText': {
lastRenderedPath: 'array.0.richText',
},
'array.1.id': {
value: '456',
initialValue: '456',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
schemaPath: postsSlug,
req,
})
// Ensure that row 1 _DOES NOT_ return with rendered components
expect(stateWithTitle?.['array.0.richText']).toHaveProperty('lastRenderedPath')
expect(stateWithTitle?.['array.0.richText']).not.toHaveProperty('customComponents')
// Ensure that row 2 _DOES_ return with rendered components
expect(stateWithTitle?.['array.1.richText']).toHaveProperty('lastRenderedPath')
expect(stateWithTitle?.['array.1.richText']).toHaveProperty('customComponents')
expect(stateWithTitle?.['array.1.richText']?.customComponents?.Field).toBeDefined()
})
})

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -140,6 +141,26 @@ export interface Post {
}
)[]
| null;
array?:
| {
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;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -243,6 +264,12 @@ export interface PostsSelect<T extends boolean = true> {
blockName?: T;
};
};
array?:
| T
| {
richText?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}

View File

@@ -245,7 +245,7 @@ export async function saveDocAndAssert(
if (expectation === 'success') {
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('/create')
} else {
await expect(page.locator('.payload-toast-container .toast-error')).toBeVisible()
}

View File

@@ -5,7 +5,9 @@ import { expect } from '@playwright/test'
export async function assertToastErrors({
page,
errors,
dismissAfterAssertion,
}: {
dismissAfterAssertion?: boolean
errors: string[]
page: Page
}): Promise<void> {
@@ -24,4 +26,13 @@ export async function assertToastErrors({
).toHaveText(error)
}
}
if (dismissAfterAssertion) {
const closeButtons = page.locator('.payload-toast-container button.payload-toast-close-button')
const count = await closeButtons.count()
for (let i = 0; i < count; i++) {
await closeButtons.nth(i).click()
}
}
}

View File

@@ -18,7 +18,9 @@ export const addListFilter = async ({
replaceExisting?: boolean
skipValueInput?: boolean
value?: string
}): Promise<Locator> => {
}): Promise<{
whereBuilder: Locator
}> => {
await openListFilters(page, {})
const whereBuilder = page.locator('.where-builder')
@@ -53,5 +55,5 @@ export const addListFilter = async ({
}
}
return whereBuilder
return { whereBuilder }
}

View File

@@ -0,0 +1,51 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
/**
* A helper function to assert the body of a network request.
* This is useful for reading the body of a request and testing whether it is correct.
* For example, if you have a form that submits data to an API, you can use this function to
* assert that the data being sent is correct.
* @param page The Playwright page
* @param options Options
* @param options.action The action to perform that will trigger the request
* @param options.expect A function to run after the request is made to assert the request body
* @returns The request body
* @example
* const requestBody = await assertRequestBody(page, {
* action: page.click('button'),
* expect: (requestBody) => expect(requestBody.foo).toBe('bar')
* })
*/
export const assertRequestBody = async <T>(
page: Page,
options: {
action: Promise<void> | void
expect?: (requestBody: T) => boolean | Promise<boolean>
requestMethod?: string
url: string
},
): Promise<T | undefined> => {
const [request] = await Promise.all([
page.waitForRequest((request) =>
Boolean(
request.url().startsWith(options.url) &&
(request.method() === options.requestMethod || 'POST'),
),
),
await options.action,
])
const requestBody = request.postData()
if (typeof requestBody === 'string') {
const parsedBody = JSON.parse(requestBody) as T
if (typeof options.expect === 'function') {
expect(await options.expect(parsedBody)).toBeTruthy()
}
return parsedBody
}
}

View File

@@ -0,0 +1,86 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
function parseRSC(rscText: string) {
// Next.js streams use special delimiters like "\n"
const chunks = rscText.split('\n').filter((line) => line.trim() !== '')
// find the chunk starting with '1:', remove the '1:' prefix and parse the rest
const match = chunks.find((chunk) => chunk.startsWith('1:'))
if (match) {
const jsonString = match.slice(2).trim()
if (jsonString) {
try {
return JSON.parse(jsonString)
} catch (err) {
console.error('Failed to parse JSON:', err)
}
}
}
return null
}
/**
* A helper function to assert the response of a network request.
* This is useful for reading the response of a request and testing whether it is correct.
* For example, if you have a form that submits data to an API, you can use this function to
* assert that the data sent back is correct.
* @param page The Playwright page
* @param options Options
* @param options.action The action to perform that will trigger the request
* @param options.expect A function to run after the request is made to assert the response body
* @param options.url The URL to match in the network requests
* @returns The request body
* @example
* const responseBody = await assertResponseBody(page, {
* action: page.click('button'),
* expect: (responseBody) => expect(responseBody.foo).toBe('bar')
* })
*/
export const assertResponseBody = async <T>(
page: Page,
options: {
action: Promise<void> | void
expect?: (requestBody: T) => boolean | Promise<boolean>
requestMethod?: string
responseContentType?: string
url?: string
},
): Promise<T | undefined> => {
const [response] = await Promise.all([
page.waitForResponse((response) =>
Boolean(
response.url().includes(options.url || '') &&
response.status() === 200 &&
response
.headers()
['content-type']?.includes(options.responseContentType || 'application/json'),
),
),
await options.action,
])
if (!response) {
throw new Error('No response received')
}
const responseBody = await response.text()
const responseType = response.headers()['content-type']?.split(';')[0]
let parsedBody: T = undefined as T
if (responseType === 'text/x-component') {
parsedBody = parseRSC(responseBody)
} else if (typeof responseBody === 'string') {
parsedBody = JSON.parse(responseBody) as T
}
if (typeof options.expect === 'function') {
expect(await options.expect(parsedBody)).toBeTruthy()
}
return parsedBody
}

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
@@ -11,7 +11,9 @@ export const openListColumns = async (
columnContainerSelector?: string
togglerSelector?: string
},
): Promise<any> => {
): Promise<{
columnContainer: Locator
}> => {
const columnContainer = page.locator(columnContainerSelector).first()
const isAlreadyOpen = await columnContainer.isVisible()
@@ -22,5 +24,5 @@ export const openListColumns = async (
await expect(page.locator(`${columnContainerSelector}.rah-static--height-auto`)).toBeVisible()
return columnContainer
return { columnContainer }
}

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
@@ -11,10 +11,12 @@ export const openListFilters = async (
filterContainerSelector?: string
togglerSelector?: string
},
) => {
const columnContainer = page.locator(filterContainerSelector).first()
): Promise<{
filterContainer: Locator
}> => {
const filterContainer = page.locator(filterContainerSelector).first()
const isAlreadyOpen = await columnContainer.isVisible()
const isAlreadyOpen = await filterContainer.isVisible()
if (!isAlreadyOpen) {
await page.locator(togglerSelector).first().click()
@@ -22,5 +24,5 @@ export const openListFilters = async (
await expect(page.locator(`${filterContainerSelector}.rah-static--height-auto`)).toBeVisible()
return columnContainer
return { filterContainer }
}

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
@@ -22,8 +22,13 @@ export const toggleColumn = async (
targetState?: 'off' | 'on'
togglerSelector?: string
},
): Promise<any> => {
const columnContainer = await openListColumns(page, { togglerSelector, columnContainerSelector })
): Promise<{
columnContainer: Locator
}> => {
const { columnContainer } = await openListColumns(page, {
togglerSelector,
columnContainerSelector,
})
const column = columnContainer.locator(`.column-selector .column-selector__column`, {
hasText: exactText(columnLabel),
@@ -57,7 +62,7 @@ export const toggleColumn = async (
await waitForColumnInURL({ page, columnName, state: targetState })
}
return column
return { columnContainer }
}
export const waitForColumnInURL = async ({

View File

@@ -0,0 +1,26 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from 'helpers.js'
export async function openListMenu({ page }: { page: Page }) {
const listMenu = page.locator('#list-menu')
await listMenu.locator('button.popup-button').click()
await expect(listMenu.locator('.popup__content')).toBeVisible()
}
export async function clickListMenuItem({
page,
menuItemLabel,
}: {
menuItemLabel: string
page: Page
}) {
await openListMenu({ page })
const menuItem = page.locator('.popup__content').locator('button', {
hasText: exactText(menuItemLabel),
})
await menuItem.click()
}

View File

@@ -75,7 +75,7 @@ export type UpdateManyArgs<
TSlug extends keyof TGeneratedTypes['collections'],
> = {
id: never
where?: WhereField
where?: Where
} & UpdateBaseArgs<TGeneratedTypes, TSlug>
export type UpdateBaseArgs<

View File

@@ -82,4 +82,27 @@ describe('i18n', () => {
page.locator('.componentWithCustomI18n .componentWithCustomI18nCustomValidI18nT'),
).toHaveText('My custom translation')
})
test('ensure translations update correctly when switching language', async () => {
await page.goto(serverURL + '/admin/account')
await page.locator('div.rs__control').click()
await page.locator('div.rs__option').filter({ hasText: 'English' }).click()
await expect(page.locator('div.payload-settings h3')).toHaveText('Payload Settings')
await page.goto(serverURL + '/admin/collections/collection1/create')
await expect(page.locator('label[for="field-fieldDefaultI18nValid"]')).toHaveText(
'Add {{label}}',
)
await page.goto(serverURL + '/admin/account')
await page.locator('div.rs__control').click()
await page.locator('div.rs__option').filter({ hasText: 'Español' }).click()
await expect(page.locator('div.payload-settings h3')).toHaveText('Configuración de la carga')
await page.goto(serverURL + '/admin/collections/collection1/create')
await expect(page.locator('label[for="field-fieldDefaultI18nValid"]')).toHaveText(
'Añadir {{label}}',
)
})
})

View File

@@ -5,6 +5,11 @@ export const versionsSlug = 'versions'
export const Versions: CollectionConfig = {
slug: versionsSlug,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'category',
relationTo: 'categories',

View File

@@ -569,7 +569,7 @@ describe('Joins Field', () => {
const version = await payload.create({
collection: 'versions',
data: { categoryVersion: category.id },
data: { title: 'version', categoryVersion: category.id },
})
const res = await payload.find({ collection: 'categories-versions', draft: false })
@@ -582,7 +582,7 @@ describe('Joins Field', () => {
const version = await payload.create({
collection: 'versions',
data: { categoryVersions: [category.id] },
data: { title: 'version', categoryVersions: [category.id] },
})
const res = await payload.find({ collection: 'categories-versions', draft: false })
@@ -595,7 +595,7 @@ describe('Joins Field', () => {
const version = await payload.create({
collection: 'versions',
data: { categoryVersion: category.id },
data: { title: 'version', categoryVersion: category.id },
})
const res = await payload.find({
@@ -606,12 +606,41 @@ describe('Joins Field', () => {
expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id)
})
it('should populate joins with hasMany when on both sides documents are in draft', async () => {
const category = await payload.create({
collection: 'categories-versions',
data: { _status: 'draft' },
draft: true,
})
const version = await payload.create({
collection: 'versions',
data: { title: 'original-title', _status: 'draft', categoryVersion: category.id },
draft: true,
})
await payload.update({
collection: 'versions',
id: version.id,
data: { title: 'updated-title' },
draft: true,
})
const res = await payload.find({
collection: 'categories-versions',
draft: true,
})
expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id)
expect(res.docs[0].relatedVersions.docs[0].title).toBe('updated-title')
})
it('should populate joins when versions on both sides draft true payload.db.queryDrafts', async () => {
const category = await payload.create({ collection: 'categories-versions', data: {} })
const version = await payload.create({
collection: 'versions',
data: { categoryVersions: [category.id] },
data: { categoryVersions: [category.id], title: 'version' },
})
const res = await payload.find({
@@ -911,6 +940,52 @@ describe('Joins Field', () => {
)
})
it('should populate joins with hasMany when on both sides documents are in draft', async () => {
const category = await payload.create({
collection: 'categories-versions',
data: { _status: 'draft' },
draft: true,
})
const version = await payload.create({
collection: 'versions',
data: { _status: 'draft', title: 'original-title', categoryVersion: category.id },
draft: true,
})
await payload.update({
collection: 'versions',
draft: true,
id: version.id,
data: { title: 'updated-title' },
})
const query = `query {
CategoriesVersions(draft: true) {
docs {
relatedVersions(
limit: 1
) {
docs {
id,
title
}
hasNextPage
}
}
}
}`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((res) => res.json())
expect(res.data.CategoriesVersions.docs[0].relatedVersions.docs[0].id).toBe(version.id)
expect(res.data.CategoriesVersions.docs[0].relatedVersions.docs[0].title).toBe(
'updated-title',
)
})
it('should have simple paginate for joins inside groups', async () => {
const queryWithLimit = `query {
Categories(where: {

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -464,6 +465,7 @@ export interface Singular {
*/
export interface Version {
id: string;
title: string;
category?: (string | null) | Category;
categoryVersion?: (string | null) | CategoriesVersion;
categoryVersions?: (string | CategoriesVersion)[] | null;
@@ -999,6 +1001,7 @@ export interface UploadsSelect<T extends boolean = true> {
* via the `definition` "versions_select".
*/
export interface VersionsSelect<T extends boolean = true> {
title?: T;
category?: T;
categoryVersion?: T;
categoryVersions?: T;
@@ -1232,7 +1235,6 @@ export interface Auth {
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
export const pagesSlug = 'pages'
import { pagesSlug } from '../../slugs.js'
export const PagesCollection: CollectionConfig = {
slug: pagesSlug,

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
export const postsSlug = 'posts'
import { postsSlug } from '../../slugs.js'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,

View File

@@ -1,7 +1,9 @@
import type { CollectionConfig } from 'payload'
import { usersSlug } from '../../slugs.js'
export const Users: CollectionConfig = {
slug: 'users',
slug: usersSlug,
admin: {
useAsTitle: 'name',
},

View File

@@ -2,13 +2,13 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser, regularUser } from '../credentials.js'
import { PagesCollection, pagesSlug } from './collections/Pages/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
import { PagesCollection } from './collections/Pages/index.js'
import { PostsCollection } from './collections/Posts/index.js'
import { TestsCollection } from './collections/Tests/index.js'
import { Users } from './collections/Users/index.js'
import { AdminGlobal } from './globals/Admin/index.js'
import { MenuGlobal } from './globals/Menu/index.js'
import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -23,39 +23,7 @@ export default buildConfigWithDefaults({
globals: [AdminGlobal, MenuGlobal],
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
name: 'Admin',
roles: ['is_admin', 'is_user'],
},
})
await payload.create({
collection: 'users',
data: {
email: regularUser.email,
password: regularUser.password,
name: 'Dev',
roles: ['is_user'],
},
})
await payload.create({
collection: pagesSlug,
data: {
text: 'example page',
},
})
await payload.create({
collection: postsSlug,
data: {
text: 'example post',
},
})
await seed(payload)
}
},
typescript: {

View File

@@ -5,35 +5,29 @@ import { Locked, NotFound } from 'payload'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Menu, Page, Post, User } from './payload-types.js'
import type { Post, User } from './payload-types.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { pagesSlug } from './collections/Pages/index.js'
import { postsSlug } from './collections/Posts/index.js'
import { menuSlug } from './globals/Menu/index.js'
import { pagesSlug, postsSlug } from './slugs.js'
const lockedDocumentCollection = 'payload-locked-documents'
let payload: Payload
let token: string
let restClient: NextRESTClient
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Locked documents', () => {
let post: Post
let page: Page
let menu: Menu
let user: any
let user2: any
let postConfig: SanitizedCollectionConfig
beforeAll(async () => {
// @ts-expect-error: initPayloadInt does not have a proper type definition
;({ payload, restClient } = await initPayloadInt(dirname))
;({ payload } = await initPayloadInt(dirname))
postConfig = payload.config.collections.find(
({ slug }) => slug === postsSlug,
@@ -49,8 +43,6 @@ describe('Locked documents', () => {
user = loginResult.user
token = loginResult.token as string
user2 = await payload.create({
collection: 'users',
data: {
@@ -66,14 +58,14 @@ describe('Locked documents', () => {
},
})
page = await payload.create({
await payload.create({
collection: pagesSlug,
data: {
text: 'some page',
},
})
menu = await payload.updateGlobal({
await payload.updateGlobal({
slug: menuSlug,
data: {
globalText: 'global text',

View File

@@ -0,0 +1,57 @@
import type { Payload } from 'payload'
import { devUser, regularUser } from '../credentials.js'
import { executePromises } from '../helpers/executePromises.js'
import { seedDB } from '../helpers/seed.js'
import { collectionSlugs, pagesSlug, postsSlug } from './slugs.js'
export const seed = async (_payload: Payload) => {
await executePromises(
[
() =>
_payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
name: 'Admin',
roles: ['is_admin', 'is_user'],
},
}),
() =>
_payload.create({
collection: 'users',
data: {
email: regularUser.email,
password: regularUser.password,
name: 'Dev',
roles: ['is_user'],
},
}),
() =>
_payload.create({
collection: pagesSlug,
data: {
text: 'example page',
},
}),
() =>
_payload.create({
collection: postsSlug,
data: {
text: 'example post',
},
}),
],
false,
)
}
export async function clearAndSeedEverything(_payload: Payload) {
return await seedDB({
_payload,
collectionSlugs,
seedFunction: seed,
snapshotKey: 'adminTests',
})
}

View File

@@ -0,0 +1,7 @@
export const pagesSlug = 'pages'
export const postsSlug = 'posts'
export const usersSlug = 'users'
export const collectionSlugs = [pagesSlug, postsSlug, usersSlug]

2
test/query-presets/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { pagesSlug } from '../../slugs.js'
export const Pages: CollectionConfig = {
slug: pagesSlug,
admin: {
useAsTitle: 'text',
},
enableQueryPresets: true,
lockDocuments: false,
fields: [
{
name: 'text',
type: 'text',
},
],
versions: {
drafts: true,
},
}

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { postsSlug } from '../../slugs.js'
export const Posts: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'text',
},
enableQueryPresets: true,
lockDocuments: false,
fields: [
{
name: 'text',
type: 'text',
},
],
versions: {
drafts: true,
},
}

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'
import { roles } from '../../fields/roles.js'
import { usersSlug } from '../../slugs.js'
export const Users: CollectionConfig = {
slug: usersSlug,
admin: {
useAsTitle: 'name',
},
auth: true,
fields: [
{
name: 'name',
type: 'text',
},
roles,
],
}

View File

@@ -0,0 +1,67 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Pages } from './collections/Pages/index.js'
import { Posts } from './collections/Posts/index.js'
import { Users } from './collections/Users/index.js'
import { roles } from './fields/roles.js'
import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
queryPresets: {
// labels: {
// singular: 'Report',
// plural: 'Reports',
// },
access: {
read: ({ req: { user } }) =>
user ? !user?.roles?.some((role) => role === 'anonymous') : false,
update: ({ req: { user } }) =>
user ? !user?.roles?.some((role) => role === 'anonymous') : false,
},
constraints: {
read: [
{
label: 'Specific Roles',
value: 'specificRoles',
fields: [roles],
access: ({ req: { user } }) => ({
'access.read.roles': {
in: user?.roles || [],
},
}),
},
],
update: [
{
label: 'Specific Roles',
value: 'specificRoles',
fields: [roles],
access: ({ req: { user } }) => ({
'access.update.roles': {
in: user?.roles || [],
},
}),
},
],
},
},
collections: [Pages, Users, Posts],
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,407 @@
import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import type { Config } from './payload-types.js'
import {
ensureCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
saveDocAndAssert,
// throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { clickListMenuItem, openListMenu } from '../helpers/e2e/toggleListMenu.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { assertURLParams } from './helpers/assertURLParams.js'
import { openQueryPresetDrawer } from './helpers/openQueryPresetDrawer.js'
import { clearSelectedPreset, selectPreset } from './helpers/togglePreset.js'
import { seedData } from './seed.js'
import { pagesSlug } from './slugs.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const { beforeAll, describe, beforeEach } = test
let page: Page
let pagesUrl: AdminUrlUtil
let payload: PayloadTestSDK<Config>
let serverURL: string
let everyoneID: string | undefined
let context: BrowserContext
let user: any
describe('Query Presets', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
pagesUrl = new AdminUrlUtil(serverURL, pagesSlug)
context = await browser.newContext()
page = await context.newPage()
user = await payload
.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
?.then((res) => res.user) // TODO: this type is wrong
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
// await throttleTest({
// page,
// context,
// delay: 'Fast 4G',
// })
// clear and reseed everything
try {
await payload.delete({
collection: 'payload-query-presets',
where: {
id: {
exists: true,
},
},
})
const [, everyone] = await Promise.all([
payload.delete({
collection: 'payload-preferences',
where: {
and: [
{
key: { equals: 'pages-list' },
},
{
'user.relationTo': {
equals: 'users',
},
},
{
'user.value': {
equals: user.id,
},
},
],
},
}),
payload.create({
collection: 'payload-query-presets',
data: seedData.everyone,
}),
payload.create({
collection: 'payload-query-presets',
data: seedData.onlyMe,
}),
payload.create({
collection: 'payload-query-presets',
data: seedData.specificUsers({ userID: user?.id || '' }),
}),
])
everyoneID = everyone.id
} catch (error) {
console.error('Error in beforeEach:', error)
}
})
test('should select preset and apply filters', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await assertURLParams({
page,
columns: seedData.everyone.columns,
where: seedData.everyone.where,
presetID: everyoneID,
})
expect(true).toBe(true)
})
test('should clear selected preset and reset filters', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await clearSelectedPreset({ page })
expect(true).toBe(true)
})
test('should delete a preset, clear selection, and reset changes', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Delete' })
await page.locator('#confirm-delete-preset #confirm-action').click()
const regex = /columns=/
await page.waitForURL((url) => !regex.test(url.search), {
timeout: TEST_TIMEOUT_LONG,
})
await expect(
page.locator('button#select-preset', {
hasText: exactText('Select Preset'),
}),
).toBeVisible()
await openQueryPresetDrawer({ page })
const modal = page.locator('[id^=list-drawer_0_]')
await expect(modal).toBeVisible()
await expect(
modal.locator('tbody tr td button', {
hasText: exactText(seedData.everyone.title),
}),
).toBeHidden()
})
test('should save last used preset to preferences and load on initial render', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await page.reload()
await assertURLParams({
page,
columns: seedData.everyone.columns,
where: seedData.everyone.where,
// presetID: everyoneID,
})
expect(true).toBe(true)
})
test('should only show "edit" and "delete" controls when there is an active preset', async () => {
await page.goto(pagesUrl.list)
await openListMenu({ page })
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Edit'),
}),
).toBeHidden()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Delete'),
}),
).toBeHidden()
await selectPreset({ page, presetTitle: seedData.everyone.title })
await openListMenu({ page })
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Edit'),
}),
).toBeVisible()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Delete'),
}),
).toBeVisible()
})
test('should only show "reset" and "save" controls when there is an active preset and changes have been made', async () => {
await page.goto(pagesUrl.list)
await openListMenu({ page })
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Reset'),
}),
).toBeHidden()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Update for everyone'),
}),
).toBeHidden()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Save'),
}),
).toBeHidden()
await selectPreset({ page, presetTitle: seedData.onlyMe.title })
await toggleColumn(page, { columnLabel: 'ID' })
await openListMenu({ page })
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Reset'),
}),
).toBeVisible()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Save'),
}),
).toBeVisible()
})
test('should conditionally render "update for everyone" label based on if preset is shared', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.onlyMe.title })
await toggleColumn(page, { columnLabel: 'ID' })
await openListMenu({ page })
// When not shared, the label is "Save"
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Save'),
}),
).toBeVisible()
await selectPreset({ page, presetTitle: seedData.everyone.title })
await toggleColumn(page, { columnLabel: 'ID' })
await openListMenu({ page })
// When shared, the label is "Update for everyone"
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Update for everyone'),
}),
).toBeVisible()
})
test('should reset active changes', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
const { columnContainer } = await toggleColumn(page, { columnLabel: 'ID' })
const column = columnContainer.locator(`.column-selector .column-selector__column`, {
hasText: exactText('ID'),
})
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Reset' })
await openListColumns(page, {})
await expect(column).toHaveClass(/column-selector__column--active/)
})
test('should only enter modified state when changes are made to an active preset', async () => {
await page.goto(pagesUrl.list)
await expect(page.locator('.list-controls__modified')).toBeHidden()
await selectPreset({ page, presetTitle: seedData.everyone.title })
await expect(page.locator('.list-controls__modified')).toBeHidden()
await toggleColumn(page, { columnLabel: 'ID' })
await expect(page.locator('.list-controls__modified')).toBeVisible()
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Update for everyone' })
await expect(page.locator('.list-controls__modified')).toBeHidden()
await toggleColumn(page, { columnLabel: 'ID' })
await expect(page.locator('.list-controls__modified')).toBeVisible()
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Reset' })
await expect(page.locator('.list-controls__modified')).toBeHidden()
})
test('can edit a preset through the document drawer', async () => {
const presetTitle = 'New Preset'
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await clickListMenuItem({ page, menuItemLabel: 'Edit' })
const drawer = page.locator('[id^=doc-drawer_payload-query-presets_0_]')
const titleValue = drawer.locator('input[name="title"]')
await expect(titleValue).toHaveValue(seedData.everyone.title)
const newTitle = `${seedData.everyone.title} (Updated)`
await drawer.locator('input[name="title"]').fill(newTitle)
await saveDocAndAssert(page)
await drawer.locator('button.doc-drawer__header-close').click()
await expect(drawer).toBeHidden()
await expect(page.locator('button#select-preset')).toHaveText(newTitle)
})
test('should not display query presets when admin.enableQueryPresets is not true', async () => {
// go to users list view and ensure the query presets select is not visible
const usersURL = new AdminUrlUtil(serverURL, 'users')
await page.goto(usersURL.list)
await expect(page.locator('#select-preset')).toBeHidden()
})
// eslint-disable-next-line playwright/no-skipped-test, playwright/expect-expect
test.skip('can save a preset', () => {
// select a preset, make a change to the presets, click "save for everyone" or "save", and ensure the changes persist
})
test('can create new preset', async () => {
await page.goto(pagesUrl.list)
const presetTitle = 'New Preset'
await clickListMenuItem({ page, menuItemLabel: 'Create New' })
const modal = page.locator('[id^=doc-drawer_payload-query-presets_0_]')
await expect(modal).toBeVisible()
await modal.locator('input[name="title"]').fill(presetTitle)
const currentURL = page.url()
await saveDocAndAssert(page)
await expect(modal).toBeHidden()
await page.waitForURL(() => page.url() !== currentURL)
await expect(
page.locator('button#select-preset', {
hasText: exactText(presetTitle),
}),
).toBeVisible()
})
test('only shows query presets related to the underlying collection', async () => {
// no results on `users` collection
const postsUrl = new AdminUrlUtil(serverURL, 'posts')
await page.goto(postsUrl.list)
const drawer = await openQueryPresetDrawer({ page })
await expect(drawer.locator('.table table > tbody > tr')).toHaveCount(0)
await expect(drawer.locator('.collection-list__no-results')).toBeVisible()
// results on `pages` collection
await page.goto(pagesUrl.list)
await openQueryPresetDrawer({ page })
await expect(drawer.locator('.table table > tbody > tr')).toHaveCount(3)
await drawer.locator('.collection-list__no-results').isHidden()
})
})

View File

@@ -0,0 +1,23 @@
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: {
projectService: {
allowDefaultProject: ['./*.ts', './*.tsx'],
defaultProject: './tsconfig.json',
},
tsconfigDirName: import.meta.dirname,
...rootParserOptions,
},
},
},
]
export default index

View File

@@ -0,0 +1,21 @@
import type { Field } from 'payload'
export const roles: Field = {
name: 'roles',
type: 'select',
hasMany: true,
options: [
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'user',
},
{
label: 'Anonymous',
value: 'anonymous',
},
],
}

View File

@@ -0,0 +1,39 @@
import type { Page } from '@playwright/test'
import type { ColumnPreference, Where } from 'payload'
// import { transformColumnsToSearchParams, transformWhereQuery } from 'payload/shared'
// import * as qs from 'qs-esm'
import { transformColumnsToSearchParams } from 'payload/shared'
export async function assertURLParams({
page,
columns,
where,
presetID,
}: {
columns?: ColumnPreference[]
page: Page
presetID?: string | undefined
where: Where
}) {
if (where) {
// TODO: can't get columns to encode correctly
// const whereQuery = qs.stringify(transformWhereQuery(where))
// const encodedWhere = encodeURIComponent(whereQuery)
}
if (columns) {
const escapedColumns = encodeURIComponent(
JSON.stringify(transformColumnsToSearchParams(columns)),
)
const columnsRegex = new RegExp(`columns=${escapedColumns}`)
await page.waitForURL(columnsRegex)
}
if (presetID) {
const presetRegex = new RegExp(`preset=${presetID}`)
await page.waitForURL(presetRegex)
}
}

View File

@@ -0,0 +1,11 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
export async function openQueryPresetDrawer({ page }: { page: Page }): Promise<Locator> {
await page.click('button#select-preset')
const drawer = page.locator('dialog[id^="list-drawer_0_"]')
await expect(drawer).toBeVisible()
await expect(drawer.locator('.collection-list--payload-query-presets')).toBeVisible()
return drawer
}

View File

@@ -0,0 +1,53 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from 'helpers.js'
import { TEST_TIMEOUT_LONG } from 'playwright.config.js'
import { openQueryPresetDrawer } from './openQueryPresetDrawer.js'
export async function selectPreset({ page, presetTitle }: { page: Page; presetTitle: string }) {
await openQueryPresetDrawer({ page })
const modal = page.locator('[id^=list-drawer_0_]')
await expect(modal).toBeVisible()
const currentURL = page.url()
await modal
.locator('tbody tr td button', {
hasText: exactText(presetTitle),
})
.first()
.click()
await page.waitForURL(() => page.url() !== currentURL)
await expect(
page.locator('button#select-preset', {
hasText: exactText(presetTitle),
}),
).toBeVisible()
}
export async function clearSelectedPreset({ page }: { page: Page }) {
const queryPresetsControl = page.locator('button#select-preset')
const clearButton = queryPresetsControl.locator('#clear-preset')
if (await clearButton.isVisible()) {
await clearButton.click()
}
const regex = /columns=/
await page.waitForURL((url) => !regex.test(url.search), {
timeout: TEST_TIMEOUT_LONG,
})
await expect(queryPresetsControl.locator('#clear-preset')).toBeHidden()
await expect(
page.locator('button#select-preset', {
hasText: exactText('Select Preset'),
}),
).toBeVisible()
}

View File

@@ -0,0 +1,568 @@
import type { NextRESTClient } from 'helpers/NextRESTClient.js'
import type { Payload, User } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { devUser, regularUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
const queryPresetsCollectionSlug = 'payload-query-presets'
let payload: Payload
let restClient: NextRESTClient
let user: User
let user2: User
let anonymousUser: User
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Query Presets', () => {
beforeAll(async () => {
// @ts-expect-error: initPayloadInt does not have a proper type definition
;({ payload, restClient } = await initPayloadInt(dirname))
user = await payload
.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
?.then((result) => result.user)
user2 = await payload
.login({
collection: 'users',
data: {
email: regularUser.email,
password: regularUser.password,
},
})
?.then((result) => result.user)
anonymousUser = await payload
.login({
collection: 'users',
data: {
email: 'anonymous@email.com',
password: regularUser.password,
},
})
?.then((result) => result.user)
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
describe('default access control', () => {
it('should only allow logged in users to perform actions', async () => {
// create
try {
const result = await payload.create({
collection: queryPresetsCollectionSlug,
user: undefined,
overrideAccess: false,
data: {
title: 'Only Logged In Users',
relatedCollection: 'pages',
},
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
const { id } = await payload.create({
collection: queryPresetsCollectionSlug,
data: {
title: 'Only Logged In Users',
relatedCollection: 'pages',
},
})
// read
try {
const result = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: undefined,
overrideAccess: false,
id,
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
// update
try {
const result = await payload.update({
collection: queryPresetsCollectionSlug,
id,
user: undefined,
overrideAccess: false,
data: {
title: 'Only Logged In Users (Updated)',
},
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
// make sure the update didn't go through
const preset = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
id,
})
expect(preset.title).toBe('Only Logged In Users')
}
// delete
try {
const result = await payload.delete({
collection: queryPresetsCollectionSlug,
id: 'some-id',
user: undefined,
overrideAccess: false,
})
expect(result).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
// make sure the delete didn't go through
const preset = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
id,
})
expect(preset.title).toBe('Only Logged In Users')
}
})
it('should respect access when set to "specificUsers"', async () => {
const presetForSpecificUsers = await payload.create({
collection: queryPresetsCollectionSlug,
user,
data: {
title: 'Specific Users',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'specificUsers',
users: [user.id],
},
update: {
constraint: 'specificUsers',
users: [user.id],
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
overrideAccess: false,
id: presetForSpecificUsers.id,
})
expect(foundPresetWithUser1.id).toBe(presetForSpecificUsers.id)
try {
const foundPresetWithUser2 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
overrideAccess: false,
id: presetForSpecificUsers.id,
})
expect(foundPresetWithUser2).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificUsers.id,
user,
overrideAccess: false,
data: {
title: 'Specific Users (Updated)',
},
})
expect(presetUpdatedByUser1.title).toBe('Specific Users (Updated)')
try {
const presetUpdatedByUser2 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificUsers.id,
user: user2,
overrideAccess: false,
data: {
title: 'Specific Users (Updated)',
},
})
expect(presetUpdatedByUser2).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect access when set to "onlyMe"', async () => {
// create a new doc so that the creating user is the owner
const presetForOnlyMe = await payload.create({
collection: queryPresetsCollectionSlug,
user,
data: {
title: 'Only Me',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'onlyMe',
},
update: {
constraint: 'onlyMe',
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
overrideAccess: false,
id: presetForOnlyMe.id,
})
expect(foundPresetWithUser1.id).toBe(presetForOnlyMe.id)
try {
const foundPresetWithUser2 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
overrideAccess: false,
id: presetForOnlyMe.id,
})
expect(foundPresetWithUser2).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForOnlyMe.id,
user,
overrideAccess: false,
data: {
title: 'Only Me (Updated)',
},
})
expect(presetUpdatedByUser1.title).toBe('Only Me (Updated)')
try {
const presetUpdatedByUser2 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForOnlyMe.id,
user: user2,
overrideAccess: false,
data: {
title: 'Only Me (Updated)',
},
})
expect(presetUpdatedByUser2).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect access when set to "everyone"', async () => {
const presetForEveryone = await payload.create({
collection: queryPresetsCollectionSlug,
user,
data: {
title: 'Everyone',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'everyone',
},
update: {
constraint: 'everyone',
},
delete: {
constraint: 'everyone',
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
overrideAccess: false,
id: presetForEveryone.id,
})
expect(foundPresetWithUser1.id).toBe(presetForEveryone.id)
const foundPresetWithUser2 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
overrideAccess: false,
id: presetForEveryone.id,
})
expect(foundPresetWithUser2.id).toBe(presetForEveryone.id)
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForEveryone.id,
user,
overrideAccess: false,
data: {
title: 'Everyone (Update 1)',
},
})
expect(presetUpdatedByUser1.title).toBe('Everyone (Update 1)')
const presetUpdatedByUser2 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForEveryone.id,
user: user2,
overrideAccess: false,
data: {
title: 'Everyone (Update 2)',
},
})
expect(presetUpdatedByUser2.title).toBe('Everyone (Update 2)')
})
})
describe('user-defined access control', () => {
it('should respect top-level access control overrides', async () => {
const preset = await payload.create({
collection: queryPresetsCollectionSlug,
user,
data: {
title: 'Top-Level Access Control Override',
relatedCollection: 'pages',
access: {
read: {
constraint: 'everyone',
},
update: {
constraint: 'everyone',
},
delete: {
constraint: 'everyone',
},
},
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
overrideAccess: false,
id: preset.id,
})
expect(foundPresetWithUser1.id).toBe(preset.id)
try {
const foundPresetWithAnonymousUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: anonymousUser,
overrideAccess: false,
id: preset.id,
})
expect(foundPresetWithAnonymousUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect access when set to "specificRoles"', async () => {
const presetForSpecificRoles = await payload.create({
collection: queryPresetsCollectionSlug,
user,
data: {
title: 'Specific Roles',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'specificRoles',
roles: ['admin'],
},
update: {
constraint: 'specificRoles',
roles: ['admin'],
},
},
relatedCollection: 'pages',
},
})
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
overrideAccess: false,
id: presetForSpecificRoles.id,
})
expect(foundPresetWithUser1.id).toBe(presetForSpecificRoles.id)
try {
const foundPresetWithUser2 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
overrideAccess: false,
id: presetForSpecificRoles.id,
})
expect(foundPresetWithUser2).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificRoles.id,
user,
overrideAccess: false,
data: {
title: 'Specific Roles (Updated)',
},
})
expect(presetUpdatedByUser1.title).toBe('Specific Roles (Updated)')
try {
const presetUpdatedByUser2 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificRoles.id,
user: user2,
overrideAccess: false,
data: {
title: 'Specific Roles (Updated)',
},
})
expect(presetUpdatedByUser2).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
})
it.skip('should disable query presets when "enabledQueryPresets" is not true on the collection', async () => {
try {
const result = await payload.create({
collection: 'payload-query-presets',
user,
data: {
title: 'Disabled Query Presets',
relatedCollection: 'pages',
},
})
// TODO: this test always passes because this expect throws an error which is caught and passes the 'catch' block
expect(result).toBeFalsy()
} catch (error) {
expect(error).toBeDefined()
}
})
describe('Where object formatting', () => {
it('transforms "where" query objects into the "and" / "or" format', async () => {
const result = await payload.create({
collection: queryPresetsCollectionSlug,
user,
data: {
title: 'Where Object Formatting',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'everyone',
},
update: {
constraint: 'everyone',
},
delete: {
constraint: 'everyone',
},
},
relatedCollection: 'pages',
},
})
expect(result.where).toMatchObject({
or: [
{
and: [
{
text: {
equals: 'example page',
},
},
],
},
],
})
})
})
})

View File

@@ -0,0 +1,386 @@
/* 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: {
pages: Page;
users: User;
posts: Post;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
'payload-query-presets': PayloadQueryPreset;
};
collectionsJoins: {};
collectionsSelect: {
pages: PagesSelect<false> | PagesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
'payload-query-presets': PayloadQueryPresetsSelect<false> | PayloadQueryPresetsSelect<true>;
};
db: {
defaultIDType: number;
};
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` "pages".
*/
export interface Page {
id: number;
text?: 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: number;
name?: string | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
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` "posts".
*/
export interface Post {
id: number;
text?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'pages';
value: number | Page;
} | null)
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | 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: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-query-presets".
*/
export interface PayloadQueryPreset {
id: number;
title: string;
isShared?: boolean | null;
access?: {
read?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null;
users?: (number | User)[] | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
};
update?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null;
users?: (number | User)[] | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
};
delete?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers') | null;
users?: (number | User)[] | null;
};
};
where?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
columns?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
relatedCollection: 'pages' | 'posts';
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
text?: 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> {
name?: 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` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
_status?: 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` "payload-query-presets_select".
*/
export interface PayloadQueryPresetsSelect<T extends boolean = true> {
title?: T;
isShared?: T;
access?:
| T
| {
read?:
| T
| {
constraint?: T;
users?: T;
roles?: T;
};
update?:
| T
| {
constraint?: T;
users?: T;
roles?: T;
};
delete?:
| T
| {
constraint?: T;
users?: T;
};
};
where?: T;
columns?: T;
relatedCollection?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

File diff suppressed because it is too large Load Diff

182
test/query-presets/seed.ts Normal file
View File

@@ -0,0 +1,182 @@
import type { Payload, QueryPreset } from 'payload'
import { devUser as devCredentials, regularUser as regularCredentials } from '../credentials.js'
import { executePromises } from '../helpers/executePromises.js'
import { seedDB } from '../helpers/seed.js'
import { collectionSlugs, pagesSlug, usersSlug } from './slugs.js'
type SeededQueryPreset = {
relatedCollection: 'pages'
} & Omit<QueryPreset, 'id' | 'relatedCollection'>
export const seedData: {
everyone: SeededQueryPreset
onlyMe: SeededQueryPreset
specificUsers: (args: { userID: string }) => SeededQueryPreset
} = {
onlyMe: {
relatedCollection: pagesSlug,
isShared: false,
title: 'Only Me',
columns: [
{
accessor: 'text',
active: true,
},
],
access: {
delete: {
constraint: 'onlyMe',
},
update: {
constraint: 'onlyMe',
},
read: {
constraint: 'onlyMe',
},
},
where: {
text: {
equals: 'example page',
},
},
},
everyone: {
relatedCollection: pagesSlug,
isShared: true,
title: 'Everyone',
access: {
delete: {
constraint: 'everyone',
},
update: {
constraint: 'everyone',
},
read: {
constraint: 'everyone',
},
},
columns: [
{
accessor: 'text',
active: true,
},
],
where: {
text: {
equals: 'example page',
},
},
},
specificUsers: ({ userID }: { userID: string }) => ({
title: 'Specific Users',
isShared: true,
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'specificUsers',
users: [userID],
},
update: {
constraint: 'specificUsers',
users: [userID],
},
delete: {
constraint: 'specificUsers',
users: [userID],
},
},
columns: [
{
accessor: 'text',
active: true,
},
],
relatedCollection: pagesSlug,
}),
}
export const seed = async (_payload: Payload) => {
const [devUser] = await executePromises(
[
() =>
_payload.create({
collection: usersSlug,
data: {
email: devCredentials.email,
password: devCredentials.password,
name: 'Admin',
roles: ['admin'],
},
}),
() =>
_payload.create({
collection: usersSlug,
data: {
email: regularCredentials.email,
password: regularCredentials.password,
name: 'User',
roles: ['user'],
},
}),
() =>
_payload.create({
collection: usersSlug,
data: {
email: 'anonymous@email.com',
password: regularCredentials.password,
name: 'User',
roles: ['anonymous'],
},
}),
],
false,
)
await executePromises(
[
() =>
_payload.create({
collection: pagesSlug,
data: {
text: 'example page',
},
}),
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
overrideAccess: false,
data: seedData.specificUsers({ userID: devUser?.id || '' }),
}),
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
overrideAccess: false,
data: seedData.everyone,
}),
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
overrideAccess: false,
data: seedData.onlyMe,
}),
],
false,
)
}
export async function clearAndSeedEverything(_payload: Payload) {
return await seedDB({
_payload,
collectionSlugs,
seedFunction: seed,
snapshotKey: 'adminTests',
})
}

View File

@@ -0,0 +1,7 @@
export const usersSlug = 'users'
export const pagesSlug = 'pages'
export const postsSlug = 'posts'
export const collectionSlugs = [usersSlug, pagesSlug, postsSlug]

View File

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

View File

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

View File

@@ -10,8 +10,10 @@ import { updatePostStep1, updatePostStep2 } from './runners/updatePost.js'
import { clearAndSeedEverything } from './seed.js'
import { externalWorkflow } from './workflows/externalWorkflow.js'
import { inlineTaskTestWorkflow } from './workflows/inlineTaskTest.js'
import { inlineTaskTestDelayedWorkflow } from './workflows/inlineTaskTestDelayed.js'
import { longRunningWorkflow } from './workflows/longRunning.js'
import { noRetriesSetWorkflow } from './workflows/noRetriesSet.js'
import { parallelTaskWorkflow } from './workflows/parallelTaskWorkflow.js'
import { retries0Workflow } from './workflows/retries0.js'
import { retriesBackoffTestWorkflow } from './workflows/retriesBackoffTest.js'
import { retriesRollbackTestWorkflow } from './workflows/retriesRollbackTest.js'
@@ -103,6 +105,11 @@ export default buildConfigWithDefaults({
},
}
},
processingOrder: {
queues: {
lifo: '-createdAt',
},
},
tasks: [
{
retries: 2,
@@ -375,11 +382,13 @@ export default buildConfigWithDefaults({
workflowRetries2TasksRetriesUndefinedWorkflow,
workflowRetries2TasksRetries0Workflow,
inlineTaskTestWorkflow,
inlineTaskTestDelayedWorkflow,
externalWorkflow,
retriesBackoffTestWorkflow,
subTaskWorkflow,
subTaskFailsWorkflow,
longRunningWorkflow,
parallelTaskWorkflow,
],
},
editor: lexicalEditor(),

View File

@@ -533,6 +533,106 @@ describe('Queues', () => {
payload.config.jobs.deleteJobOnComplete = true
})
it('ensure jobs run in FIFO order by default', async () => {
await payload.jobs.queue({
workflow: 'inlineTaskTestDelayed',
input: {
message: 'task 1',
},
})
await new Promise((resolve) => setTimeout(resolve, 100))
await payload.jobs.queue({
workflow: 'inlineTaskTestDelayed',
input: {
message: 'task 2',
},
})
await payload.jobs.run({
sequential: true,
})
const allSimples = await payload.find({
collection: 'simple',
limit: 100,
sort: 'createdAt',
})
expect(allSimples.totalDocs).toBe(2)
expect(allSimples.docs?.[0]?.title).toBe('task 1')
expect(allSimples.docs?.[1]?.title).toBe('task 2')
})
it('ensure jobs can run LIFO if processingOrder is passed', async () => {
await payload.jobs.queue({
workflow: 'inlineTaskTestDelayed',
input: {
message: 'task 1',
},
})
await new Promise((resolve) => setTimeout(resolve, 100))
await payload.jobs.queue({
workflow: 'inlineTaskTestDelayed',
input: {
message: 'task 2',
},
})
await payload.jobs.run({
sequential: true,
processingOrder: '-createdAt',
})
const allSimples = await payload.find({
collection: 'simple',
limit: 100,
sort: 'createdAt',
})
expect(allSimples.totalDocs).toBe(2)
expect(allSimples.docs?.[0]?.title).toBe('task 2')
expect(allSimples.docs?.[1]?.title).toBe('task 1')
})
it('ensure job config processingOrder using queues object is respected', async () => {
await payload.jobs.queue({
workflow: 'inlineTaskTestDelayed',
queue: 'lifo',
input: {
message: 'task 1',
},
})
await new Promise((resolve) => setTimeout(resolve, 100))
await payload.jobs.queue({
workflow: 'inlineTaskTestDelayed',
queue: 'lifo',
input: {
message: 'task 2',
},
})
await payload.jobs.run({
sequential: true,
queue: 'lifo',
})
const allSimples = await payload.find({
collection: 'simple',
limit: 100,
sort: 'createdAt',
})
expect(allSimples.totalDocs).toBe(2)
expect(allSimples.docs?.[0]?.title).toBe('task 2')
expect(allSimples.docs?.[1]?.title).toBe('task 1')
})
it('can create new inline jobs', async () => {
await payload.jobs.queue({
workflow: 'inlineTaskTest',
@@ -1265,4 +1365,40 @@ describe('Queues', () => {
expect(jobAfterRun.log[0].error.message).toBe('custom error message')
expect(jobAfterRun.log[0].state).toBe('failed')
})
it('can reliably run workflows with parallel tasks', async () => {
const amount = 500
payload.config.jobs.deleteJobOnComplete = false
const job = await payload.jobs.queue({
workflow: 'parallelTask',
input: {},
})
await payload.jobs.run()
const jobAfterRun = await payload.findByID({
collection: 'payload-jobs',
id: job.id,
})
expect(jobAfterRun.hasError).toBe(false)
expect(jobAfterRun.log?.length).toBe(amount)
const simpleDocs = await payload.find({
collection: 'simple',
limit: amount,
depth: 0,
})
expect(simpleDocs.docs).toHaveLength(amount)
// Ensure all docs are created (= all tasks are run once)
for (let i = 1; i <= simpleDocs.docs.length; i++) {
const simpleDoc = simpleDocs.docs.find((doc) => doc.title === `parallel task ${i}`)
const logEntry = jobAfterRun?.log?.find((log) => log.taskID === `parallel task ${i}`)
expect(simpleDoc).toBeDefined()
expect(logEntry).toBeDefined()
expect((logEntry?.output as any)?.simpleID).toBe(simpleDoc?.id)
}
})
})

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -102,6 +103,9 @@ export interface Config {
CreateSimpleRetries0: TaskCreateSimpleRetries0;
CreateSimpleWithDuplicateMessage: TaskCreateSimpleWithDuplicateMessage;
ExternalTask: TaskExternalTask;
ThrowError: TaskThrowError;
ReturnError: TaskReturnError;
ReturnCustomError: TaskReturnCustomError;
inline: {
input: unknown;
output: unknown;
@@ -119,11 +123,13 @@ export interface Config {
workflowRetries2TasksRetriesUndefined: WorkflowWorkflowRetries2TasksRetriesUndefined;
workflowRetries2TasksRetries0: WorkflowWorkflowRetries2TasksRetries0;
inlineTaskTest: WorkflowInlineTaskTest;
inlineTaskTestDelayed: WorkflowInlineTaskTestDelayed;
externalWorkflow: WorkflowExternalWorkflow;
retriesBackoffTest: WorkflowRetriesBackoffTest;
subTask: WorkflowSubTask;
subTaskFails: WorkflowSubTaskFails;
longRunning: WorkflowLongRunning;
parallelTask: WorkflowParallelTask;
};
};
}
@@ -259,7 +265,10 @@ export interface PayloadJob {
| 'CreateSimpleRetriesUndefined'
| 'CreateSimpleRetries0'
| 'CreateSimpleWithDuplicateMessage'
| 'ExternalTask';
| 'ExternalTask'
| 'ThrowError'
| 'ReturnError'
| 'ReturnCustomError';
taskID: string;
input?:
| {
@@ -305,11 +314,13 @@ export interface PayloadJob {
| 'workflowRetries2TasksRetriesUndefined'
| 'workflowRetries2TasksRetries0'
| 'inlineTaskTest'
| 'inlineTaskTestDelayed'
| 'externalWorkflow'
| 'retriesBackoffTest'
| 'subTask'
| 'subTaskFails'
| 'longRunning'
| 'parallelTask'
)
| null;
taskSlug?:
@@ -322,6 +333,9 @@ export interface PayloadJob {
| 'CreateSimpleRetries0'
| 'CreateSimpleWithDuplicateMessage'
| 'ExternalTask'
| 'ThrowError'
| 'ReturnError'
| 'ReturnCustomError'
)
| null;
queue?: string | null;
@@ -583,6 +597,32 @@ export interface TaskExternalTask {
simpleID: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskThrowError".
*/
export interface TaskThrowError {
input?: unknown;
output?: unknown;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskReturnError".
*/
export interface TaskReturnError {
input?: unknown;
output?: unknown;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskReturnCustomError".
*/
export interface TaskReturnCustomError {
input: {
errorMessage: string;
};
output?: unknown;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "MyUpdatePostWorkflowType".
@@ -684,6 +724,15 @@ export interface WorkflowInlineTaskTest {
message: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "WorkflowInlineTaskTestDelayed".
*/
export interface WorkflowInlineTaskTestDelayed {
input: {
message: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "WorkflowExternalWorkflow".
@@ -727,6 +776,13 @@ export interface WorkflowSubTaskFails {
export interface WorkflowLongRunning {
input?: unknown;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "WorkflowParallelTask".
*/
export interface WorkflowParallelTask {
input?: unknown;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View File

@@ -0,0 +1,38 @@
import type { WorkflowConfig } from 'payload'
export const inlineTaskTestDelayedWorkflow: WorkflowConfig<'inlineTaskTestDelayed'> = {
slug: 'inlineTaskTestDelayed',
inputSchema: [
{
name: 'message',
type: 'text',
required: true,
},
],
handler: async ({ job, inlineTask }) => {
await inlineTask('1', {
task: async ({ input, req }) => {
// Wait 100ms
await new Promise((resolve) => setTimeout(resolve, 100))
const newSimple = await req.payload.create({
collection: 'simple',
req,
data: {
title: input.message,
},
})
await new Promise((resolve) => setTimeout(resolve, 100))
return {
output: {
simpleID: newSimple.id,
},
}
},
input: {
message: job.input.message,
},
})
},
}

View File

@@ -0,0 +1,29 @@
import type { WorkflowConfig } from 'payload'
export const parallelTaskWorkflow: WorkflowConfig<'parallelTask'> = {
slug: 'parallelTask',
inputSchema: [],
handler: async ({ job, inlineTask }) => {
const taskIDs = Array.from({ length: 500 }, (_, i) => i + 1).map((i) => i.toString())
await Promise.all(
taskIDs.map(async (taskID) => {
return await inlineTask(`parallel task ${taskID}`, {
task: async ({ req }) => {
const newSimple = await req.payload.db.create({
collection: 'simple',
data: {
title: 'parallel task ' + taskID,
},
})
return {
output: {
simpleID: newSimple.id,
},
}
},
})
}),
)
},
}

View File

@@ -336,6 +336,41 @@ describe('Relationships', () => {
expect(query.docs).toHaveLength(1) // Due to limit: 1
})
it('should allow querying by relationships with an object where as AND', async () => {
const director = await payload.create({
collection: 'directors',
data: { name: 'Director1', localized: 'Director1_Localized' },
})
const movie = await payload.create({
collection: 'movies',
data: { director: director.id },
depth: 0,
})
const { docs: trueRes } = await payload.find({
collection: 'movies',
depth: 0,
where: {
'director.name': { equals: 'Director1' },
'director.localized': { equals: 'Director1_Localized' },
},
})
expect(trueRes).toStrictEqual([movie])
const { docs: falseRes } = await payload.find({
collection: 'movies',
depth: 0,
where: {
'director.name': { equals: 'Director1_Fake' },
'director.localized': { equals: 'Director1_Localized' },
},
})
expect(falseRes).toStrictEqual([])
})
it('should allow querying within blocks', async () => {
const rel = await payload.create({
collection: relationSlug,
@@ -397,21 +432,7 @@ describe('Relationships', () => {
expect(result.docs[0].id).toBe(id)
})
describe('Custom ID', () => {
it('should query a custom id relation', async () => {
const { customIdRelation } = await restClient
.GET(`/${slug}/${post.id}`)
.then((res) => res.json())
expect(customIdRelation).toMatchObject({ id: generatedCustomId })
})
it('should query a custom id number relation', async () => {
const { customIdNumberRelation } = await restClient
.GET(`/${slug}/${post.id}`)
.then((res) => res.json())
expect(customIdNumberRelation).toMatchObject({ id: generatedCustomIdNumber })
})
describe('hasMany relationships', () => {
it('should retrieve totalDocs correctly with hasMany,', async () => {
const movie1 = await payload.create({
collection: 'movies',
@@ -592,47 +613,59 @@ describe('Relationships', () => {
expect(query1.totalDocs).toStrictEqual(1)
})
it('should sort by a property of a hasMany relationship', async () => {
const movie1 = await payload.create({
collection: 'movies',
it('should query using "in" by hasMany relationship field', async () => {
const tree1 = await payload.create({
collection: treeSlug,
data: {
name: 'Pulp Fiction',
text: 'Tree 1',
},
})
const movie2 = await payload.create({
collection: 'movies',
const tree2 = await payload.create({
collection: treeSlug,
data: {
name: 'Inception',
parent: tree1.id,
text: 'Tree 2',
},
})
await payload.delete({ collection: 'directors', where: {} })
const director1 = await payload.create({
collection: 'directors',
const tree3 = await payload.create({
collection: treeSlug,
data: {
name: 'Quentin Tarantino',
movies: [movie1.id],
},
})
const director2 = await payload.create({
collection: 'directors',
data: {
name: 'Christopher Nolan',
movies: [movie2.id],
parent: tree2.id,
text: 'Tree 3',
},
})
const result = await payload.find({
collection: 'directors',
const tree4 = await payload.create({
collection: treeSlug,
data: {
parent: tree3.id,
text: 'Tree 4',
},
})
const validParents = [tree2.id, tree3.id]
const query = await payload.find({
collection: treeSlug,
depth: 0,
sort: '-movies.name',
sort: 'createdAt',
where: {
parent: {
in: validParents,
},
},
})
// should only return tree3 and tree4
expect(result.docs[0].id).toStrictEqual(director1.id)
expect(query.totalDocs).toEqual(2)
expect(query.docs[0].text).toEqual('Tree 3')
expect(query.docs[1].text).toEqual('Tree 4')
})
})
describe('sorting by relationships', () => {
it('should sort by a property of a relationship', async () => {
await payload.delete({ collection: 'directors', where: {} })
await payload.delete({ collection: 'movies', where: {} })
@@ -719,236 +752,61 @@ describe('Relationships', () => {
expect(localized_res_2.docs).toStrictEqual([movie_1, movie_2])
})
it('should query using "in" by hasMany relationship field', async () => {
const tree1 = await payload.create({
collection: treeSlug,
it('should sort by a property of a hasMany relationship', async () => {
const movie1 = await payload.create({
collection: 'movies',
data: {
text: 'Tree 1',
name: 'Pulp Fiction',
},
})
const tree2 = await payload.create({
collection: treeSlug,
const movie2 = await payload.create({
collection: 'movies',
data: {
parent: tree1.id,
text: 'Tree 2',
name: 'Inception',
},
})
const tree3 = await payload.create({
collection: treeSlug,
await payload.delete({ collection: 'directors', where: {} })
const director1 = await payload.create({
collection: 'directors',
data: {
parent: tree2.id,
text: 'Tree 3',
name: 'Quentin Tarantino',
movies: [movie1.id],
},
})
const director2 = await payload.create({
collection: 'directors',
data: {
name: 'Christopher Nolan',
movies: [movie2.id],
},
})
const tree4 = await payload.create({
collection: treeSlug,
data: {
parent: tree3.id,
text: 'Tree 4',
},
})
const validParents = [tree2.id, tree3.id]
const query = await payload.find({
collection: treeSlug,
const result = await payload.find({
collection: 'directors',
depth: 0,
sort: 'createdAt',
where: {
parent: {
in: validParents,
},
},
sort: '-movies.name',
})
// should only return tree3 and tree4
expect(query.totalDocs).toEqual(2)
expect(query.docs[0].text).toEqual('Tree 3')
expect(query.docs[1].text).toEqual('Tree 4')
expect(result.docs[0].id).toStrictEqual(director1.id)
})
})
describe('Custom ID', () => {
it('should query a custom id relation', async () => {
const { customIdRelation } = await restClient
.GET(`/${slug}/${post.id}`)
.then((res) => res.json())
expect(customIdRelation).toMatchObject({ id: generatedCustomId })
})
it('should validate the format of text id relationships', async () => {
await expect(async () =>
createPost({
// @ts-expect-error Sending bad data to test error handling
customIdRelation: 1234,
}),
).rejects.toThrow('The following field is invalid: Custom Id Relation')
})
it('should validate the format of number id relationships', async () => {
await expect(async () =>
createPost({
// @ts-expect-error Sending bad data to test error handling
customIdNumberRelation: 'bad-input',
}),
).rejects.toThrow('The following field is invalid: Custom Id Number Relation')
})
it('should allow update removing a relationship', async () => {
const response = await restClient.PATCH(`/${slug}/${post.id}`, {
body: JSON.stringify({
customIdRelation: null,
relationField: null,
}),
})
const doc = await response.json()
expect(response.status).toEqual(200)
expect(doc.relationField).toBeFalsy()
})
it('should query a polymorphic relationship field with mixed custom ids and default', async () => {
const customIDNumber = await payload.create({
collection: 'custom-id-number',
data: { id: 999 },
})
const customIDText = await payload.create({
collection: 'custom-id',
data: { id: 'custom-id' },
})
const page = await payload.create({
collection: 'pages',
data: {},
})
const relToCustomIdText = await payload.create({
collection: 'rels-to-pages-and-custom-text-ids',
data: {
rel: {
relationTo: 'custom-id',
value: customIDText.id,
},
},
})
const relToCustomIdNumber = await payload.create({
collection: 'rels-to-pages-and-custom-text-ids',
data: {
rel: {
relationTo: 'custom-id-number',
value: customIDNumber.id,
},
},
})
const relToPage = await payload.create({
collection: 'rels-to-pages-and-custom-text-ids',
data: {
rel: {
relationTo: 'pages',
value: page.id,
},
},
})
const pageResult = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
and: [
{
'rel.value': {
equals: page.id,
},
},
{
'rel.relationTo': {
equals: 'pages',
},
},
],
},
})
expect(pageResult.totalDocs).toBe(1)
expect(pageResult.docs[0].id).toBe(relToPage.id)
const customIDResult = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
and: [
{
'rel.value': {
equals: customIDText.id,
},
},
{
'rel.relationTo': {
equals: 'custom-id',
},
},
],
},
})
expect(customIDResult.totalDocs).toBe(1)
expect(customIDResult.docs[0].id).toBe(relToCustomIdText.id)
const customIDNumberResult = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
and: [
{
'rel.value': {
equals: customIDNumber.id,
},
},
{
'rel.relationTo': {
equals: 'custom-id-number',
},
},
],
},
})
expect(customIDNumberResult.totalDocs).toBe(1)
expect(customIDNumberResult.docs[0].id).toBe(relToCustomIdNumber.id)
const inResult_1 = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
'rel.value': {
in: [page.id, customIDNumber.id],
},
},
})
expect(inResult_1.totalDocs).toBe(2)
expect(inResult_1.docs.some((each) => each.id === relToPage.id)).toBeTruthy()
expect(inResult_1.docs.some((each) => each.id === relToCustomIdNumber.id)).toBeTruthy()
const inResult_2 = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
'rel.value': {
in: [customIDNumber.id, customIDText.id],
},
},
})
expect(inResult_2.totalDocs).toBe(2)
expect(inResult_2.docs.some((each) => each.id === relToCustomIdText.id)).toBeTruthy()
expect(inResult_2.docs.some((each) => each.id === relToCustomIdNumber.id)).toBeTruthy()
const inResult_3 = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
'rel.value': {
in: [customIDNumber.id, customIDText.id, page.id],
},
},
})
expect(inResult_3.totalDocs).toBe(3)
expect(inResult_3.docs.some((each) => each.id === relToCustomIdText.id)).toBeTruthy()
expect(inResult_3.docs.some((each) => each.id === relToCustomIdNumber.id)).toBeTruthy()
expect(inResult_3.docs.some((each) => each.id === relToPage.id)).toBeTruthy()
it('should query a custom id number relation', async () => {
const { customIdNumberRelation } = await restClient
.GET(`/${slug}/${post.id}`)
.then((res) => res.json())
expect(customIdNumberRelation).toMatchObject({ id: generatedCustomIdNumber })
})
})
@@ -1072,6 +930,19 @@ describe('Relationships', () => {
expect(docs.map((doc) => doc?.id)).not.toContain(localizedPost2.id)
})
})
it('should allow update removing a relationship', async () => {
const response = await restClient.PATCH(`/${slug}/${post.id}`, {
body: JSON.stringify({
customIdRelation: null,
relationField: null,
}),
})
const doc = await response.json()
expect(response.status).toEqual(200)
expect(doc.relationField).toBeFalsy()
})
})
describe('Nested Querying', () => {
@@ -1797,6 +1668,174 @@ describe('Relationships', () => {
expect(updated.status).toBe('completed')
})
it('should validate the format of text id relationships', async () => {
await expect(async () =>
createPost({
// @ts-expect-error Sending bad data to test error handling
customIdRelation: 1234,
}),
).rejects.toThrow('The following field is invalid: Custom Id Relation')
})
it('should validate the format of number id relationships', async () => {
await expect(async () =>
createPost({
// @ts-expect-error Sending bad data to test error handling
customIdNumberRelation: 'bad-input',
}),
).rejects.toThrow('The following field is invalid: Custom Id Number Relation')
})
it('should query a polymorphic relationship field with mixed custom ids and default', async () => {
const customIDNumber = await payload.create({
collection: 'custom-id-number',
data: { id: 999 },
})
const customIDText = await payload.create({
collection: 'custom-id',
data: { id: 'custom-id' },
})
const page = await payload.create({
collection: 'pages',
data: {},
})
const relToCustomIdText = await payload.create({
collection: 'rels-to-pages-and-custom-text-ids',
data: {
rel: {
relationTo: 'custom-id',
value: customIDText.id,
},
},
})
const relToCustomIdNumber = await payload.create({
collection: 'rels-to-pages-and-custom-text-ids',
data: {
rel: {
relationTo: 'custom-id-number',
value: customIDNumber.id,
},
},
})
const relToPage = await payload.create({
collection: 'rels-to-pages-and-custom-text-ids',
data: {
rel: {
relationTo: 'pages',
value: page.id,
},
},
})
const pageResult = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
and: [
{
'rel.value': {
equals: page.id,
},
},
{
'rel.relationTo': {
equals: 'pages',
},
},
],
},
})
expect(pageResult.totalDocs).toBe(1)
expect(pageResult.docs[0].id).toBe(relToPage.id)
const customIDResult = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
and: [
{
'rel.value': {
equals: customIDText.id,
},
},
{
'rel.relationTo': {
equals: 'custom-id',
},
},
],
},
})
expect(customIDResult.totalDocs).toBe(1)
expect(customIDResult.docs[0].id).toBe(relToCustomIdText.id)
const customIDNumberResult = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
and: [
{
'rel.value': {
equals: customIDNumber.id,
},
},
{
'rel.relationTo': {
equals: 'custom-id-number',
},
},
],
},
})
expect(customIDNumberResult.totalDocs).toBe(1)
expect(customIDNumberResult.docs[0].id).toBe(relToCustomIdNumber.id)
const inResult_1 = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
'rel.value': {
in: [page.id, customIDNumber.id],
},
},
})
expect(inResult_1.totalDocs).toBe(2)
expect(inResult_1.docs.some((each) => each.id === relToPage.id)).toBeTruthy()
expect(inResult_1.docs.some((each) => each.id === relToCustomIdNumber.id)).toBeTruthy()
const inResult_2 = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
'rel.value': {
in: [customIDNumber.id, customIDText.id],
},
},
})
expect(inResult_2.totalDocs).toBe(2)
expect(inResult_2.docs.some((each) => each.id === relToCustomIdText.id)).toBeTruthy()
expect(inResult_2.docs.some((each) => each.id === relToCustomIdNumber.id)).toBeTruthy()
const inResult_3 = await payload.find({
collection: 'rels-to-pages-and-custom-text-ids',
where: {
'rel.value': {
in: [customIDNumber.id, customIDText.id, page.id],
},
},
})
expect(inResult_3.totalDocs).toBe(3)
expect(inResult_3.docs.some((each) => each.id === relToCustomIdText.id)).toBeTruthy()
expect(inResult_3.docs.some((each) => each.id === relToCustomIdNumber.id)).toBeTruthy()
expect(inResult_3.docs.some((each) => each.id === relToPage.id)).toBeTruthy()
})
})
})

19
test/sort/Seed.tsx Normal file
View File

@@ -0,0 +1,19 @@
/* eslint-disable no-console */
'use client'
export const Seed = () => {
return (
<button
onClick={async () => {
try {
await fetch('/api/seed', { method: 'POST' })
} catch (error) {
console.error(error)
}
}}
type="button"
>
Seed
</button>
)
}

View File

@@ -0,0 +1,27 @@
import type { CollectionConfig } from 'payload'
import { orderableJoinSlug } from '../OrderableJoin/index.js'
export const orderableSlug = 'orderable'
export const OrderableCollection: CollectionConfig = {
slug: orderableSlug,
orderable: true,
admin: {
useAsTitle: 'title',
components: {
beforeList: ['/Seed.tsx#Seed'],
},
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'orderableField',
type: 'relationship',
relationTo: orderableJoinSlug,
},
],
}

View File

@@ -0,0 +1,39 @@
import type { CollectionConfig } from 'payload'
export const orderableJoinSlug = 'orderable-join'
export const OrderableJoinCollection: CollectionConfig = {
slug: orderableJoinSlug,
admin: {
useAsTitle: 'title',
components: {
beforeList: ['/Seed.tsx#Seed'],
},
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'orderableJoinField1',
type: 'join',
on: 'orderableField',
orderable: true,
collection: 'orderable',
},
{
name: 'orderableJoinField2',
type: 'join',
on: 'orderableField',
orderable: true,
collection: 'orderable',
},
{
name: 'nonOrderableJoinField',
type: 'join',
on: 'orderableField',
collection: 'orderable',
},
],
}

View File

@@ -1,3 +1,5 @@
import type { CollectionSlug, Payload } from 'payload'
import { fileURLToPath } from 'node:url'
import path from 'path'
@@ -6,17 +8,39 @@ import { devUser } from '../credentials.js'
import { DefaultSortCollection } from './collections/DefaultSort/index.js'
import { DraftsCollection } from './collections/Drafts/index.js'
import { LocalizedCollection } from './collections/Localized/index.js'
import { OrderableCollection } from './collections/Orderable/index.js'
import { OrderableJoinCollection } from './collections/OrderableJoin/index.js'
import { PostsCollection } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection, DraftsCollection, DefaultSortCollection, LocalizedCollection],
collections: [
PostsCollection,
DraftsCollection,
DefaultSortCollection,
LocalizedCollection,
OrderableCollection,
OrderableJoinCollection,
],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
endpoints: [
{
path: '/seed',
method: 'post',
handler: async (req) => {
await seedSortable(req.payload)
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
},
},
],
cors: ['http://localhost:3000', 'http://localhost:3001'],
localization: {
locales: ['en', 'nb'],
@@ -30,8 +54,41 @@ export default buildConfigWithDefaults({
password: devUser.password,
},
})
await seedSortable(payload)
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
export async function createData(
payload: Payload,
collection: CollectionSlug,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>[],
) {
for (const item of data) {
await payload.create({ collection, data: item })
}
}
async function seedSortable(payload: Payload) {
await payload.delete({ collection: 'orderable', where: {} })
await payload.delete({ collection: 'orderable-join', where: {} })
const joinA = await payload.create({ collection: 'orderable-join', data: { title: 'Join A' } })
await createData(payload, 'orderable', [
{ title: 'A', orderableField: joinA.id },
{ title: 'B', orderableField: joinA.id },
{ title: 'C', orderableField: joinA.id },
{ title: 'D', orderableField: joinA.id },
])
await payload.create({ collection: 'orderable-join', data: { title: 'Join B' } })
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
}

152
test/sort/e2e.spec.ts Normal file
View File

@@ -0,0 +1,152 @@
import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { RESTClient } from 'helpers/rest.js'
import path from 'path'
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 { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { orderableSlug } from './collections/Orderable/index.js'
import { orderableJoinSlug } from './collections/OrderableJoin/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const { beforeAll, describe } = test
let page: Page
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let payload: PayloadTestSDK<Config>
let client: RESTClient
let serverURL: string
let context: BrowserContext
describe('Sort functionality', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})
// NOTES: It works for me in headed browser but not in headless, I don't know why.
// If you are debugging this test, remember to press the seed button before each attempt.
// assertRows contains expect
// eslint-disable-next-line playwright/expect-expect
test('Orderable collection', async () => {
const url = new AdminUrlUtil(serverURL, orderableSlug)
await page.goto(`${url.list}?sort=-_order`)
// SORT BY ORDER ASCENDING
await page.locator('.sort-header button').nth(0).click()
await assertRows(0, 'A', 'B', 'C', 'D')
await moveRow(2, 3) // move to middle
await assertRows(0, 'A', 'C', 'B', 'D')
await moveRow(3, 1) // move to top
await assertRows(0, 'B', 'A', 'C', 'D')
await moveRow(1, 4) // move to bottom
await assertRows(0, 'A', 'C', 'D', 'B')
// SORT BY ORDER DESCENDING
await page.locator('.sort-header button').nth(0).click()
await page.waitForURL(/sort=-_order/, { timeout: 2000 })
await assertRows(0, 'B', 'D', 'C', 'A')
await moveRow(1, 3) // move to middle
await assertRows(0, 'D', 'C', 'B', 'A')
await moveRow(3, 1) // move to top
await assertRows(0, 'B', 'D', 'C', 'A')
await moveRow(1, 4) // move to bottom
await assertRows(0, 'D', 'C', 'A', 'B')
// SORT BY TITLE
await page.getByLabel('Sort by Title Ascending').click()
await page.waitForURL(/sort=title/, { timeout: 2000 })
await moveRow(1, 3, 'warning') // warning because not sorted by order first
})
test('Orderable join fields', async () => {
const url = new AdminUrlUtil(serverURL, orderableJoinSlug)
await page.goto(url.list)
await page.getByText('Join A').click()
await expect(page.locator('.sort-header button')).toHaveCount(2)
await page.locator('.sort-header button').nth(0).click()
await assertRows(0, 'A', 'B', 'C', 'D')
await moveRow(2, 3, 'success', 0) // move to middle
await assertRows(0, 'A', 'C', 'B', 'D')
await page.locator('.sort-header button').nth(1).click()
await assertRows(1, 'A', 'B', 'C', 'D')
await moveRow(1, 4, 'success', 1) // move to end
await assertRows(1, 'B', 'C', 'D', 'A')
await page.reload()
await page.locator('.sort-header button').nth(0).click()
await page.locator('.sort-header button').nth(1).click()
await assertRows(0, 'A', 'C', 'B', 'D')
await assertRows(1, 'B', 'C', 'D', 'A')
})
})
async function moveRow(
from: number,
to: number,
expected: 'success' | 'warning' = 'success',
nthTable = 0,
) {
// counting from 1, zero excluded
const table = page.locator(`tbody`).nth(nthTable)
const dragHandle = table.locator(`.sort-row`)
const source = dragHandle.nth(from - 1)
const target = dragHandle.nth(to - 1)
const sourceBox = await source.boundingBox()
const targetBox = await target.boundingBox()
if (!sourceBox || !targetBox) {
throw new Error(
`Could not find elements to DnD. Probably the dndkit animation is not finished. Try increasing the timeout`,
)
}
// steps is important: move slightly to trigger the drag sensor of DnD-kit
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2, {
steps: 10,
})
await page.mouse.down()
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
steps: 10,
})
await page.mouse.up()
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(400) // dndkit animation
if (expected === 'warning') {
const toast = page.locator('.payload-toast-item.toast-warning')
await expect(toast).toHaveText(
'To reorder the rows you must first sort them by the "Order" column',
)
}
}
async function assertRows(nthTable: number, ...expectedRows: Array<string>) {
const table = page.locator('tbody').nth(nthTable)
const cellTitle = table.locator('.cell-title > :first-child')
const rows = table.locator('.sort-row')
await expect.poll(() => rows.count()).toBe(expectedRows.length)
for (let i = 0; i < expectedRows.length; i++) {
await expect(cellTitle.nth(i)).toHaveText(expectedRows[i]!)
}
}

View File

@@ -4,8 +4,11 @@ import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Orderable, OrderableJoin } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { orderableSlug } from './collections/Orderable/index.js'
import { orderableJoinSlug } from './collections/OrderableJoin/index.js'
let payload: Payload
let restClient: NextRESTClient
@@ -15,8 +18,8 @@ const dirname = path.dirname(filename)
describe('Sort', () => {
beforeAll(async () => {
const initialized = await initPayloadInt(dirname)
;({ payload, restClient } = initialized)
// @ts-expect-error: initPayloadInt does not have a proper type definition
;({ payload, restClient } = await initPayloadInt(dirname))
})
afterAll(async () => {
@@ -63,7 +66,7 @@ describe('Sort', () => {
})
})
describe('Sinlge sort field', () => {
describe('Single sort field', () => {
it('should sort posts by text field', async () => {
const posts = await payload.find({
collection: 'posts',
@@ -326,6 +329,84 @@ describe('Sort', () => {
])
})
})
describe('Orderable join', () => {
let related: OrderableJoin
let orderable1: Orderable
let orderable2: Orderable
let orderable3: Orderable
beforeAll(async () => {
related = await payload.create({
collection: orderableJoinSlug,
data: {
title: 'test',
},
})
orderable1 = await payload.create({
collection: orderableSlug,
data: {
title: 'test 1',
orderableField: related.id,
},
})
orderable2 = await payload.create({
collection: orderableSlug,
data: {
title: 'test 2',
orderableField: related.id,
},
})
orderable3 = await payload.create({
collection: orderableSlug,
data: {
title: 'test 3',
orderableField: related.id,
},
})
})
it('should set order by default', () => {
expect(orderable1._orderable_orderableJoinField1_order).toBeDefined()
})
it('should allow setting the order with the local API', async () => {
// create two orderableJoinSlug docs
orderable2 = await payload.update({
collection: orderableSlug,
id: orderable2.id,
data: {
title: 'test',
orderableField: related.id,
_orderable_orderableJoinField1_order: 'e4',
},
})
const orderable4 = await payload.create({
collection: orderableSlug,
data: {
title: 'test',
orderableField: related.id,
_orderable_orderableJoinField1_order: 'e2',
},
})
expect(orderable2._orderable_orderableJoinField1_order).toBe('e4')
expect(orderable4._orderable_orderableJoinField1_order).toBe('e2')
})
it('should sort join docs in the correct', async () => {
related = await payload.findByID({
collection: orderableJoinSlug,
id: related.id,
depth: 1,
})
const orders = (related.orderableJoinField1 as { docs: Orderable[] }).docs.map((doc) =>
parseInt(doc._orderable_orderableJoinField1_order, 16),
) as [number, number, number]
expect(orders[0]).toBeLessThan(orders[1])
expect(orders[1]).toBeLessThan(orders[2])
})
})
})
describe('REST API', () => {
@@ -344,7 +425,7 @@ describe('Sort', () => {
await payload.delete({ collection: 'posts', where: {} })
})
describe('Sinlge sort field', () => {
describe('Single sort field', () => {
it('should sort posts by text field', async () => {
const res = await restClient
.GET(`/posts`, {

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -70,17 +71,27 @@ export interface Config {
drafts: Draft;
'default-sort': DefaultSort;
localized: Localized;
orderable: Orderable;
'orderable-join': OrderableJoin;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsJoins: {
'orderable-join': {
orderableJoinField1: 'orderable';
orderableJoinField2: 'orderable';
nonOrderableJoinField: 'orderable';
};
};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
drafts: DraftsSelect<false> | DraftsSelect<true>;
'default-sort': DefaultSortSelect<false> | DefaultSortSelect<true>;
localized: LocalizedSelect<false> | LocalizedSelect<true>;
orderable: OrderableSelect<false> | OrderableSelect<true>;
'orderable-join': OrderableJoinSelect<false> | OrderableJoinSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -174,6 +185,45 @@ export interface Localized {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orderable".
*/
export interface Orderable {
id: string;
_orderable_orderableJoinField2_order?: string;
_orderable_orderableJoinField1_order?: string;
_order?: string;
title?: string | null;
orderableField?: (string | null) | OrderableJoin;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orderable-join".
*/
export interface OrderableJoin {
id: string;
title?: string | null;
orderableJoinField1?: {
docs?: (string | Orderable)[];
hasNextPage?: boolean;
totalDocs?: number;
};
orderableJoinField2?: {
docs?: (string | Orderable)[];
hasNextPage?: boolean;
totalDocs?: number;
};
nonOrderableJoinField?: {
docs?: (string | Orderable)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -214,6 +264,14 @@ export interface PayloadLockedDocument {
relationTo: 'localized';
value: string | Localized;
} | null)
| ({
relationTo: 'orderable';
value: string | Orderable;
} | null)
| ({
relationTo: 'orderable-join';
value: string | OrderableJoin;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -316,6 +374,31 @@ export interface LocalizedSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orderable_select".
*/
export interface OrderableSelect<T extends boolean = true> {
_orderable_orderableJoinField2_order?: T;
_orderable_orderableJoinField1_order?: T;
_order?: T;
title?: T;
orderableField?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orderable-join_select".
*/
export interface OrderableJoinSelect<T extends boolean = true> {
title?: T;
orderableJoinField1?: T;
orderableJoinField2?: T;
nonOrderableJoinField?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -735,6 +735,31 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: 'best-fit',
fields: [
{
name: 'withAdminThumbnail',
type: 'upload',
relationTo: 'admin-thumbnail-function',
},
{
name: 'withinRange',
type: 'upload',
relationTo: enlargeSlug,
},
{
name: 'nextSmallestOutOfRange',
type: 'upload',
relationTo: 'focal-only',
},
{
name: 'original',
type: 'upload',
relationTo: 'focal-only',
},
],
},
],
onInit: async (payload) => {
const uploadsDir = path.resolve(dirname, './media')

View File

@@ -66,6 +66,7 @@ let uploadsOne: AdminUrlUtil
let uploadsTwo: AdminUrlUtil
let customUploadFieldURL: AdminUrlUtil
let hideFileInputOnCreateURL: AdminUrlUtil
let bestFitURL: AdminUrlUtil
let consoleErrorsFromPage: string[] = []
let collectErrorsFromPage: () => boolean
let stopCollectingErrorsFromPage: () => boolean
@@ -99,6 +100,7 @@ describe('Uploads', () => {
uploadsTwo = new AdminUrlUtil(serverURL, 'uploads-2')
customUploadFieldURL = new AdminUrlUtil(serverURL, customUploadFieldSlug)
hideFileInputOnCreateURL = new AdminUrlUtil(serverURL, hideFileInputOnCreateSlug)
bestFitURL = new AdminUrlUtil(serverURL, 'best-fit')
const context = await browser.newContext()
page = await context.newPage()
@@ -1349,4 +1351,53 @@ describe('Uploads', () => {
await expect(page.locator('.file-field .file-details__remove')).toBeHidden()
})
describe('imageSizes best fit', () => {
test('should select adminThumbnail if one exists', async () => {
await page.goto(bestFitURL.create)
await page.locator('#field-withAdminThumbnail button.upload__listToggler').click()
await page.locator('tr.row-1 td.cell-filename button.default-cell__first-cell').click()
const thumbnail = page.locator('#field-withAdminThumbnail div.thumbnail > img')
await expect(thumbnail).toHaveAttribute(
'src',
'https://payloadcms.com/images/universal-truth.jpg',
)
})
test('should select an image within target range', async () => {
await page.goto(bestFitURL.create)
await page.locator('#field-withinRange button.upload__createNewToggler').click()
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByText('Select a file').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(path.join(dirname, 'test-image.jpg'))
await page.locator('dialog button#action-save').click()
const thumbnail = page.locator('#field-withinRange div.thumbnail > img')
await expect(thumbnail).toHaveAttribute('src', '/api/enlarge/file/test-image-180x50.jpg')
})
test('should select next smallest image outside of range but smaller than original', async () => {
await page.goto(bestFitURL.create)
await page.locator('#field-nextSmallestOutOfRange button.upload__createNewToggler').click()
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByText('Select a file').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(path.join(dirname, 'test-image.jpg'))
await page.locator('dialog button#action-save').click()
const thumbnail = page.locator('#field-nextSmallestOutOfRange div.thumbnail > img')
await expect(thumbnail).toHaveAttribute('src', '/api/focal-only/file/test-image-400x300.jpg')
})
test('should select original if smaller than next available size', async () => {
await page.goto(bestFitURL.create)
await page.locator('#field-original button.upload__createNewToggler').click()
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByText('Select a file').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(path.join(dirname, 'small.png'))
await page.locator('dialog button#action-save').click()
const thumbnail = page.locator('#field-original div.thumbnail > img')
await expect(thumbnail).toHaveAttribute('src', '/api/focal-only/file/small.png')
})
})
})

View File

@@ -101,6 +101,7 @@ export interface Config {
'media-without-relation-preview': MediaWithoutRelationPreview;
'relation-preview': RelationPreview;
'hide-file-input-on-create': HideFileInputOnCreate;
'best-fit': BestFit;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -142,6 +143,7 @@ export interface Config {
'media-without-relation-preview': MediaWithoutRelationPreviewSelect<false> | MediaWithoutRelationPreviewSelect<true>;
'relation-preview': RelationPreviewSelect<false> | RelationPreviewSelect<true>;
'hide-file-input-on-create': HideFileInputOnCreateSelect<false> | HideFileInputOnCreateSelect<true>;
'best-fit': BestFitSelect<false> | BestFitSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -1260,6 +1262,19 @@ export interface RelationPreview {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "best-fit".
*/
export interface BestFit {
id: string;
withAdminThumbnail?: (string | null) | AdminThumbnailFunction;
withinRange?: (string | null) | Enlarge;
nextSmallestOutOfRange?: (string | null) | FocalOnly;
original?: (string | null) | FocalOnly;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -1420,6 +1435,10 @@ export interface PayloadLockedDocument {
relationTo: 'hide-file-input-on-create';
value: string | HideFileInputOnCreate;
} | null)
| ({
relationTo: 'best-fit';
value: string | BestFit;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -2632,6 +2651,18 @@ export interface HideFileInputOnCreateSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "best-fit_select".
*/
export interface BestFitSelect<T extends boolean = true> {
withAdminThumbnail?: T;
withinRange?: T;
nextSmallestOutOfRange?: T;
original?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -0,0 +1,628 @@
import type { DefaultTypedEditorState, SerializedBlockNode } from '@payloadcms/richtext-lexical'
import { mediaCollectionSlug, textCollectionSlug } from '../../slugs.js'
export function generateLexicalData(args: {
mediaID: number | string
textID: number | string
updated: boolean
}): DefaultTypedEditorState {
return {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Fugiat esse${args.updated ? ' new ' : ''}in dolor aleiqua ${args.updated ? 'gillum' : 'cillum'} proident ad cillum excepteur mollit reprehenderit mollit commodo. Pariatur incididunt non exercitation est mollit nisi labore${args.updated ? ' ' : 'delete'}officia cupidatat amet commodo commodo proident occaecat.`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
{
children: [
{
detail: 0,
format: args.updated ? 1 : 0,
mode: 'normal',
style: '',
text: 'Some ',
type: 'text',
version: 1,
},
{
detail: 0,
format: args.updated ? 0 : 1,
mode: 'normal',
style: '',
text: 'Bold',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' and ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 1,
mode: 'normal',
style: '',
text: 'Italic',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' text with ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'a link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: args.updated ? 'https://www.payloadcms.com' : 'https://www.google.com',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd9',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' and ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'another link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payload.ai',
newTab: args.updated ? true : false,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' text ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: args.updated ? 'third link updated' : 'third link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payloadcms.com/docs',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '.',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link with description',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payloadcms.com/docs',
description: args.updated ? 'updated description' : 'description',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'text',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'identical link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payloadcms.com/docs2',
description: 'description',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'One',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: args.updated ? 'Two updated' : 'Two',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Three',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
...(args.updated
? [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Four',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 4,
},
]
: []),
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'number',
start: 1,
tag: 'ol',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'One',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Two',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: args.updated ? 'Three' : 'Three original',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'bullet',
start: 1,
tag: 'ul',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Checked',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
checked: true,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Unchecked',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
checked: args.updated ? false : true,
value: 2,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'check',
start: 1,
tag: 'ul',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading1${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h1',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading2${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h2',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading3${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h3',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading4${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h4',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading5${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h5',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading6${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h6',
},
{
type: 'upload',
version: 3,
format: '',
id: '67d8693c76b36f346ecffd8',
relationTo: mediaCollectionSlug,
value: args.mediaID,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Quote${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'quote',
version: 1,
},
{
type: 'relationship',
version: 2,
format: '',
relationTo: textCollectionSlug,
value: args.textID,
},
{
type: 'block',
version: 2,
format: '',
fields: {
id: '67d8693c706b36f346ecffd7',
radios: args.updated ? 'option1' : 'option3',
someText: `Text1${args.updated ? ' updated' : ''}`,
blockName: '',
someTextRequired: 'Text2',
blockType: 'myBlock',
},
} as SerializedBlockNode,
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
}
}

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
import { diffCollectionSlug, draftCollectionSlug } from '../slugs.js'
import { diffCollectionSlug, draftCollectionSlug } from '../../slugs.js'
export const Diff: CollectionConfig = {
slug: diffCollectionSlug,
@@ -39,6 +39,64 @@ export const Diff: CollectionConfig = {
},
],
},
{
slug: 'CollapsibleBlock',
fields: [
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
type: 'collapsible',
label: 'Nested Collapsible',
fields: [
{
name: 'textInCollapsibleInCollapsibleBlock',
type: 'text',
},
],
},
{
type: 'row',
fields: [
{
name: 'textInRowInCollapsibleBlock',
type: 'text',
},
],
},
],
},
],
},
{
slug: 'TabsBlock',
fields: [
{
type: 'tabs',
tabs: [
{
name: 'namedTab1InBlock',
fields: [
{
name: 'textInNamedTab1InBlock',
type: 'text',
},
],
},
{
label: 'Unnamed Tab 2 In Block',
fields: [
{
name: 'textInUnnamedTab2InBlock',
type: 'text',
},
],
},
],
},
],
},
],
},
{

View File

@@ -0,0 +1,17 @@
import type { CollectionConfig } from 'payload'
import { textCollectionSlug } from '../slugs.js'
export const TextCollection: CollectionConfig = {
slug: textCollectionSlug,
admin: {
useAsTitle: 'text',
},
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
}

View File

@@ -6,7 +6,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import AutosavePosts from './collections/Autosave.js'
import AutosaveWithValidate from './collections/AutosaveWithValidate.js'
import CustomIDs from './collections/CustomIDs.js'
import { Diff } from './collections/Diff.js'
import { Diff } from './collections/Diff/index.js'
import DisablePublish from './collections/DisablePublish.js'
import DraftPosts from './collections/Drafts.js'
import DraftWithMax from './collections/DraftsWithMax.js'
@@ -14,6 +14,7 @@ import DraftsWithValidate from './collections/DraftsWithValidate.js'
import LocalizedPosts from './collections/Localized.js'
import { Media } from './collections/Media.js'
import Posts from './collections/Posts.js'
import { TextCollection } from './collections/Text.js'
import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js'
import DisablePublishGlobal from './globals/DisablePublish.js'
@@ -42,6 +43,7 @@ export default buildConfigWithDefaults({
VersionPosts,
CustomIDs,
Diff,
TextCollection,
Media,
],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal],

View File

@@ -981,6 +981,8 @@ describe('Versions', () => {
// Fill with invalid data again, then reload and accept the warning, should contain previous data
await titleField.fill('')
await waitForAutoSaveToRunAndComplete(page, 'error')
await page.reload()
await expect(titleField).toBeEnabled()
@@ -1197,11 +1199,11 @@ describe('Versions', () => {
const textInArrayES = page.locator('[data-field-path="arrayLocalized"][data-locale="es"]')
await expect(textInArrayES).toContainText('No Array Localizeds found')
await expect(textInArrayES).toBeHidden()
await page.locator('#modifiedOnly').click()
await expect(textInArrayES).toBeHidden()
await expect(textInArrayES).toContainText('No Array Localizeds found')
})
test('correctly renders diff for block fields', async () => {
@@ -1213,6 +1215,62 @@ describe('Versions', () => {
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(3)).toHaveText('textInBlock2')
})
test('correctly renders diff for collapsibles within block fields', async () => {
await navigateToVersionFieldsDiff()
const textInBlock = page.locator(
'[data-field-path="blocks.1.textInCollapsibleInCollapsibleBlock"]',
)
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(1)).toHaveText(
'textInCollapsibleInCollapsibleBlock',
)
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(3)).toHaveText(
'textInCollapsibleInCollapsibleBlock2',
)
})
test('correctly renders diff for rows within block fields', async () => {
await navigateToVersionFieldsDiff()
const textInBlock = page.locator('[data-field-path="blocks.1.textInRowInCollapsibleBlock"]')
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(1)).toHaveText(
'textInRowInCollapsibleBlock',
)
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(3)).toHaveText(
'textInRowInCollapsibleBlock2',
)
})
test('correctly renders diff for named tabs within block fields', async () => {
await navigateToVersionFieldsDiff()
const textInBlock = page.locator(
'[data-field-path="blocks.2.namedTab1InBlock.textInNamedTab1InBlock"]',
)
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(1)).toHaveText(
'textInNamedTab1InBlock',
)
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(3)).toHaveText(
'textInNamedTab1InBlock2',
)
})
test('correctly renders diff for unnamed tabs within block fields', async () => {
await navigateToVersionFieldsDiff()
const textInBlock = page.locator('[data-field-path="blocks.2.textInUnnamedTab2InBlock"]')
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(1)).toHaveText(
'textInUnnamedTab2InBlock',
)
await expect(textInBlock.locator('tr').nth(1).locator('td').nth(3)).toHaveText(
'textInUnnamedTab2InBlock2',
)
})
test('correctly renders diff for checkbox fields', async () => {
await navigateToVersionFieldsDiff()
@@ -1326,12 +1384,17 @@ describe('Versions', () => {
const richtext = page.locator('[data-field-path="richtext"]')
await expect(richtext.locator('tr').nth(16).locator('td').nth(1)).toHaveText(
'"text": "richtext",',
)
await expect(richtext.locator('tr').nth(16).locator('td').nth(3)).toHaveText(
'"text": "richtext2",',
)
const oldDiff = richtext.locator('.lexical-diff__diff-old')
const newDiff = richtext.locator('.lexical-diff__diff-new')
const oldHTML =
`Fugiat <span data-match-type="delete">essein</span> dolor aleiqua <span data-match-type="delete">cillum</span> proident ad cillum excepteur mollit reprehenderit mollit commodo. Pariatur incididunt non exercitation est mollit nisi <span data-match-type="delete">laboredeleteofficia</span> cupidatat amet commodo commodo proident occaecat.
`.trim()
const newHTML =
`Fugiat <span data-match-type="create">esse new in</span> dolor aleiqua <span data-match-type="create">gillum</span> proident ad cillum excepteur mollit reprehenderit mollit commodo. Pariatur incididunt non exercitation est mollit nisi <span data-match-type="create">labore officia</span> cupidatat amet commodo commodo proident occaecat.`.trim()
expect(await oldDiff.locator('p').first().innerHTML()).toEqual(oldHTML)
expect(await newDiff.locator('p').first().innerHTML()).toEqual(newHTML)
})
test('correctly renders diff for richtext fields with custom Diff component', async () => {

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -77,6 +78,7 @@ export interface Config {
'version-posts': VersionPost;
'custom-ids': CustomId;
diff: Diff;
text: Text;
media: Media;
users: User;
'payload-jobs': PayloadJob;
@@ -97,6 +99,7 @@ export interface Config {
'version-posts': VersionPostsSelect<false> | VersionPostsSelect<true>;
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
diff: DiffSelect<false> | DiffSelect<true>;
text: TextSelect<false> | TextSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
@@ -312,12 +315,30 @@ export interface Diff {
}[]
| null;
blocks?:
| {
textInBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'TextBlock';
}[]
| (
| {
textInBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'TextBlock';
}
| {
textInCollapsibleInCollapsibleBlock?: string | null;
textInRowInCollapsibleBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'CollapsibleBlock';
}
| {
namedTab1InBlock?: {
textInNamedTab1InBlock?: string | null;
};
textInUnnamedTab2InBlock?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'TabsBlock';
}
)[]
| null;
checkbox?: boolean | null;
code?: string | null;
@@ -395,6 +416,16 @@ export interface Media {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text".
*/
export interface Text {
id: string;
text: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -555,6 +586,10 @@ export interface PayloadLockedDocument {
relationTo: 'diff';
value: string | Diff;
} | null)
| ({
relationTo: 'text';
value: string | Text;
} | null)
| ({
relationTo: 'media';
value: string | Media;
@@ -772,6 +807,26 @@ export interface DiffSelect<T extends boolean = true> {
id?: T;
blockName?: T;
};
CollapsibleBlock?:
| T
| {
textInCollapsibleInCollapsibleBlock?: T;
textInRowInCollapsibleBlock?: T;
id?: T;
blockName?: T;
};
TabsBlock?:
| T
| {
namedTab1InBlock?:
| T
| {
textInNamedTab1InBlock?: T;
};
textInUnnamedTab2InBlock?: T;
id?: T;
blockName?: T;
};
};
checkbox?: T;
code?: T;
@@ -803,6 +858,15 @@ export interface DiffSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text_select".
*/
export interface TextSelect<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".

View File

@@ -6,6 +6,7 @@ import type { DraftPost } from './payload-types.js'
import { devUser } from '../credentials.js'
import { executePromises } from '../helpers/executePromises.js'
import { generateLexicalData } from './collections/Diff/generateLexicalData.js'
import {
autosaveWithValidateCollectionSlug,
diffCollectionSlug,
@@ -119,6 +120,20 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
},
})
const { id: doc1ID } = await _payload.create({
collection: 'text',
data: {
text: 'Document 1',
},
})
const { id: doc2ID } = await _payload.create({
collection: 'text',
data: {
text: 'Document 2',
},
})
const diffDoc = await _payload.create({
collection: diffCollectionSlug,
locale: 'en',
@@ -138,6 +153,18 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
blockType: 'TextBlock',
textInBlock: 'textInBlock',
},
{
blockType: 'CollapsibleBlock',
textInCollapsibleInCollapsibleBlock: 'textInCollapsibleInCollapsibleBlock',
textInRowInCollapsibleBlock: 'textInRowInCollapsibleBlock',
},
{
blockType: 'TabsBlock',
namedTab1InBlock: {
textInNamedTab1InBlock: 'textInNamedTab1InBlock',
},
textInUnnamedTab2InBlock: 'textInUnnamedTab2InBlock',
},
],
checkbox: true,
code: 'code',
@@ -153,7 +180,11 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
point: [1, 2],
radio: 'option1',
relationship: manyDraftsID,
richtext: textToLexicalJSON({ text: 'richtext' }),
richtext: generateLexicalData({
mediaID: uploadedImage,
textID: doc1ID,
updated: false,
}) as any,
richtextWithCustomDiff: textToLexicalJSON({ text: 'richtextWithCustomDiff' }),
select: 'option1',
text: 'text',
@@ -186,6 +217,18 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
blockType: 'TextBlock',
textInBlock: 'textInBlock2',
},
{
blockType: 'CollapsibleBlock',
textInCollapsibleInCollapsibleBlock: 'textInCollapsibleInCollapsibleBlock2',
textInRowInCollapsibleBlock: 'textInRowInCollapsibleBlock2',
},
{
blockType: 'TabsBlock',
namedTab1InBlock: {
textInNamedTab1InBlock: 'textInNamedTab1InBlock2',
},
textInUnnamedTab2InBlock: 'textInUnnamedTab2InBlock2',
},
],
checkbox: false,
code: 'code2',
@@ -201,7 +244,11 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
point: [1, 3],
radio: 'option2',
relationship: draft2.id,
richtext: textToLexicalJSON({ text: 'richtext2' }),
richtext: generateLexicalData({
mediaID: uploadedImage2,
textID: doc2ID,
updated: true,
}) as any,
richtextWithCustomDiff: textToLexicalJSON({ text: 'richtextWithCustomDiff2' }),
select: 'option2',
text: 'text2',

View File

@@ -20,6 +20,8 @@ export const disablePublishSlug = 'disable-publish'
export const disablePublishGlobalSlug = 'disable-publish-global'
export const textCollectionSlug = 'text'
export const collectionSlugs = [
autosaveCollectionSlug,
draftCollectionSlug,
@@ -27,6 +29,7 @@ export const collectionSlugs = [
diffCollectionSlug,
mediaCollectionSlug,
versionCollectionSlug,
textCollectionSlug,
]
export const autoSaveGlobalSlug = 'autosave-global'