feat: adds beforeDocumentControls slot to allow custom component injection next to document controls (#12104)
### What
This PR introduces a new `beforeDocumentControls` slot to the edit view
of both collections and globals.
It allows injecting one or more custom components next to the document
control buttons (e.g., Save, Publish, Save Draft) in the admin UI —
useful for adding context, additional buttons, or custom UI elements.
#### Usage
##### For collections:
```
admin: {
components: {
edit: {
beforeDocumentControls: ['/path/to/CustomComponent'],
},
},
},
```
##### For globals:
```
admin: {
components: {
elements: {
beforeDocumentControls: ['/path/to/CustomComponent'],
},
},
},
```
This commit is contained in:
@@ -102,7 +102,8 @@ export const MyCollection: CollectionConfig = {
|
|||||||
The following options are available:
|
The following options are available:
|
||||||
|
|
||||||
| Path | 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). |
|
| `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). |
|
| `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). |
|
| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). |
|
||||||
@@ -134,7 +135,8 @@ export const MyGlobal: GlobalConfig = {
|
|||||||
The following options are available:
|
The following options are available:
|
||||||
|
|
||||||
| Path | 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). |
|
| `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). |
|
| `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). |
|
| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). |
|
||||||
@@ -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 <div>This is a custom beforeDocumentControl button (Server)</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Client Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
import React from 'react'
|
||||||
|
import type { BeforeDocumentControlsClientProps } from 'payload'
|
||||||
|
|
||||||
|
export function MyCustomDocumentControlButton(
|
||||||
|
props: BeforeDocumentControlsClientProps,
|
||||||
|
) {
|
||||||
|
return <div>This is a custom beforeDocumentControl button (Client)</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### SaveDraftButton
|
### SaveDraftButton
|
||||||
|
|
||||||
The `SaveDraftButton` property allows you to render a custom Save Draft Button in the Edit View.
|
The `SaveDraftButton` property allows you to render a custom Save Draft Button in the Edit View.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
BeforeDocumentControlsServerPropsOnly,
|
||||||
DefaultServerFunctionArgs,
|
DefaultServerFunctionArgs,
|
||||||
DocumentSlots,
|
DocumentSlots,
|
||||||
PayloadRequest,
|
PayloadRequest,
|
||||||
@@ -42,6 +43,18 @@ export const renderDocumentSlots: (args: {
|
|||||||
// TODO: Add remaining serverProps
|
// 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 =
|
const CustomPreviewButton =
|
||||||
collectionConfig?.admin?.components?.edit?.PreviewButton ||
|
collectionConfig?.admin?.components?.edit?.PreviewButton ||
|
||||||
globalConfig?.admin?.components?.elements?.PreviewButton
|
globalConfig?.admin?.components?.elements?.PreviewButton
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ export type FieldRow = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentSlots = {
|
export type DocumentSlots = {
|
||||||
|
BeforeDocumentControls?: React.ReactNode
|
||||||
Description?: React.ReactNode
|
Description?: React.ReactNode
|
||||||
PreviewButton?: React.ReactNode
|
PreviewButton?: React.ReactNode
|
||||||
PublishButton?: React.ReactNode
|
PublishButton?: React.ReactNode
|
||||||
@@ -578,6 +579,9 @@ export type { LanguageOptions } from './LanguageOptions.js'
|
|||||||
export type { RichTextAdapter, RichTextAdapterProvider, RichTextHooks } from './RichText.js'
|
export type { RichTextAdapter, RichTextAdapterProvider, RichTextHooks } from './RichText.js'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
BeforeDocumentControlsClientProps,
|
||||||
|
BeforeDocumentControlsServerProps,
|
||||||
|
BeforeDocumentControlsServerPropsOnly,
|
||||||
DocumentSubViewTypes,
|
DocumentSubViewTypes,
|
||||||
DocumentTabClientProps,
|
DocumentTabClientProps,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ export type DocumentTabServerPropsOnly = {
|
|||||||
readonly permissions: SanitizedPermissions
|
readonly permissions: SanitizedPermissions
|
||||||
} & ServerProps
|
} & ServerProps
|
||||||
|
|
||||||
export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerPropsOnly
|
|
||||||
|
|
||||||
export type DocumentTabClientProps = {
|
export type DocumentTabClientProps = {
|
||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerPropsOnly
|
||||||
|
|
||||||
export type DocumentTabCondition = (args: {
|
export type DocumentTabCondition = (args: {
|
||||||
collectionConfig: SanitizedCollectionConfig
|
collectionConfig: SanitizedCollectionConfig
|
||||||
config: SanitizedConfig
|
config: SanitizedConfig
|
||||||
@@ -75,3 +75,10 @@ export type DocumentTabConfig = {
|
|||||||
export type DocumentTabComponent = PayloadComponent<{
|
export type DocumentTabComponent = PayloadComponent<{
|
||||||
path: string
|
path: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
// BeforeDocumentControls
|
||||||
|
|
||||||
|
export type BeforeDocumentControlsClientProps = {}
|
||||||
|
export type BeforeDocumentControlsServerPropsOnly = {} & ServerProps
|
||||||
|
export type BeforeDocumentControlsServerProps = BeforeDocumentControlsClientProps &
|
||||||
|
BeforeDocumentControlsServerPropsOnly
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function iterateCollections({
|
|||||||
addToImportMap(collection.admin?.components?.beforeListTable)
|
addToImportMap(collection.admin?.components?.beforeListTable)
|
||||||
addToImportMap(collection.admin?.components?.Description)
|
addToImportMap(collection.admin?.components?.Description)
|
||||||
|
|
||||||
|
addToImportMap(collection.admin?.components?.edit?.beforeDocumentControls)
|
||||||
addToImportMap(collection.admin?.components?.edit?.PreviewButton)
|
addToImportMap(collection.admin?.components?.edit?.PreviewButton)
|
||||||
addToImportMap(collection.admin?.components?.edit?.PublishButton)
|
addToImportMap(collection.admin?.components?.edit?.PublishButton)
|
||||||
addToImportMap(collection.admin?.components?.edit?.SaveButton)
|
addToImportMap(collection.admin?.components?.edit?.SaveButton)
|
||||||
|
|||||||
@@ -279,6 +279,10 @@ export type CollectionAdminOptions = {
|
|||||||
* Components within the edit view
|
* Components within the edit view
|
||||||
*/
|
*/
|
||||||
edit?: {
|
edit?: {
|
||||||
|
/**
|
||||||
|
* Inject custom components before the document controls
|
||||||
|
*/
|
||||||
|
beforeDocumentControls?: CustomComponent[]
|
||||||
/**
|
/**
|
||||||
* Replaces the "Preview" button
|
* Replaces the "Preview" button
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
} from '../../admin/types.js'
|
} from '../../admin/types.js'
|
||||||
import type {
|
import type {
|
||||||
Access,
|
Access,
|
||||||
|
CustomComponent,
|
||||||
EditConfig,
|
EditConfig,
|
||||||
Endpoint,
|
Endpoint,
|
||||||
EntityDescription,
|
EntityDescription,
|
||||||
@@ -80,6 +81,10 @@ export type GlobalAdminOptions = {
|
|||||||
*/
|
*/
|
||||||
components?: {
|
components?: {
|
||||||
elements?: {
|
elements?: {
|
||||||
|
/**
|
||||||
|
* Inject custom components before the document controls
|
||||||
|
*/
|
||||||
|
beforeDocumentControls?: CustomComponent[]
|
||||||
Description?: EntityDescriptionComponent
|
Description?: EntityDescriptionComponent
|
||||||
/**
|
/**
|
||||||
* Replaces the "Preview" button
|
* Replaces the "Preview" button
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const baseClass = 'doc-controls'
|
|||||||
|
|
||||||
export const DocumentControls: React.FC<{
|
export const DocumentControls: React.FC<{
|
||||||
readonly apiURL: string
|
readonly apiURL: string
|
||||||
|
readonly BeforeDocumentControls?: React.ReactNode
|
||||||
readonly customComponents?: {
|
readonly customComponents?: {
|
||||||
readonly PreviewButton?: React.ReactNode
|
readonly PreviewButton?: React.ReactNode
|
||||||
readonly PublishButton?: React.ReactNode
|
readonly PublishButton?: React.ReactNode
|
||||||
@@ -67,6 +68,7 @@ export const DocumentControls: React.FC<{
|
|||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
slug,
|
slug,
|
||||||
|
BeforeDocumentControls,
|
||||||
customComponents: {
|
customComponents: {
|
||||||
PreviewButton: CustomPreviewButton,
|
PreviewButton: CustomPreviewButton,
|
||||||
PublishButton: CustomPublishButton,
|
PublishButton: CustomPublishButton,
|
||||||
@@ -222,6 +224,7 @@ export const DocumentControls: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<div className={`${baseClass}__controls-wrapper`}>
|
<div className={`${baseClass}__controls-wrapper`}>
|
||||||
<div className={`${baseClass}__controls`}>
|
<div className={`${baseClass}__controls`}>
|
||||||
|
{BeforeDocumentControls}
|
||||||
{(collectionConfig?.admin.preview || globalConfig?.admin.preview) && (
|
{(collectionConfig?.admin.preview || globalConfig?.admin.preview) && (
|
||||||
<RenderCustomComponent
|
<RenderCustomComponent
|
||||||
CustomComponent={CustomPreviewButton}
|
CustomComponent={CustomPreviewButton}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const baseClass = 'collection-edit'
|
|||||||
// When rendered within a drawer, props are empty
|
// When rendered within a drawer, props are empty
|
||||||
// This is solely to support custom edit views which get server-rendered
|
// This is solely to support custom edit views which get server-rendered
|
||||||
export function DefaultEditView({
|
export function DefaultEditView({
|
||||||
|
BeforeDocumentControls,
|
||||||
Description,
|
Description,
|
||||||
PreviewButton,
|
PreviewButton,
|
||||||
PublishButton,
|
PublishButton,
|
||||||
@@ -511,6 +512,7 @@ export function DefaultEditView({
|
|||||||
/>
|
/>
|
||||||
<DocumentControls
|
<DocumentControls
|
||||||
apiURL={apiURL}
|
apiURL={apiURL}
|
||||||
|
BeforeDocumentControls={BeforeDocumentControls}
|
||||||
customComponents={{
|
customComponents={{
|
||||||
PreviewButton,
|
PreviewButton,
|
||||||
PublishButton,
|
PublishButton,
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export const Posts: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
edit: {
|
||||||
|
beforeDocumentControls: [
|
||||||
|
'/components/BeforeDocumentControls/CustomDraftButton/index.js#CustomDraftButton',
|
||||||
|
'/components/BeforeDocumentControls/CustomSaveButton/index.js#CustomSaveButton',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
defaultLimit: 5,
|
defaultLimit: 5,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { BeforeDocumentControlsServerProps } from 'payload'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const baseClass = 'custom-draft-button'
|
||||||
|
|
||||||
|
export function CustomDraftButton(props: BeforeDocumentControlsServerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={baseClass}
|
||||||
|
id="custom-draft-button"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'calc(var(--base) / 4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="nav__label" style={{ color: 'var(--theme-text)', margin: 0 }}>
|
||||||
|
Custom Draft Button
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { BeforeDocumentControlsServerProps } from 'payload'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const baseClass = 'custom-save-button'
|
||||||
|
|
||||||
|
export function CustomSaveButton(props: BeforeDocumentControlsServerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={baseClass}
|
||||||
|
id="custom-save-button"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'calc(var(--base) / 4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="nav__label" style={{ color: 'var(--theme-text)', margin: 0 }}>
|
||||||
|
Custom Save Button
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ describe('Document View', () => {
|
|||||||
})
|
})
|
||||||
expect(collectionItems.docs.length).toBe(1)
|
expect(collectionItems.docs.length).toBe(1)
|
||||||
await page.goto(
|
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)
|
await expect(page.locator('.not-found')).toHaveCount(1)
|
||||||
})
|
})
|
||||||
@@ -333,20 +333,32 @@ describe('Document View', () => {
|
|||||||
await navigateToDoc(page, postsUrl)
|
await navigateToDoc(page, postsUrl)
|
||||||
await page.locator('#field-title').fill(title)
|
await page.locator('#field-title').fill(title)
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
|
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
await wait(500)
|
await wait(500)
|
||||||
|
|
||||||
const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content')
|
const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content')
|
||||||
await expect(drawer1Content).toBeVisible()
|
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
|
await drawer1Content
|
||||||
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
|
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
const drawer2Content = page.locator('[id^=doc-drawer_posts_2_] .drawer__content')
|
const drawer2Content = page.locator('[id^=doc-drawer_posts_2_] .drawer__content')
|
||||||
await expect(drawer2Content).toBeVisible()
|
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')
|
await expect(fileField).toHaveValue('some file text')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('custom document controls', () => {
|
||||||
|
test('should show custom elements in document controls in collection', async () => {
|
||||||
|
await page.goto(postsUrl.create)
|
||||||
|
const customDraftButton = page.locator('#custom-draft-button')
|
||||||
|
const customSaveButton = page.locator('#custom-save-button')
|
||||||
|
|
||||||
|
await expect(customDraftButton).toBeVisible()
|
||||||
|
await expect(customSaveButton).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should show custom elements in document controls in global', async () => {
|
||||||
|
await page.goto(globalURL.global(globalSlug))
|
||||||
|
const customDraftButton = page.locator('#custom-draft-button')
|
||||||
|
|
||||||
|
await expect(customDraftButton).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function createPost(overrides?: Partial<Post>): Promise<Post> {
|
async function createPost(overrides?: Partial<Post>): Promise<Post> {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const Global: GlobalConfig = {
|
|||||||
slug: globalSlug,
|
slug: globalSlug,
|
||||||
admin: {
|
admin: {
|
||||||
components: {
|
components: {
|
||||||
|
elements: {
|
||||||
|
beforeDocumentControls: [
|
||||||
|
'/components/BeforeDocumentControls/CustomDraftButton/index.js#CustomDraftButton',
|
||||||
|
],
|
||||||
|
},
|
||||||
views: {
|
views: {
|
||||||
edit: {
|
edit: {
|
||||||
api: {
|
api: {
|
||||||
|
|||||||
Reference in New Issue
Block a user