diff --git a/docs/custom-components/edit-view.mdx b/docs/custom-components/edit-view.mdx
index 4f26004a86..03043e2daf 100644
--- a/docs/custom-components/edit-view.mdx
+++ b/docs/custom-components/edit-view.mdx
@@ -101,14 +101,15 @@ export const MyCollection: CollectionConfig = {
The following options are available:
-| Path | Description |
-| ----------------- | -------------------------------------------------------------------------------------- |
-| `SaveButton` | A button that saves the current document. [More details](#savebutton). |
-| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). |
-| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). |
-| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). |
-| `Description` | A description of the Collection. [More details](#description). |
-| `Upload` | A file upload component. [More details](#upload). |
+| Path | Description |
+| ------------------------ | ---------------------------------------------------------------------------------------------------- |
+| `beforeDocumentControls` | Inject custom components before the Save / Publish buttons. [More details](#beforedocumentcontrols). |
+| `SaveButton` | A button that saves the current document. [More details](#savebutton). |
+| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). |
+| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). |
+| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). |
+| `Description` | A description of the Collection. [More details](#description). |
+| `Upload` | A file upload component. [More details](#upload). |
#### Globals
@@ -133,13 +134,14 @@ export const MyGlobal: GlobalConfig = {
The following options are available:
-| Path | Description |
-| ----------------- | -------------------------------------------------------------------------------------- |
-| `SaveButton` | A button that saves the current document. [More details](#savebutton). |
-| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). |
-| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). |
-| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). |
-| `Description` | A description of the Global. [More details](#description). |
+| Path | Description |
+| ------------------------ | ---------------------------------------------------------------------------------------------------- |
+| `beforeDocumentControls` | Inject custom components before the Save / Publish buttons. [More details](#beforedocumentcontrols). |
+| `SaveButton` | A button that saves the current document. [More details](#savebutton). |
+| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). |
+| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). |
+| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). |
+| `Description` | A description of the Global. [More details](#description). |
### SaveButton
@@ -191,6 +193,73 @@ export function MySaveButton(props: SaveButtonClientProps) {
}
```
+### beforeDocumentControls
+
+The `beforeDocumentControls` property allows you to render custom components just before the default document action buttons (like Save, Publish, or Preview). This is useful for injecting custom buttons, status indicators, or any other UI elements before the built-in controls.
+
+To add `beforeDocumentControls` components, use the `components.edit.beforeDocumentControls` property in you [Collection Config](../configuration/collections) or `components.elements.beforeDocumentControls` in your [Global Config](../configuration/globals):
+
+#### Collections
+
+```
+export const MyCollection: CollectionConfig = {
+ admin: {
+ components: {
+ edit: {
+ // highlight-start
+ beforeDocumentControls: ['/path/to/CustomComponent'],
+ // highlight-end
+ },
+ },
+ },
+}
+```
+
+#### Globals
+
+```
+export const MyGlobal: GlobalConfig = {
+ admin: {
+ components: {
+ elements: {
+ // highlight-start
+ beforeDocumentControls: ['/path/to/CustomComponent'],
+ // highlight-end
+ },
+ },
+ },
+}
+```
+
+Here's an example of a custom `beforeDocumentControls` component:
+
+#### Server Component
+
+```tsx
+import React from 'react'
+import type { BeforeDocumentControlsServerProps } from 'payload'
+
+export function MyCustomDocumentControlButton(
+ props: BeforeDocumentControlsServerProps,
+) {
+ return
This is a custom beforeDocumentControl button (Server)
+}
+```
+
+#### Client Component
+
+```tsx
+'use client'
+import React from 'react'
+import type { BeforeDocumentControlsClientProps } from 'payload'
+
+export function MyCustomDocumentControlButton(
+ props: BeforeDocumentControlsClientProps,
+) {
+ return This is a custom beforeDocumentControl button (Client)
+}
+```
+
### SaveDraftButton
The `SaveDraftButton` property allows you to render a custom Save Draft Button in the Edit View.
diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx
index 143abd58f4..aa1b860c2c 100644
--- a/docs/fields/blocks.mdx
+++ b/docs/fields/blocks.mdx
@@ -352,18 +352,20 @@ const config = buildConfig({
},
],
},
- {
+ {
slug: 'collection2',
fields: [
{
name: 'editor',
type: 'richText',
editor: lexicalEditor({
- BlocksFeature({
- // Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit
- blocks: ['TextBlock'],
- })
- })
+ features: [
+ BlocksFeature({
+ // Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit
+ blocks: ['TextBlock'],
+ }),
+ ],
+ }),
},
],
},
diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx
index 5b8b0bf463..17b46884c5 100644
--- a/docs/fields/overview.mdx
+++ b/docs/fields/overview.mdx
@@ -541,6 +541,7 @@ The `ctx` object:
| Property | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`blockData`** | The nearest parent block's data. If the field is not inside a block, this will be `undefined`. |
+| **`operation`** | A string relating to which operation the field type is currently executing within. |
| **`path`** | The full path to the field in the schema, represented as an array of string segments, including array indexes. I.e `['group', 'myArray', '1', 'textField']`. |
| **`user`** | The currently authenticated user object. |
diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx
index 201a7993f7..e6c4faf81d 100644
--- a/docs/fields/relationship.mdx
+++ b/docs/fields/relationship.mdx
@@ -94,6 +94,7 @@ The Relationship Field inherits all of the default options from the base [Field
| **`allowCreate`** | Set to `false` if you'd like to disable the ability to create new documents from within the relationship field. |
| **`allowEdit`** | Set to `false` if you'd like to disable the ability to edit documents from within the relationship field. |
| **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More](#sort-options) |
+| **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. |
### Sort Options
diff --git a/docs/versions/autosave.mdx b/docs/versions/autosave.mdx
index 077c473259..5d1b9d6a73 100644
--- a/docs/versions/autosave.mdx
+++ b/docs/versions/autosave.mdx
@@ -22,6 +22,7 @@ Collections and Globals both support the same options for configuring autosave.
| Drafts Autosave Options | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interval` | Define an `interval` in milliseconds to automatically save progress while documents are edited. Document updates are "debounced" at this interval. Defaults to `800`. |
+| `showSaveDraftButton` | Set this to `true` to show the "Save as draft" button even while autosave is enabled. Defaults to `false`. |
**Example config with versions, drafts, and autosave enabled:**
@@ -50,9 +51,13 @@ export const Pages: CollectionConfig = {
drafts: {
autosave: true,
- // Alternatively, you can specify an `interval`:
+ // Alternatively, you can specify an object to customize autosave:
// autosave: {
+ // Define how often the document should be autosaved (in milliseconds)
// interval: 1500,
+ //
+ // Show the "Save as draft" button even while autosave is enabled
+ // showSaveDraftButton: true,
// },
},
},
diff --git a/package.json b/package.json
index 3fe296d29a..026c32f976 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
- "version": "3.34.0",
+ "version": "3.35.1",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json
index 48dd1f5aff..6292ace878 100644
--- a/packages/admin-bar/package.json
+++ b/packages/admin-bar/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json
index e5778f0ee6..d0a1244a45 100644
--- a/packages/create-payload-app/package.json
+++ b/packages/create-payload-app/package.json
@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
- "version": "3.34.0",
+ "version": "3.35.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json
index 999c46e82f..b433605a93 100644
--- a/packages/db-mongodb/package.json
+++ b/packages/db-mongodb/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json
index d6deaef12f..b6017ea73c 100644
--- a/packages/db-postgres/package.json
+++ b/packages/db-postgres/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/db-postgres/src/connect.ts b/packages/db-postgres/src/connect.ts
index 4a323558a8..f7aabfc0e1 100644
--- a/packages/db-postgres/src/connect.ts
+++ b/packages/db-postgres/src/connect.ts
@@ -3,7 +3,6 @@ import type { Connect, Migration, Payload } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle'
import { drizzle } from 'drizzle-orm/node-postgres'
-import pg from 'pg'
import type { PostgresAdapter } from './types.js'
@@ -61,7 +60,7 @@ export const connect: Connect = async function connect(
try {
if (!this.pool) {
- this.pool = new pg.Pool(this.poolOptions)
+ this.pool = new this.pg.Pool(this.poolOptions)
await connectWithReconnect({ adapter: this, payload: this.payload })
}
diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts
index 41258c590b..8d9597c510 100644
--- a/packages/db-postgres/src/index.ts
+++ b/packages/db-postgres/src/index.ts
@@ -54,6 +54,7 @@ import {
} from '@payloadcms/drizzle/postgres'
import { pgEnum, pgSchema, pgTable } from 'drizzle-orm/pg-core'
import { createDatabaseAdapter, defaultBeginTransaction } from 'payload'
+import pgDependency from 'pg'
import { fileURLToPath } from 'url'
import type { Args, PostgresAdapter } from './types.js'
@@ -130,6 +131,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj
localesSuffix: args.localesSuffix || '_locales',
logger: args.logger,
operators: operatorMap,
+ pg: args.pg || pgDependency,
pgSchema: adapterSchema,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
pool: undefined,
diff --git a/packages/db-postgres/src/types.ts b/packages/db-postgres/src/types.ts
index 2eb8082a63..8d19181cf2 100644
--- a/packages/db-postgres/src/types.ts
+++ b/packages/db-postgres/src/types.ts
@@ -12,6 +12,8 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type { PgSchema, PgTableFn, PgTransactionConfig } from 'drizzle-orm/pg-core'
import type { Pool, PoolConfig } from 'pg'
+type PgDependency = typeof import('pg')
+
export type Args = {
/**
* Transform the schema after it's built.
@@ -45,6 +47,7 @@ export type Args = {
localesSuffix?: string
logger?: DrizzleConfig['logger']
migrationDir?: string
+ pg?: PgDependency
pool: PoolConfig
prodMigrations?: {
down: (args: MigrateDownArgs) => Promise
@@ -74,6 +77,7 @@ type ResolveSchemaType = 'schema' extends keyof T
type Drizzle = NodePgDatabase>
export type PostgresAdapter = {
drizzle: Drizzle
+ pg: PgDependency
pool: Pool
poolOptions: PoolConfig
} & BasePostgresAdapter
@@ -98,6 +102,8 @@ declare module 'payload' {
initializing: Promise
localesSuffix?: string
logger: DrizzleConfig['logger']
+ /** Optionally inject your own node-postgres. This is required if you wish to instrument the driver with @payloadcms/plugin-sentry. */
+ pg?: PgDependency
pgSchema?: { table: PgTableFn } | PgSchema
pool: Pool
poolOptions: Args['pool']
diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json
index f1323ac0d2..12390b6c5c 100644
--- a/packages/db-sqlite/package.json
+++ b/packages/db-sqlite/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json
index 54409e27db..f33b6c80b6 100644
--- a/packages/db-vercel-postgres/package.json
+++ b/packages/db-vercel-postgres/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json
index 98dcd0b753..866f2477b2 100644
--- a/packages/drizzle/package.json
+++ b/packages/drizzle/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/drizzle/src/createTableName.ts b/packages/drizzle/src/createTableName.ts
index bdd2ff2326..c52f5daa94 100644
--- a/packages/drizzle/src/createTableName.ts
+++ b/packages/drizzle/src/createTableName.ts
@@ -78,7 +78,9 @@ export const createTableName = ({
if (result.length > 63) {
throw new APIError(
- `Exceeded max identifier length for table or enum name of 63 characters. Invalid name: ${result}`,
+ `Exceeded max identifier length for table or enum name of 63 characters. Invalid name: ${result}.
+Tip: You can use the dbName property to reduce the table name length.
+ `,
)
}
diff --git a/packages/drizzle/src/transform/write/traverseFields.ts b/packages/drizzle/src/transform/write/traverseFields.ts
index 7fa8430cc6..57bfc1f65e 100644
--- a/packages/drizzle/src/transform/write/traverseFields.ts
+++ b/packages/drizzle/src/transform/write/traverseFields.ts
@@ -496,6 +496,10 @@ export const traverseFields = ({
formattedValue = sql`ST_GeomFromGeoJSON(${JSON.stringify(value)})`
}
+ if (field.type === 'text' && value && typeof value !== 'string') {
+ formattedValue = JSON.stringify(value)
+ }
+
if (field.type === 'date') {
if (typeof value === 'number' && !Number.isNaN(value)) {
formattedValue = new Date(value).toISOString()
diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json
index b4c8e3173a..1cdc4983a8 100644
--- a/packages/email-nodemailer/package.json
+++ b/packages/email-nodemailer/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json
index b4b12efefb..338b35c5af 100644
--- a/packages/email-resend/package.json
+++ b/packages/email-resend/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index fb1d132baa..db700269b7 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
- "version": "3.34.0",
+ "version": "3.35.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json
index 52539c7b84..5ae4fd75a6 100644
--- a/packages/live-preview-react/package.json
+++ b/packages/live-preview-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json
index 4553c78e9d..c6d2c35823 100644
--- a/packages/live-preview-vue/package.json
+++ b/packages/live-preview-vue/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json
index 5092769da7..338ffa0c2e 100644
--- a/packages/live-preview/package.json
+++ b/packages/live-preview/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/next/package.json b/packages/next/package.json
index b93b208a9a..f4ae1af419 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
- "version": "3.34.0",
+ "version": "3.35.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -156,6 +156,11 @@
"types": "./dist/exports/templates.d.ts",
"default": "./dist/exports/templates.js"
},
+ "./auth": {
+ "import": "./dist/exports/auth.js",
+ "types": "./dist/exports/auth.d.ts",
+ "default": "./dist/exports/auth.js"
+ },
"./utilities": {
"import": "./dist/exports/utilities.js",
"types": "./dist/exports/utilities.d.ts",
diff --git a/packages/next/src/views/Document/renderDocumentSlots.tsx b/packages/next/src/views/Document/renderDocumentSlots.tsx
index dde74e5e26..dee93e9410 100644
--- a/packages/next/src/views/Document/renderDocumentSlots.tsx
+++ b/packages/next/src/views/Document/renderDocumentSlots.tsx
@@ -1,4 +1,5 @@
import type {
+ BeforeDocumentControlsServerPropsOnly,
DefaultServerFunctionArgs,
DocumentSlots,
PayloadRequest,
@@ -42,6 +43,18 @@ export const renderDocumentSlots: (args: {
// TODO: Add remaining serverProps
}
+ const BeforeDocumentControls =
+ collectionConfig?.admin?.components?.edit?.beforeDocumentControls ||
+ globalConfig?.admin?.components?.elements?.beforeDocumentControls
+
+ if (BeforeDocumentControls) {
+ components.BeforeDocumentControls = RenderServerComponent({
+ Component: BeforeDocumentControls,
+ importMap: req.payload.importMap,
+ serverProps: serverProps satisfies BeforeDocumentControlsServerPropsOnly,
+ })
+ }
+
const CustomPreviewButton =
collectionConfig?.admin?.components?.edit?.PreviewButton ||
globalConfig?.admin?.components?.elements?.PreviewButton
diff --git a/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx b/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx
index 19fef263af..148f967964 100644
--- a/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx
+++ b/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx
@@ -31,7 +31,6 @@ export const ToolbarControls: React.FC = () => {
{breakpoints.find((bp) => bp.name == breakpoint)?.label ?? customOption.label}
-
}
@@ -82,7 +81,6 @@ export const ToolbarControls: React.FC = () => {
button={
{zoom * 100}%
-
}
diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json
index 81ca737a5b..bbaf0361da 100644
--- a/packages/payload-cloud/package.json
+++ b/packages/payload-cloud/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/payload/package.json b/packages/payload/package.json
index a6f547c16e..e294512b71 100644
--- a/packages/payload/package.json
+++ b/packages/payload/package.json
@@ -1,6 +1,6 @@
{
"name": "payload",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts
index 2a0d469c4b..28eb11330d 100644
--- a/packages/payload/src/admin/types.ts
+++ b/packages/payload/src/admin/types.ts
@@ -553,6 +553,7 @@ export type FieldRow = {
}
export type DocumentSlots = {
+ BeforeDocumentControls?: React.ReactNode
Description?: React.ReactNode
PreviewButton?: React.ReactNode
PublishButton?: React.ReactNode
@@ -579,6 +580,9 @@ export type { LanguageOptions } from './LanguageOptions.js'
export type { RichTextAdapter, RichTextAdapterProvider, RichTextHooks } from './RichText.js'
export type {
+ BeforeDocumentControlsClientProps,
+ BeforeDocumentControlsServerProps,
+ BeforeDocumentControlsServerPropsOnly,
DocumentSubViewTypes,
DocumentTabClientProps,
/**
diff --git a/packages/payload/src/admin/views/document.ts b/packages/payload/src/admin/views/document.ts
index db0b2ee2db..7e8d14f145 100644
--- a/packages/payload/src/admin/views/document.ts
+++ b/packages/payload/src/admin/views/document.ts
@@ -36,12 +36,12 @@ export type DocumentTabServerPropsOnly = {
readonly permissions: SanitizedPermissions
} & ServerProps
-export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerPropsOnly
-
export type DocumentTabClientProps = {
path: string
}
+export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerPropsOnly
+
export type DocumentTabCondition = (args: {
collectionConfig: SanitizedCollectionConfig
config: SanitizedConfig
@@ -75,3 +75,10 @@ export type DocumentTabConfig = {
export type DocumentTabComponent = PayloadComponent<{
path: string
}>
+
+// BeforeDocumentControls
+
+export type BeforeDocumentControlsClientProps = {}
+export type BeforeDocumentControlsServerPropsOnly = {} & ServerProps
+export type BeforeDocumentControlsServerProps = BeforeDocumentControlsClientProps &
+ BeforeDocumentControlsServerPropsOnly
diff --git a/packages/payload/src/bin/generateImportMap/iterateCollections.ts b/packages/payload/src/bin/generateImportMap/iterateCollections.ts
index 9aa7783576..27134fc86f 100644
--- a/packages/payload/src/bin/generateImportMap/iterateCollections.ts
+++ b/packages/payload/src/bin/generateImportMap/iterateCollections.ts
@@ -36,6 +36,7 @@ export function iterateCollections({
addToImportMap(collection.admin?.components?.beforeListTable)
addToImportMap(collection.admin?.components?.Description)
+ addToImportMap(collection.admin?.components?.edit?.beforeDocumentControls)
addToImportMap(collection.admin?.components?.edit?.PreviewButton)
addToImportMap(collection.admin?.components?.edit?.PublishButton)
addToImportMap(collection.admin?.components?.edit?.SaveButton)
diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts
index afb8a0686c..f8bbfc1ce5 100644
--- a/packages/payload/src/collections/config/types.ts
+++ b/packages/payload/src/collections/config/types.ts
@@ -284,6 +284,10 @@ export type CollectionAdminOptions = {
* Components within the edit view
*/
edit?: {
+ /**
+ * Inject custom components before the document controls
+ */
+ beforeDocumentControls?: CustomComponent[]
/**
* Replaces the "Preview" button
*/
diff --git a/packages/payload/src/collections/config/useAsTitle.ts b/packages/payload/src/collections/config/useAsTitle.ts
index a57aa32e17..c4633f94f7 100644
--- a/packages/payload/src/collections/config/useAsTitle.ts
+++ b/packages/payload/src/collections/config/useAsTitle.ts
@@ -33,9 +33,9 @@ export const validateUseAsTitle = (config: CollectionConfig) => {
}
}
} else {
- if (useAsTitleField && fieldIsVirtual(useAsTitleField)) {
+ if (useAsTitleField && 'virtual' in useAsTitleField && useAsTitleField.virtual === true) {
throw new InvalidConfiguration(
- `The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" in the collection "${config.slug}" is virtual. A virtual field cannot be used as the title.`,
+ `The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" in the collection "${config.slug}" is virtual. A virtual field can be used as the title only when linked to a relationship field.`,
)
}
if (!useAsTitleField) {
diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts
index 48810c7285..f0c9117f00 100644
--- a/packages/payload/src/collections/operations/find.ts
+++ b/packages/payload/src/collections/operations/find.ts
@@ -28,6 +28,7 @@ import { buildVersionCollectionFields } from '../../versions/buildCollectionFiel
import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js'
import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js'
import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort.js'
+import { sanitizeSortQuery } from './utilities/sanitizeSortQuery.js'
import { buildAfterOperation } from './utils.js'
export type Arguments = {
@@ -96,7 +97,7 @@ export const findOperation = async <
req,
select: incomingSelect,
showHiddenFields,
- sort,
+ sort: incomingSort,
where,
} = args
@@ -143,6 +144,11 @@ export const findOperation = async <
let fullWhere = combineQueries(where, accessResult)
+ const sort = sanitizeSortQuery({
+ fields: collection.config.flattenedFields,
+ sort: incomingSort,
+ })
+
const sanitizedJoins = await sanitizeJoinQuery({
collectionConfig,
joins,
@@ -170,7 +176,10 @@ export const findOperation = async <
pagination: usePagination,
req,
select: getQueryDraftsSelect({ select }),
- sort: getQueryDraftsSort({ collectionConfig, sort }),
+ sort: getQueryDraftsSort({
+ collectionConfig,
+ sort,
+ }),
where: fullWhere,
})
} else {
diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts
index f64ca7f47d..ab2e2308fa 100644
--- a/packages/payload/src/collections/operations/update.ts
+++ b/packages/payload/src/collections/operations/update.ts
@@ -27,6 +27,7 @@ import { sanitizeSelect } from '../../utilities/sanitizeSelect.js'
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js'
import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort.js'
+import { sanitizeSortQuery } from './utilities/sanitizeSortQuery.js'
import { updateDocument } from './utilities/update.js'
import { buildAfterOperation } from './utils.js'
@@ -103,7 +104,7 @@ export const updateOperation = async <
req,
select: incomingSelect,
showHiddenFields,
- sort,
+ sort: incomingSort,
where,
} = args
@@ -136,6 +137,11 @@ export const updateOperation = async <
const fullWhere = combineQueries(where, accessResult)
+ const sort = sanitizeSortQuery({
+ fields: collection.config.flattenedFields,
+ sort: incomingSort,
+ })
+
let docs
if (collectionConfig.versions?.drafts && shouldSaveDraft) {
diff --git a/packages/payload/src/collections/operations/utilities/sanitizeSortQuery.ts b/packages/payload/src/collections/operations/utilities/sanitizeSortQuery.ts
new file mode 100644
index 0000000000..692d13d318
--- /dev/null
+++ b/packages/payload/src/collections/operations/utilities/sanitizeSortQuery.ts
@@ -0,0 +1,51 @@
+import type { FlattenedField } from '../../../fields/config/types.js'
+
+const sanitizeSort = ({ fields, sort }: { fields: FlattenedField[]; sort: string }): string => {
+ let sortProperty = sort
+ let desc = false
+ if (sort.indexOf('-') === 0) {
+ desc = true
+ sortProperty = sortProperty.substring(1)
+ }
+
+ const segments = sortProperty.split('.')
+
+ for (const segment of segments) {
+ const field = fields.find((each) => each.name === segment)
+ if (!field) {
+ return sort
+ }
+
+ if ('fields' in field) {
+ fields = field.flattenedFields
+ continue
+ }
+
+ if ('virtual' in field && typeof field.virtual === 'string') {
+ return `${desc ? '-' : ''}${field.virtual}`
+ }
+ }
+
+ return sort
+}
+
+/**
+ * Sanitizes the sort parameter, for example virtual fields linked to relationships are replaced with the full path.
+ */
+export const sanitizeSortQuery = ({
+ fields,
+ sort,
+}: {
+ fields: FlattenedField[]
+ sort?: string | string[]
+}): string | string[] | undefined => {
+ if (!sort) {
+ return undefined
+ }
+
+ if (Array.isArray(sort)) {
+ return sort.map((sort) => sanitizeSort({ fields, sort }))
+ }
+
+ return sanitizeSort({ fields, sort })
+}
diff --git a/packages/payload/src/config/orderable/index.ts b/packages/payload/src/config/orderable/index.ts
index be3cdc53c1..c09de82a12 100644
--- a/packages/payload/src/config/orderable/index.ts
+++ b/packages/payload/src/config/orderable/index.ts
@@ -257,7 +257,6 @@ export const addOrderableEndpoint = (config: SanitizedConfig) => {
},
depth: 0,
req,
- select: { id: true },
})
}
diff --git a/packages/payload/src/database/queryValidation/validateQueryPaths.ts b/packages/payload/src/database/queryValidation/validateQueryPaths.ts
index 563b90b051..c6af5ccf23 100644
--- a/packages/payload/src/database/queryValidation/validateQueryPaths.ts
+++ b/packages/payload/src/database/queryValidation/validateQueryPaths.ts
@@ -28,22 +28,6 @@ type Args = {
}
)
-const flattenWhere = (query: Where): WhereField[] => {
- const flattenedConstraints: WhereField[] = []
-
- for (const [key, val] of Object.entries(query)) {
- if ((key === 'and' || key === 'or') && Array.isArray(val)) {
- for (const subVal of val) {
- flattenedConstraints.push(...flattenWhere(subVal))
- }
- } else {
- flattenedConstraints.push({ [key]: val })
- }
- }
-
- return flattenedConstraints
-}
-
export async function validateQueryPaths({
collectionConfig,
errors = [],
@@ -61,17 +45,47 @@ export async function validateQueryPaths({
const fields = versionFields || (globalConfig || collectionConfig).flattenedFields
if (typeof where === 'object') {
- const whereFields = flattenWhere(where)
// We need to determine if the whereKey is an AND, OR, or a schema path
const promises = []
- for (const constraint of whereFields) {
- for (const path in constraint) {
- for (const operator in constraint[path]) {
- const val = constraint[path][operator]
+ for (const path in where) {
+ const constraint = where[path]
+
+ if ((path === 'and' || path === 'or') && Array.isArray(constraint)) {
+ for (const item of constraint) {
+ if (collectionConfig) {
+ promises.push(
+ validateQueryPaths({
+ collectionConfig,
+ errors,
+ overrideAccess,
+ policies,
+ req,
+ versionFields,
+ where: item,
+ }),
+ )
+ } else {
+ promises.push(
+ validateQueryPaths({
+ errors,
+ globalConfig,
+ overrideAccess,
+ policies,
+ req,
+ versionFields,
+ where: item,
+ }),
+ )
+ }
+ }
+ } else if (!Array.isArray(constraint)) {
+ for (const operator in constraint) {
+ const val = constraint[operator]
if (validOperatorSet.has(operator as Operator)) {
promises.push(
validateSearchParam({
collectionConfig,
+ constraint: where as WhereField,
errors,
fields,
globalConfig,
diff --git a/packages/payload/src/database/queryValidation/validateSearchParams.ts b/packages/payload/src/database/queryValidation/validateSearchParams.ts
index 9ec7fde56d..125df0336a 100644
--- a/packages/payload/src/database/queryValidation/validateSearchParams.ts
+++ b/packages/payload/src/database/queryValidation/validateSearchParams.ts
@@ -2,17 +2,19 @@
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { FlattenedField } from '../../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
-import type { PayloadRequest } from '../../types/index.js'
+import type { PayloadRequest, WhereField } from '../../types/index.js'
import type { EntityPolicies, PathToQuery } from './types.js'
import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js'
import { getEntityPolicies } from '../../utilities/getEntityPolicies.js'
+import { getFieldByPath } from '../../utilities/getFieldByPath.js'
import isolateObjectProperty from '../../utilities/isolateObjectProperty.js'
import { getLocalizedPaths } from '../getLocalizedPaths.js'
import { validateQueryPaths } from './validateQueryPaths.js'
type Args = {
collectionConfig?: SanitizedCollectionConfig
+ constraint: WhereField
errors: { path: string }[]
fields: FlattenedField[]
globalConfig?: SanitizedGlobalConfig
@@ -32,6 +34,7 @@ type Args = {
*/
export async function validateSearchParam({
collectionConfig,
+ constraint,
errors,
fields,
globalConfig,
@@ -100,8 +103,13 @@ export async function validateSearchParam({
return
}
- if (fieldIsVirtual(field)) {
- errors.push({ path })
+ if ('virtual' in field && field.virtual) {
+ if (field.virtual === true) {
+ errors.push({ path })
+ } else {
+ constraint[`${field.virtual}`] = constraint[path]
+ delete constraint[path]
+ }
}
if (polymorphicJoin && path === 'relationTo') {
diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts
index 442980b6bf..3050e6250c 100644
--- a/packages/payload/src/fields/config/types.ts
+++ b/packages/payload/src/fields/config/types.ts
@@ -269,6 +269,7 @@ export type Condition = (
siblingData: Partial,
{
blockData,
+ operation,
path,
user,
}: {
@@ -276,6 +277,10 @@ export type Condition = (
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: Partial
+ /**
+ * A string relating to which operation the field type is currently executing within.
+ */
+ operation: Operation
/**
* The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas.
*/
@@ -509,9 +514,9 @@ export interface FieldBase {
/**
* Pass `true` to disable field in the DB
* for [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges):
- * A virtual field cannot be used in `admin.useAsTitle`
+ * A virtual field can be used in `admin.useAsTitle` only when linked to a relationship.
*/
- virtual?: boolean
+ virtual?: boolean | string
}
export interface FieldBaseClient {
@@ -1143,6 +1148,7 @@ type SharedRelationshipPropertiesClient = FieldBaseClient &
type RelationshipAdmin = {
allowCreate?: boolean
allowEdit?: boolean
+ appearance?: 'drawer' | 'select'
components?: {
afterInput?: CustomComponent[]
beforeInput?: CustomComponent[]
@@ -1157,7 +1163,7 @@ type RelationshipAdmin = {
} & Admin
type RelationshipAdminClient = AdminClient &
- Pick
+ Pick
export type PolymorphicRelationshipField = {
admin?: {
@@ -1949,7 +1955,7 @@ export function fieldShouldBeLocalized({
}
export function fieldIsVirtual(field: Field | Tab): boolean {
- return 'virtual' in field && field.virtual
+ return 'virtual' in field && Boolean(field.virtual)
}
export type HookName =
diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts
index fd2b0d1443..20612d465c 100644
--- a/packages/payload/src/fields/hooks/afterRead/promise.ts
+++ b/packages/payload/src/fields/hooks/afterRead/promise.ts
@@ -2,7 +2,6 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
-import type { RequestContext } from '../../../index.js'
import type {
JsonObject,
PayloadRequest,
@@ -13,6 +12,7 @@ import type {
import type { Block, Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
+import { type RequestContext } from '../../../index.js'
import { getBlockSelect } from '../../../utilities/getBlockSelect.js'
import { stripUnselectedFields } from '../../../utilities/stripUnselectedFields.js'
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js'
@@ -20,6 +20,7 @@ import { getDefaultValue } from '../../getDefaultValue.js'
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
import { relationshipPopulationPromise } from './relationshipPopulationPromise.js'
import { traverseFields } from './traverseFields.js'
+import { virtualFieldPopulationPromise } from './virtualFieldPopulationPromise.js'
type Args = {
/**
@@ -306,6 +307,24 @@ export const promise = async ({
}
}
+ if ('virtual' in field && typeof field.virtual === 'string') {
+ populationPromises.push(
+ virtualFieldPopulationPromise({
+ name: field.name,
+ draft,
+ fallbackLocale,
+ fields: (collection || global).flattenedFields,
+ locale,
+ overrideAccess,
+ ref: doc,
+ req,
+ segments: field.virtual.split('.'),
+ showHiddenFields,
+ siblingDoc,
+ }),
+ )
+ }
+
// Execute access control
let allowDefaultValue = true
if (triggerAccessControl && field.access && field.access.read) {
diff --git a/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts
new file mode 100644
index 0000000000..4c8eb758d1
--- /dev/null
+++ b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts
@@ -0,0 +1,144 @@
+import type { PayloadRequest } from '../../../types/index.js'
+import type { FlattenedField } from '../../config/types.js'
+
+import { createDataloaderCacheKey } from '../../../collections/dataloader.js'
+
+export const virtualFieldPopulationPromise = async ({
+ name,
+ draft,
+ fallbackLocale,
+ fields,
+ locale,
+ overrideAccess,
+ ref,
+ req,
+ segments,
+ showHiddenFields,
+ siblingDoc,
+}: {
+ draft: boolean
+ fallbackLocale: string
+ fields: FlattenedField[]
+ locale: string
+ name: string
+ overrideAccess: boolean
+ ref: any
+ req: PayloadRequest
+ segments: string[]
+ showHiddenFields: boolean
+ siblingDoc: Record
+}): Promise => {
+ const currentSegment = segments.shift()
+
+ if (!currentSegment) {
+ return
+ }
+
+ const currentValue = ref[currentSegment]
+
+ if (typeof currentValue === 'undefined') {
+ return
+ }
+
+ // Final step
+ if (segments.length === 0) {
+ siblingDoc[name] = currentValue
+ return
+ }
+
+ const currentField = fields.find((each) => each.name === currentSegment)
+
+ if (!currentField) {
+ return
+ }
+
+ if (currentField.type === 'group' || currentField.type === 'tab') {
+ if (!currentValue || typeof currentValue !== 'object') {
+ return
+ }
+
+ return virtualFieldPopulationPromise({
+ name,
+ draft,
+ fallbackLocale,
+ fields: currentField.flattenedFields,
+ locale,
+ overrideAccess,
+ ref: currentValue,
+ req,
+ segments,
+ showHiddenFields,
+ siblingDoc,
+ })
+ }
+
+ if (
+ (currentField.type === 'relationship' || currentField.type === 'upload') &&
+ typeof currentField.relationTo === 'string' &&
+ !currentField.hasMany
+ ) {
+ let docID: number | string
+
+ if (typeof currentValue === 'object' && currentValue) {
+ docID = currentValue.id
+ } else {
+ docID = currentValue
+ }
+
+ if (typeof docID !== 'string' && typeof docID !== 'number') {
+ return
+ }
+
+ const select = {}
+ let currentSelectRef: any = select
+ const currentFields = req.payload.collections[currentField.relationTo].config.flattenedFields
+
+ for (let i = 0; i < segments.length; i++) {
+ const field = currentFields.find((each) => each.name === segments[i])
+
+ const shouldBreak =
+ i === segments.length - 1 || field?.type === 'relationship' || field?.type === 'upload'
+
+ currentSelectRef[segments[i]] = shouldBreak ? true : {}
+ currentSelectRef = currentSelectRef[segments[i]]
+
+ if (shouldBreak) {
+ break
+ }
+ }
+
+ const populatedDoc = await req.payloadDataLoader.load(
+ createDataloaderCacheKey({
+ collectionSlug: currentField.relationTo,
+ currentDepth: 0,
+ depth: 0,
+ docID,
+ draft,
+ fallbackLocale,
+ locale,
+ overrideAccess,
+ select,
+ showHiddenFields,
+ transactionID: req.transactionID as number,
+ }),
+ )
+
+ if (!populatedDoc) {
+ return
+ }
+
+ return virtualFieldPopulationPromise({
+ name,
+ draft,
+ fallbackLocale,
+ fields: req.payload.collections[currentField.relationTo].config.flattenedFields,
+ locale,
+ overrideAccess,
+ ref: populatedDoc,
+ req,
+ segments,
+ showHiddenFields,
+ siblingDoc,
+ })
+ }
+}
diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts
index 662e4706f9..87a323a852 100644
--- a/packages/payload/src/fields/hooks/beforeChange/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts
@@ -109,7 +109,12 @@ export const promise = async ({
const passesCondition = field.admin?.condition
? Boolean(
- field.admin.condition(data, siblingData, { blockData, path: pathSegments, user: req.user }),
+ field.admin.condition(data, siblingData, {
+ blockData,
+ operation,
+ path: pathSegments,
+ user: req.user,
+ }),
)
: true
let skipValidationFromHere = skipValidation || !passesCondition
diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts
index 7521695068..a2bbeb94c1 100644
--- a/packages/payload/src/globals/config/types.ts
+++ b/packages/payload/src/globals/config/types.ts
@@ -9,6 +9,7 @@ import type {
} from '../../admin/types.js'
import type {
Access,
+ CustomComponent,
EditConfig,
Endpoint,
EntityDescription,
@@ -80,6 +81,10 @@ export type GlobalAdminOptions = {
*/
components?: {
elements?: {
+ /**
+ * Inject custom components before the document controls
+ */
+ beforeDocumentControls?: CustomComponent[]
Description?: EntityDescriptionComponent
/**
* Replaces the "Preview" button
diff --git a/packages/payload/src/query-presets/access.ts b/packages/payload/src/query-presets/access.ts
index 4d62a088ca..d7bb050493 100644
--- a/packages/payload/src/query-presets/access.ts
+++ b/packages/payload/src/query-presets/access.ts
@@ -71,7 +71,17 @@ export const getAccess = (config: Config): Record =>
return {
and: [
- ...(typeof constraintAccess === 'object' ? [constraintAccess] : []),
+ ...(typeof constraintAccess === 'object'
+ ? [constraintAccess]
+ : constraintAccess === false
+ ? [
+ {
+ id: {
+ equals: null,
+ },
+ },
+ ]
+ : []),
{
[`access.${operation}.constraint`]: {
equals: constraint.value,
diff --git a/packages/payload/src/query-presets/constraints.ts b/packages/payload/src/query-presets/constraints.ts
index 2453276b54..8e9cec4f5d 100644
--- a/packages/payload/src/query-presets/constraints.ts
+++ b/packages/payload/src/query-presets/constraints.ts
@@ -78,7 +78,7 @@ export const getConstraints = (config: Config): Field => ({
},
...(config?.queryPresets?.constraints?.[operation]?.reduce(
(acc: Field[], option: QueryPresetConstraint) => {
- option.fields.forEach((field, index) => {
+ option.fields?.forEach((field, index) => {
acc.push({ ...field })
if (fieldAffectsData(field)) {
diff --git a/packages/payload/src/query-presets/types.ts b/packages/payload/src/query-presets/types.ts
index a2f35de730..722a2fc6e2 100644
--- a/packages/payload/src/query-presets/types.ts
+++ b/packages/payload/src/query-presets/types.ts
@@ -25,7 +25,7 @@ export type QueryPreset = {
export type QueryPresetConstraint = {
access: Access
- fields: Field[]
+ fields?: Field[]
label: string
value: string
}
diff --git a/packages/payload/src/utilities/handleEndpoints.ts b/packages/payload/src/utilities/handleEndpoints.ts
index 25a70d04b1..e5fe5c6bef 100644
--- a/packages/payload/src/utilities/handleEndpoints.ts
+++ b/packages/payload/src/utilities/handleEndpoints.ts
@@ -222,8 +222,12 @@ export const handleEndpoints = async ({
}
const response = await handler(req)
+
return new Response(response.body, {
- headers: mergeHeaders(req.responseHeaders ?? new Headers(), response.headers),
+ headers: headersWithCors({
+ headers: mergeHeaders(req.responseHeaders ?? new Headers(), response.headers),
+ req,
+ }),
status: response.status,
statusText: response.statusText,
})
diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts
index 43c0d21854..efbc4e3c95 100644
--- a/packages/payload/src/versions/types.ts
+++ b/packages/payload/src/versions/types.ts
@@ -6,6 +6,13 @@ export type Autosave = {
* @default 800
*/
interval?: number
+ /**
+ * When set to `true`, the "Save as draft" button will be displayed even while autosave is enabled.
+ * By default, this button is hidden to avoid redundancy with autosave behavior.
+ *
+ * @default false
+ */
+ showSaveDraftButton?: boolean
}
export type SchedulePublish = {
diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json
index a2ad20c57a..47dab5d7bd 100644
--- a/packages/plugin-cloud-storage/package.json
+++ b/packages/plugin-cloud-storage/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json
index 45716a4e5e..2ef45b15b1 100644
--- a/packages/plugin-form-builder/package.json
+++ b/packages/plugin-form-builder/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",
diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json
index 25d9dc144d..ce73cf23fb 100644
--- a/packages/plugin-import-export/package.json
+++ b/packages/plugin-import-export/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-import-export",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx
index 1a33f1fe1e..67461e1678 100644
--- a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx
+++ b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx
@@ -20,8 +20,7 @@ const baseClass = 'fields-to-export'
export const FieldsToExport: SelectFieldClientComponent = (props) => {
const { id } = useDocumentInfo()
- const { path } = props
- const { setValue, value } = useField({ path })
+ const { setValue, value } = useField()
const { value: collectionSlug } = useField({ path: 'collectionSlug' })
const { getEntityConfig } = useConfig()
const { collection } = useImportExport()
diff --git a/packages/plugin-import-export/src/components/SortBy/index.tsx b/packages/plugin-import-export/src/components/SortBy/index.tsx
index 75d7cbb297..0d06a21162 100644
--- a/packages/plugin-import-export/src/components/SortBy/index.tsx
+++ b/packages/plugin-import-export/src/components/SortBy/index.tsx
@@ -20,12 +20,12 @@ const baseClass = 'sort-by-fields'
export const SortBy: SelectFieldClientComponent = (props) => {
const { id } = useDocumentInfo()
- const { path } = props
- const { setValue, value } = useField({ path })
+ const { setValue, value } = useField()
const { value: collectionSlug } = useField({ path: 'collectionSlug' })
const { query } = useListQuery()
const { getEntityConfig } = useConfig()
const { collection } = useImportExport()
+
const [displayedValue, setDisplayedValue] = useState<{
id: string
label: ReactNode
diff --git a/packages/plugin-import-export/src/components/WhereField/index.tsx b/packages/plugin-import-export/src/components/WhereField/index.tsx
index 02c1d3db38..68f4daf653 100644
--- a/packages/plugin-import-export/src/components/WhereField/index.tsx
+++ b/packages/plugin-import-export/src/components/WhereField/index.tsx
@@ -11,6 +11,7 @@ export const WhereField: React.FC = () => {
const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({
path: 'selectionToUse',
})
+
const { setValue } = useField({ path: 'where' })
const { selectAll, selected } = useSelection()
const { query } = useListQuery()
diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json
index edebd5a072..37f1898379 100644
--- a/packages/plugin-multi-tenant/package.json
+++ b/packages/plugin-multi-tenant/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-multi-tenant",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx
index 4f6605e60b..b0a8bf0b90 100644
--- a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx
+++ b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx
@@ -16,8 +16,8 @@ type Props = {
} & RelationshipFieldClientProps
export const TenantField = (args: Props) => {
- const { debug, path, unique } = args
- const { setValue, value } = useField({ path })
+ const { debug, unique } = args
+ const { setValue, value } = useField()
const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection()
const hasSetValueRef = React.useRef(false)
diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json
index 4a2e3ee2d3..a04087888d 100644
--- a/packages/plugin-nested-docs/package.json
+++ b/packages/plugin-nested-docs/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json
index 1890cf43a3..65c605e62a 100644
--- a/packages/plugin-redirects/package.json
+++ b/packages/plugin-redirects/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json
index 9b12464f9b..295d36315e 100644
--- a/packages/plugin-search/package.json
+++ b/packages/plugin-search/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Search plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts b/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts
index 04a395598c..2c7fbcb64a 100644
--- a/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts
+++ b/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts
@@ -1,43 +1,28 @@
import type { DeleteFromSearch } from '../../types.js'
-export const deleteFromSearch: DeleteFromSearch = async ({
- collection,
- doc,
- pluginConfig,
- req: { payload },
- req,
-}) => {
- const searchSlug = pluginConfig?.searchOverrides?.slug || 'search'
- try {
- const searchDocQuery = await payload.find({
- collection: searchSlug,
- depth: 0,
- limit: 1,
- pagination: false,
- req,
- where: {
- doc: {
- equals: {
- relationTo: collection.slug,
- value: doc.id,
+export const deleteFromSearch: DeleteFromSearch =
+ (pluginConfig) =>
+ async ({ id, collection, req: { payload }, req }) => {
+ const searchSlug = pluginConfig?.searchOverrides?.slug || 'search'
+
+ try {
+ await payload.delete({
+ collection: searchSlug,
+ depth: 0,
+ req,
+ where: {
+ 'doc.relationTo': {
+ equals: collection.slug,
+ },
+ 'doc.value': {
+ equals: id,
},
},
- },
- })
-
- if (searchDocQuery?.docs?.[0]) {
- await payload.delete({
- id: searchDocQuery?.docs?.[0]?.id,
- collection: searchSlug,
- req,
+ })
+ } catch (err: unknown) {
+ payload.logger.error({
+ err,
+ msg: `Error deleting ${searchSlug} doc.`,
})
}
- } catch (err: unknown) {
- payload.logger.error({
- err,
- msg: `Error deleting ${searchSlug} doc.`,
- })
}
-
- return doc
-}
diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts
index 05a4939dc1..fc4be4bd5d 100644
--- a/packages/plugin-search/src/index.ts
+++ b/packages/plugin-search/src/index.ts
@@ -1,4 +1,4 @@
-import type { CollectionAfterChangeHook, CollectionAfterDeleteHook, Config } from 'payload'
+import type { CollectionAfterChangeHook, Config } from 'payload'
import type { SanitizedSearchPluginConfig, SearchPluginConfig } from './types.js'
@@ -7,7 +7,6 @@ import { syncWithSearch } from './Search/hooks/syncWithSearch.js'
import { generateSearchCollection } from './Search/index.js'
type CollectionAfterChangeHookArgs = Parameters[0]
-type CollectionAfterDeleteHookArgs = Parameters[0]
export const searchPlugin =
(incomingPluginConfig: SearchPluginConfig) =>
@@ -67,14 +66,9 @@ export const searchPlugin =
})
},
],
- afterDelete: [
- ...(existingHooks?.afterDelete || []),
- async (args: CollectionAfterDeleteHookArgs) => {
- await deleteFromSearch({
- ...args,
- pluginConfig,
- })
- },
+ beforeDelete: [
+ ...(existingHooks?.beforeDelete || []),
+ deleteFromSearch(pluginConfig),
],
},
}
diff --git a/packages/plugin-search/src/types.ts b/packages/plugin-search/src/types.ts
index 4b0e4c1f36..bd6dd0d15c 100644
--- a/packages/plugin-search/src/types.ts
+++ b/packages/plugin-search/src/types.ts
@@ -1,6 +1,6 @@
import type {
CollectionAfterChangeHook,
- CollectionAfterDeleteHook,
+ CollectionBeforeDeleteHook,
CollectionConfig,
Field,
Locale,
@@ -96,8 +96,4 @@ export type SyncDocArgs = {
// Convert the `collection` arg from `SanitizedCollectionConfig` to a string
export type SyncWithSearch = (Args: SyncWithSearchArgs) => ReturnType
-export type DeleteFromSearch = (
- Args: {
- pluginConfig: SearchPluginConfig
- } & Parameters[0],
-) => ReturnType
+export type DeleteFromSearch = (args: SearchPluginConfig) => CollectionBeforeDeleteHook
diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json
index 960ae04401..9b1218a7b7 100644
--- a/packages/plugin-sentry/package.json
+++ b/packages/plugin-sentry/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-sentry",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Sentry plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json
index ef225b3cd0..4f13a39f91 100644
--- a/packages/plugin-seo/package.json
+++ b/packages/plugin-seo/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "SEO plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx
index 5a544a9f30..7b93ff3777 100644
--- a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx
+++ b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx
@@ -1,6 +1,6 @@
'use client'
-import type { FieldType, Options } from '@payloadcms/ui'
+import type { FieldType } from '@payloadcms/ui'
import type { TextareaFieldClientProps } from 'payload'
import {
@@ -38,7 +38,6 @@ export const MetaDescriptionComponent: React.FC = (props)
required,
},
hasGenerateDescriptionFn,
- path,
readOnly,
} = props
@@ -58,12 +57,14 @@ export const MetaDescriptionComponent: React.FC = (props)
const maxLength = maxLengthFromProps || maxLengthDefault
const minLength = minLengthFromProps || minLengthDefault
- const { customComponents, errorMessage, setValue, showError, value }: FieldType =
- useField({
- path,
- } as Options)
-
- const { AfterInput, BeforeInput, Label } = customComponents ?? {}
+ const {
+ customComponents: { AfterInput, BeforeInput, Label } = {},
+ errorMessage,
+ path,
+ setValue,
+ showError,
+ value,
+ }: FieldType = useField()
const regenerateDescription = useCallback(async () => {
if (!hasGenerateDescriptionFn) {
diff --git a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx
index 987c4a2041..acfc2aaef9 100644
--- a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx
+++ b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx
@@ -1,6 +1,6 @@
'use client'
-import type { FieldType, Options } from '@payloadcms/ui'
+import type { FieldType } from '@payloadcms/ui'
import type { UploadFieldClientProps } from 'payload'
import {
@@ -30,9 +30,8 @@ export const MetaImageComponent: React.FC = (props) => {
const {
field: { label, localized, relationTo, required },
hasGenerateImageFn,
- path,
readOnly,
- } = props || {}
+ } = props
const {
config: {
@@ -42,10 +41,14 @@ export const MetaImageComponent: React.FC = (props) => {
getEntityConfig,
} = useConfig()
- const field: FieldType = useField({ ...props, path } as Options)
- const { customComponents } = field
-
- const { Error, Label } = customComponents ?? {}
+ const {
+ customComponents: { Error, Label } = {},
+ filterOptions,
+ path,
+ setValue,
+ showError,
+ value,
+ }: FieldType = useField()
const { t } = useTranslation()
@@ -53,8 +56,6 @@ export const MetaImageComponent: React.FC = (props) => {
const { getData } = useForm()
const docInfo = useDocumentInfo()
- const { setValue, showError, value } = field
-
const regenerateImage = useCallback(async () => {
if (!hasGenerateImageFn) {
return
@@ -174,7 +175,7 @@ export const MetaImageComponent: React.FC = (props) => {
api={api}
collection={collection}
Error={Error}
- filterOptions={field.filterOptions}
+ filterOptions={filterOptions}
onChange={(incomingImage) => {
if (incomingImage !== null) {
if (typeof incomingImage === 'object') {
diff --git a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx
index e1496ca63d..4d7e57b119 100644
--- a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx
+++ b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx
@@ -1,6 +1,6 @@
'use client'
-import type { FieldType, Options } from '@payloadcms/ui'
+import type { FieldType } from '@payloadcms/ui'
import type { TextFieldClientProps } from 'payload'
import {
@@ -33,9 +33,8 @@ export const MetaTitleComponent: React.FC = (props) => {
const {
field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required },
hasGenerateTitleFn,
- path,
readOnly,
- } = props || {}
+ } = props
const { t } = useTranslation()
@@ -46,8 +45,14 @@ export const MetaTitleComponent: React.FC = (props) => {
},
} = useConfig()
- const field: FieldType = useField({ path } as Options)
- const { customComponents: { AfterInput, BeforeInput, Label } = {} } = field
+ const {
+ customComponents: { AfterInput, BeforeInput, Label } = {},
+ errorMessage,
+ path,
+ setValue,
+ showError,
+ value,
+ }: FieldType = useField()
const locale = useLocale()
const { getData } = useForm()
@@ -56,8 +61,6 @@ export const MetaTitleComponent: React.FC = (props) => {
const minLength = minLengthFromProps || minLengthDefault
const maxLength = maxLengthFromProps || maxLengthDefault
- const { errorMessage, setValue, showError, value } = field
-
const regenerateTitle = useCallback(async () => {
if (!hasGenerateTitleFn) {
return
diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json
index d9cfa22929..e45211aaa1 100644
--- a/packages/plugin-stripe/package.json
+++ b/packages/plugin-stripe/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json
index 3f2ebc3ab8..f6e2057f6b 100644
--- a/packages/richtext-lexical/package.json
+++ b/packages/richtext-lexical/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/richtext-lexical/src/features/indent/client/index.tsx b/packages/richtext-lexical/src/features/indent/client/index.tsx
index e3e625ec4e..cf56a2c0c6 100644
--- a/packages/richtext-lexical/src/features/indent/client/index.tsx
+++ b/packages/richtext-lexical/src/features/indent/client/index.tsx
@@ -68,12 +68,7 @@ const toolbarGroups = ({ disabledNodes }: IndentFeatureProps): ToolbarGroup[] =>
if (!nodes?.length) {
return false
}
- if (nodes.some((node) => disabledNodes?.includes(node.getType()))) {
- return false
- }
- return !$pointsAncestorMatch(selection, (node) =>
- (disabledNodes ?? []).includes(node.getType()),
- )
+ return !nodes.some((node) => disabledNodes?.includes(node.getType()))
},
key: 'indentIncrease',
label: ({ i18n }) => {
diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx
index 65edb6ede5..1f5bc28257 100644
--- a/packages/richtext-lexical/src/field/Field.tsx
+++ b/packages/richtext-lexical/src/field/Field.tsx
@@ -36,7 +36,6 @@ const RichTextComponent: React.FC<
editorConfig,
field,
field: {
- name,
admin: { className, description, readOnly: readOnlyFromAdmin } = {},
label,
localized,
@@ -48,7 +47,6 @@ const RichTextComponent: React.FC<
} = props
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
- const path = pathFromProps ?? name
const editDepth = useEditDepth()
@@ -70,11 +68,12 @@ const RichTextComponent: React.FC<
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled: disabledFromField,
initialValue,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json
index e676f1feda..af889d5f00 100644
--- a/packages/richtext-slate/package.json
+++ b/packages/richtext-slate/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/richtext-slate/src/field/RichText.tsx b/packages/richtext-slate/src/field/RichText.tsx
index 1effc7eb09..fbcb1df135 100644
--- a/packages/richtext-slate/src/field/RichText.tsx
+++ b/packages/richtext-slate/src/field/RichText.tsx
@@ -28,7 +28,6 @@ import type { LoadedSlateFieldProps } from './types.js'
import { defaultRichTextValue } from '../data/defaultValue.js'
import { richTextValidate } from '../data/validation.js'
import { listTypes } from './elements/listTypes.js'
-import './index.scss'
import { hotkeys } from './hotkeys.js'
import { toggleLeaf } from './leaves/toggle.js'
import { withEnterBreakOut } from './plugins/withEnterBreakOut.js'
@@ -37,6 +36,7 @@ import { ElementButtonProvider } from './providers/ElementButtonProvider.js'
import { ElementProvider } from './providers/ElementProvider.js'
import { LeafButtonProvider } from './providers/LeafButtonProvider.js'
import { LeafProvider } from './providers/LeafProvider.js'
+import './index.scss'
const baseClass = 'rich-text'
@@ -66,7 +66,6 @@ const RichTextField: React.FC = (props) => {
validate = richTextValidate,
} = props
- const path = pathFromProps ?? name
const schemaPath = schemaPathFromProps ?? name
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
@@ -97,11 +96,12 @@ const RichTextField: React.FC = (props) => {
customComponents: { Description, Error, Label } = {},
disabled: disabledFromField,
initialValue,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json
index ab80d73da8..b342dbb4c8 100644
--- a/packages/storage-azure/package.json
+++ b/packages/storage-azure/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json
index d07cc8f344..625022b32e 100644
--- a/packages/storage-gcs/package.json
+++ b/packages/storage-gcs/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json
index 108df5bd0a..0e65946913 100644
--- a/packages/storage-s3/package.json
+++ b/packages/storage-s3/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json
index 8fbe66d115..56f1ba3c99 100644
--- a/packages/storage-uploadthing/package.json
+++ b/packages/storage-uploadthing/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json
index 4b517b9d03..84af5e067f 100644
--- a/packages/storage-vercel-blob/package.json
+++ b/packages/storage-vercel-blob/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
- "version": "3.34.0",
+ "version": "3.35.1",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/translations/package.json b/packages/translations/package.json
index 042a299ab2..2608d3cb10 100644
--- a/packages/translations/package.json
+++ b/packages/translations/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
- "version": "3.34.0",
+ "version": "3.35.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 064d724778..95ed06f956 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/ui",
- "version": "3.34.0",
+ "version": "3.35.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss b/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss
index a1e95f4b7f..5f09ceb7c2 100644
--- a/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss
+++ b/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss
@@ -33,10 +33,6 @@
width: calc(var(--base) * 1.2);
height: calc(var(--base) * 1.2);
- svg {
- max-width: 1rem;
- }
-
&:hover {
background-color: var(--theme-elevation-200);
}
diff --git a/packages/ui/src/elements/Button/index.scss b/packages/ui/src/elements/Button/index.scss
index d5a2ae6ef0..9bfd25278c 100644
--- a/packages/ui/src/elements/Button/index.scss
+++ b/packages/ui/src/elements/Button/index.scss
@@ -6,7 +6,7 @@
}
.btn--withPopup {
- margin-block: 24px;
+ margin-block: 4px;
.btn {
margin: 0;
}
diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx
index 4d9b7f024d..30490d222b 100644
--- a/packages/ui/src/elements/DocumentControls/index.tsx
+++ b/packages/ui/src/elements/DocumentControls/index.tsx
@@ -38,6 +38,7 @@ const baseClass = 'doc-controls'
export const DocumentControls: React.FC<{
readonly apiURL: string
+ readonly BeforeDocumentControls?: React.ReactNode
readonly customComponents?: {
readonly PreviewButton?: React.ReactNode
readonly PublishButton?: React.ReactNode
@@ -68,6 +69,7 @@ export const DocumentControls: React.FC<{
const {
id,
slug,
+ BeforeDocumentControls,
customComponents: {
PreviewButton: CustomPreviewButton,
PublishButton: CustomPublishButton,
@@ -134,9 +136,23 @@ export const DocumentControls: React.FC<{
const unsavedDraftWithValidations =
!id && collectionConfig?.versions?.drafts && collectionConfig.versions?.drafts.validate
+ const collectionConfigDrafts = collectionConfig?.versions?.drafts
+ const globalConfigDrafts = globalConfig?.versions?.drafts
+
const autosaveEnabled =
- (collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
- (globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave)
+ (collectionConfigDrafts && collectionConfigDrafts?.autosave) ||
+ (globalConfigDrafts && globalConfigDrafts?.autosave)
+
+ const collectionAutosaveEnabled = collectionConfigDrafts && collectionConfigDrafts?.autosave
+ const globalAutosaveEnabled = globalConfigDrafts && globalConfigDrafts?.autosave
+
+ const showSaveDraftButton =
+ (collectionAutosaveEnabled &&
+ collectionConfigDrafts.autosave !== false &&
+ collectionConfigDrafts.autosave.showSaveDraftButton === true) ||
+ (globalAutosaveEnabled &&
+ globalConfigDrafts.autosave !== false &&
+ globalConfigDrafts.autosave.showSaveDraftButton === true)
const showCopyToLocale = localization && !collectionConfig?.admin?.disableCopyToLocale
@@ -214,6 +230,7 @@ export const DocumentControls: React.FC<{
+ {BeforeDocumentControls}
{(collectionConfig?.admin.preview || globalConfig?.admin.preview) && (
{collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts ? (
- {(unsavedDraftWithValidations || !autosaveEnabled) && (
+ {(unsavedDraftWithValidations ||
+ !autosaveEnabled ||
+ (autosaveEnabled && showSaveDraftButton)) && (
}
diff --git a/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss b/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss
index 545fc80954..d3590b927c 100644
--- a/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss
+++ b/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss
@@ -7,7 +7,7 @@
white-space: nowrap;
display: flex;
padding-inline-start: base(0.4);
- padding-inline-end: base(0.4);
+ padding-inline-end: base(0.2);
background-color: var(--theme-elevation-100);
border-radius: var(--style-radius-s);
@@ -24,7 +24,6 @@
&__current {
display: flex;
align-items: center;
- gap: base(0.3);
}
button {
diff --git a/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx b/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx
index 2ddfe6cc3c..c6665d66be 100644
--- a/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx
+++ b/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx
@@ -28,7 +28,7 @@ export const LocalizerLabel: React.FC<{
{`${getTranslation(locale.label, i18n)}`}
-
+
)
diff --git a/packages/ui/src/elements/PerPage/index.scss b/packages/ui/src/elements/PerPage/index.scss
index 96a24a52c0..ce7ac9700c 100644
--- a/packages/ui/src/elements/PerPage/index.scss
+++ b/packages/ui/src/elements/PerPage/index.scss
@@ -36,10 +36,6 @@
}
}
- &__chevron {
- padding-left: calc(var(--base) / 4);
- }
-
&__button-active {
font-weight: bold;
color: var(--theme-text);
diff --git a/packages/ui/src/elements/Pill/index.scss b/packages/ui/src/elements/Pill/index.scss
index 7136c7ae5d..ad65f2d103 100644
--- a/packages/ui/src/elements/Pill/index.scss
+++ b/packages/ui/src/elements/Pill/index.scss
@@ -55,8 +55,9 @@
}
&--has-icon {
+ gap: 0;
padding-inline-start: base(0.4);
- padding-inline-end: base(0.3);
+ padding-inline-end: base(0.1);
svg {
display: block;
diff --git a/packages/ui/src/elements/Popup/PopupTrigger/index.scss b/packages/ui/src/elements/Popup/PopupTrigger/index.scss
index c3626bcb83..08cab98043 100644
--- a/packages/ui/src/elements/Popup/PopupTrigger/index.scss
+++ b/packages/ui/src/elements/Popup/PopupTrigger/index.scss
@@ -17,15 +17,15 @@
}
&--size-small {
- padding: base(0.4);
+ padding: base(0.2);
}
&--size-medium {
- padding: base(0.6);
+ padding: base(0.3);
}
&--size-large {
- padding: base(0.8);
+ padding: base(0.4);
}
&--disabled {
diff --git a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx
index 78570d6671..6c21780e5f 100644
--- a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx
+++ b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx
@@ -11,9 +11,8 @@ import './index.scss'
export const QueryPresetsColumnField: JSONFieldClientComponent = ({
field: { label, required },
- path,
}) => {
- const { value } = useField({ path })
+ const { path, value } = useField()
return (
diff --git a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx
index 564cbe82b1..a44fd868bb 100644
--- a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx
+++ b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx
@@ -88,9 +88,8 @@ const transformWhereToNaturalLanguage = (
export const QueryPresetsWhereField: JSONFieldClientComponent = ({
field: { label, required },
- path,
}) => {
- const { value } = useField({ path })
+ const { path, value } = useField()
const { collectionSlug } = useListQuery()
const { getEntityConfig } = useConfig()
diff --git a/packages/ui/src/elements/ReactSelect/index.scss b/packages/ui/src/elements/ReactSelect/index.scss
index a76e639335..1cfd4e12e6 100644
--- a/packages/ui/src/elements/ReactSelect/index.scss
+++ b/packages/ui/src/elements/ReactSelect/index.scss
@@ -17,10 +17,6 @@
padding: base(0.5) base(0.6);
}
- .rs__indicators {
- gap: calc(var(--base) / 4);
- }
-
.rs__indicator {
padding: 0px 4px;
cursor: pointer;
diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx
index 3247dd3c94..79f998188f 100644
--- a/packages/ui/src/elements/RelationshipTable/index.tsx
+++ b/packages/ui/src/elements/RelationshipTable/index.tsx
@@ -335,6 +335,7 @@ export const RelationshipTable: React.FC
= (pro
defaultLimit={
field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit
}
+ defaultSort={field.defaultSort ?? collectionConfig?.defaultSort}
modifySearchParams={false}
onQueryChange={setQuery}
orderableFieldName={
diff --git a/packages/ui/src/elements/SortColumn/index.scss b/packages/ui/src/elements/SortColumn/index.scss
index bfdadfac9b..31b61f8676 100644
--- a/packages/ui/src/elements/SortColumn/index.scss
+++ b/packages/ui/src/elements/SortColumn/index.scss
@@ -31,7 +31,7 @@
&__button {
margin: 0;
opacity: 0.3;
- padding: calc(var(--base) / 4);
+ padding: calc(var(--base) / 4) 0;
display: inline-flex;
align-items: center;
justify-content: center;
diff --git a/packages/ui/src/fields/Array/index.tsx b/packages/ui/src/fields/Array/index.tsx
index ed2501903c..954937599b 100644
--- a/packages/ui/src/fields/Array/index.tsx
+++ b/packages/ui/src/fields/Array/index.tsx
@@ -46,7 +46,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
required,
},
forceRender = false,
- path,
+ path: pathFromProps,
permissions,
readOnly,
schemaPath: schemaPathFromProps,
@@ -113,13 +113,14 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
errorPaths,
+ path,
rows = [],
showError,
valid,
value,
} = useField({
hasRows: true,
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Blocks/index.tsx b/packages/ui/src/fields/Blocks/index.tsx
index 0dea396448..99a8578083 100644
--- a/packages/ui/src/fields/Blocks/index.tsx
+++ b/packages/ui/src/fields/Blocks/index.tsx
@@ -48,7 +48,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
minRows: minRowsProp,
required,
},
- path,
+ path: pathFromProps,
permissions,
readOnly,
schemaPath: schemaPathFromProps,
@@ -101,13 +101,14 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
errorPaths,
+ path,
rows = [],
showError,
valid,
value,
} = useField({
hasRows: true,
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Checkbox/index.tsx b/packages/ui/src/fields/Checkbox/index.tsx
index e3fc493cf6..2a685e6ea7 100644
--- a/packages/ui/src/fields/Checkbox/index.tsx
+++ b/packages/ui/src/fields/Checkbox/index.tsx
@@ -39,7 +39,7 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => {
} = {} as CheckboxFieldClientProps['field'],
onChange: onChangeFromProps,
partialChecked,
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -60,12 +60,13 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
disableFormData,
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Code/index.tsx b/packages/ui/src/fields/Code/index.tsx
index 5a9f38e45b..143df342ef 100644
--- a/packages/ui/src/fields/Code/index.tsx
+++ b/packages/ui/src/fields/Code/index.tsx
@@ -31,7 +31,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
required,
},
onMount,
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -48,11 +48,12 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/DateTime/index.tsx b/packages/ui/src/fields/DateTime/index.tsx
index da801b937a..4afbc3c919 100644
--- a/packages/ui/src/fields/DateTime/index.tsx
+++ b/packages/ui/src/fields/DateTime/index.tsx
@@ -33,7 +33,7 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => {
required,
timezone,
},
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -59,11 +59,12 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Email/index.tsx b/packages/ui/src/fields/Email/index.tsx
index 305ad5b287..6cce6d753a 100644
--- a/packages/ui/src/fields/Email/index.tsx
+++ b/packages/ui/src/fields/Email/index.tsx
@@ -16,8 +16,8 @@ import { withCondition } from '../../forms/withCondition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { FieldLabel } from '../FieldLabel/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js'
-import './index.scss'
import { fieldBaseClass } from '../shared/index.js'
+import './index.scss'
const EmailFieldComponent: EmailFieldClientComponent = (props) => {
const {
@@ -33,7 +33,7 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => {
localized,
required,
} = {} as EmailFieldClientProps['field'],
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -52,11 +52,12 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Hidden/index.tsx b/packages/ui/src/fields/Hidden/index.tsx
index 90c4e0f98e..3938e354d3 100644
--- a/packages/ui/src/fields/Hidden/index.tsx
+++ b/packages/ui/src/fields/Hidden/index.tsx
@@ -8,14 +8,15 @@ import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
/**
+ * Renders an input with `type="hidden"`.
* This is mainly used to save a value on the form that is not visible to the user.
* For example, this sets the `ìd` property of a block in the Blocks field.
*/
const HiddenFieldComponent: React.FC = (props) => {
- const { disableModifyingForm = true, path, value: valueFromProps } = props
+ const { disableModifyingForm = true, path: pathFromProps, value: valueFromProps } = props
- const { setValue, value } = useField({
- path,
+ const { path, setValue, value } = useField({
+ potentiallyStalePath: pathFromProps,
})
useEffect(() => {
diff --git a/packages/ui/src/fields/JSON/index.tsx b/packages/ui/src/fields/JSON/index.tsx
index 75754cb181..c0b80a670a 100644
--- a/packages/ui/src/fields/JSON/index.tsx
+++ b/packages/ui/src/fields/JSON/index.tsx
@@ -28,7 +28,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
localized,
required,
},
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -50,11 +50,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
initialValue,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx
index 1a75eff971..43610cb406 100644
--- a/packages/ui/src/fields/Join/index.tsx
+++ b/packages/ui/src/fields/Join/index.tsx
@@ -131,7 +131,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
on,
required,
},
- path,
+ path: pathFromProps,
} = props
const { id: docID, docConfig } = useDocumentInfo()
@@ -140,10 +140,11 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
+ path,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
})
const filterOptions: null | Where = useMemo(() => {
diff --git a/packages/ui/src/fields/Number/index.tsx b/packages/ui/src/fields/Number/index.tsx
index 98be6bee36..c7f135a26a 100644
--- a/packages/ui/src/fields/Number/index.tsx
+++ b/packages/ui/src/fields/Number/index.tsx
@@ -38,7 +38,7 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
required,
},
onChange: onChangeFromProps,
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -57,11 +57,12 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Point/index.tsx b/packages/ui/src/fields/Point/index.tsx
index 3d3ec40a94..dbe86849fc 100644
--- a/packages/ui/src/fields/Point/index.tsx
+++ b/packages/ui/src/fields/Point/index.tsx
@@ -26,7 +26,7 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => {
localized,
required,
},
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -45,11 +45,12 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value = [null, null],
} = useField<[number, number]>({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/RadioGroup/index.tsx b/packages/ui/src/fields/RadioGroup/index.tsx
index bb5746bda0..0542d55b94 100644
--- a/packages/ui/src/fields/RadioGroup/index.tsx
+++ b/packages/ui/src/fields/RadioGroup/index.tsx
@@ -34,7 +34,7 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => {
required,
} = {} as RadioFieldClientProps['field'],
onChange: onChangeFromProps,
- path,
+ path: pathFromProps,
readOnly,
validate,
value: valueFromProps,
@@ -54,11 +54,12 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value: valueFromContext,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx
index f704829812..449c1e1374 100644
--- a/packages/ui/src/fields/Relationship/index.tsx
+++ b/packages/ui/src/fields/Relationship/index.tsx
@@ -1,5 +1,10 @@
'use client'
-import type { PaginatedDocs, RelationshipFieldClientComponent, Where } from 'payload'
+import type {
+ FilterOptionsResult,
+ PaginatedDocs,
+ RelationshipFieldClientComponent,
+ Where,
+} from 'payload'
import { dequal } from 'dequal/lite'
import { wordBoundariesRegex } from 'payload/shared'
@@ -7,11 +12,13 @@ import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js'
+import type { ListDrawerProps } from '../../elements/ListDrawer/types.js'
import type { ReactSelectAdapterProps } from '../../elements/ReactSelect/types.js'
import type { GetResults, Option, Value } from './types.js'
import { AddNewRelation } from '../../elements/AddNewRelation/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
+import { useListDrawer } from '../../elements/ListDrawer/index.js'
import { ReactSelect } from '../../elements/ReactSelect/index.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { FieldDescription } from '../../fields/FieldDescription/index.js'
@@ -45,6 +52,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
admin: {
allowCreate = true,
allowEdit = true,
+ appearance = 'select',
className,
description,
isSortable = true,
@@ -56,7 +64,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
relationTo,
required,
},
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -104,13 +112,15 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
disabled,
filterOptions,
initialValue,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
+
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const valueRef = useRef(value)
@@ -121,6 +131,60 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
collectionSlug: currentlyOpenRelationship.collectionSlug,
})
+ // Filter selected values from displaying in the list drawer
+ const listDrawerFilterOptions = useMemo(() => {
+ let newFilterOptions = filterOptions
+
+ if (value) {
+ ;(Array.isArray(value) ? value : [value]).forEach((val) => {
+ ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relationTo) => {
+ newFilterOptions = {
+ ...(filterOptions || {}),
+ [relationTo]: {
+ ...(typeof filterOptions?.[relationTo] === 'object' ? filterOptions[relationTo] : {}),
+ id: {
+ not_in: [typeof val === 'object' ? val.value : val],
+ },
+ },
+ }
+ })
+ })
+ }
+
+ return newFilterOptions
+ }, [filterOptions, value, relationTo])
+
+ const [
+ ListDrawer,
+ ,
+ { closeDrawer: closeListDrawer, isDrawerOpen: isListDrawerOpen, openDrawer: openListDrawer },
+ ] = useListDrawer({
+ collectionSlugs: hasMultipleRelations ? relationTo : [relationTo],
+ filterOptions: listDrawerFilterOptions,
+ })
+
+ const onListSelect = useCallback>(
+ ({ collectionSlug, doc }) => {
+ const formattedSelection = hasMultipleRelations
+ ? {
+ relationTo: collectionSlug,
+ value: doc.id,
+ }
+ : doc.id
+
+ if (hasMany) {
+ const withSelection = Array.isArray(value) ? value : []
+ withSelection.push(formattedSelection)
+ setValue(withSelection)
+ } else {
+ setValue(formattedSelection)
+ }
+
+ closeListDrawer()
+ },
+ [hasMany, hasMultipleRelations, setValue, closeListDrawer, value],
+ )
+
const openDrawerWhenRelationChanges = useRef(false)
const getResults: GetResults = useCallback(
@@ -601,18 +665,19 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
{!errorLoading && (
{
if (!option) {
@@ -622,9 +687,11 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
? `${option.relationTo}_${option.value}`
: (option.value as string)
}}
- isLoading={isLoading}
+ isLoading={appearance === 'select' && isLoading}
isMulti={hasMany}
+ isSearchable={appearance === 'select'}
isSortable={isSortable}
+ menuIsOpen={appearance === 'select' ? menuIsOpen : false}
onChange={
!(readOnly || disabled)
? (selected) => {
@@ -661,19 +728,22 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
setMenuIsOpen(false)
}}
onMenuOpen={() => {
- setMenuIsOpen(true)
-
- if (!hasLoadedFirstPageRef.current) {
- setIsLoading(true)
- void getResults({
- filterOptions,
- lastLoadedPage: {},
- onSuccess: () => {
- hasLoadedFirstPageRef.current = true
- setIsLoading(false)
- },
- value: initialValue,
- })
+ if (appearance === 'drawer') {
+ openListDrawer()
+ } else if (appearance === 'select') {
+ setMenuIsOpen(true)
+ if (!hasLoadedFirstPageRef.current) {
+ setIsLoading(true)
+ void getResults({
+ filterOptions,
+ lastLoadedPage: {},
+ onSuccess: () => {
+ hasLoadedFirstPageRef.current = true
+ setIsLoading(false)
+ },
+ value: initialValue,
+ })
+ }
}
}}
onMenuScrollToBottom={() => {
@@ -711,6 +781,9 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
{currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && (
)}
+ {appearance === 'drawer' && !readOnly && (
+
+ )}
)
}
diff --git a/packages/ui/src/fields/Select/index.tsx b/packages/ui/src/fields/Select/index.tsx
index f49af6fe6c..b8b67af5f9 100644
--- a/packages/ui/src/fields/Select/index.tsx
+++ b/packages/ui/src/fields/Select/index.tsx
@@ -46,7 +46,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
required,
},
onChange: onChangeFromProps,
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -65,11 +65,12 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Tabs/index.tsx b/packages/ui/src/fields/Tabs/index.tsx
index 5b7e9b6bf3..b8be36c96e 100644
--- a/packages/ui/src/fields/Tabs/index.tsx
+++ b/packages/ui/src/fields/Tabs/index.tsx
@@ -269,15 +269,13 @@ function TabContent({
parentIndexPath,
parentPath,
parentSchemaPath,
- path,
permissions,
readOnly,
}: ActiveTabProps) {
const { i18n } = useTranslation()
- const { customComponents: { AfterInput, BeforeInput, Description, Field } = {} } = useField({
- path,
- })
+ const { customComponents: { AfterInput, BeforeInput, Description, Field } = {}, path } =
+ useField()
if (Field) {
return Field
diff --git a/packages/ui/src/fields/Text/index.tsx b/packages/ui/src/fields/Text/index.tsx
index afa9aaa536..637b5efe4d 100644
--- a/packages/ui/src/fields/Text/index.tsx
+++ b/packages/ui/src/fields/Text/index.tsx
@@ -32,7 +32,7 @@ const TextFieldComponent: TextFieldClientComponent = (props) => {
required,
},
inputRef,
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -55,11 +55,12 @@ const TextFieldComponent: TextFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Textarea/index.tsx b/packages/ui/src/fields/Textarea/index.tsx
index f27695f369..3a3ffba6be 100644
--- a/packages/ui/src/fields/Textarea/index.tsx
+++ b/packages/ui/src/fields/Textarea/index.tsx
@@ -29,7 +29,7 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => {
minLength,
required,
},
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -61,11 +61,12 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/fields/Upload/index.tsx b/packages/ui/src/fields/Upload/index.tsx
index 681e5c18e5..9fc43b2037 100644
--- a/packages/ui/src/fields/Upload/index.tsx
+++ b/packages/ui/src/fields/Upload/index.tsx
@@ -28,7 +28,7 @@ export function UploadComponent(props: UploadFieldClientProps) {
relationTo,
required,
},
- path,
+ path: pathFromProps,
readOnly,
validate,
} = props
@@ -50,11 +50,12 @@ export function UploadComponent(props: UploadFieldClientProps) {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
filterOptions,
+ path,
setValue,
showError,
value,
} = useField({
- path,
+ potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
diff --git a/packages/ui/src/forms/Form/fieldReducer.ts b/packages/ui/src/forms/Form/fieldReducer.ts
index d305a0e7ab..2260a2d940 100644
--- a/packages/ui/src/forms/Form/fieldReducer.ts
+++ b/packages/ui/src/forms/Form/fieldReducer.ts
@@ -28,11 +28,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
const newRow: Row = {
id: (subFieldState?.id?.value as string) || new ObjectId().toHexString(),
- blockType: blockType || undefined,
collapsed: false,
isLoading: true,
}
+ if (blockType) {
+ newRow.blockType = blockType
+ }
+
withNewRow.splice(rowIndex, 0, newRow)
if (blockType) {
diff --git a/packages/ui/src/forms/RenderFields/context.ts b/packages/ui/src/forms/RenderFields/context.ts
new file mode 100644
index 0000000000..d6d9da067d
--- /dev/null
+++ b/packages/ui/src/forms/RenderFields/context.ts
@@ -0,0 +1,14 @@
+import React from 'react'
+
+export const FieldPathContext = React.createContext(undefined)
+
+export const useFieldPath = () => {
+ const context = React.useContext(FieldPathContext)
+
+ if (!context) {
+ // swallow the error, not all fields are wrapped in a FieldPathContext
+ return undefined
+ }
+
+ return context
+}
diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx
index bf447d9511..ed662e202e 100644
--- a/packages/ui/src/forms/RenderFields/index.tsx
+++ b/packages/ui/src/forms/RenderFields/index.tsx
@@ -7,8 +7,9 @@ import type { RenderFieldsProps } from './types.js'
import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js'
import { useOperation } from '../../providers/Operation/index.js'
-import { RenderField } from './RenderField.js'
+import { FieldPathContext } from './context.js'
import './index.scss'
+import { RenderField } from './RenderField.js'
const baseClass = 'render-fields'
@@ -90,18 +91,19 @@ export const RenderFields: React.FC = (props) => {
})
return (
-
+
+
+
)
})}
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
index 0951315668..b3896560dc 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
+++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
@@ -827,6 +827,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
if (passesCondition && typeof tab.admin?.condition === 'function') {
tabPassesCondition = tab.admin.condition(fullData, data, {
blockData,
+ operation,
path: pathSegments,
user: req.user,
})
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
index 5e7b2e5340..f934198319 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
+++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
@@ -151,6 +151,7 @@ export const iterateFields = async ({
? Boolean(
field.admin.condition(fullData || {}, data || {}, {
blockData,
+ operation,
path: pathSegments,
user: req.user,
}),
diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx
index f3fc737df1..987b819957 100644
--- a/packages/ui/src/forms/useField/index.tsx
+++ b/packages/ui/src/forms/useField/index.tsx
@@ -23,14 +23,27 @@ import {
useFormProcessing,
useFormSubmitted,
} from '../Form/context.js'
+import { useFieldPath } from '../RenderFields/context.js'
/**
* Get and set the value of a form field.
*
* @see https://payloadcms.com/docs/admin/react-hooks#usefield
*/
-export const useField = (options: Options): FieldType => {
- const { disableFormData = false, hasRows, path, validate } = options
+export const useField = (options?: Options): FieldType => {
+ const {
+ disableFormData = false,
+ hasRows,
+ path: pathFromOptions,
+ potentiallyStalePath,
+ validate,
+ } = options || {}
+
+ const pathFromContext = useFieldPath()
+
+ // This is a workaround for stale props given to server rendered components.
+ // See the notes in the `potentiallyStalePath` type definition for more details.
+ const path = pathFromOptions || pathFromContext || potentiallyStalePath
const submitted = useFormSubmitted()
const processing = useFormProcessing()
diff --git a/packages/ui/src/forms/useField/types.ts b/packages/ui/src/forms/useField/types.ts
index 2f43cbab68..e91e60e8bd 100644
--- a/packages/ui/src/forms/useField/types.ts
+++ b/packages/ui/src/forms/useField/types.ts
@@ -3,7 +3,27 @@ import type { FieldState, FilterOptionsResult, Row, Validate } from 'payload'
export type Options = {
disableFormData?: boolean
hasRows?: boolean
- path: string
+ /**
+ * If `path` is provided to this hook, it will be used outright. This is useful when calling this hook directly within a custom component.
+ * Otherwise, the field will attempt to get the path from the `FieldPathContext` via the `useFieldPath` hook.
+ * If still not found, the `potentiallyStalePath` arg will be used. See the note below about why this is important.
+ */
+ path?: string
+ /**
+ * Custom server components receive a static `path` prop at render-time, leading to temporarily stale paths when re-ordering rows in form state.
+ * This is because when manipulating rows, field paths change in form state, but the prop remains the same until the component is re-rendered on the server.
+ * This causes the component to temporarily point to the wrong field in form state until the server responds with a freshly rendered component.
+ * To prevent this, fields are wrapped with a `FieldPathContext` which is guaranteed to be up-to-date.
+ * The `path` prop that Payload's default fields receive, then, are sent into this hook as the `potentiallyStalePath` arg.
+ * This ensures that:
+ * 1. Custom components that use this hook directly will still respect the `path` prop as top priority.
+ * 2. Custom server components that blindly spread their props into default Payload fields still prefer the dynamic path from context.
+ * 3. Components that render default Payload fields directly do not require a `FieldPathProvider`, e.g. the email field in the account view.
+ */
+ potentiallyStalePath?: string
+ /**
+ * Client-side validation function fired when the form is submitted.
+ */
validate?: Validate
}
@@ -17,6 +37,7 @@ export type FieldType = {
formProcessing: boolean
formSubmitted: boolean
initialValue?: T
+ path: string
readOnly?: boolean
rows?: Row[]
setValue: (val: unknown, disableModifyingForm?: boolean) => void
diff --git a/packages/ui/src/icons/Chevron/index.scss b/packages/ui/src/icons/Chevron/index.scss
index 5cad750b50..f984fe8c71 100644
--- a/packages/ui/src/icons/Chevron/index.scss
+++ b/packages/ui/src/icons/Chevron/index.scss
@@ -2,8 +2,8 @@
@layer payload-default {
.icon--chevron {
- height: calc(var(--base) / 2);
- width: calc(var(--base) / 2);
+ height: var(--base);
+ width: var(--base);
.stroke {
fill: none;
@@ -18,8 +18,8 @@
}
&.icon--size-small {
- height: 8px;
- width: 8px;
+ height: 12px;
+ width: 12px;
}
}
}
diff --git a/packages/ui/src/icons/Chevron/index.tsx b/packages/ui/src/icons/Chevron/index.tsx
index 93c7b23c6c..d59ac0b8d3 100644
--- a/packages/ui/src/icons/Chevron/index.tsx
+++ b/packages/ui/src/icons/Chevron/index.tsx
@@ -24,10 +24,10 @@ export const ChevronIcon: React.FC<{
? 'rotate(180deg)'
: undefined,
}}
- viewBox="0 0 22 12"
+ viewBox="0 0 20 20"
width="100%"
xmlns="http://www.w3.org/2000/svg"
>
-
+
)
diff --git a/packages/ui/src/providers/ListQuery/index.tsx b/packages/ui/src/providers/ListQuery/index.tsx
index 6e60f64163..2d8a1a3f24 100644
--- a/packages/ui/src/providers/ListQuery/index.tsx
+++ b/packages/ui/src/providers/ListQuery/index.tsx
@@ -48,7 +48,10 @@ export const ListQueryProvider: React.FC = ({
if (modifySearchParams) {
return searchParams
} else {
- return {}
+ return {
+ limit: String(defaultLimit),
+ sort: defaultSort,
+ }
}
})
diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx
index aa11611683..eb48063e1d 100644
--- a/packages/ui/src/views/Edit/index.tsx
+++ b/packages/ui/src/views/Edit/index.tsx
@@ -42,6 +42,7 @@ const baseClass = 'collection-edit'
// When rendered within a drawer, props are empty
// This is solely to support custom edit views which get server-rendered
export function DefaultEditView({
+ BeforeDocumentControls,
Description,
PreviewButton,
PublishButton,
@@ -511,6 +512,7 @@ export function DefaultEditView({
/>
+
+ Custom Draft Button
+
+
+ )
+}
diff --git a/test/admin/components/BeforeDocumentControls/CustomSaveButton/index.tsx b/test/admin/components/BeforeDocumentControls/CustomSaveButton/index.tsx
new file mode 100644
index 0000000000..d4ef70125e
--- /dev/null
+++ b/test/admin/components/BeforeDocumentControls/CustomSaveButton/index.tsx
@@ -0,0 +1,23 @@
+import type { BeforeDocumentControlsServerProps } from 'payload'
+
+import React from 'react'
+
+const baseClass = 'custom-save-button'
+
+export function CustomSaveButton(props: BeforeDocumentControlsServerProps) {
+ return (
+
+ )
+}
diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts
index 0fee868040..d3830b255d 100644
--- a/test/admin/e2e/document-view/e2e.spec.ts
+++ b/test/admin/e2e/document-view/e2e.spec.ts
@@ -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): Promise {
diff --git a/test/admin/globals/Global.ts b/test/admin/globals/Global.ts
index e654c584b6..7f0d615102 100644
--- a/test/admin/globals/Global.ts
+++ b/test/admin/globals/Global.ts
@@ -6,6 +6,11 @@ export const Global: GlobalConfig = {
slug: globalSlug,
admin: {
components: {
+ elements: {
+ beforeDocumentControls: [
+ '/components/BeforeDocumentControls/CustomDraftButton/index.js#CustomDraftButton',
+ ],
+ },
views: {
edit: {
api: {
diff --git a/test/database/config.ts b/test/database/config.ts
index b8331c6958..d1656c347a 100644
--- a/test/database/config.ts
+++ b/test/database/config.ts
@@ -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',
diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts
index f4e6263e6f..a4c3210a9a 100644
--- a/test/database/int.spec.ts
+++ b/test/database/int.spec.ts
@@ -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 () => {
diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts
index f2692eaaa8..f29cf028fc 100644
--- a/test/database/payload-types.ts
+++ b/test/database/payload-types.ts
@@ -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 | CategoriesSelect;
posts: PostsSelect | PostsSelect;
'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect | ErrorOnUnnamedFieldsSelect;
'default-values': DefaultValuesSelect | DefaultValuesSelect;
@@ -96,6 +99,7 @@ export interface Config {
'pg-migrations': PgMigrationsSelect | PgMigrationsSelect;
'custom-schema': CustomSchemaSelect | CustomSchemaSelect;
places: PlacesSelect | PlacesSelect;
+ 'virtual-relations': VirtualRelationsSelect | VirtualRelationsSelect;
'fields-persistance': FieldsPersistanceSelect | FieldsPersistanceSelect;
'custom-ids': CustomIdsSelect | CustomIdsSelect;
'fake-custom-ids': FakeCustomIdsSelect | FakeCustomIdsSelect;
@@ -114,11 +118,13 @@ export interface Config {
global: Global;
'global-2': Global2;
'global-3': Global3;
+ 'virtual-relation-global': VirtualRelationGlobal;
};
globalsSelect: {
global: GlobalSelect | GlobalSelect;
'global-2': Global2Select | Global2Select;
'global-3': Global3Select | Global3Select;
+ 'virtual-relation-global': VirtualRelationGlobalSelect | VirtualRelationGlobalSelect;
};
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 {
+ title?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect {
title?: T;
+ category?: T;
+ localized?: T;
+ text?: T;
number?: T;
D1?:
| T
@@ -747,6 +800,19 @@ export interface PlacesSelect {
updatedAt?: T;
createdAt?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "virtual-relations_select".
+ */
+export interface VirtualRelationsSelect {
+ 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 {
createdAt?: T;
globalType?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "virtual-relation-global_select".
+ */
+export interface VirtualRelationGlobalSelect {
+ postTitle?: T;
+ post?: T;
+ updatedAt?: T;
+ createdAt?: T;
+ globalType?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
diff --git a/test/field-error-states/e2e.spec.ts b/test/field-error-states/e2e.spec.ts
index 3398d1b8fd..49bb09b559 100644
--- a/test/field-error-states/e2e.spec.ts
+++ b/test/field-error-states/e2e.spec.ts
@@ -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')
diff --git a/test/fields-relationship/PrePopulateFieldUI/index.tsx b/test/fields-relationship/PopulateFieldButton/index.tsx
similarity index 91%
rename from test/fields-relationship/PrePopulateFieldUI/index.tsx
rename to test/fields-relationship/PopulateFieldButton/index.tsx
index 1ed66a3c2a..c9d8b51e9d 100644
--- a/test/fields-relationship/PrePopulateFieldUI/index.tsx
+++ b/test/fields-relationship/PopulateFieldButton/index.tsx
@@ -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(() => {
diff --git a/test/fields-relationship/collections/UpdatedExternally/index.ts b/test/fields-relationship/collections/UpdatedExternally/index.ts
index 8d208a4000..8e2cd9f5e5 100644
--- a/test/fields-relationship/collections/UpdatedExternally/index.ts
+++ b/test/fields-relationship/collections/UpdatedExternally/index.ts
@@ -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',
diff --git a/test/fields/collections/ConditionalLogic/e2e.spec.ts b/test/fields/collections/ConditionalLogic/e2e.spec.ts
index 3c990f2347..a5dc5ed9d2 100644
--- a/test/fields/collections/ConditionalLogic/e2e.spec.ts
+++ b/test/fields/collections/ConditionalLogic/e2e.spec.ts
@@ -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()
+ })
})
diff --git a/test/fields/collections/ConditionalLogic/index.ts b/test/fields/collections/ConditionalLogic/index.ts
index 864eff4462..96af0df38c 100644
--- a/test/fields/collections/ConditionalLogic/index.ts
+++ b/test/fields/collections/ConditionalLogic/index.ts
@@ -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
diff --git a/test/fields/collections/Relationship/e2e.spec.ts b/test/fields/collections/Relationship/e2e.spec.ts
index eed7ef78ac..5fd1c5a110 100644
--- a/test/fields/collections/Relationship/e2e.spec.ts
+++ b/test/fields/collections/Relationship/e2e.spec.ts
@@ -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): Promise {
diff --git a/test/fields/collections/Relationship/index.ts b/test/fields/collections/Relationship/index.ts
index ee2ee915fa..1a8f6ff924 100644
--- a/test/fields/collections/Relationship/index.ts
+++ b/test/fields/collections/Relationship/index.ts
@@ -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,
}
diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts
index 89fa819e9b..8b1ce2ad53 100644
--- a/test/fields/payload-types.ts
+++ b/test/fields/payload-types.ts
@@ -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 {
text?: T;
toggleField?: T;
fieldWithCondition?: T;
+ fieldWithOperationCondition?: T;
customFieldWithField?: T;
customFieldWithHOC?: T;
customClientFieldWithCondition?: T;
@@ -2786,6 +2811,13 @@ export interface RelationshipFieldsSelect {
relationshipWithMinRows?: T;
relationToRow?: T;
relationToRowMany?: T;
+ relationshipDrawer?: T;
+ relationshipDrawerReadOnly?: T;
+ polymorphicRelationshipDrawer?: T;
+ relationshipDrawerHasMany?: T;
+ relationshipDrawerHasManyPolymorphic?: T;
+ relationshipDrawerWithAllowCreateFalse?: T;
+ relationshipDrawerWithFilterOptions?: T;
updatedAt?: T;
createdAt?: T;
}
diff --git a/test/form-state/collections/Posts/index.ts b/test/form-state/collections/Posts/index.ts
index 14550604fd..54183c26f7 100644
--- a/test/form-state/collections/Posts/index.ts
+++ b/test/form-state/collections/Posts/index.ts
@@ -83,6 +83,10 @@ export const PostsCollection: CollectionConfig = {
},
},
},
+ {
+ name: 'defaultTextField',
+ type: 'text',
+ },
],
},
],
diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts
index 596cb061d0..d44b812ec8 100644
--- a/test/form-state/e2e.spec.ts
+++ b/test/form-state/e2e.spec.ts
@@ -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(
diff --git a/test/form-state/payload-types.ts b/test/form-state/payload-types.ts
index 72b04f150d..dd00cd42ef 100644
--- a/test/form-state/payload-types.ts
+++ b/test/form-state/payload-types.ts
@@ -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
| {
customTextField?: T;
+ defaultTextField?: T;
id?: T;
};
updatedAt?: T;
diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts
index faaa3bdb3d..cf34f031be 100644
--- a/test/plugin-search/int.spec.ts
+++ b/test/plugin-search/int.spec.ts
@@ -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,
},
},
})
diff --git a/test/query-presets/config.ts b/test/query-presets/config.ts
index 2a786ef4af..ebf5dd1119 100644
--- a/test/query-presets/config.ts
+++ b/test/query-presets/config.ts
@@ -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: [
{
diff --git a/test/query-presets/int.spec.ts b/test/query-presets/int.spec.ts
index 3fba8f4f65..42bd771ac1 100644
--- a/test/query-presets/int.spec.ts
+++ b/test/query-presets/int.spec.ts
@@ -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 () => {
diff --git a/test/query-presets/payload-types.ts b/test/query-presets/payload-types.ts
index 759cc0ae93..3918740441 100644
--- a/test/query-presets/payload-types.ts
+++ b/test/query-presets/payload-types.ts
@@ -86,7 +86,7 @@ export interface Config {
'payload-query-presets': PayloadQueryPresetsSelect | PayloadQueryPresetsSelect;
};
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?:
diff --git a/test/query-presets/seed.ts b/test/query-presets/seed.ts
index 38f116f67e..029c283919 100644
--- a/test/query-presets/seed.ts
+++ b/test/query-presets/seed.ts
@@ -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,
)
diff --git a/test/sort/collections/Drafts/index.ts b/test/sort/collections/Drafts/index.ts
index a6d73ae2d9..eab76ebcbc 100644
--- a/test/sort/collections/Drafts/index.ts
+++ b/test/sort/collections/Drafts/index.ts
@@ -7,6 +7,7 @@ export const DraftsCollection: CollectionConfig = {
admin: {
useAsTitle: 'text',
},
+ orderable: true,
versions: {
drafts: true,
},
diff --git a/test/sort/e2e.spec.ts b/test/sort/e2e.spec.ts
index fdc53d19dc..f8a0421378 100644
--- a/test/sort/e2e.spec.ts
+++ b/test/sort/e2e.spec.ts
@@ -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')
})
diff --git a/test/sort/int.spec.ts b/test/sort/int.spec.ts
index 12689c3393..0fea47ee92 100644
--- a/test/sort/int.spec.ts
+++ b/test/sort/int.spec.ts
@@ -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
diff --git a/test/versions/collections/AutosaveWithDraftButton.ts b/test/versions/collections/AutosaveWithDraftButton.ts
new file mode 100644
index 0000000000..3fc719a78d
--- /dev/null
+++ b/test/versions/collections/AutosaveWithDraftButton.ts
@@ -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
diff --git a/test/versions/config.ts b/test/versions/config.ts
index 1b23833611..36fdb76728 100644
--- a/test/versions/config.ts
+++ b/test/versions/config.ts
@@ -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',
diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts
index e5d9e7cd1a..2798fb52cd 100644
--- a/test/versions/e2e.spec.ts
+++ b/test/versions/e2e.spec.ts
@@ -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)
diff --git a/test/versions/globals/AutosaveWithDraftButton.ts b/test/versions/globals/AutosaveWithDraftButton.ts
new file mode 100644
index 0000000000..5d8f1372a8
--- /dev/null
+++ b/test/versions/globals/AutosaveWithDraftButton.ts
@@ -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
diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts
index 6e5e4fc8c6..c016d16822 100644
--- a/test/versions/payload-types.ts
+++ b/test/versions/payload-types.ts
@@ -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 | DisablePublishSelect;
posts: PostsSelect | PostsSelect;
'autosave-posts': AutosavePostsSelect | AutosavePostsSelect;
+ 'autosave-with-draft-button-posts': AutosaveWithDraftButtonPostsSelect | AutosaveWithDraftButtonPostsSelect;
'autosave-with-validate-posts': AutosaveWithValidatePostsSelect | AutosaveWithValidatePostsSelect;
'draft-posts': DraftPostsSelect | DraftPostsSelect;
'draft-with-max-posts': DraftWithMaxPostsSelect | DraftWithMaxPostsSelect;
@@ -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 | AutosaveGlobalSelect;
+ 'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobalSelect | AutosaveWithDraftButtonGlobalSelect;
'draft-global': DraftGlobalSelect | DraftGlobalSelect;
'draft-with-max-global': DraftWithMaxGlobalSelect | DraftWithMaxGlobalSelect;
'disable-publish-global': DisablePublishGlobalSelect | DisablePublishGlobalSelect;
@@ -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 {
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 {
+ 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 {
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 {
+ 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;
};
diff --git a/test/versions/slugs.ts b/test/versions/slugs.ts
index 2dccdb8b65..5807c381b5 100644
--- a/test/versions/slugs.ts
+++ b/test/versions/slugs.ts
@@ -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]