Merge branch 'main' into feat/folders

This commit is contained in:
Jarrod Flesch
2025-04-18 10:56:46 -04:00
157 changed files with 2010 additions and 445 deletions

View File

@@ -69,6 +69,12 @@ export const Posts: CollectionConfig = {
},
},
],
edit: {
beforeDocumentControls: [
'/components/BeforeDocumentControls/CustomDraftButton/index.js#CustomDraftButton',
'/components/BeforeDocumentControls/CustomSaveButton/index.js#CustomSaveButton',
],
},
},
pagination: {
defaultLimit: 5,

View File

@@ -0,0 +1,23 @@
import type { BeforeDocumentControlsServerProps } from 'payload'
import React from 'react'
const baseClass = 'custom-draft-button'
export function CustomDraftButton(props: BeforeDocumentControlsServerProps) {
return (
<div
className={baseClass}
id="custom-draft-button"
style={{
display: 'flex',
flexDirection: 'column',
gap: 'calc(var(--base) / 4)',
}}
>
<p className="nav__label" style={{ color: 'var(--theme-text)', margin: 0 }}>
Custom Draft Button
</p>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type { BeforeDocumentControlsServerProps } from 'payload'
import React from 'react'
const baseClass = 'custom-save-button'
export function CustomSaveButton(props: BeforeDocumentControlsServerProps) {
return (
<div
className={baseClass}
id="custom-save-button"
style={{
display: 'flex',
flexDirection: 'column',
gap: 'calc(var(--base) / 4)',
}}
>
<p className="nav__label" style={{ color: 'var(--theme-text)', margin: 0 }}>
Custom Save Button
</p>
</div>
)
}

View File

@@ -110,7 +110,7 @@ describe('Document View', () => {
})
expect(collectionItems.docs.length).toBe(1)
await page.goto(
`${postsUrl.collection(noApiViewGlobalSlug)}/${collectionItems.docs[0].id}/api`,
`${postsUrl.collection(noApiViewGlobalSlug)}/${collectionItems?.docs[0]?.id}/api`,
)
await expect(page.locator('.not-found')).toHaveCount(1)
})
@@ -333,20 +333,32 @@ describe('Document View', () => {
await navigateToDoc(page, postsUrl)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
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 drawerLeft = await drawer1Content.boundingBox().then((box) => box.x)
const drawer1Box = await drawer1Content.boundingBox()
await expect.poll(() => drawer1Box).not.toBeNull()
const drawerLeft = drawer1Box!.x
await drawer1Content
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
.click()
const drawer2Content = page.locator('[id^=doc-drawer_posts_2_] .drawer__content')
await expect(drawer2Content).toBeVisible()
const drawer2Left = await drawer2Content.boundingBox().then((box) => box.x)
expect(drawer2Left > drawerLeft).toBe(true)
const drawer2Box = await drawer2Content.boundingBox()
await expect.poll(() => drawer2Box).not.toBeNull()
const drawer2Left = drawer2Box!.x
await expect.poll(() => drawer2Left > drawerLeft).toBe(true)
})
})
@@ -523,6 +535,24 @@ describe('Document View', () => {
await expect(fileField).toHaveValue('some file text')
})
})
describe('custom document controls', () => {
test('should show custom elements in document controls in collection', async () => {
await page.goto(postsUrl.create)
const customDraftButton = page.locator('#custom-draft-button')
const customSaveButton = page.locator('#custom-save-button')
await expect(customDraftButton).toBeVisible()
await expect(customSaveButton).toBeVisible()
})
test('should show custom elements in document controls in global', async () => {
await page.goto(globalURL.global(globalSlug))
const customDraftButton = page.locator('#custom-draft-button')
await expect(customDraftButton).toBeVisible()
})
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {

View File

@@ -6,6 +6,11 @@ export const Global: GlobalConfig = {
slug: globalSlug,
admin: {
components: {
elements: {
beforeDocumentControls: [
'/components/BeforeDocumentControls/CustomDraftButton/index.js#CustomDraftButton',
],
},
views: {
edit: {
api: {

View File

@@ -36,6 +36,15 @@ export default buildConfigWithDefaults({
},
},
collections: [
{
slug: 'categories',
fields: [
{
type: 'text',
name: 'title',
},
],
},
{
slug: postsSlug,
fields: [
@@ -43,6 +52,21 @@ export default buildConfigWithDefaults({
name: 'title',
type: 'text',
required: true,
// access: { read: () => false },
},
{
type: 'relationship',
relationTo: 'categories',
name: 'category',
},
{
name: 'localized',
type: 'text',
localized: true,
},
{
name: 'text',
type: 'text',
},
{
name: 'number',
@@ -433,6 +457,33 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: 'virtual-relations',
admin: { useAsTitle: 'postTitle' },
fields: [
{
name: 'postTitle',
type: 'text',
virtual: 'post.title',
},
{
name: 'postCategoryTitle',
type: 'text',
virtual: 'post.category.title',
},
{
name: 'postLocalized',
type: 'text',
virtual: 'post.localized',
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
],
versions: { drafts: true },
},
{
slug: fieldsPersistanceSlug,
fields: [
@@ -658,6 +709,21 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: 'virtual-relation-global',
fields: [
{
type: 'text',
name: 'postTitle',
virtual: 'post.title',
},
{
type: 'relationship',
name: 'post',
relationTo: 'posts',
},
],
},
],
localization: {
defaultLocale: 'en',

View File

@@ -7,6 +7,7 @@ import {
migrateRelationshipsV2_V3,
migrateVersionsV1_V2,
} from '@payloadcms/db-mongodb/migration-utils'
import { objectToFrontmatter } from '@payloadcms/richtext-lexical'
import { randomUUID } from 'crypto'
import { type Table } from 'drizzle-orm'
import * as drizzlePg from 'drizzle-orm/pg-core'
@@ -1977,6 +1978,145 @@ describe('database', () => {
expect(res.textWithinRow).toBeUndefined()
expect(res.textWithinTabs).toBeUndefined()
})
it('should allow virtual field with reference', async () => {
const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } })
const { id } = await payload.create({
collection: 'virtual-relations',
depth: 0,
data: { post: post.id },
})
const doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id })
expect(doc.postTitle).toBe('my-title')
const draft = await payload.find({
collection: 'virtual-relations',
depth: 0,
where: { id: { equals: id } },
draft: true,
})
expect(draft.docs[0]?.postTitle).toBe('my-title')
})
it('should allow virtual field with reference localized', async () => {
const post = await payload.create({
collection: 'posts',
data: { title: 'my-title', localized: 'localized en' },
})
await payload.update({
collection: 'posts',
id: post.id,
locale: 'es',
data: { localized: 'localized es' },
})
const { id } = await payload.create({
collection: 'virtual-relations',
depth: 0,
data: { post: post.id },
})
let doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id })
expect(doc.postLocalized).toBe('localized en')
doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id, locale: 'es' })
expect(doc.postLocalized).toBe('localized es')
})
it('should allow to query by a virtual field with reference', async () => {
await payload.delete({ collection: 'posts', where: {} })
await payload.delete({ collection: 'virtual-relations', where: {} })
const post_1 = await payload.create({ collection: 'posts', data: { title: 'Dan' } })
const post_2 = await payload.create({ collection: 'posts', data: { title: 'Mr.Dan' } })
const doc_1 = await payload.create({
collection: 'virtual-relations',
depth: 0,
data: { post: post_1.id },
})
const doc_2 = await payload.create({
collection: 'virtual-relations',
depth: 0,
data: { post: post_2.id },
})
const { docs: ascDocs } = await payload.find({
collection: 'virtual-relations',
sort: 'postTitle',
depth: 0,
})
expect(ascDocs[0]?.id).toBe(doc_1.id)
expect(ascDocs[1]?.id).toBe(doc_2.id)
const { docs: descDocs } = await payload.find({
collection: 'virtual-relations',
sort: '-postTitle',
depth: 0,
})
expect(descDocs[1]?.id).toBe(doc_1.id)
expect(descDocs[0]?.id).toBe(doc_2.id)
})
it.todo('should allow to sort by a virtual field with reference')
it('should allow virtual field 2x deep', async () => {
const category = await payload.create({
collection: 'categories',
data: { title: '1-category' },
})
const post = await payload.create({
collection: 'posts',
data: { title: '1-post', category: category.id },
})
const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } })
expect(doc.postCategoryTitle).toBe('1-category')
})
it('should allow to query by virtual field 2x deep', async () => {
const category = await payload.create({
collection: 'categories',
data: { title: '2-category' },
})
const post = await payload.create({
collection: 'posts',
data: { title: '2-post', category: category.id },
})
const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } })
const found = await payload.find({
collection: 'virtual-relations',
where: { postCategoryTitle: { equals: '2-category' } },
})
expect(found.docs).toHaveLength(1)
expect(found.docs[0].id).toBe(doc.id)
})
it('should allow referenced virtual field in globals', async () => {
const post = await payload.create({ collection: 'posts', data: { title: 'post' } })
const globalData = await payload.updateGlobal({
slug: 'virtual-relation-global',
data: { post: post.id },
depth: 0,
})
expect(globalData.postTitle).toBe('post')
})
})
it('should convert numbers to text', async () => {
const result = await payload.create({
collection: postsSlug,
data: {
title: 'testing',
// @ts-expect-error hardcoding a number and expecting that it will convert to string
text: 1,
},
})
expect(result.text).toStrictEqual('1')
})
it('should not allow to query by a field with `virtual: true`', async () => {

View File

@@ -67,6 +67,7 @@ export interface Config {
};
blocks: {};
collections: {
categories: Category;
posts: Post;
'error-on-unnamed-fields': ErrorOnUnnamedField;
'default-values': DefaultValue;
@@ -75,6 +76,7 @@ export interface Config {
'pg-migrations': PgMigration;
'custom-schema': CustomSchema;
places: Place;
'virtual-relations': VirtualRelation;
'fields-persistance': FieldsPersistance;
'custom-ids': CustomId;
'fake-custom-ids': FakeCustomId;
@@ -88,6 +90,7 @@ export interface Config {
};
collectionsJoins: {};
collectionsSelect: {
categories: CategoriesSelect<false> | CategoriesSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect<false> | ErrorOnUnnamedFieldsSelect<true>;
'default-values': DefaultValuesSelect<false> | DefaultValuesSelect<true>;
@@ -96,6 +99,7 @@ export interface Config {
'pg-migrations': PgMigrationsSelect<false> | PgMigrationsSelect<true>;
'custom-schema': CustomSchemaSelect<false> | CustomSchemaSelect<true>;
places: PlacesSelect<false> | PlacesSelect<true>;
'virtual-relations': VirtualRelationsSelect<false> | VirtualRelationsSelect<true>;
'fields-persistance': FieldsPersistanceSelect<false> | FieldsPersistanceSelect<true>;
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
'fake-custom-ids': FakeCustomIdsSelect<false> | FakeCustomIdsSelect<true>;
@@ -114,11 +118,13 @@ export interface Config {
global: Global;
'global-2': Global2;
'global-3': Global3;
'virtual-relation-global': VirtualRelationGlobal;
};
globalsSelect: {
global: GlobalSelect<false> | GlobalSelect<true>;
'global-2': Global2Select<false> | Global2Select<true>;
'global-3': Global3Select<false> | Global3Select<true>;
'virtual-relation-global': VirtualRelationGlobalSelect<false> | VirtualRelationGlobalSelect<true>;
};
locale: 'en' | 'es';
user: User & {
@@ -147,6 +153,16 @@ export interface UserAuthOperations {
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
*/
export interface Category {
id: string;
title?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
@@ -154,6 +170,9 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title: string;
category?: (string | null) | Category;
localized?: string | null;
text?: string | null;
number?: number | null;
D1?: {
D2?: {
@@ -346,6 +365,20 @@ export interface Place {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtual-relations".
*/
export interface VirtualRelation {
id: string;
postTitle?: string | null;
postCategoryTitle?: string | null;
postLocalized?: string | null;
post?: (string | null) | Post;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "fields-persistance".
@@ -465,6 +498,10 @@ export interface User {
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
@@ -497,6 +534,10 @@ export interface PayloadLockedDocument {
relationTo: 'places';
value: string | Place;
} | null)
| ({
relationTo: 'virtual-relations';
value: string | VirtualRelation;
} | null)
| ({
relationTo: 'fields-persistance';
value: string | FieldsPersistance;
@@ -567,12 +608,24 @@ export interface PayloadMigration {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories_select".
*/
export interface CategoriesSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
category?: T;
localized?: T;
text?: T;
number?: T;
D1?:
| T
@@ -747,6 +800,19 @@ export interface PlacesSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtual-relations_select".
*/
export interface VirtualRelationsSelect<T extends boolean = true> {
postTitle?: T;
postCategoryTitle?: T;
postLocalized?: T;
post?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "fields-persistance_select".
@@ -917,6 +983,17 @@ export interface Global3 {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtual-relation-global".
*/
export interface VirtualRelationGlobal {
id: string;
postTitle?: string | null;
post?: (string | null) | Post;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global_select".
@@ -947,6 +1024,17 @@ export interface Global3Select<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtual-relation-global_select".
*/
export interface VirtualRelationGlobalSelect<T extends boolean = true> {
postTitle?: T;
post?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View File

@@ -57,7 +57,10 @@ describe('Field Error States', () => {
// add third child array
await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-row').click()
// remove the row
await page.locator('#parentArray-0-childArray-row-2 .array-actions__button').click()
await page
.locator('#parentArray-0-childArray-row-2 .array-actions__action.array-actions__remove')
.click()
@@ -68,6 +71,7 @@ describe('Field Error States', () => {
'#parentArray-row-0 > .collapsible > .collapsible__toggle-wrap .array-field__row-error-pill',
{ state: 'hidden', timeout: 500 },
)
expect(errorPill).toBeNull()
})
@@ -77,13 +81,11 @@ describe('Field Error States', () => {
await saveDocAndAssert(page, '#action-save-draft')
})
// eslint-disable-next-line playwright/expect-expect
test('should validate drafts when enabled', async () => {
await page.goto(validateDraftsOn.create)
await saveDocAndAssert(page, '#action-save-draft', 'error')
})
// eslint-disable-next-line playwright/expect-expect
test('should show validation errors when validate and autosave are enabled', async () => {
await page.goto(validateDraftsOnAutosave.create)
await page.locator('#field-title').fill('valid')

View File

@@ -4,12 +4,12 @@ import * as React from 'react'
import { collection1Slug } from '../slugs.js'
export const PrePopulateFieldUI: React.FC<{
export const PopulateFieldButton: React.FC<{
hasMany?: boolean
hasMultipleRelations?: boolean
path?: string
targetFieldPath: string
}> = ({ hasMany = true, hasMultipleRelations = false, path, targetFieldPath }) => {
}> = ({ hasMany = true, hasMultipleRelations = false, targetFieldPath }) => {
const { setValue } = useField({ path: targetFieldPath })
const addDefaults = React.useCallback(() => {

View File

@@ -19,7 +19,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: {
hasMany: false,
hasMultipleRelations: false,
@@ -50,7 +50,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: {
hasMultipleRelations: false,
targetFieldPath: 'relationHasMany',
@@ -80,7 +80,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: {
hasMultipleRelations: true,
targetFieldPath: 'relationToManyHasMany',

View File

@@ -10,6 +10,7 @@ import type { Config } from '../../payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
// throttleTest,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
@@ -225,4 +226,19 @@ describe('Conditional Logic', () => {
await expect(numberField).toBeVisible()
})
test('should render field based on operation argument', async () => {
await page.goto(url.create)
const textField = page.locator('#field-text')
const fieldWithOperationCondition = page.locator('#field-fieldWithOperationCondition')
await textField.fill('some text')
await expect(fieldWithOperationCondition).toBeVisible()
await saveDocAndAssert(page)
await expect(fieldWithOperationCondition).toBeHidden()
})
})

View File

@@ -24,6 +24,19 @@ const ConditionalLogic: CollectionConfig = {
condition: ({ toggleField }) => Boolean(toggleField),
},
},
{
name: 'fieldWithOperationCondition',
type: 'text',
admin: {
condition: (data, siblingData, { operation }) => {
if (operation === 'create') {
return true
}
return false
},
},
},
{
name: 'customFieldWithField',
type: 'text',
@@ -217,7 +230,7 @@ const ConditionalLogic: CollectionConfig = {
name: 'numberField',
type: 'number',
admin: {
condition: (data, siblingData, { path, user }) => {
condition: (data, siblingData, { path }) => {
// Ensure path has enough depth
if (path.length < 5) {
return false

View File

@@ -650,6 +650,163 @@ describe('relationship', () => {
await expect(page.locator(tableRowLocator)).toHaveCount(1)
})
test('should be able to select relationship with drawer appearance', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawer')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--single-value__text')
await expect(selectedValue).toBeVisible()
// Fill required field
await page.locator('#field-relationship').click()
await page.locator('.rs__option:has-text("Seeded text document")').click()
await saveDocAndAssert(page)
})
test('should be able to search within relationship list drawer', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawer')
await relationshipField.click()
const searchField = page.locator('.list-drawer .search-filter')
await expect(searchField).toBeVisible()
const searchInput = searchField.locator('input')
await searchInput.fill('seeded')
const rows = page.locator('.list-drawer table tbody tr')
await expect(rows).toHaveCount(1)
const closeButton = page.locator('.list-drawer__header-close')
await closeButton.click()
await expect(page.locator('.list-drawer')).toBeHidden()
})
test('should handle read-only relationship field when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const readOnlyField = page.locator(
'#field-relationshipDrawerReadOnly .rs__control--is-disabled',
)
await expect(readOnlyField).toBeVisible()
})
test('should handle polymorphic relationship when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-polymorphicRelationshipDrawer')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const relationToSelector = page.locator('.list-header__select-collection')
await expect(relationToSelector).toBeVisible()
await relationToSelector.locator('.rs__control').click()
const option = relationToSelector.locator('.rs__option').nth(1)
await option.click()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--single-value__text')
await expect(selectedValue).toBeVisible()
// Fill required field
await page.locator('#field-relationship').click()
await page.locator('.rs__option:has-text("Seeded text document")').click()
await saveDocAndAssert(page)
})
test('should handle `hasMany` relationship when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerHasMany')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--multi-value-label__text')
await expect(selectedValue).toBeVisible()
})
test('should handle `hasMany` polymorphic relationship when `appearance: "drawer"`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerHasManyPolymorphic')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const firstRow = listDrawerContent.locator('table tbody tr').first()
const button = firstRow.locator('button')
await button.click()
await expect(listDrawerContent).toBeHidden()
const selectedValue = relationshipField.locator('.relationship--multi-value-label__text')
await expect(selectedValue).toBeVisible()
})
test('should not be allowed to create in relationship list drawer when `allowCreate` is `false`', async () => {
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerWithAllowCreateFalse')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const createNewButton = listDrawerContent.locator('list-drawer__create-new-button')
await expect(createNewButton).toBeHidden()
})
test('should respect `filterOptions` in the relationship list drawer for filtered relationship', async () => {
// Create test documents
await createTextFieldDoc({ text: 'list drawer test' })
await createTextFieldDoc({ text: 'not test' })
await page.goto(url.create)
const relationshipField = page.locator('#field-relationshipDrawerWithFilterOptions')
await relationshipField.click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const rows = page.locator('.list-drawer table tbody tr')
await expect(rows).toHaveCount(1)
})
test('should filter out existing values from relationship list drawer', async () => {
await page.goto(url.create)
await page.locator('#field-relationshipDrawer').click()
const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content')
await expect(listDrawerContent).toBeVisible()
const rows = listDrawerContent.locator('table tbody tr')
await expect(rows).toHaveCount(2)
await listDrawerContent.getByText('Seeded text document', { exact: true }).click()
const selectedValue = page.locator(
'#field-relationshipDrawer .relationship--single-value__text',
)
await expect(selectedValue).toHaveText('Seeded text document')
await page.locator('#field-relationshipDrawer').click()
const newRows = listDrawerContent.locator('table tbody tr')
await expect(newRows).toHaveCount(1)
await expect(listDrawerContent.getByText('Seeded text document')).toHaveCount(0)
})
})
async function createTextFieldDoc(overrides?: Partial<TextField>): Promise<TextField> {

View File

@@ -126,6 +126,71 @@ const RelationshipFields: CollectionConfig = {
type: 'relationship',
hasMany: true,
},
{
name: 'relationshipDrawer',
relationTo: 'text-fields',
admin: { appearance: 'drawer' },
type: 'relationship',
},
{
name: 'relationshipDrawerReadOnly',
relationTo: 'text-fields',
admin: {
readOnly: true,
appearance: 'drawer',
},
type: 'relationship',
},
{
name: 'polymorphicRelationshipDrawer',
admin: { appearance: 'drawer' },
type: 'relationship',
relationTo: ['text-fields', 'array-fields'],
},
{
name: 'relationshipDrawerHasMany',
relationTo: 'text-fields',
admin: {
appearance: 'drawer',
},
hasMany: true,
type: 'relationship',
},
{
name: 'relationshipDrawerHasManyPolymorphic',
relationTo: ['text-fields'],
admin: {
appearance: 'drawer',
},
hasMany: true,
type: 'relationship',
},
{
name: 'relationshipDrawerWithAllowCreateFalse',
admin: {
allowCreate: false,
appearance: 'drawer',
},
type: 'relationship',
relationTo: 'text-fields',
},
{
name: 'relationshipDrawerWithFilterOptions',
admin: { appearance: 'drawer' },
type: 'relationship',
relationTo: ['text-fields'],
filterOptions: ({ relationTo }) => {
if (relationTo === 'text-fields') {
return {
text: {
equals: 'list drawer test',
},
}
} else {
return true
}
},
},
],
slug: relationshipFieldsSlug,
}

View File

@@ -790,6 +790,7 @@ export interface ConditionalLogic {
text: string;
toggleField?: boolean | null;
fieldWithCondition?: string | null;
fieldWithOperationCondition?: string | null;
customFieldWithField?: string | null;
customFieldWithHOC?: string | null;
customClientFieldWithCondition?: string | null;
@@ -1312,6 +1313,29 @@ export interface RelationshipField {
| null;
relationToRow?: (string | null) | RowField;
relationToRowMany?: (string | RowField)[] | null;
relationshipDrawer?: (string | null) | TextField;
relationshipDrawerReadOnly?: (string | null) | TextField;
polymorphicRelationshipDrawer?:
| ({
relationTo: 'text-fields';
value: string | TextField;
} | null)
| ({
relationTo: 'array-fields';
value: string | ArrayField;
} | null);
relationshipDrawerHasMany?: (string | TextField)[] | null;
relationshipDrawerHasManyPolymorphic?:
| {
relationTo: 'text-fields';
value: string | TextField;
}[]
| null;
relationshipDrawerWithAllowCreateFalse?: (string | null) | TextField;
relationshipDrawerWithFilterOptions?: {
relationTo: 'text-fields';
value: string | TextField;
} | null;
updatedAt: string;
createdAt: string;
}
@@ -2341,6 +2365,7 @@ export interface ConditionalLogicSelect<T extends boolean = true> {
text?: T;
toggleField?: T;
fieldWithCondition?: T;
fieldWithOperationCondition?: T;
customFieldWithField?: T;
customFieldWithHOC?: T;
customClientFieldWithCondition?: T;
@@ -2786,6 +2811,13 @@ export interface RelationshipFieldsSelect<T extends boolean = true> {
relationshipWithMinRows?: T;
relationToRow?: T;
relationToRowMany?: T;
relationshipDrawer?: T;
relationshipDrawerReadOnly?: T;
polymorphicRelationshipDrawer?: T;
relationshipDrawerHasMany?: T;
relationshipDrawerHasManyPolymorphic?: T;
relationshipDrawerWithAllowCreateFalse?: T;
relationshipDrawerWithFilterOptions?: T;
updatedAt?: T;
createdAt?: T;
}

View File

@@ -83,6 +83,10 @@ export const PostsCollection: CollectionConfig = {
},
},
},
{
name: 'defaultTextField',
type: 'text',
},
],
},
],

View File

@@ -213,7 +213,37 @@ test.describe('Form State', () => {
})
})
test('new rows should contain default values', async () => {
test('should not render stale values for server components while form state is in flight', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-array #array-row-0 #field-array__0__customTextField').fill('1')
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-array #array-row-1 #field-array__1__customTextField').fill('2')
// block the next form state request from firing to ensure the field remains in stale state
await page.route(postsUrl.create, async (route) => {
if (route.request().method() === 'POST' && route.request().url() === postsUrl.create) {
await route.abort()
}
await route.continue()
})
// remove the first row
await page.locator('#field-array #array-row-0 .array-actions__button').click()
await page
.locator('#field-array #array-row-0 .array-actions__action.array-actions__remove')
.click()
await expect(
page.locator('#field-array #array-row-0 #field-array__0__customTextField'),
).toHaveValue('2')
})
test('should queue onChange functions', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-array .array-field__add-row').click()
await expect(

View File

@@ -144,6 +144,7 @@ export interface Post {
array?:
| {
customTextField?: string | null;
defaultTextField?: string | null;
id?: string | null;
}[]
| null;
@@ -254,6 +255,7 @@ export interface PostsSelect<T extends boolean = true> {
| T
| {
customTextField?: T;
defaultTextField?: T;
id?: T;
};
updatedAt?: T;

View File

@@ -1,5 +1,6 @@
import type { Payload } from 'payload'
import path from 'path'
import { NotFound, type Payload } from 'payload'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -300,8 +301,8 @@ describe('@payloadcms/plugin-search', () => {
collection: 'search',
depth: 0,
where: {
'doc.value': {
equals: page.id,
id: {
equals: results[0].id,
},
},
})

View File

@@ -24,9 +24,9 @@ export default buildConfigWithDefaults({
// },
access: {
read: ({ req: { user } }) =>
user ? !user?.roles?.some((role) => role === 'anonymous') : false,
user ? user && !user?.roles?.some((role) => role === 'anonymous') : false,
update: ({ req: { user } }) =>
user ? !user?.roles?.some((role) => role === 'anonymous') : false,
user ? user && !user?.roles?.some((role) => role === 'anonymous') : false,
},
constraints: {
read: [
@@ -40,6 +40,11 @@ export default buildConfigWithDefaults({
},
}),
},
{
label: 'Noone',
value: 'noone',
access: () => false,
},
],
update: [
{

View File

@@ -503,6 +503,42 @@ describe('Query Presets', () => {
expect((error as Error).message).toBe('You are not allowed to perform this action.')
}
})
it('should respect boolean access control results', async () => {
// create a preset with the read constraint set to "noone"
const presetForNoone = await payload.create({
collection: queryPresetsCollectionSlug,
user,
data: {
relatedCollection: 'pages',
title: 'Noone',
where: {
text: {
equals: 'example page',
},
},
access: {
read: {
constraint: 'noone',
},
},
},
})
try {
const foundPresetWithUser1 = await payload.findByID({
collection: queryPresetsCollectionSlug,
depth: 0,
user,
overrideAccess: false,
id: presetForNoone.id,
})
expect(foundPresetWithUser1).toBeFalsy()
} catch (error: unknown) {
expect((error as Error).message).toBe('Not Found')
}
})
})
it.skip('should disable query presets when "enabledQueryPresets" is not true on the collection', async () => {

View File

@@ -86,7 +86,7 @@ export interface Config {
'payload-query-presets': PayloadQueryPresetsSelect<false> | PayloadQueryPresetsSelect<true>;
};
db: {
defaultIDType: number;
defaultIDType: string;
};
globals: {};
globalsSelect: {};
@@ -122,7 +122,7 @@ export interface UserAuthOperations {
* via the `definition` "pages".
*/
export interface Page {
id: number;
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
@@ -133,7 +133,7 @@ export interface Page {
* via the `definition` "users".
*/
export interface User {
id: number;
id: string;
name?: string | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
updatedAt: string;
@@ -152,7 +152,7 @@ export interface User {
* via the `definition` "posts".
*/
export interface Post {
id: number;
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
@@ -163,24 +163,24 @@ export interface Post {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
id: string;
document?:
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null)
| ({
relationTo: 'users';
value: number | User;
value: string | User;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
updatedAt: string;
createdAt: string;
@@ -190,10 +190,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
id: string;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
key?: string | null;
value?:
@@ -213,7 +213,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -224,23 +224,23 @@ export interface PayloadMigration {
* via the `definition` "payload-query-presets".
*/
export interface PayloadQueryPreset {
id: number;
id: string;
title: string;
isShared?: boolean | null;
access?: {
read?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null;
users?: (number | User)[] | null;
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone') | null;
users?: (string | User)[] | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
};
update?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null;
users?: (number | User)[] | null;
users?: (string | User)[] | null;
roles?: ('admin' | 'user' | 'anonymous')[] | null;
};
delete?: {
constraint?: ('everyone' | 'onlyMe' | 'specificUsers') | null;
users?: (number | User)[] | null;
users?: (string | User)[] | null;
};
};
where?:

View File

@@ -167,6 +167,21 @@ export const seed = async (_payload: Payload) => {
overrideAccess: false,
data: seedData.onlyMe,
}),
() =>
_payload.create({
collection: 'payload-query-presets',
user: devUser,
overrideAccess: false,
data: {
relatedCollection: 'pages',
title: 'Noone',
access: {
read: {
constraint: 'noone',
},
},
},
}),
],
false,
)

View File

@@ -7,6 +7,7 @@ export const DraftsCollection: CollectionConfig = {
admin: {
useAsTitle: 'text',
},
orderable: true,
versions: {
drafts: true,
},

View File

@@ -82,19 +82,15 @@ describe('Sort functionality', () => {
await page.getByText('Join A').click()
await expect(page.locator('.sort-header button')).toHaveCount(2)
await page.locator('.sort-header button').nth(0).click()
await assertRows(0, 'A', 'B', 'C', 'D')
await moveRow(2, 3, 'success', 0) // move to middle
await assertRows(0, 'A', 'C', 'B', 'D')
await page.locator('.sort-header button').nth(1).click()
await assertRows(1, 'A', 'B', 'C', 'D')
await moveRow(1, 4, 'success', 1) // move to end
await assertRows(1, 'B', 'C', 'D', 'A')
await page.reload()
await page.locator('.sort-header button').nth(0).click()
await page.locator('.sort-header button').nth(1).click()
await assertRows(0, 'A', 'C', 'B', 'D')
await assertRows(1, 'B', 'C', 'D', 'A')
})

View File

@@ -4,9 +4,10 @@ import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Orderable, OrderableJoin } from './payload-types.js'
import type { Draft, Orderable, OrderableJoin } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { draftsSlug } from './collections/Drafts/index.js'
import { orderableSlug } from './collections/Orderable/index.js'
import { orderableJoinSlug } from './collections/OrderableJoin/index.js'
@@ -330,6 +331,121 @@ describe('Sort', () => {
})
})
describe('Orderable', () => {
let orderable1: Orderable
let orderable2: Orderable
let orderableDraft1: Draft
let orderableDraft2: Draft
beforeAll(async () => {
orderable1 = await payload.create({
collection: orderableSlug,
data: {
title: 'Orderable 1',
},
})
orderable2 = await payload.create({
collection: orderableSlug,
data: {
title: 'Orderable 2',
},
})
orderableDraft1 = await payload.create({
collection: draftsSlug,
data: {
text: 'Orderable 1',
_status: 'draft',
},
})
orderableDraft2 = await payload.create({
collection: draftsSlug,
data: {
text: 'Orderable 2',
_status: 'draft',
},
})
})
it('should set order by default', async () => {
const ordered = await payload.find({
collection: orderableSlug,
where: {
title: {
contains: 'Orderable ',
},
},
})
expect(orderable1._order).toBeDefined()
expect(orderable2._order).toBeDefined()
expect(parseInt(orderable1._order, 16)).toBeLessThan(parseInt(orderable2._order, 16))
expect(ordered.docs[0].id).toStrictEqual(orderable1.id)
expect(ordered.docs[1].id).toStrictEqual(orderable2.id)
})
it('should allow reordering with REST API', async () => {
const res = await restClient.POST('/reorder', {
body: JSON.stringify({
collectionSlug: orderableSlug,
docsToMove: [orderable1.id],
newKeyWillBe: 'greater',
orderableFieldName: '_order',
target: {
id: orderable2.id,
key: orderable2._order,
},
}),
})
expect(res.status).toStrictEqual(200)
const ordered = await payload.find({
collection: 'orderable',
where: {
title: {
contains: 'Orderable ',
},
},
})
expect(parseInt(ordered.docs[0]._order, 16)).toBeLessThan(
parseInt(ordered.docs[1]._order, 16),
)
})
it('should allow reordering with REST API with drafts enabled', async () => {
const res = await restClient.POST('/reorder', {
body: JSON.stringify({
collectionSlug: draftsSlug,
docsToMove: [orderableDraft1.id],
newKeyWillBe: 'greater',
orderableFieldName: '_order',
target: {
id: orderableDraft2.id,
key: orderableDraft2._order,
},
}),
})
expect(res.status).toStrictEqual(200)
const ordered = await payload.find({
collection: draftsSlug,
draft: true,
where: {
text: {
contains: 'Orderable ',
},
},
})
expect(ordered.docs).toHaveLength(2)
expect(parseInt(ordered.docs[0]._order, 16)).toBeLessThan(
parseInt(ordered.docs[1]._order, 16),
)
})
})
describe('Orderable join', () => {
let related: OrderableJoin
let orderable1: Orderable

View File

@@ -0,0 +1,32 @@
import type { CollectionConfig } from 'payload'
import { autosaveWithDraftButtonSlug } from '../slugs.js'
const AutosaveWithDraftButtonPosts: CollectionConfig = {
slug: autosaveWithDraftButtonSlug,
labels: {
singular: 'Autosave with Draft Button Post',
plural: 'Autosave with Draft Button Posts',
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'subtitle', 'createdAt', '_status'],
},
versions: {
drafts: {
autosave: {
showSaveDraftButton: true,
interval: 1000,
},
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
}
export default AutosaveWithDraftButtonPosts

View File

@@ -4,6 +4,7 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import AutosavePosts from './collections/Autosave.js'
import AutosaveWithDraftButtonPosts from './collections/AutosaveWithDraftButton.js'
import AutosaveWithValidate from './collections/AutosaveWithValidate.js'
import CustomIDs from './collections/CustomIDs.js'
import { Diff } from './collections/Diff/index.js'
@@ -17,6 +18,7 @@ import Posts from './collections/Posts.js'
import { TextCollection } from './collections/Text.js'
import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js'
import AutosaveWithDraftButtonGlobal from './globals/AutosaveWithDraftButton.js'
import DisablePublishGlobal from './globals/DisablePublish.js'
import DraftGlobal from './globals/Draft.js'
import DraftWithMaxGlobal from './globals/DraftWithMax.js'
@@ -35,6 +37,7 @@ export default buildConfigWithDefaults({
DisablePublish,
Posts,
AutosavePosts,
AutosaveWithDraftButtonPosts,
AutosaveWithValidate,
DraftPosts,
DraftWithMax,
@@ -46,7 +49,14 @@ export default buildConfigWithDefaults({
TextCollection,
Media,
],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal],
globals: [
AutosaveGlobal,
AutosaveWithDraftButtonGlobal,
DraftGlobal,
DraftWithMaxGlobal,
DisablePublishGlobal,
LocalizedGlobal,
],
indexSortableFields: true,
localization: {
defaultLocale: 'en',

View File

@@ -48,6 +48,8 @@ import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
autosaveCollectionSlug,
autoSaveGlobalSlug,
autosaveWithDraftButtonGlobal,
autosaveWithDraftButtonSlug,
autosaveWithValidateCollectionSlug,
customIDSlug,
diffCollectionSlug,
@@ -78,6 +80,7 @@ describe('Versions', () => {
let url: AdminUrlUtil
let serverURL: string
let autosaveURL: AdminUrlUtil
let autosaveWithDraftButtonURL: AdminUrlUtil
let autosaveWithValidateURL: AdminUrlUtil
let draftWithValidateURL: AdminUrlUtil
let disablePublishURL: AdminUrlUtil
@@ -116,6 +119,7 @@ describe('Versions', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, draftCollectionSlug)
autosaveURL = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
autosaveWithDraftButtonURL = new AdminUrlUtil(serverURL, autosaveWithDraftButtonSlug)
autosaveWithValidateURL = new AdminUrlUtil(serverURL, autosaveWithValidateCollectionSlug)
disablePublishURL = new AdminUrlUtil(serverURL, disablePublishSlug)
customIDURL = new AdminUrlUtil(serverURL, customIDSlug)
@@ -201,78 +205,6 @@ describe('Versions', () => {
await expect(page.locator('#field-title')).toHaveValue('v1')
})
test('should show global versions view level action in globals versions view', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(`${global.global(draftGlobalSlug)}/versions`)
await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1)
})
// TODO: Check versions/:version-id view for collections / globals
test('global — has versions tab', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(global.global(draftGlobalSlug))
const docURL = page.url()
const pathname = new URL(docURL).pathname
const versionsTab = page.locator('.doc-tab', {
hasText: 'Versions',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const href = versionsTab.locator('a').first()
await expect(href).toHaveAttribute('href', `${pathname}/versions`)
})
test('global — respects max number of versions', async () => {
await payload.updateGlobal({
slug: draftWithMaxGlobalSlug,
data: {
title: 'initial title',
},
})
const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug)
await page.goto(global.global(draftWithMaxGlobalSlug))
const titleFieldInitial = page.locator('#field-title')
await titleFieldInitial.fill('updated title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldInitial).toHaveValue('updated title')
const versionsTab = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const titleFieldUpdated = page.locator('#field-title')
await titleFieldUpdated.fill('latest title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldUpdated).toHaveValue('latest title')
const versionsTabUpdated = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTabUpdated.waitFor({ state: 'visible' })
expect(versionsTabUpdated).toBeTruthy()
})
test('global — has versions route', async () => {
const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
await page.goto(versionsURL)
await expect(() => {
expect(page.url()).toMatch(/\/versions/)
}).toPass({ timeout: 10000, intervals: [100] })
})
test('collection - should autosave', async () => {
await page.goto(autosaveURL.create)
await page.locator('#field-title').fill('autosave title')
@@ -309,6 +241,16 @@ describe('Versions', () => {
await expect(drawer.locator('.id-label')).toBeVisible()
})
test('collection - should show "save as draft" button when showSaveDraftButton is true', async () => {
await page.goto(autosaveWithDraftButtonURL.create)
await expect(page.locator('#action-save-draft')).toBeVisible()
})
test('collection - should not show "save as draft" button when showSaveDraftButton is false', async () => {
await page.goto(autosaveURL.create)
await expect(page.locator('#action-save-draft')).toBeHidden()
})
test('collection - autosave - should not create duplicates when clicking Create new', async () => {
// This test checks that when we click "Create new" in the list view, it only creates 1 extra document and not more
const { totalDocs: initialDocsCount } = await payload.find({
@@ -402,17 +344,6 @@ describe('Versions', () => {
await expect(newUpdatedAt).not.toHaveText(initialUpdatedAt)
})
test('global - should autosave', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
await page.goto(url.global(autoSaveGlobalSlug))
const titleField = page.locator('#field-title')
await titleField.fill('global title')
await waitForAutoSaveToRunAndComplete(page)
await expect(titleField).toHaveValue('global title')
await page.goto(url.global(autoSaveGlobalSlug))
await expect(page.locator('#field-title')).toHaveValue('global title')
})
test('should retain localized data during autosave', async () => {
const en = 'en'
const es = 'es'
@@ -519,12 +450,6 @@ describe('Versions', () => {
await expect(page.locator('#field-title')).toHaveValue('title')
})
test('globals — should hide publish button when access control prevents update', async () => {
const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug)
await page.goto(url.global(disablePublishGlobalSlug))
await expect(page.locator('#action-save')).not.toBeAttached()
})
test('collections — should hide publish button when access control prevents create', async () => {
await page.goto(disablePublishURL.create)
await expect(page.locator('#action-save')).not.toBeAttached()
@@ -652,6 +577,107 @@ describe('Versions', () => {
})
})
describe('draft globals', () => {
test('should show global versions view level action in globals versions view', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(`${global.global(draftGlobalSlug)}/versions`)
await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1)
})
test('global — has versions tab', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(global.global(draftGlobalSlug))
const docURL = page.url()
const pathname = new URL(docURL).pathname
const versionsTab = page.locator('.doc-tab', {
hasText: 'Versions',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const href = versionsTab.locator('a').first()
await expect(href).toHaveAttribute('href', `${pathname}/versions`)
})
test('global — respects max number of versions', async () => {
await payload.updateGlobal({
slug: draftWithMaxGlobalSlug,
data: {
title: 'initial title',
},
})
const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug)
await page.goto(global.global(draftWithMaxGlobalSlug))
const titleFieldInitial = page.locator('#field-title')
await titleFieldInitial.fill('updated title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldInitial).toHaveValue('updated title')
const versionsTab = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const titleFieldUpdated = page.locator('#field-title')
await titleFieldUpdated.fill('latest title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldUpdated).toHaveValue('latest title')
const versionsTabUpdated = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTabUpdated.waitFor({ state: 'visible' })
expect(versionsTabUpdated).toBeTruthy()
})
test('global — has versions route', async () => {
const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
await page.goto(versionsURL)
await expect(() => {
expect(page.url()).toMatch(/\/versions/)
}).toPass({ timeout: 10000, intervals: [100] })
})
test('global - should show "save as draft" button when showSaveDraftButton is true', async () => {
const url = new AdminUrlUtil(serverURL, autosaveWithDraftButtonGlobal)
await page.goto(url.global(autosaveWithDraftButtonGlobal))
await expect(page.locator('#action-save-draft')).toBeVisible()
})
test('global - should not show "save as draft" button when showSaveDraftButton is false', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
await page.goto(url.global(autoSaveGlobalSlug))
await expect(page.locator('#action-save-draft')).toBeHidden()
})
test('global - should autosave', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
await page.goto(url.global(autoSaveGlobalSlug))
const titleField = page.locator('#field-title')
await titleField.fill('global title')
await waitForAutoSaveToRunAndComplete(page)
await expect(titleField).toHaveValue('global title')
await page.goto(url.global(autoSaveGlobalSlug))
await expect(page.locator('#field-title')).toHaveValue('global title')
})
test('globals — should hide publish button when access control prevents update', async () => {
const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug)
await page.goto(url.global(disablePublishGlobalSlug))
await expect(page.locator('#action-save')).not.toBeAttached()
})
})
describe('Scheduled publish', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, draftCollectionSlug)

View File

@@ -0,0 +1,26 @@
import type { GlobalConfig } from 'payload'
import { autosaveWithDraftButtonGlobal } from '../slugs.js'
const AutosaveWithDraftButtonGlobal: GlobalConfig = {
slug: autosaveWithDraftButtonGlobal,
fields: [
{
name: 'title',
type: 'text',
localized: true,
required: true,
},
],
label: 'Autosave with Draft Button Global',
versions: {
drafts: {
autosave: {
showSaveDraftButton: true,
interval: 1000,
},
},
},
}
export default AutosaveWithDraftButtonGlobal

View File

@@ -70,6 +70,7 @@ export interface Config {
'disable-publish': DisablePublish;
posts: Post;
'autosave-posts': AutosavePost;
'autosave-with-draft-button-posts': AutosaveWithDraftButtonPost;
'autosave-with-validate-posts': AutosaveWithValidatePost;
'draft-posts': DraftPost;
'draft-with-max-posts': DraftWithMaxPost;
@@ -91,6 +92,7 @@ export interface Config {
'disable-publish': DisablePublishSelect<false> | DisablePublishSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'autosave-posts': AutosavePostsSelect<false> | AutosavePostsSelect<true>;
'autosave-with-draft-button-posts': AutosaveWithDraftButtonPostsSelect<false> | AutosaveWithDraftButtonPostsSelect<true>;
'autosave-with-validate-posts': AutosaveWithValidatePostsSelect<false> | AutosaveWithValidatePostsSelect<true>;
'draft-posts': DraftPostsSelect<false> | DraftPostsSelect<true>;
'draft-with-max-posts': DraftWithMaxPostsSelect<false> | DraftWithMaxPostsSelect<true>;
@@ -112,6 +114,7 @@ export interface Config {
};
globals: {
'autosave-global': AutosaveGlobal;
'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobal;
'draft-global': DraftGlobal;
'draft-with-max-global': DraftWithMaxGlobal;
'disable-publish-global': DisablePublishGlobal;
@@ -119,6 +122,7 @@ export interface Config {
};
globalsSelect: {
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobalSelect<false> | AutosaveWithDraftButtonGlobalSelect<true>;
'draft-global': DraftGlobalSelect<false> | DraftGlobalSelect<true>;
'draft-with-max-global': DraftWithMaxGlobalSelect<false> | DraftWithMaxGlobalSelect<true>;
'disable-publish-global': DisablePublishGlobalSelect<false> | DisablePublishGlobalSelect<true>;
@@ -228,6 +232,17 @@ export interface DraftPost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-posts".
*/
export interface AutosaveWithDraftButtonPost {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-validate-posts".
@@ -554,6 +569,10 @@ export interface PayloadLockedDocument {
relationTo: 'autosave-posts';
value: string | AutosavePost;
} | null)
| ({
relationTo: 'autosave-with-draft-button-posts';
value: string | AutosaveWithDraftButtonPost;
} | null)
| ({
relationTo: 'autosave-with-validate-posts';
value: string | AutosaveWithValidatePost;
@@ -676,6 +695,16 @@ export interface AutosavePostsSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-posts_select".
*/
export interface AutosaveWithDraftButtonPostsSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-validate-posts_select".
@@ -973,6 +1002,17 @@ export interface AutosaveGlobal {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-global".
*/
export interface AutosaveWithDraftButtonGlobal {
id: string;
title: string;
_status?: ('draft' | 'published') | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-global".
@@ -1029,6 +1069,17 @@ export interface AutosaveGlobalSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-global_select".
*/
export interface AutosaveWithDraftButtonGlobalSelect<T extends boolean = true> {
title?: T;
_status?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-global_select".
@@ -1082,10 +1133,15 @@ export interface TaskSchedulePublish {
input: {
type?: ('publish' | 'unpublish') | null;
locale?: string | null;
doc?: {
relationTo: 'draft-posts';
value: string | DraftPost;
} | null;
doc?:
| ({
relationTo: 'autosave-posts';
value: string | AutosavePost;
} | null)
| ({
relationTo: 'draft-posts';
value: string | DraftPost;
} | null);
global?: 'draft-global' | null;
user?: (string | null) | User;
};

View File

@@ -1,5 +1,7 @@
export const autosaveCollectionSlug = 'autosave-posts'
export const autosaveWithDraftButtonSlug = 'autosave-with-draft-button-posts'
export const autosaveWithValidateCollectionSlug = 'autosave-with-validate-posts'
export const customIDSlug = 'custom-ids'
@@ -33,7 +35,11 @@ export const collectionSlugs = [
]
export const autoSaveGlobalSlug = 'autosave-global'
export const autosaveWithDraftButtonGlobal = 'autosave-with-draft-button-global'
export const draftGlobalSlug = 'draft-global'
export const draftWithMaxGlobalSlug = 'draft-with-max-global'
export const globalSlugs = [autoSaveGlobalSlug, draftGlobalSlug]