Merge branch 'main' into HEAD

This commit is contained in:
Jarrod Flesch
2025-05-15 16:28:06 -04:00
315 changed files with 6880 additions and 2590 deletions

View File

@@ -360,6 +360,65 @@ describe('Document View', () => {
await expect.poll(() => drawer2Left > drawerLeft).toBe(true)
})
test('document drawer displays a link to document', async () => {
await navigateToDoc(page, postsUrl)
// change the relationship to a document which is a different one than the current one
await page.locator('#field-relationship').click()
await page.locator('#field-relationship .rs__option').nth(2).click()
await saveDocAndAssert(page)
// open relationship drawer
await page
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
.click()
const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content')
await expect(drawer1Content).toBeVisible()
// modify the title to trigger the leave page modal
await page.locator('.drawer__content #field-title').fill('New Title')
// Open link in a new tab by holding down the Meta or Control key
const documentLink = page.locator('.id-label a')
const documentId = String(await documentLink.textContent())
await documentLink.click()
const leavePageModal = page.locator('#leave-without-saving #confirm-action').last()
await expect(leavePageModal).toBeVisible()
await leavePageModal.click()
await page.waitForURL(postsUrl.edit(documentId))
})
test('document can be opened in a new tab from within the drawer', async () => {
await navigateToDoc(page, postsUrl)
await page
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
.click()
await wait(500)
const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content')
await expect(drawer1Content).toBeVisible()
const currentUrl = page.url()
// Open link in a new tab by holding down the Meta or Control key
const documentLink = page.locator('.id-label a')
const documentId = String(await documentLink.textContent())
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
documentLink.click({ modifiers: ['ControlOrMeta'] }),
])
// Wait for navigation to complete in the new tab and ensure correct URL
await expect(newPage.locator('.doc-header')).toBeVisible()
// using contain here, because after load the lists view will add query params like "?limit=10"
expect(newPage.url()).toContain(postsUrl.edit(documentId))
// Ensure the original page did not change
expect(page.url()).toBe(currentUrl)
})
})
describe('descriptions', () => {

View File

@@ -393,6 +393,24 @@ describe('List View', () => {
await expect(page.locator(tableRowLocator)).toHaveCount(2)
})
test('should search for nested fields in field dropdown', async () => {
await page.goto(postsUrl.list)
await openListFilters(page, {})
const whereBuilder = page.locator('.where-builder')
await whereBuilder.locator('.where-builder__add-first-filter').click()
const conditionField = whereBuilder.locator('.condition__field')
await conditionField.click()
await conditionField.locator('input.rs__input').fill('Tab 1 > Title')
await expect(
conditionField.locator('.rs__menu-list').locator('div', {
hasText: exactText('Tab 1 > Title'),
}),
).toBeVisible()
})
test('should allow to filter in array field', async () => {
await createArray()

View File

@@ -88,6 +88,7 @@ export interface Config {
'base-list-filters': BaseListFilter;
with300documents: With300Document;
'with-list-drawer': WithListDrawer;
placeholder: Placeholder;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -115,6 +116,7 @@ export interface Config {
'base-list-filters': BaseListFiltersSelect<false> | BaseListFiltersSelect<true>;
with300documents: With300DocumentsSelect<false> | With300DocumentsSelect<true>;
'with-list-drawer': WithListDrawerSelect<false> | WithListDrawerSelect<true>;
placeholder: PlaceholderSelect<false> | PlaceholderSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -484,6 +486,19 @@ export interface WithListDrawer {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "placeholder".
*/
export interface Placeholder {
id: string;
defaultSelect?: 'option1' | null;
placeholderSelect?: 'option1' | null;
defaultRelationship?: (string | null) | Post;
placeholderRelationship?: (string | null) | Post;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -574,6 +589,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'with-list-drawer';
value: string | WithListDrawer;
} | null)
| ({
relationTo: 'placeholder';
value: string | Placeholder;
} | null);
globalSlug?: string | null;
user: {
@@ -901,6 +920,18 @@ export interface WithListDrawerSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "placeholder_select".
*/
export interface PlaceholderSelect<T extends boolean = true> {
defaultSelect?: T;
placeholderSelect?: T;
defaultRelationship?: T;
placeholderRelationship?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -700,6 +700,55 @@ export default buildConfigWithDefaults({
},
],
globals: [
{
slug: 'header',
fields: [
{
name: 'itemsLvl1',
type: 'array',
dbName: 'header_items_lvl1',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl2',
type: 'array',
dbName: 'header_items_lvl2',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl3',
type: 'array',
dbName: 'header_items_lvl3',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl4',
type: 'array',
dbName: 'header_items_lvl4',
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
],
},
],
},
],
},
{
slug: 'global',
dbName: 'customGlobal',

View File

@@ -2452,6 +2452,17 @@ describe('database', () => {
expect(res.docs[0].id).toBe(customID.id)
})
it('deep nested arrays', async () => {
await payload.updateGlobal({
slug: 'header',
data: { itemsLvl1: [{ itemsLvl2: [{ itemsLvl3: [{ itemsLvl4: [{ label: 'label' }] }] }] }] },
})
const header = await payload.findGlobal({ slug: 'header' })
expect(header.itemsLvl1[0]?.itemsLvl2[0]?.itemsLvl3[0]?.itemsLvl4[0]?.label).toBe('label')
})
it('should count with a query that contains subqueries', async () => {
const category = await payload.create({
collection: 'categories',

View File

@@ -115,12 +115,14 @@ export interface Config {
defaultIDType: string;
};
globals: {
header: Header;
global: Global;
'global-2': Global2;
'global-3': Global3;
'virtual-relation-global': VirtualRelationGlobal;
};
globalsSelect: {
header: HeaderSelect<false> | HeaderSelect<true>;
global: GlobalSelect<false> | GlobalSelect<true>;
'global-2': Global2Select<false> | Global2Select<true>;
'global-3': Global3Select<false> | Global3Select<true>;
@@ -977,6 +979,39 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "header".
*/
export interface Header {
id: string;
itemsLvl1?:
| {
label?: string | null;
itemsLvl2?:
| {
label?: string | null;
itemsLvl3?:
| {
label?: string | null;
itemsLvl4?:
| {
label?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global".
@@ -1018,6 +1053,39 @@ export interface VirtualRelationGlobal {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "header_select".
*/
export interface HeaderSelect<T extends boolean = true> {
itemsLvl1?:
| T
| {
label?: T;
itemsLvl2?:
| T
| {
label?: T;
itemsLvl3?:
| T
| {
label?: T;
itemsLvl4?:
| T
| {
label?: T;
id?: T;
};
id?: T;
};
id?: T;
};
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global_select".

View File

@@ -1,463 +0,0 @@
{
"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

@@ -1,122 +0,0 @@
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,639 @@
{
"id": "36a35217-e468-4780-becb-9146c56e3e54",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_at": {
"name": "created_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"reset_password_token": {
"name": "reset_password_token",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"reset_password_expiration": {
"name": "reset_password_expiration",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": false
},
"salt": {
"name": "salt",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"hash": {
"name": "hash",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"login_attempts": {
"name": "login_attempts",
"type": "numeric",
"primaryKey": false,
"notNull": false,
"default": 0
},
"lock_until": {
"name": "lock_until",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"users_updated_at_idx": {
"name": "users_updated_at_idx",
"columns": [
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_created_at_idx": {
"name": "users_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_email_idx": {
"name": "users_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payload_locked_documents": {
"name": "payload_locked_documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"global_slug": {
"name": "global_slug",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_at": {
"name": "created_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"payload_locked_documents_global_slug_idx": {
"name": "payload_locked_documents_global_slug_idx",
"columns": [
{
"expression": "global_slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_locked_documents_updated_at_idx": {
"name": "payload_locked_documents_updated_at_idx",
"columns": [
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_locked_documents_created_at_idx": {
"name": "payload_locked_documents_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payload_locked_documents_rels": {
"name": "payload_locked_documents_rels",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"users_id": {
"name": "users_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"payload_locked_documents_rels_order_idx": {
"name": "payload_locked_documents_rels_order_idx",
"columns": [
{
"expression": "order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_locked_documents_rels_parent_idx": {
"name": "payload_locked_documents_rels_parent_idx",
"columns": [
{
"expression": "parent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_locked_documents_rels_path_idx": {
"name": "payload_locked_documents_rels_path_idx",
"columns": [
{
"expression": "path",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_locked_documents_rels_users_id_idx": {
"name": "payload_locked_documents_rels_users_id_idx",
"columns": [
{
"expression": "users_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payload_preferences": {
"name": "payload_preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"key": {
"name": "key",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"value": {
"name": "value",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_at": {
"name": "created_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"payload_preferences_key_idx": {
"name": "payload_preferences_key_idx",
"columns": [
{
"expression": "key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_preferences_updated_at_idx": {
"name": "payload_preferences_updated_at_idx",
"columns": [
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_preferences_created_at_idx": {
"name": "payload_preferences_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payload_preferences_rels": {
"name": "payload_preferences_rels",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"users_id": {
"name": "users_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"payload_preferences_rels_order_idx": {
"name": "payload_preferences_rels_order_idx",
"columns": [
{
"expression": "order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_preferences_rels_parent_idx": {
"name": "payload_preferences_rels_parent_idx",
"columns": [
{
"expression": "parent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_preferences_rels_path_idx": {
"name": "payload_preferences_rels_path_idx",
"columns": [
{
"expression": "path",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_preferences_rels_users_id_idx": {
"name": "payload_preferences_rels_users_id_idx",
"columns": [
{
"expression": "users_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payload_migrations": {
"name": "payload_migrations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"batch": {
"name": "batch",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_at": {
"name": "created_at",
"type": "timestamp(3) with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"payload_migrations_updated_at_idx": {
"name": "payload_migrations_updated_at_idx",
"columns": [
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payload_migrations_created_at_idx": {
"name": "payload_migrations_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@@ -0,0 +1,112 @@
import type { MigrateDownArgs, MigrateUpArgs} from '@payloadcms/db-postgres';
import { sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE IF NOT EXISTS "payload_locked_documents" (
"id" serial PRIMARY KEY NOT NULL,
"global_slug" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS "payload_locked_documents_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE IF NOT EXISTS "payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS "payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE IF NOT EXISTS "payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE INDEX IF NOT EXISTS "users_updated_at_idx" ON "users" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "users_created_at_idx" ON "users" USING btree ("created_at");
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_idx" ON "users" USING btree ("email");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
CREATE INDEX IF NOT EXISTS "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
CREATE INDEX IF NOT EXISTS "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
CREATE INDEX IF NOT EXISTS "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
CREATE INDEX IF NOT EXISTS "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX IF NOT EXISTS "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
CREATE INDEX IF NOT EXISTS "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
CREATE INDEX IF NOT EXISTS "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "users" CASCADE;
DROP TABLE "payload_locked_documents" CASCADE;
DROP TABLE "payload_locked_documents_rels" CASCADE;
DROP TABLE "payload_preferences" CASCADE;
DROP TABLE "payload_preferences_rels" CASCADE;
DROP TABLE "payload_migrations" CASCADE;`)
}

View File

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

View File

@@ -115,6 +115,16 @@ const DateFields: CollectionConfig = {
},
],
},
{
type: 'array',
name: 'array',
fields: [
{
name: 'date',
type: 'date',
},
],
},
],
}

View File

@@ -0,0 +1,123 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
import type { Config } from '../../payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { assertToastErrors } from '../../../helpers/assertToastErrors.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { groupFieldsSlug } from '../../slugs.js'
import { namedGroupDoc } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
let payload: PayloadTestSDK<Config>
let client: RESTClient
let page: Page
let serverURL: string
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
let url: AdminUrlUtil
describe('Group', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname,
// prebuild,
}))
url = new AdminUrlUtil(serverURL, groupFieldsSlug)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
if (client) {
await client.logout()
}
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})
describe('Named', () => {
test('should display field in list view', async () => {
await page.goto(url.list)
const textCell = page.locator('.row-1 .cell-group')
await expect(textCell).toContainText(JSON.stringify(namedGroupDoc.group?.text), {
useInnerText: true,
})
})
})
describe('Unnamed', () => {
test('should display field in list view', async () => {
await page.goto(url.list)
const textCell = page.locator('.row-1 .cell-insideUnnamedGroup')
await expect(textCell).toContainText(namedGroupDoc?.insideUnnamedGroup ?? '', {
useInnerText: true,
})
})
test('should display field in list view deeply nested', async () => {
await page.goto(url.list)
const textCell = page.locator('.row-1 .cell-deeplyNestedGroup')
await expect(textCell).toContainText(JSON.stringify(namedGroupDoc.deeplyNestedGroup), {
useInnerText: true,
})
})
test('should display field visually within nested groups', async () => {
await page.goto(url.create)
// Makes sure the fields are rendered
await page.mouse.wheel(0, 2000)
const unnamedGroupSelector = `.field-type.group-field #field-insideUnnamedGroup`
const unnamedGroupField = page.locator(unnamedGroupSelector)
await expect(unnamedGroupField).toBeVisible()
// Makes sure the fields are rendered
await page.mouse.wheel(0, 2000)
// A bit repetitive but this selector should fail if the group is not nested
const unnamedNestedGroupSelector = `.field-type.group-field .field-type.group-field .field-type.group-field .field-type.group-field .field-type.group-field #field-deeplyNestedGroup__insideNestedUnnamedGroup`
const unnamedNestedGroupField = page.locator(unnamedNestedGroupSelector)
await expect(unnamedNestedGroupField).toBeVisible()
})
})
})

View File

@@ -8,6 +8,9 @@ export const groupDefaultChild = 'child takes priority'
const GroupFields: CollectionConfig = {
slug: groupFieldsSlug,
versions: true,
admin: {
defaultColumns: ['id', 'group', 'insideUnnamedGroup', 'deeplyNestedGroup'],
},
fields: [
{
label: 'Group Field',
@@ -301,6 +304,51 @@ const GroupFields: CollectionConfig = {
},
],
},
{
type: 'group',
label: 'Unnamed group',
fields: [
{
type: 'text',
name: 'insideUnnamedGroup',
},
],
},
{
type: 'group',
label: 'Deeply nested group',
fields: [
{
type: 'group',
label: 'Deeply nested group',
fields: [
{
type: 'group',
name: 'deeplyNestedGroup',
label: 'Deeply nested group',
fields: [
{
type: 'group',
label: 'Deeply nested group',
fields: [
{
type: 'group',
label: 'Deeply nested group',
fields: [
{
type: 'text',
name: 'insideNestedUnnamedGroup',
},
],
},
],
},
],
},
],
},
],
},
],
}

View File

@@ -1,6 +1,6 @@
import type { GroupField } from '../../payload-types.js'
export const groupDoc: Partial<GroupField> = {
export const namedGroupDoc: Partial<GroupField> = {
group: {
text: 'some text within a group',
subGroup: {
@@ -12,4 +12,8 @@ export const groupDoc: Partial<GroupField> = {
],
},
},
insideUnnamedGroup: 'text in unnamed group',
deeplyNestedGroup: {
insideNestedUnnamedGroup: 'text in nested unnamed group',
},
}

View File

@@ -15,7 +15,7 @@ import { arrayDefaultValue } from './collections/Array/index.js'
import { blocksDoc } from './collections/Blocks/shared.js'
import { dateDoc } from './collections/Date/shared.js'
import { groupDefaultChild, groupDefaultValue } from './collections/Group/index.js'
import { groupDoc } from './collections/Group/shared.js'
import { namedGroupDoc } from './collections/Group/shared.js'
import { defaultNumber } from './collections/Number/index.js'
import { numberDoc } from './collections/Number/shared.js'
import { pointDoc } from './collections/Point/shared.js'
@@ -600,6 +600,56 @@ describe('Fields', () => {
expect(result.docs[0].id).toEqual(doc.id)
})
// Function to generate random date between start and end dates
function getRandomDate(start: Date, end: Date): string {
const date = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()))
return date.toISOString()
}
// Generate sample data
const dataSample = Array.from({ length: 100 }, (_, index) => {
const startDate = new Date('2024-01-01')
const endDate = new Date('2025-12-31')
return {
array: Array.from({ length: 5 }, (_, listIndex) => {
return {
date: getRandomDate(startDate, endDate),
}
}),
...dateDoc,
}
})
it('should query a date field inside an array field', async () => {
await payload.delete({ collection: 'date-fields', where: {} })
for (const doc of dataSample) {
await payload.create({
collection: 'date-fields',
data: doc,
})
}
const res = await payload.find({
collection: 'date-fields',
where: { 'array.date': { greater_than: new Date('2025-06-01').toISOString() } },
})
const filter = (doc: any) =>
doc.array.some((item) => new Date(item.date).getTime() > new Date('2025-06-01').getTime())
expect(res.docs.every(filter)).toBe(true)
expect(dataSample.filter(filter)).toHaveLength(res.totalDocs)
// eslint-disable-next-line jest/no-conditional-in-test
if (res.totalDocs > 10) {
// This is where postgres might fail! selectDistinct actually removed some rows here, because it distincts by:
// not only ID, but also created_at, updated_at, items_date
expect(res.docs).toHaveLength(10)
} else {
expect(res.docs.length).toBeLessThanOrEqual(res.totalDocs)
}
})
})
describe('select', () => {
@@ -1564,7 +1614,7 @@ describe('Fields', () => {
it('should create with ids and nested ids', async () => {
const docWithIDs = (await payload.create({
collection: groupFieldsSlug,
data: groupDoc,
data: namedGroupDoc,
})) as Partial<GroupField>
expect(docWithIDs.group.subGroup.arrayWithinGroup[0].id).toBeDefined()
})
@@ -1863,6 +1913,53 @@ describe('Fields', () => {
})
})
it('should work with unnamed group', async () => {
const groupDoc = await payload.create({
collection: groupFieldsSlug,
data: {
insideUnnamedGroup: 'Hello world',
deeplyNestedGroup: { insideNestedUnnamedGroup: 'Secondfield' },
},
})
expect(groupDoc).toMatchObject({
id: expect.anything(),
insideUnnamedGroup: 'Hello world',
deeplyNestedGroup: {
insideNestedUnnamedGroup: 'Secondfield',
},
})
})
it('should work with unnamed group - graphql', async () => {
const mutation = `mutation {
createGroupField(
data: {
insideUnnamedGroup: "Hello world",
deeplyNestedGroup: { insideNestedUnnamedGroup: "Secondfield" },
group: {text: "hello"}
}
) {
insideUnnamedGroup
deeplyNestedGroup {
insideNestedUnnamedGroup
}
}
}`
const groupDoc = await restClient.GRAPHQL_POST({
body: JSON.stringify({ query: mutation }),
})
const data = (await groupDoc.json()).data.createGroupField
expect(data).toMatchObject({
insideUnnamedGroup: 'Hello world',
deeplyNestedGroup: {
insideNestedUnnamedGroup: 'Secondfield',
},
})
})
it('should query a subfield within a localized group', async () => {
const text = 'find this'
const hit = await payload.create({
@@ -2307,7 +2404,7 @@ describe('Fields', () => {
it('should return empty object for groups when no data present', async () => {
const doc = await payload.create({
collection: groupFieldsSlug,
data: groupDoc,
data: namedGroupDoc,
})
expect(doc.potentiallyEmptyGroup).toBeDefined()

View File

@@ -929,6 +929,12 @@ export interface DateField {
id?: string | null;
}[]
| null;
array?:
| {
date?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -1074,6 +1080,10 @@ export interface GroupField {
}[]
| null;
};
insideUnnamedGroup?: string | null;
deeplyNestedGroup?: {
insideNestedUnnamedGroup?: string | null;
};
updatedAt: string;
createdAt: string;
}
@@ -1326,10 +1336,16 @@ export interface RelationshipField {
} | null);
relationshipDrawerHasMany?: (string | TextField)[] | null;
relationshipDrawerHasManyPolymorphic?:
| {
relationTo: 'text-fields';
value: string | TextField;
}[]
| (
| {
relationTo: 'text-fields';
value: string | TextField;
}
| {
relationTo: 'array-fields';
value: string | ArrayField;
}
)[]
| null;
relationshipDrawerWithAllowCreateFalse?: (string | null) | TextField;
relationshipDrawerWithFilterOptions?: {
@@ -2492,6 +2508,12 @@ export interface DateFieldsSelect<T extends boolean = true> {
dayAndTime_tz?: T;
id?: T;
};
array?:
| T
| {
date?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
@@ -2658,6 +2680,12 @@ export interface GroupFieldsSelect<T extends boolean = true> {
| {
email?: T;
};
insideUnnamedGroup?: T;
deeplyNestedGroup?:
| T
| {
insideNestedUnnamedGroup?: T;
};
updatedAt?: T;
createdAt?: T;
}

View File

@@ -16,7 +16,7 @@ import { conditionalLogicDoc } from './collections/ConditionalLogic/shared.js'
import { customRowID, customTabID, nonStandardID } from './collections/CustomID/shared.js'
import { dateDoc } from './collections/Date/shared.js'
import { anotherEmailDoc, emailDoc } from './collections/Email/shared.js'
import { groupDoc } from './collections/Group/shared.js'
import { namedGroupDoc } from './collections/Group/shared.js'
import { jsonDoc } from './collections/JSON/shared.js'
import { numberDoc } from './collections/Number/shared.js'
import { pointDoc } from './collections/Point/shared.js'
@@ -223,7 +223,7 @@ export const seed = async (_payload: Payload) => {
await _payload.create({
collection: groupFieldsSlug,
data: groupDoc,
data: namedGroupDoc,
depth: 0,
overrideAccess: true,
})

View File

@@ -104,5 +104,50 @@ describe('graphql', () => {
expect(res.hyphenated_name).toStrictEqual('example-hyphenated-name')
})
it('should not error because of non nullable fields', async () => {
await payload.delete({ collection: 'posts', where: {} })
// this is an array if any errors
const res_1 = await restClient
.GRAPHQL_POST({
body: JSON.stringify({
query: `
query {
Posts {
docs {
title
}
prevPage
}
}
`,
}),
})
.then((res) => res.json())
expect(res_1.errors).toBeFalsy()
await payload.create({
collection: 'posts',
data: { title: 'any-title' },
})
const res_2 = await restClient
.GRAPHQL_POST({
body: JSON.stringify({
query: `
query {
Posts(limit: 1) {
docs {
title
}
}
}
`,
}),
})
.then((res) => res.json())
expect(res_2.errors).toBeFalsy()
})
})
})

View File

@@ -185,10 +185,21 @@ export async function login(args: LoginArgs): Promise<void> {
const { customAdminRoutes, customRoutes, data = devUser, page, serverURL } = args
const {
admin: { routes: { createFirstUser, login: incomingLoginRoute } = {} },
admin: {
routes: { createFirstUser, login: incomingLoginRoute, logout: incomingLogoutRoute } = {},
},
routes: { admin: incomingAdminRoute } = {},
} = getRoutes({ customAdminRoutes, customRoutes })
const logoutRoute = formatAdminURL({
serverURL,
adminRoute: incomingAdminRoute,
path: incomingLogoutRoute,
})
await page.goto(logoutRoute)
await wait(500)
const adminRoute = formatAdminURL({ serverURL, adminRoute: incomingAdminRoute, path: '' })
const loginRoute = formatAdminURL({
serverURL,

View File

@@ -4,7 +4,6 @@ import type { PgTable } from 'drizzle-orm/pg-core'
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
import type { Payload } from 'payload'
import { GenericTable } from '@payloadcms/drizzle/types'
import { sql } from 'drizzle-orm'
import { isMongoose } from './isMongoose.js'

View File

@@ -11,6 +11,7 @@ import {
} from './collections/Lexical/index.js'
import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js'
import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js'
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
@@ -28,6 +29,7 @@ export const baseConfig: Partial<Config> = {
// ...extend config here
collections: [
LexicalFullyFeatured,
LexicalLinkFeature,
getLexicalFieldsCollection({
blocks: lexicalBlocks,
inlineBlocks: lexicalInlineBlocks,

View File

@@ -0,0 +1,79 @@
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import { lexicalLinkFeatureSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone } from '../../../helpers.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from './utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
test.describe.configure({ mode: 'parallel' })
const { serverURL } = await initPayloadE2ENoConfig({
dirname,
})
describe('Lexical Link Feature', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()
})
beforeEach(async ({ page }) => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: [
path.resolve(dirname, './collections/Upload/uploads'),
path.resolve(dirname, './collections/Upload2/uploads2'),
],
})
const url = new AdminUrlUtil(serverURL, lexicalLinkFeatureSlug)
const lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
test('can add new custom fields in link feature modal', async ({ page }) => {
const lexical = new LexicalHelpers(page)
await lexical.editor.fill('link')
await lexical.editor.selectText()
const linkButtonClass = `.rich-text-lexical__wrap .fixed-toolbar .toolbar-popup__button-link`
const linkButton = page.locator(linkButtonClass).first()
await linkButton.click()
const customField = lexical.drawer.locator('#field-someText')
await expect(customField).toBeVisible()
})
test('can set default value of newTab checkbox to checked', async ({ page }) => {
const lexical = new LexicalHelpers(page)
await lexical.editor.fill('link')
await lexical.editor.selectText()
const linkButtonClass = `.rich-text-lexical__wrap .fixed-toolbar .toolbar-popup__button-link`
const linkButton = page.locator(linkButtonClass).first()
await linkButton.click()
const checkboxField = lexical.drawer.locator(`[id^="field-newTab"]`)
await expect(checkboxField).toBeChecked()
})
})

View File

@@ -0,0 +1,44 @@
import type { CheckboxField, CollectionConfig } from 'payload'
import {
FixedToolbarFeature,
lexicalEditor,
LinkFeature,
TreeViewFeature,
} from '@payloadcms/richtext-lexical'
import { lexicalLinkFeatureSlug } from '../../slugs.js'
export const LexicalLinkFeature: CollectionConfig = {
slug: lexicalLinkFeatureSlug,
labels: {
singular: 'Lexical Link Feature',
plural: 'Lexical Link Feature',
},
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
LinkFeature({
fields: ({ defaultFields }) => {
const modifiedFields = defaultFields.map((field) => {
if (field.name === 'newTab') {
return { ...field, defaultValue: true } as CheckboxField
}
return field
})
return [...modifiedFields, { type: 'text', name: 'someText' }]
},
}),
FixedToolbarFeature(),
],
}),
},
],
}

View File

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

View File

@@ -84,6 +84,7 @@ export interface Config {
blocks: {};
collections: {
'lexical-fully-featured': LexicalFullyFeatured;
'lexical-link-feature': LexicalLinkFeature;
'lexical-fields': LexicalField;
'lexical-migrate-fields': LexicalMigrateField;
'lexical-localized-fields': LexicalLocalizedField;
@@ -103,6 +104,7 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
'lexical-fully-featured': LexicalFullyFeaturedSelect<false> | LexicalFullyFeaturedSelect<true>;
'lexical-link-feature': LexicalLinkFeatureSelect<false> | LexicalLinkFeatureSelect<true>;
'lexical-fields': LexicalFieldsSelect<false> | LexicalFieldsSelect<true>;
'lexical-migrate-fields': LexicalMigrateFieldsSelect<false> | LexicalMigrateFieldsSelect<true>;
'lexical-localized-fields': LexicalLocalizedFieldsSelect<false> | LexicalLocalizedFieldsSelect<true>;
@@ -179,6 +181,30 @@ export interface LexicalFullyFeatured {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-link-feature".
*/
export interface LexicalLinkFeature {
id: string;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields".
@@ -804,6 +830,10 @@ export interface PayloadLockedDocument {
relationTo: 'lexical-fully-featured';
value: string | LexicalFullyFeatured;
} | null)
| ({
relationTo: 'lexical-link-feature';
value: string | LexicalLinkFeature;
} | null)
| ({
relationTo: 'lexical-fields';
value: string | LexicalField;
@@ -903,6 +933,15 @@ export interface LexicalFullyFeaturedSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-link-feature_select".
*/
export interface LexicalLinkFeatureSelect<T extends boolean = true> {
richText?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields_select".

View File

@@ -2,6 +2,8 @@ export const usersSlug = 'users'
export const lexicalFullyFeaturedSlug = 'lexical-fully-featured'
export const lexicalFieldsSlug = 'lexical-fields'
export const lexicalLinkFeatureSlug = 'lexical-link-feature'
export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'
export const lexicalMigrateFieldsSlug = 'lexical-migrate-fields'
export const lexicalRelationshipFieldsSlug = 'lexical-relationship-fields'

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { localizedDateFieldsSlug } from '../../shared.js'
export const LocalizedDateFields: CollectionConfig = {
slug: localizedDateFieldsSlug,
versions: {
drafts: true,
},
fields: [
{
type: 'date',
name: 'localizedDate',
localized: true,
},
{
type: 'date',
name: 'date',
},
],
}

View File

@@ -11,6 +11,7 @@ import { devUser } from '../credentials.js'
import { ArrayCollection } from './collections/Array/index.js'
import { BlocksCollection } from './collections/Blocks/index.js'
import { Group } from './collections/Group/index.js'
import { LocalizedDateFields } from './collections/LocalizedDateFields/index.js'
import { LocalizedDrafts } from './collections/LocalizedDrafts/index.js'
import { LocalizedWithinLocalized } from './collections/LocalizedWithinLocalized/index.js'
import { NestedArray } from './collections/NestedArray/index.js'
@@ -25,6 +26,7 @@ import {
defaultLocale,
englishTitle,
hungarianLocale,
localizedDateFieldsSlug,
localizedPostsSlug,
localizedSortSlug,
portugueseLocale,
@@ -64,6 +66,7 @@ export default buildConfigWithDefaults({
NestedArray,
NestedFields,
LocalizedDrafts,
LocalizedDateFields,
{
admin: {
listSearchableFields: 'name',
@@ -478,6 +481,14 @@ export default buildConfigWithDefaults({
},
})
await payload.create({
collection: localizedDateFieldsSlug,
data: {
localizedDate: new Date().toISOString(),
date: new Date().toISOString(),
},
})
console.log('SEED 1')
await payload.create({

View File

@@ -27,6 +27,7 @@ import {
defaultLocale as englishLocale,
englishTitle,
hungarianLocale,
localizedDateFieldsSlug,
localizedPostsSlug,
localizedSortSlug,
portugueseLocale,
@@ -431,6 +432,32 @@ describe('Localization', () => {
})
})
describe('Localized date', () => {
it('can create a localized date', async () => {
const document = await payload.create({
collection: localizedDateFieldsSlug,
data: {
localizedDate: new Date().toISOString(),
date: new Date().toISOString(),
},
})
expect(document.localizedDate).toBeTruthy()
})
it('data is typed as string', async () => {
const document = await payload.create({
collection: localizedDateFieldsSlug,
data: {
localizedDate: new Date().toISOString(),
date: new Date().toISOString(),
},
})
expect(typeof document.localizedDate).toBe('string')
expect(typeof document.date).toBe('string')
})
})
describe('Localized Sort Count', () => {
const expectedTotalDocs = 5
const posts: LocalizedSort[] = []

View File

@@ -72,6 +72,7 @@ export interface Config {
'nested-arrays': NestedArray;
'nested-field-tables': NestedFieldTable;
'localized-drafts': LocalizedDraft;
'localized-date-fields': LocalizedDateField;
users: User;
'localized-posts': LocalizedPost;
'no-localized-fields': NoLocalizedField;
@@ -97,6 +98,7 @@ export interface Config {
'nested-arrays': NestedArraysSelect<false> | NestedArraysSelect<true>;
'nested-field-tables': NestedFieldTablesSelect<false> | NestedFieldTablesSelect<true>;
'localized-drafts': LocalizedDraftsSelect<false> | LocalizedDraftsSelect<true>;
'localized-date-fields': LocalizedDateFieldsSelect<false> | LocalizedDateFieldsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
'no-localized-fields': NoLocalizedFieldsSelect<false> | NoLocalizedFieldsSelect<true>;
@@ -330,6 +332,18 @@ export interface LocalizedDraft {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-date-fields".
*/
export interface LocalizedDateField {
id: string;
localizedDate?: string | null;
date?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -713,6 +727,10 @@ export interface PayloadLockedDocument {
relationTo: 'localized-drafts';
value: string | LocalizedDraft;
} | null)
| ({
relationTo: 'localized-date-fields';
value: string | LocalizedDateField;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -952,6 +970,17 @@ export interface LocalizedDraftsSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-date-fields_select".
*/
export interface LocalizedDateFieldsSelect<T extends boolean = true> {
localizedDate?: T;
date?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -12,6 +12,7 @@ export const hungarianLocale = 'hu'
// Slugs
export const localizedPostsSlug = 'localized-posts'
export const localizedDateFieldsSlug = 'localized-date-fields'
export const withLocalizedRelSlug = 'with-localized-relationship'
export const relationshipLocalizedSlug = 'relationship-localized'
export const withRequiredLocalizedFields = 'localized-required'

View File

@@ -41,7 +41,7 @@ export default buildConfigWithDefaults({
isGlobal: true,
},
},
tenantSelectorLabel: 'Sites',
tenantSelectorLabel: 'Site',
}),
],
typescript: {

View File

@@ -15,7 +15,4 @@ export const Pages: CollectionConfig = {
type: 'text',
},
],
versions: {
drafts: true,
},
}

View File

@@ -15,7 +15,4 @@ export const Posts: CollectionConfig = {
type: 'text',
},
],
versions: {
drafts: true,
},
}

View File

@@ -23,10 +23,8 @@ export default buildConfigWithDefaults({
// plural: 'Reports',
// },
access: {
read: ({ req: { user } }) =>
user ? user && !user?.roles?.some((role) => role === 'anonymous') : false,
update: ({ req: { user } }) =>
user ? user && !user?.roles?.some((role) => role === 'anonymous') : false,
read: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')),
update: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')),
},
constraints: {
read: [
@@ -60,7 +58,7 @@ export default buildConfigWithDefaults({
],
},
},
collections: [Pages, Users, Posts],
collections: [Pages, Posts, Users],
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)

View File

@@ -8,7 +8,7 @@ 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 type { Config, PayloadQueryPreset } from './payload-types.js'
import {
ensureCompilationIsDone,
@@ -39,6 +39,13 @@ let serverURL: string
let everyoneID: string | undefined
let context: BrowserContext
let user: any
let ownerUser: any
let seededData: {
everyone: PayloadQueryPreset
onlyMe: PayloadQueryPreset
specificUsers: PayloadQueryPreset
}
describe('Query Presets', () => {
beforeAll(async ({ browser }, testInfo) => {
@@ -60,6 +67,19 @@ describe('Query Presets', () => {
})
?.then((res) => res.user) // TODO: this type is wrong
ownerUser = await payload
.find({
collection: 'users',
where: {
name: {
equals: 'Owner',
},
},
limit: 1,
depth: 0,
})
?.then((res) => res.docs[0])
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
@@ -83,7 +103,7 @@ describe('Query Presets', () => {
},
})
const [, everyone] = await Promise.all([
const [, everyone, onlyMe, specificUsers] = await Promise.all([
payload.delete({
collection: 'payload-preferences',
where: {
@@ -106,18 +126,24 @@ describe('Query Presets', () => {
}),
payload.create({
collection: 'payload-query-presets',
data: seedData.everyone,
data: seedData.everyone({ ownerUserID: ownerUser?.id || '' }),
}),
payload.create({
collection: 'payload-query-presets',
data: seedData.onlyMe,
data: seedData.onlyMe({ ownerUserID: ownerUser?.id || '' }),
}),
payload.create({
collection: 'payload-query-presets',
data: seedData.specificUsers({ userID: user?.id || '' }),
data: seedData.specificUsers({ ownerUserID: ownerUser?.id || '', adminUserID: user.id }),
}),
])
seededData = {
everyone,
onlyMe,
specificUsers,
}
everyoneID = everyone.id
} catch (error) {
console.error('Error in beforeEach:', error)
@@ -126,12 +152,12 @@ describe('Query Presets', () => {
test('should select preset and apply filters', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await selectPreset({ page, presetTitle: seededData.everyone.title })
await assertURLParams({
page,
columns: seedData.everyone.columns,
where: seedData.everyone.where,
columns: seededData.everyone.columns,
where: seededData.everyone.where,
presetID: everyoneID,
})
@@ -140,14 +166,14 @@ describe('Query Presets', () => {
test('should clear selected preset and reset filters', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await selectPreset({ page, presetTitle: seededData.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 selectPreset({ page, presetTitle: seededData.everyone.title })
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Delete' })
@@ -172,21 +198,21 @@ describe('Query Presets', () => {
await expect(
modal.locator('tbody tr td button', {
hasText: exactText(seedData.everyone.title),
hasText: exactText(seededData.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 selectPreset({ page, presetTitle: seededData.everyone.title })
await page.reload()
await assertURLParams({
page,
columns: seedData.everyone.columns,
where: seedData.everyone.where,
columns: seededData.everyone.columns,
where: seededData.everyone.where,
// presetID: everyoneID,
})
@@ -209,7 +235,7 @@ describe('Query Presets', () => {
}),
).toBeHidden()
await selectPreset({ page, presetTitle: seedData.everyone.title })
await selectPreset({ page, presetTitle: seededData.everyone.title })
await openListMenu({ page })
@@ -249,7 +275,7 @@ describe('Query Presets', () => {
}),
).toBeHidden()
await selectPreset({ page, presetTitle: seedData.onlyMe.title })
await selectPreset({ page, presetTitle: seededData.onlyMe.title })
await toggleColumn(page, { columnLabel: 'ID' })
@@ -271,7 +297,7 @@ describe('Query Presets', () => {
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 selectPreset({ page, presetTitle: seededData.onlyMe.title })
await toggleColumn(page, { columnLabel: 'ID' })
@@ -284,7 +310,7 @@ describe('Query Presets', () => {
}),
).toBeVisible()
await selectPreset({ page, presetTitle: seedData.everyone.title })
await selectPreset({ page, presetTitle: seededData.everyone.title })
await toggleColumn(page, { columnLabel: 'ID' })
@@ -300,7 +326,7 @@ describe('Query Presets', () => {
test('should reset active changes', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await selectPreset({ page, presetTitle: seededData.everyone.title })
const { columnContainer } = await toggleColumn(page, { columnLabel: 'ID' })
@@ -318,7 +344,7 @@ describe('Query Presets', () => {
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 selectPreset({ page, presetTitle: seededData.everyone.title })
await expect(page.locator('.list-controls__modified')).toBeHidden()
await toggleColumn(page, { columnLabel: 'ID' })
await expect(page.locator('.list-controls__modified')).toBeVisible()
@@ -337,14 +363,14 @@ describe('Query Presets', () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seedData.everyone.title })
await selectPreset({ page, presetTitle: seededData.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)
await expect(titleValue).toHaveValue(seededData.everyone.title)
const newTitle = `${seedData.everyone.title} (Updated)`
const newTitle = `${seededData.everyone.title} (Updated)`
await drawer.locator('input[name="title"]').fill(newTitle)
await saveDocAndAssert(page)
@@ -391,9 +417,9 @@ describe('Query Presets', () => {
})
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)
// no results on `posts` 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()

View File

@@ -9,13 +9,13 @@ export const roles: Field = {
label: 'Admin',
value: 'admin',
},
{
label: 'Editor',
value: 'editor',
},
{
label: 'User',
value: 'user',
},
{
label: 'Anonymous',
value: 'anonymous',
},
],
}

View File

@@ -1,4 +1,3 @@
import type { NextRESTClient } from 'helpers/NextRESTClient.js'
import type { Payload, User } from 'payload'
import path from 'path'
@@ -10,10 +9,9 @@ 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
let adminUser: User
let editorUser: User
let publicUser: User
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -21,9 +19,9 @@ 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))
;({ payload } = await initPayloadInt(dirname))
user = await payload
adminUser = await payload
.login({
collection: 'users',
data: {
@@ -33,7 +31,7 @@ describe('Query Presets', () => {
})
?.then((result) => result.user)
user2 = await payload
editorUser = await payload
.login({
collection: 'users',
data: {
@@ -43,11 +41,11 @@ describe('Query Presets', () => {
})
?.then((result) => result.user)
anonymousUser = await payload
publicUser = await payload
.login({
collection: 'users',
data: {
email: 'anonymous@email.com',
email: 'public@email.com',
password: regularUser.password,
},
})
@@ -155,7 +153,8 @@ describe('Query Presets', () => {
it('should respect access when set to "specificUsers"', async () => {
const presetForSpecificUsers = await payload.create({
collection: queryPresetsCollectionSlug,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Users',
where: {
@@ -166,11 +165,11 @@ describe('Query Presets', () => {
access: {
read: {
constraint: 'specificUsers',
users: [user.id],
users: [adminUser.id],
},
update: {
constraint: 'specificUsers',
users: [user.id],
users: [adminUser.id],
},
},
relatedCollection: 'pages',
@@ -180,7 +179,7 @@ describe('Query Presets', () => {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
user: adminUser,
overrideAccess: false,
id: presetForSpecificUsers.id,
})
@@ -188,53 +187,53 @@ describe('Query Presets', () => {
expect(foundPresetWithUser1.id).toBe(presetForSpecificUsers.id)
try {
const foundPresetWithUser2 = await payload.findByID({
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
user: editorUser,
overrideAccess: false,
id: presetForSpecificUsers.id,
})
expect(foundPresetWithUser2).toBeFalsy()
expect(foundPresetWithEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
const presetUpdatedByUser1 = await payload.update({
const presetUpdatedByAdminUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificUsers.id,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Users (Updated)',
},
})
expect(presetUpdatedByUser1.title).toBe('Specific Users (Updated)')
expect(presetUpdatedByAdminUser.title).toBe('Specific Users (Updated)')
try {
const presetUpdatedByUser2 = await payload.update({
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificUsers.id,
user: user2,
user: editorUser,
overrideAccess: false,
data: {
title: 'Specific Users (Updated)',
},
})
expect(presetUpdatedByUser2).toBeFalsy()
expect(presetUpdatedByEditorUser).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,
overrideAccess: false,
user: adminUser,
data: {
title: 'Only Me',
where: {
@@ -257,7 +256,7 @@ describe('Query Presets', () => {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
user: adminUser,
overrideAccess: false,
id: presetForOnlyMe.id,
})
@@ -265,15 +264,15 @@ describe('Query Presets', () => {
expect(foundPresetWithUser1.id).toBe(presetForOnlyMe.id)
try {
const foundPresetWithUser2 = await payload.findByID({
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
user: editorUser,
overrideAccess: false,
id: presetForOnlyMe.id,
})
expect(foundPresetWithUser2).toBeFalsy()
expect(foundPresetWithEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
@@ -281,7 +280,7 @@ describe('Query Presets', () => {
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForOnlyMe.id,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Only Me (Updated)',
@@ -291,17 +290,17 @@ describe('Query Presets', () => {
expect(presetUpdatedByUser1.title).toBe('Only Me (Updated)')
try {
const presetUpdatedByUser2 = await payload.update({
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForOnlyMe.id,
user: user2,
user: editorUser,
overrideAccess: false,
data: {
title: 'Only Me (Updated)',
},
})
expect(presetUpdatedByUser2).toBeFalsy()
expect(presetUpdatedByEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
@@ -310,7 +309,8 @@ describe('Query Presets', () => {
it('should respect access when set to "everyone"', async () => {
const presetForEveryone = await payload.create({
collection: queryPresetsCollectionSlug,
user,
overrideAccess: false,
user: adminUser,
data: {
title: 'Everyone',
where: {
@@ -336,27 +336,27 @@ describe('Query Presets', () => {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
user: adminUser,
overrideAccess: false,
id: presetForEveryone.id,
})
expect(foundPresetWithUser1.id).toBe(presetForEveryone.id)
const foundPresetWithUser2 = await payload.findByID({
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
user: editorUser,
overrideAccess: false,
id: presetForEveryone.id,
})
expect(foundPresetWithUser2.id).toBe(presetForEveryone.id)
expect(foundPresetWithEditorUser.id).toBe(presetForEveryone.id)
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForEveryone.id,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Everyone (Update 1)',
@@ -365,17 +365,105 @@ describe('Query Presets', () => {
expect(presetUpdatedByUser1.title).toBe('Everyone (Update 1)')
const presetUpdatedByUser2 = await payload.update({
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForEveryone.id,
user: user2,
user: editorUser,
overrideAccess: false,
data: {
title: 'Everyone (Update 2)',
},
})
expect(presetUpdatedByUser2.title).toBe('Everyone (Update 2)')
expect(presetUpdatedByEditorUser.title).toBe('Everyone (Update 2)')
})
it('should prevent accidental lockout', async () => {
// attempt to create a preset without access to read or update
try {
const presetWithoutAccess = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Prevent Lockout',
relatedCollection: 'pages',
access: {
read: {
constraint: 'specificUsers',
users: [],
},
update: {
constraint: 'specificUsers',
users: [],
},
delete: {
constraint: 'specificUsers',
users: [],
},
},
},
})
expect(presetWithoutAccess).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Cannot remove yourself from this preset.')
}
const presetWithUser1 = await payload.create({
collection: queryPresetsCollectionSlug,
user: adminUser,
overrideAccess: false,
data: {
title: 'Prevent Lockout',
relatedCollection: 'pages',
access: {
read: {
constraint: 'specificUsers',
users: [adminUser.id],
},
update: {
constraint: 'specificUsers',
users: [adminUser.id],
},
delete: {
constraint: 'specificUsers',
users: [adminUser.id],
},
},
},
})
// attempt to update the preset to lock the user out of access
try {
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetWithUser1.id,
user: adminUser,
overrideAccess: false,
data: {
title: 'Prevent Lockout (Updated)',
access: {
read: {
constraint: 'specificUsers',
users: [],
},
update: {
constraint: 'specificUsers',
users: [],
},
delete: {
constraint: 'specificUsers',
users: [],
},
},
},
})
expect(presetUpdatedByUser1).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Cannot remove yourself from this preset.')
}
})
})
@@ -383,7 +471,8 @@ describe('Query Presets', () => {
it('should respect top-level access control overrides', async () => {
const preset = await payload.create({
collection: queryPresetsCollectionSlug,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Top-Level Access Control Override',
relatedCollection: 'pages',
@@ -404,7 +493,7 @@ describe('Query Presets', () => {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
user: adminUser,
overrideAccess: false,
id: preset.id,
})
@@ -412,15 +501,15 @@ describe('Query Presets', () => {
expect(foundPresetWithUser1.id).toBe(preset.id)
try {
const foundPresetWithAnonymousUser = await payload.findByID({
const foundPresetWithPublicUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: anonymousUser,
user: publicUser,
overrideAccess: false,
id: preset.id,
})
expect(foundPresetWithAnonymousUser).toBeFalsy()
expect(foundPresetWithPublicUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
@@ -429,7 +518,8 @@ describe('Query Presets', () => {
it('should respect access when set to "specificRoles"', async () => {
const presetForSpecificRoles = await payload.create({
collection: queryPresetsCollectionSlug,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Roles',
where: {
@@ -454,7 +544,7 @@ describe('Query Presets', () => {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
user: adminUser,
overrideAccess: false,
id: presetForSpecificRoles.id,
})
@@ -462,15 +552,15 @@ describe('Query Presets', () => {
expect(foundPresetWithUser1.id).toBe(presetForSpecificRoles.id)
try {
const foundPresetWithUser2 = await payload.findByID({
const foundPresetWithEditorUser = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user: user2,
user: editorUser,
overrideAccess: false,
id: presetForSpecificRoles.id,
})
expect(foundPresetWithUser2).toBeFalsy()
expect(foundPresetWithEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
@@ -478,7 +568,7 @@ describe('Query Presets', () => {
const presetUpdatedByUser1 = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificRoles.id,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Specific Roles (Updated)',
@@ -488,17 +578,17 @@ describe('Query Presets', () => {
expect(presetUpdatedByUser1.title).toBe('Specific Roles (Updated)')
try {
const presetUpdatedByUser2 = await payload.update({
const presetUpdatedByEditorUser = await payload.update({
collection: queryPresetsCollectionSlug,
id: presetForSpecificRoles.id,
user: user2,
user: editorUser,
overrideAccess: false,
data: {
title: 'Specific Roles (Updated)',
},
})
expect(presetUpdatedByUser2).toBeFalsy()
expect(presetUpdatedByEditorUser).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
@@ -508,7 +598,7 @@ describe('Query Presets', () => {
// create a preset with the read constraint set to "noone"
const presetForNoone = await payload.create({
collection: queryPresetsCollectionSlug,
user,
user: adminUser,
data: {
relatedCollection: 'pages',
title: 'Noone',
@@ -529,7 +619,7 @@ describe('Query Presets', () => {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
user: adminUser,
overrideAccess: false,
id: presetForNoone.id,
})
@@ -545,7 +635,8 @@ describe('Query Presets', () => {
try {
const result = await payload.create({
collection: 'payload-query-presets',
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Disabled Query Presets',
relatedCollection: 'pages',
@@ -563,7 +654,8 @@ describe('Query Presets', () => {
it('transforms "where" query objects into the "and" / "or" format', async () => {
const result = await payload.create({
collection: queryPresetsCollectionSlug,
user,
user: adminUser,
overrideAccess: false,
data: {
title: 'Where Object Formatting',
where: {

View File

@@ -68,8 +68,8 @@ export interface Config {
blocks: {};
collections: {
pages: Page;
users: User;
posts: Post;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -78,8 +78,8 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
pages: PagesSelect<false> | PagesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -126,7 +126,16 @@ export interface Page {
text?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -135,7 +144,7 @@ export interface Page {
export interface User {
id: string;
name?: string | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
roles?: ('admin' | 'editor' | 'user')[] | null;
updatedAt: string;
createdAt: string;
email: string;
@@ -147,17 +156,6 @@ export interface User {
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
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".
@@ -169,13 +167,13 @@ export interface PayloadLockedDocument {
relationTo: 'pages';
value: string | Page;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
@@ -231,12 +229,12 @@ export interface PayloadQueryPreset {
read?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone') | null;
users?: (string | User)[] | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
roles?: ('admin' | 'editor' | 'user')[] | null;
};
update?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null;
users?: (string | User)[] | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
roles?: ('admin' | 'editor' | 'user')[] | null;
};
delete?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers') | null;
@@ -262,6 +260,10 @@ export interface PayloadQueryPreset {
| boolean
| null;
relatedCollection: 'pages' | 'posts';
/**
* This is a tempoary field used to determine if updating the preset would remove the user's access to it. When `true`, this record will be deleted after running the preset's `validate` function.
*/
isTemp?: boolean | null;
updatedAt: string;
createdAt: string;
}
@@ -273,7 +275,15 @@ 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` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -292,16 +302,6 @@ export interface UsersSelect<T extends boolean = true> {
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".
@@ -368,6 +368,7 @@ export interface PayloadQueryPresetsSelect<T extends boolean = true> {
where?: T;
columns?: T;
relatedCollection?: T;
isTemp?: T;
updatedAt?: T;
createdAt?: T;
}

View File

@@ -10,11 +10,11 @@ type SeededQueryPreset = {
} & Omit<QueryPreset, 'id' | 'relatedCollection'>
export const seedData: {
everyone: SeededQueryPreset
onlyMe: SeededQueryPreset
specificUsers: (args: { userID: string }) => SeededQueryPreset
everyone: () => SeededQueryPreset
onlyMe: () => SeededQueryPreset
specificUsers: (args: { adminUserID: string }) => SeededQueryPreset
} = {
onlyMe: {
onlyMe: () => ({
relatedCollection: pagesSlug,
isShared: false,
title: 'Only Me',
@@ -40,8 +40,8 @@ export const seedData: {
equals: 'example page',
},
},
},
everyone: {
}),
everyone: () => ({
relatedCollection: pagesSlug,
isShared: true,
title: 'Everyone',
@@ -67,8 +67,8 @@ export const seedData: {
equals: 'example page',
},
},
},
specificUsers: ({ userID }: { userID: string }) => ({
}),
specificUsers: ({ adminUserID }: { adminUserID: string }) => ({
title: 'Specific Users',
isShared: true,
where: {
@@ -79,15 +79,15 @@ export const seedData: {
access: {
read: {
constraint: 'specificUsers',
users: [userID],
users: [adminUserID],
},
update: {
constraint: 'specificUsers',
users: [userID],
users: [adminUserID],
},
delete: {
constraint: 'specificUsers',
users: [userID],
users: [adminUserID],
},
},
columns: [
@@ -101,7 +101,7 @@ export const seedData: {
}
export const seed = async (_payload: Payload) => {
const [devUser] = await executePromises(
const [adminUser] = await executePromises(
[
() =>
_payload.create({
@@ -119,18 +119,18 @@ export const seed = async (_payload: Payload) => {
data: {
email: regularCredentials.email,
password: regularCredentials.password,
name: 'User',
roles: ['user'],
name: 'Editor',
roles: ['editor'],
},
}),
() =>
_payload.create({
collection: usersSlug,
data: {
email: 'anonymous@email.com',
email: 'public@email.com',
password: regularCredentials.password,
name: 'User',
roles: ['anonymous'],
name: 'Public User',
roles: ['user'],
},
}),
],
@@ -149,29 +149,30 @@ export const seed = async (_payload: Payload) => {
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
user: adminUser,
overrideAccess: false,
data: seedData.specificUsers({ userID: devUser?.id || '' }),
data: seedData.specificUsers({
adminUserID: adminUser?.id || '',
}),
}),
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
user: adminUser,
overrideAccess: false,
data: seedData.everyone,
data: seedData.everyone(),
}),
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
user: adminUser,
overrideAccess: false,
data: seedData.onlyMe,
data: seedData.onlyMe(),
}),
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
overrideAccess: false,
user: adminUser,
data: {
relatedCollection: 'pages',
title: 'Noone',

View File

@@ -670,18 +670,6 @@ describe('Relationships', () => {
await payload.delete({ collection: 'directors', where: {} })
await payload.delete({ collection: 'movies', where: {} })
const director_1 = await payload.create({
collection: 'directors',
data: { name: 'Dan', localized: 'Dan' },
})
await payload.update({
collection: 'directors',
id: director_1.id,
locale: 'de',
data: { localized: 'Mr. Dan' },
})
const director_2 = await payload.create({
collection: 'directors',
data: { name: 'Mr. Dan', localized: 'Mr. Dan' },
@@ -694,6 +682,18 @@ describe('Relationships', () => {
data: { localized: 'Dan' },
})
const director_1 = await payload.create({
collection: 'directors',
data: { name: 'Dan', localized: 'Dan' },
})
await payload.update({
collection: 'directors',
id: director_1.id,
locale: 'de',
data: { localized: 'Mr. Dan' },
})
const movie_1 = await payload.create({
collection: 'movies',
depth: 0,

View File

@@ -0,0 +1,9 @@
import type { CollectionConfig } from 'payload'
import { mediaWithSignedDownloadsSlug } from '../shared.js'
export const MediaWithSignedDownloads: CollectionConfig = {
slug: mediaWithSignedDownloadsSlug,
upload: true,
fields: [],
}

View File

@@ -7,8 +7,9 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { Media } from './collections/Media.js'
import { MediaWithPrefix } from './collections/MediaWithPrefix.js'
import { MediaWithSignedDownloads } from './collections/MediaWithSignedDownloads.js'
import { Users } from './collections/Users.js'
import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js'
import { mediaSlug, mediaWithPrefixSlug, mediaWithSignedDownloadsSlug, prefix } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -25,7 +26,7 @@ export default buildConfigWithDefaults({
baseDir: path.resolve(dirname),
},
},
collections: [Media, MediaWithPrefix, Users],
collections: [Media, MediaWithPrefix, MediaWithSignedDownloads, Users],
onInit: async (payload) => {
await payload.create({
collection: 'users',
@@ -42,6 +43,9 @@ export default buildConfigWithDefaults({
[mediaWithPrefixSlug]: {
prefix,
},
[mediaWithSignedDownloadsSlug]: {
signedDownloads: true,
},
},
bucket: process.env.S3_BUCKET,
config: {

View File

@@ -4,12 +4,16 @@ import * as AWS from '@aws-sdk/client-s3'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js'
import { mediaSlug, mediaWithPrefixSlug, mediaWithSignedDownloadsSlug, prefix } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
let restClient: NextRESTClient
let payload: Payload
describe('@payloadcms/storage-s3', () => {
@@ -17,7 +21,7 @@ describe('@payloadcms/storage-s3', () => {
let client: AWS.S3Client
beforeAll(async () => {
;({ payload } = await initPayloadInt(dirname))
;({ payload, restClient } = await initPayloadInt(dirname))
TEST_BUCKET = process.env.S3_BUCKET
client = new AWS.S3({
@@ -77,15 +81,38 @@ describe('@payloadcms/storage-s3', () => {
expect(upload.url).toEqual(`/api/${mediaWithPrefixSlug}/file/${String(upload.filename)}`)
})
it('can download with signed downloads', async () => {
await payload.create({
collection: mediaWithSignedDownloadsSlug,
data: {},
filePath: path.resolve(dirname, '../uploads/image.png'),
})
const response = await restClient.GET(`/${mediaWithSignedDownloadsSlug}/file/image.png`)
expect(response.status).toBe(302)
const url = response.headers.get('Location')
expect(url).toBeDefined()
expect(url!).toContain(`/${TEST_BUCKET}/image.png`)
expect(new URLSearchParams(url!).get('x-id')).toBe('GetObject')
const file = await fetch(url!)
expect(file.headers.get('Content-Type')).toBe('image/png')
})
describe('R2', () => {
it.todo('can upload')
})
async function createTestBucket() {
const makeBucketRes = await client.send(new AWS.CreateBucketCommand({ Bucket: TEST_BUCKET }))
try {
const makeBucketRes = await client.send(new AWS.CreateBucketCommand({ Bucket: TEST_BUCKET }))
if (makeBucketRes.$metadata.httpStatusCode !== 200) {
throw new Error(`Failed to create bucket. ${makeBucketRes.$metadata.httpStatusCode}`)
if (makeBucketRes.$metadata.httpStatusCode !== 200) {
throw new Error(`Failed to create bucket. ${makeBucketRes.$metadata.httpStatusCode}`)
}
} catch (e) {
if (e instanceof AWS.BucketAlreadyOwnedByYou) {
console.log('Bucket already exists')
}
}
}
@@ -96,7 +123,9 @@ describe('@payloadcms/storage-s3', () => {
}),
)
if (!listedObjects?.Contents?.length) return
if (!listedObjects?.Contents?.length) {
return
}
const deleteParams = {
Bucket: TEST_BUCKET,

View File

@@ -69,6 +69,7 @@ export interface Config {
collections: {
media: Media;
'media-with-prefix': MediaWithPrefix;
'media-with-signed-downloads': MediaWithSignedDownload;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -78,6 +79,7 @@ export interface Config {
collectionsSelect: {
media: MediaSelect<false> | MediaSelect<true>;
'media-with-prefix': MediaWithPrefixSelect<false> | MediaWithPrefixSelect<true>;
'media-with-signed-downloads': MediaWithSignedDownloadsSelect<false> | MediaWithSignedDownloadsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -171,6 +173,24 @@ export interface MediaWithPrefix {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media-with-signed-downloads".
*/
export interface MediaWithSignedDownload {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -203,6 +223,10 @@ export interface PayloadLockedDocument {
relationTo: 'media-with-prefix';
value: string | MediaWithPrefix;
} | null)
| ({
relationTo: 'media-with-signed-downloads';
value: string | MediaWithSignedDownload;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -309,6 +333,23 @@ export interface MediaWithPrefixSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media-with-signed-downloads_select".
*/
export interface MediaWithSignedDownloadsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -1,3 +1,5 @@
export const mediaSlug = 'media'
export const mediaWithPrefixSlug = 'media-with-prefix'
export const prefix = 'test-prefix'
export const mediaWithSignedDownloadsSlug = 'media-with-signed-downloads'

View File

@@ -1 +1 @@
NODE_OPTIONS="--no-deprecation"
NODE_OPTIONS="--no-deprecation --no-experimental-strip-types"

View File

@@ -38,6 +38,26 @@ export default buildConfigWithDefaults({
},
],
},
{
type: 'group',
label: 'Unnamed Group',
fields: [
{
type: 'text',
name: 'insideUnnamedGroup',
},
],
},
{
type: 'group',
name: 'namedGroup',
fields: [
{
type: 'text',
name: 'insideNamedGroup',
},
],
},
{
name: 'radioField',
type: 'radio',

View File

@@ -144,6 +144,10 @@ export interface Post {
text?: string | null;
title?: string | null;
selectField: MySelectOptions;
insideUnnamedGroup?: string | null;
namedGroup?: {
insideNamedGroup?: string | null;
};
radioField: MyRadioOptions;
updatedAt: string;
createdAt: string;
@@ -264,6 +268,12 @@ export interface PostsSelect<T extends boolean = true> {
text?: T;
title?: T;
selectField?: T;
insideUnnamedGroup?: T;
namedGroup?:
| T
| {
insideNamedGroup?: T;
};
radioField?: T;
updatedAt?: T;
createdAt?: T;

View File

@@ -145,4 +145,17 @@ describe('Types testing', () => {
expect(asType<Post['radioField']>()).type.toBe<MyRadioOptions>()
})
})
describe('fields', () => {
describe('Group', () => {
test('correctly ignores unnamed group', () => {
expect<Post>().type.toHaveProperty('insideUnnamedGroup')
})
test('generates nested group name', () => {
expect<Post>().type.toHaveProperty('namedGroup')
expect<NonNullable<Post['namedGroup']>>().type.toHaveProperty('insideNamedGroup')
})
})
})
})

View File

@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { errorOnUnpublishSlug } from '../slugs.js'
const ErrorOnUnpublish: CollectionConfig = {
slug: errorOnUnpublishSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
versions: {
drafts: true,
},
hooks: {
beforeValidate: [
({ data, originalDoc }) => {
if (data?._status === 'draft' && originalDoc?._status === 'published') {
throw new APIError('Custom error on unpublish', 400, {}, true)
}
},
],
},
}
export default ErrorOnUnpublish

View File

@@ -12,6 +12,7 @@ import DisablePublish from './collections/DisablePublish.js'
import DraftPosts from './collections/Drafts.js'
import DraftWithMax from './collections/DraftsWithMax.js'
import DraftsWithValidate from './collections/DraftsWithValidate.js'
import ErrorOnUnpublish from './collections/ErrorOnUnpublish.js'
import LocalizedPosts from './collections/Localized.js'
import { Media } from './collections/Media.js'
import Posts from './collections/Posts.js'
@@ -42,6 +43,7 @@ export default buildConfigWithDefaults({
DraftPosts,
DraftWithMax,
DraftsWithValidate,
ErrorOnUnpublish,
LocalizedPosts,
VersionPosts,
CustomIDs,

View File

@@ -60,6 +60,7 @@ import {
draftWithMaxCollectionSlug,
draftWithMaxGlobalSlug,
draftWithValidateCollectionSlug,
errorOnUnpublishSlug,
localizedCollectionSlug,
localizedGlobalSlug,
postCollectionSlug,
@@ -86,6 +87,7 @@ describe('Versions', () => {
let disablePublishURL: AdminUrlUtil
let customIDURL: AdminUrlUtil
let postURL: AdminUrlUtil
let errorOnUnpublishURL: AdminUrlUtil
let id: string
beforeAll(async ({ browser }, testInfo) => {
@@ -124,6 +126,7 @@ describe('Versions', () => {
disablePublishURL = new AdminUrlUtil(serverURL, disablePublishSlug)
customIDURL = new AdminUrlUtil(serverURL, customIDSlug)
postURL = new AdminUrlUtil(serverURL, postCollectionSlug)
errorOnUnpublishURL = new AdminUrlUtil(serverURL, errorOnUnpublishSlug)
})
test('collection — has versions tab', async () => {
@@ -579,6 +582,22 @@ describe('Versions', () => {
await expect(page.locator('#action-save')).not.toBeAttached()
})
test('collections — should show custom error message when unpublishing fails', async () => {
const publishedDoc = await payload.create({
collection: errorOnUnpublishSlug,
data: {
_status: 'published',
title: 'title',
},
})
await page.goto(errorOnUnpublishURL.edit(String(publishedDoc.id)))
await page.locator('#action-unpublish').click()
await page.locator('[id^="confirm-un-publish-"] #confirm-action').click()
await expect(
page.locator('.payload-toast-item:has-text("Custom error on unpublish")'),
).toBeVisible()
})
test('should show documents title in relationship even if draft document', async () => {
await payload.create({
collection: autosaveCollectionSlug,

View File

@@ -75,6 +75,7 @@ export interface Config {
'draft-posts': DraftPost;
'draft-with-max-posts': DraftWithMaxPost;
'draft-with-validate-posts': DraftWithValidatePost;
'error-on-unpublish': ErrorOnUnpublish;
'localized-posts': LocalizedPost;
'version-posts': VersionPost;
'custom-ids': CustomId;
@@ -97,6 +98,7 @@ export interface Config {
'draft-posts': DraftPostsSelect<false> | DraftPostsSelect<true>;
'draft-with-max-posts': DraftWithMaxPostsSelect<false> | DraftWithMaxPostsSelect<true>;
'draft-with-validate-posts': DraftWithValidatePostsSelect<false> | DraftWithValidatePostsSelect<true>;
'error-on-unpublish': ErrorOnUnpublishSelect<false> | ErrorOnUnpublishSelect<true>;
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
'version-posts': VersionPostsSelect<false> | VersionPostsSelect<true>;
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
@@ -289,6 +291,17 @@ export interface DraftWithValidatePost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "error-on-unpublish".
*/
export interface ErrorOnUnpublish {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-posts".
@@ -589,6 +602,10 @@ export interface PayloadLockedDocument {
relationTo: 'draft-with-validate-posts';
value: string | DraftWithValidatePost;
} | null)
| ({
relationTo: 'error-on-unpublish';
value: string | ErrorOnUnpublish;
} | null)
| ({
relationTo: 'localized-posts';
value: string | LocalizedPost;
@@ -778,6 +795,16 @@ export interface DraftWithValidatePostsSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "error-on-unpublish_select".
*/
export interface ErrorOnUnpublishSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-posts_select".

View File

@@ -19,6 +19,7 @@ export const mediaCollectionSlug = 'media'
export const versionCollectionSlug = 'version-posts'
export const disablePublishSlug = 'disable-publish'
export const errorOnUnpublishSlug = 'error-on-unpublish'
export const disablePublishGlobalSlug = 'disable-publish-global'