diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx
index e4c5c514b6..43b361de65 100644
--- a/docs/fields/blocks.mdx
+++ b/docs/fields/blocks.mdx
@@ -84,6 +84,51 @@ The Blocks Field inherits all of the default options from the base [Field Admin
| **`initCollapsed`** | Set the initial collapsed state |
| **`isSortable`** | Disable order sorting by setting this value to `false` |
+#### Customizing the way your block is rendered in Lexical
+
+If you're using this block within the [Lexical editor](/docs/lexical/overview), you can also customize how the block is rendered in the Lexical editor itself by specifying custom components.
+
+- `admin.components.Label` - pass a custom React component here to customize the way that the label is rendered for this block
+- `admin.components.Block` - pass a component here to completely override the way the block is rendered in Lexical with your own component
+
+This is super handy if you'd like to present your editors with a very deliberate and nicely designed block "preview" right in your rich text.
+
+For example, if you have a `gallery` block, you might want to actually render the gallery of images directly in your Lexical block. With the `admin.components.Block` property, you can do exactly that!
+
+
+ Tip:
+ If you customize the way your block is rendered in Lexical, you can import utility components to easily edit / remove your block - so that you don't have to build all of this yourself.
+
+
+To import these utility components for one of your custom blocks, you can import the following:
+
+```ts
+import {
+ // Edit block buttons (choose the one that corresponds to your usage)
+ // When clicked, this will open a drawer with your block's fields
+ // so your editors can edit them
+ InlineBlockEditButton,
+ BlockEditButton,
+
+ // Buttons that will remove this block from Lexical
+ // (choose the one that corresponds to your usage)
+ InlineBlockRemoveButton,
+ BlockRemoveButton,
+
+ // The label that should be rendered for an inline block
+ InlineBlockLabel,
+
+ // The default "container" that is rendered for an inline block
+ // if you want to re-use it
+ InlineBlockContainer,
+
+ // The default "collapsible" UI that is rendered for a regular block
+ // if you want to re-use it
+ BlockCollapsible,
+
+} from '@payloadcms/richtext-lexical/client'
+```
+
## Block Configs
Blocks are defined as separate configs of their own.
diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts
index bd96fadb53..6cfe651840 100644
--- a/packages/payload/src/admin/forms/Form.ts
+++ b/packages/payload/src/admin/forms/Form.ts
@@ -22,6 +22,11 @@ export type FilterOptionsResult = {
export type FieldState = {
customComponents?: {
+ /**
+ * This is used by UI fields, as they can have arbitrary components defined if used
+ * as a vessel to bring in custom components.
+ */
+ [key: string]: React.ReactNode | React.ReactNode[] | undefined
AfterInput?: React.ReactNode
BeforeInput?: React.ReactNode
Description?: React.ReactNode
diff --git a/packages/payload/src/bin/generateImportMap/iterateFields.ts b/packages/payload/src/bin/generateImportMap/iterateFields.ts
index 0d461cd09c..1dcc387fbb 100644
--- a/packages/payload/src/bin/generateImportMap/iterateFields.ts
+++ b/packages/payload/src/bin/generateImportMap/iterateFields.ts
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions */
import type { PayloadComponent, SanitizedConfig } from '../../config/types.js'
import type { Block, Field, Tab } from '../../fields/config/types.js'
import type { AddToImportMap, Imports, InternalImportMap } from './index.js'
@@ -9,6 +10,12 @@ function hasKey(
return obj != null && Object.prototype.hasOwnProperty.call(obj, key)
}
+const defaultUIFieldComponentKeys: Array<'Cell' | 'Description' | 'Field' | 'Filter'> = [
+ 'Cell',
+ 'Description',
+ 'Field',
+ 'Filter',
+]
export function genImportMapIterateFields({
addToImportMap,
baseDir,
@@ -67,10 +74,22 @@ export function genImportMapIterateFields({
imports,
})
}
+ } else if (field.type === 'ui') {
+ if (field?.admin?.components) {
+ // Render any extra, untyped components
+ for (const key in field.admin.components) {
+ if (key in defaultUIFieldComponentKeys) {
+ continue
+ }
+ addToImportMap(field.admin.components[key])
+ }
+ }
}
hasKey(field?.admin?.components, 'Label') && addToImportMap(field.admin.components.Label)
+ hasKey(field?.admin?.components, 'Block') && addToImportMap(field.admin.components.Block)
+
hasKey(field?.admin?.components, 'Cell') && addToImportMap(field?.admin?.components?.Cell)
hasKey(field?.admin?.components, 'Description') &&
diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts
index e77470668b..0a455225ca 100644
--- a/packages/payload/src/fields/config/types.ts
+++ b/packages/payload/src/fields/config/types.ts
@@ -748,8 +748,14 @@ export type TabAsFieldClient = ClientTab & Pick
export type UIField = {
admin: {
components?: {
+ /**
+ * Allow any custom components to be added to the UI field. This allows
+ * the UI field to be used as a vessel for getting components rendered.
+ */
+ [key: string]: PayloadComponent | undefined
Cell?: CustomComponent
- Field: CustomComponent
+ // Can be optional, in case the UI field is just used as a vessel for custom components
+ Field?: CustomComponent
/**
* The Filter component has to be a client component
*/
@@ -1188,16 +1194,11 @@ export type Block = {
_sanitized?: boolean
admin?: {
components?: {
- Label?: PayloadComponent<
- never,
- {
- blockKind: 'block' | 'lexicalBlock' | 'lexicalInlineBlock' | string
- /**
- * May contain the formData
- */
- formData: Record
- }
- >
+ /**
+ * This will replace the entire block component, including the block header / collapsible.
+ */
+ Block?: PayloadComponent
+ Label?: PayloadComponent
}
/** Extension point to add your custom data. Available in server and client. */
custom?: Record
@@ -1227,11 +1228,7 @@ export type Block = {
}
export type ClientBlock = {
- admin?: {
- components?: {
- Label?: React.ReactNode
- }
- } & Pick
+ admin?: Pick
fields: ClientField[]
labels?: LabelsClient
} & Pick
diff --git a/packages/richtext-lexical/src/exports/client/index.ts b/packages/richtext-lexical/src/exports/client/index.ts
index 0d217b9ce7..49c6166394 100644
--- a/packages/richtext-lexical/src/exports/client/index.ts
+++ b/packages/richtext-lexical/src/exports/client/index.ts
@@ -128,3 +128,11 @@ export {
} from '../../features/blocks/client/nodes/InlineBlocksNode.js'
export { FieldsDrawer } from '../../utilities/fieldsDrawer/Drawer.js'
+
+export { InlineBlockEditButton } from '../../features/blocks/client/componentInline/components/InlineBlockEditButton.js'
+export { InlineBlockRemoveButton } from '../../features/blocks/client/componentInline/components/InlineBlockRemoveButton.js'
+export { InlineBlockLabel } from '../../features/blocks/client/componentInline/components/InlineBlockLabel.js'
+export { InlineBlockContainer } from '../../features/blocks/client/componentInline/components/InlineBlockContainer.js'
+export { BlockCollapsible } from '../../features/blocks/client/component/components/BlockCollapsible.js'
+export { BlockEditButton } from '../../features/blocks/client/component/components/BlockEditButton.js'
+export { BlockRemoveButton } from '../../features/blocks/client/component/components/BlockRemoveButton.js'
diff --git a/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx b/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx
index 5599049348..83de21dda5 100644
--- a/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx
+++ b/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx
@@ -1,239 +1,128 @@
'use client'
-import type { ClientBlock, ClientField, CollapsedPreferences, FormState } from 'payload'
+import type { ClientField, FormState } from 'payload'
-import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
-import { getTranslation } from '@payloadcms/translations'
-import {
- Button,
- Collapsible,
- ErrorPill,
- Pill,
- RenderFields,
- SectionTitle,
- useDocumentInfo,
- useFormSubmitted,
- useTranslation,
-} from '@payloadcms/ui'
-import { dequal } from 'dequal/lite'
-import { $getNodeByKey } from 'lexical'
-import React, { useCallback, useEffect } from 'react'
+import { RenderFields } from '@payloadcms/ui'
+import React, { createContext, useMemo } from 'react'
-import type { LexicalRichTextFieldProps } from '../../../../types.js'
import type { BlockFields } from '../../server/nodes/BlocksNode.js'
-import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
-import { $isBlockNode } from '../nodes/BlocksNode.js'
-import { FormSavePlugin } from './FormSavePlugin.js'
+import { useFormSave } from './FormSavePlugin.js'
type Props = {
baseClass: string
- clientBlock: ClientBlock
- field: LexicalRichTextFieldProps['field']
+ BlockDrawer: React.FC
+ Collapsible: React.FC<{
+ children?: React.ReactNode
+ editButton?: boolean
+ errorCount?: number
+ fieldHasErrors?: boolean
+ /**
+ * Override the default label with a custom label
+ */
+ Label?: React.ReactNode
+ removeButton?: boolean
+ }>
+ CustomBlock: React.ReactNode
+ EditButton: React.FC
formData: BlockFields
formSchema: ClientField[]
- Label?: React.ReactNode
+ initialState: false | FormState | undefined
nodeKey: string
- path: string
- schemaPath: string
+
+ RemoveButton: React.FC
}
-// Recursively remove all undefined values from even being present in formData, as they will
-// cause isDeepEqual to return false if, for example, formData has a key that fields.data
-// does not have, even if it's undefined.
-// Currently, this happens if a block has another sub-blocks field. Inside formData, that sub-blocks field has an undefined blockName property.
-// Inside of fields.data however, that sub-blocks blockName property does not exist at all.
-function removeUndefinedAndNullAndEmptyArraysRecursively(obj: object) {
- for (const key in obj) {
- const value = obj[key]
- if (Array.isArray(value) && !value?.length) {
- delete obj[key]
- } else if (value && typeof value === 'object') {
- removeUndefinedAndNullAndEmptyArraysRecursively(value)
- } else if (value === undefined || value === null) {
- delete obj[key]
- }
- }
+type BlockComponentContextType = {
+ BlockCollapsible?: React.FC<{
+ children?: React.ReactNode
+ editButton?: boolean
+ /**
+ * Override the default label with a custom label
+ */
+ Label?: React.ReactNode
+ removeButton?: boolean
+ }>
+ EditButton?: React.FC
+ initialState: false | FormState | undefined
+
+ nodeKey?: string
+ RemoveButton?: React.FC
}
+const BlockComponentContext = createContext({
+ initialState: false,
+})
+
+export const useBlockComponentContext = () => React.useContext(BlockComponentContext)
+
/**
* The actual content of the Block. This should be INSIDE a Form component,
* scoped to the block. All format operations in here are thus scoped to the block's form, and
* not the whole document.
*/
export const BlockContent: React.FC = (props) => {
- const { baseClass, clientBlock, field, formSchema, Label, nodeKey } = props
- let { formData } = props
+ const {
+ BlockDrawer,
+ Collapsible,
+ CustomBlock,
+ EditButton,
+ formData,
+ formSchema,
+ initialState,
+ nodeKey,
+ RemoveButton,
+ } = props
- const { i18n } = useTranslation()
- const [editor] = useLexicalComposerContext()
- // Used for saving collapsed to preferences (and gettin' it from there again)
- // Remember, these preferences are scoped to the whole document, not just this form. This
- // is important to consider for the data path used in setDocFieldPreferences
- const { getDocPreferences, setDocFieldPreferences } = useDocumentInfo()
+ const { errorCount, fieldHasErrors } = useFormSave({ disabled: !initialState, formData, nodeKey })
- const [isCollapsed, setIsCollapsed] = React.useState()
+ const CollapsibleWithErrorProps = useMemo(
+ () =>
+ (props: {
+ children?: React.ReactNode
+ editButton?: boolean
- useEffect(() => {
- void getDocPreferences().then((currentDocPreferences) => {
- const currentFieldPreferences = currentDocPreferences?.fields[field.name]
- const collapsedArray = currentFieldPreferences?.collapsed
- setIsCollapsed(collapsedArray ? collapsedArray.includes(formData.id) : false)
- })
- }, [field.name, formData.id, getDocPreferences])
-
- const hasSubmitted = useFormSubmitted()
-
- const [errorCount, setErrorCount] = React.useState(0)
-
- const fieldHasErrors = hasSubmitted && errorCount > 0
-
- const classNames = [
- `${baseClass}__row`,
- fieldHasErrors ? `${baseClass}__row--has-errors` : `${baseClass}__row--no-errors`,
- ]
- .filter(Boolean)
- .join(' ')
-
- const onFormChange = useCallback(
- ({
- fullFieldsWithValues,
- newFormData,
- }: {
- fullFieldsWithValues: FormState
- newFormData: BlockFields
- }) => {
- newFormData.id = formData.id
- newFormData.blockType = formData.blockType
-
- removeUndefinedAndNullAndEmptyArraysRecursively(newFormData)
- removeUndefinedAndNullAndEmptyArraysRecursively(formData)
-
- // Only update if the data has actually changed. Otherwise, we may be triggering an unnecessary value change,
- // which would trigger the "Leave without saving" dialog unnecessarily
- if (!dequal(formData, newFormData)) {
- // Running this in the next tick in the meantime fixes this issue: https://github.com/payloadcms/payload/issues/4108
- // I don't know why. When this is called immediately, it might focus out of a nested lexical editor field if an update is made there.
- // My hypothesis is that the nested editor might not have fully finished its update cycle yet. By updating in the next tick, we
- // ensure that the nested editor has finished its update cycle before we update the block node.
- setTimeout(() => {
- editor.update(() => {
- const node = $getNodeByKey(nodeKey)
- if (node && $isBlockNode(node)) {
- formData = newFormData
- node.setFields(newFormData)
- }
- })
- }, 0)
- }
-
- // update error count
- if (hasSubmitted) {
- let rowErrorCount = 0
- for (const formField of Object.values(fullFieldsWithValues)) {
- if (formField?.valid === false) {
- rowErrorCount++
- }
- }
- setErrorCount(rowErrorCount)
- }
- },
- [editor, nodeKey, hasSubmitted, formData],
+ /**
+ * Override the default label with a custom label
+ */
+ Label?: React.ReactNode
+ removeButton?: boolean
+ }) => (
+
+ {props.children}
+
+ ),
+ [Collapsible, fieldHasErrors, errorCount],
)
- const onCollapsedChange = useCallback(
- (changedCollapsed: boolean) => {
- void getDocPreferences().then((currentDocPreferences) => {
- const currentFieldPreferences = currentDocPreferences?.fields[field.name]
-
- const collapsedArray = currentFieldPreferences?.collapsed
-
- const newCollapsed: CollapsedPreferences =
- collapsedArray && collapsedArray?.length ? collapsedArray : []
-
- if (changedCollapsed) {
- if (!newCollapsed.includes(formData.id)) {
- newCollapsed.push(formData.id)
- }
- } else {
- if (newCollapsed.includes(formData.id)) {
- newCollapsed.splice(newCollapsed.indexOf(formData.id), 1)
- }
- }
-
- setDocFieldPreferences(field.name, {
- collapsed: newCollapsed,
- hello: 'hi',
- })
- })
- },
- [getDocPreferences, field.name, setDocFieldPreferences, formData.id],
- )
-
- const removeBlock = useCallback(() => {
- editor.update(() => {
- $getNodeByKey(nodeKey)?.remove()
- })
- }, [editor, nodeKey])
-
- if (typeof isCollapsed !== 'boolean') {
- return null
- }
-
- return (
-
-
-