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:
Patrik
2025-04-17 15:23:17 -04:00
committed by GitHub
parent 34ea6ec14f
commit d55306980e
14 changed files with 216 additions and 21 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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,
/** /**

View File

@@ -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

View File

@@ -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)

View File

@@ -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
*/ */

View File

@@ -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

View File

@@ -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}

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -110,7 +110,7 @@ describe('Document View', () => {
}) })
expect(collectionItems.docs.length).toBe(1) 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> {

View File

@@ -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: {