feat!: on demand rsc (#8364)

Currently, Payload renders all custom components on initial compile of
the admin panel. This is problematic for two key reasons:
1. Custom components do not receive contextual data, i.e. fields do not
receive their field data, edit views do not receive their document data,
etc.
2. Components are unnecessarily rendered before they are used

This was initially required to support React Server Components within
the Payload Admin Panel for two key reasons:
1. Fields can be dynamically rendered within arrays, blocks, etc.
2. Documents can be recursively rendered within a "drawer" UI, i.e.
relationship fields
3. Payload supports server/client component composition 

In order to achieve this, components need to be rendered on the server
and passed as "slots" to the client. Currently, the pattern for this is
to render custom server components in the "client config". Then when a
view or field is needed to be rendered, we first check the client config
for a "pre-rendered" component, otherwise render our client-side
fallback component.

But for the reasons listed above, this pattern doesn't exactly make
custom server components very useful within the Payload Admin Panel,
which is where this PR comes in. Now, instead of pre-rendering all
components on initial compile, we're able to render custom components
_on demand_, only as they are needed.

To achieve this, we've established [this
pattern](https://github.com/payloadcms/payload/pull/8481) of React
Server Functions in the Payload Admin Panel. With Server Functions, we
can iterate the Payload Config and return JSX through React's
`text/x-component` content-type. This means we're able to pass
contextual props to custom components, such as data for fields and
views.

## Breaking Changes

1. Add the following to your root layout file, typically located at
`(app)/(payload)/layout.tsx`:

    ```diff
    /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
    /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
    + import type { ServerFunctionClient } from 'payload'

    import config from '@payload-config'
    import { RootLayout } from '@payloadcms/next/layouts'
    import { handleServerFunctions } from '@payloadcms/next/utilities'
    import React from 'react'

    import { importMap } from './admin/importMap.js'
    import './custom.scss'

    type Args = {
      children: React.ReactNode
    }

+ const serverFunctions: ServerFunctionClient = async function (args) {
    +  'use server'
    +  return handleServerFunctions({
    +    ...args,
    +    config,
    +    importMap,
    +  })
    + }

    const Layout = ({ children }: Args) => (
      <RootLayout
        config={config}
        importMap={importMap}
    +  serverFunctions={serverFunctions}
      >
        {children}
      </RootLayout>
    )

    export default Layout
    ```

2. If you were previously posting to the `/api/form-state` endpoint, it
no longer exists. Instead, you'll need to invoke the `form-state` Server
Function, which can be done through the _new_ `getFormState` utility:

    ```diff
    - import { getFormState } from '@payloadcms/ui'
    - const { state } = await getFormState({
    -   apiRoute: '',
    -   body: {
    -     // ...
    -   },
    -   serverURL: ''
    - })

    + const { getFormState } = useServerFunctions()
    +
    + const { state } = await getFormState({
    +   // ...
    + })
    ```

## Breaking Changes

```diff
- useFieldProps()
- useCellProps()
```

More details coming soon.

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Jacob Fletcher
2024-11-11 13:59:05 -05:00
committed by GitHub
parent 3e954f45c7
commit c96fa613bc
657 changed files with 34245 additions and 21057 deletions

View File

@@ -17,11 +17,15 @@
"types": "./src/index.tsx",
"default": "./src/index.tsx"
},
"./generateComponentMap": "./src/generateComponentMap.tsx",
"./client": {
"import": "./src/exports/client/index.ts",
"types": "./src/exports/client/index.ts",
"default": "./src/exports/client/index.ts"
},
"./rsc": {
"import": "./src/exports/server/rsc.ts",
"types": "./src/exports/server/rsc.ts",
"default": "./src/exports/server/rsc.ts"
}
},
"main": "./src/index.tsx",
@@ -70,10 +74,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./generateComponentMap": {
"import": "./dist/generateComponentMap.js",
"types": "./dist/generateComponentMap.d.ts",
"default": "./dist/generateComponentMap.js"
"./rsc": {
"import": "./dist/exports/server/rsc.js",
"types": "./dist/exports/server/rsc.d.ts",
"default": "./dist/exports/server/rsc.js"
},
"./client": {
"import": "./dist/exports/client/index.js",

View File

@@ -1,48 +0,0 @@
'use client'
import type { DefaultCellComponentProps } from 'payload'
import { useConfig, useTableCell } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const RichTextCell: React.FC<DefaultCellComponentProps<any[]>> = () => {
const { cellData, cellProps, columnIndex, customCellContext, rowData } = useTableCell()
const flattenedText = cellData?.map((i) => i?.children?.map((c) => c.text)).join(' ')
const {
config: {
routes: { admin: adminRoute },
},
} = useConfig()
const { link } = cellProps || {}
let WrapElement: React.ComponentType<any> | string = 'span'
const wrapElementProps: {
className?: string
href?: string
onClick?: () => void
type?: 'button'
} = {}
const isLink = link !== undefined ? link : columnIndex === 0
if (isLink) {
WrapElement = Link
wrapElementProps.href = customCellContext?.collectionSlug
? formatAdminURL({
adminRoute,
path: `/collections/${customCellContext?.collectionSlug}/${rowData.id}`,
})
: ''
}
if (isLink) {
return <WrapElement {...wrapElementProps}>{flattenedText}</WrapElement>
}
// Limiting the number of characters shown is done in a CSS rule
return <span>{flattenedText}</span>
}

View File

@@ -0,0 +1,87 @@
import type { DefaultCellComponentProps, Payload } from 'payload'
import { getTranslation, type I18nClient } from '@payloadcms/translations'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const RscEntrySlateCell: React.FC<
{
i18n: I18nClient
payload: Payload
} & DefaultCellComponentProps
> = (props) => {
const {
cellData,
className: classNameFromProps,
collectionConfig,
field: { admin },
field,
i18n,
link,
onClick: onClickFromProps,
payload,
rowData,
} = props
const classNameFromConfigContext = admin && 'className' in admin ? admin.className : undefined
const className =
classNameFromProps ||
(field.admin && 'className' in field.admin ? field.admin.className : null) ||
classNameFromConfigContext
const adminRoute = payload.config.routes.admin
const onClick = onClickFromProps
let WrapElement: React.ComponentType<any> | string = 'span'
const wrapElementProps: {
className?: string
href?: string
onClick?: () => void
prefetch?: false
type?: 'button'
} = {
className,
}
if (link) {
wrapElementProps.prefetch = false
WrapElement = Link
wrapElementProps.href = collectionConfig?.slug
? formatAdminURL({
adminRoute,
path: `/collections/${collectionConfig?.slug}/${rowData.id}`,
})
: ''
}
if (typeof onClick === 'function') {
WrapElement = 'button'
wrapElementProps.type = 'button'
wrapElementProps.onClick = () => {
onClick({
cellData,
collectionSlug: collectionConfig?.slug,
rowData,
})
}
}
let textContent = ''
if (cellData) {
textContent = cellData?.map((i) => i?.children?.map((c) => c.text)).join(' ')
}
if (!cellData || !textContent?.length) {
textContent = i18n.t('general:noLabel', {
label: getTranslation(('label' in field ? field.label : null) || 'data', i18n),
})
}
return <WrapElement {...wrapElementProps}>{textContent}</WrapElement>
}

View File

@@ -1,6 +1,5 @@
'use client'
export { RichTextCell } from '../../cell/index.js'
export { BlockquoteElementButton } from '../../field/elements/blockquote/Button.js'
export { BlockquoteElement } from '../../field/elements/blockquote/Element.js'
export { ElementButton } from '../../field/elements/Button.js'
@@ -55,11 +54,11 @@ export { CodeLeafButton } from '../../field/leaves/code/LeafButton.js'
export { ItalicLeaf } from '../../field/leaves/italic/Italic/index.js'
export { ItalicLeafButton } from '../../field/leaves/italic/LeafButton.js'
export { StrikethroughLeafButton } from '../../field/leaves/strikethrough/LeafButton.js'
export { StrikethroughLeaf } from '../../field/leaves/strikethrough/Strikethrough/index.js'
export { UnderlineLeafButton } from '../../field/leaves/underline/LeafButton.js'
export { UnderlineLeafButton } from '../../field/leaves/underline/LeafButton.js'
export { UnderlineLeaf } from '../../field/leaves/underline/Underline/index.js'
export { useElement } from '../../field/providers/ElementProvider.js'

View File

@@ -0,0 +1,2 @@
export { RscEntrySlateCell } from '../../cell/rscEntry.js'
export { RscEntrySlateField } from '../../field/rscEntry.js'

View File

@@ -6,17 +6,7 @@ import type { HistoryEditor } from 'slate-history'
import type { ReactEditor } from 'slate-react'
import { getTranslation } from '@payloadcms/translations'
import {
FieldDescription,
FieldError,
FieldLabel,
RenderComponent,
useEditDepth,
useField,
useFieldProps,
useTranslation,
withCondition,
} from '@payloadcms/ui'
import { FieldLabel, useEditDepth, useField, useTranslation, withCondition } from '@payloadcms/ui'
import { isHotkey } from 'is-hotkey'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { createEditor, Node, Element as SlateElement, Text, Transforms } from 'slate'
@@ -51,30 +41,24 @@ declare module 'slate' {
const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
const {
descriptionProps,
elements,
errorProps,
field,
field: {
name,
_path: pathFromProps,
admin: {
className,
components: { Description, Error, Label },
placeholder,
readOnly: readOnlyFromAdmin,
style,
width,
} = {},
admin: { className, placeholder, readOnly: readOnlyFromAdmin, style, width } = {},
label,
required,
},
labelProps,
leaves,
path: pathFromProps,
plugins,
readOnly: readOnlyFromTopLevelProps,
schemaPath: schemaPathFromProps,
validate = richTextValidate,
} = props
const path = pathFromProps ?? name
const schemaPath = schemaPathFromProps ?? name
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
const { i18n } = useTranslation()
@@ -99,16 +83,19 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
[validate, required, i18n],
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const {
customComponents: { Description, Error, Label } = {},
formInitializing,
initialValue,
setValue,
showError,
value,
} = useField({
path,
validate: memoizedValidate,
})
const { formInitializing, initialValue, path, schemaPath, setValue, showError, value } = useField(
{
path: pathFromContext ?? pathFromProps ?? name,
validate: memoizedValidate,
},
)
const disabled = readOnlyFromProps || readOnlyFromContext || formInitializing
const disabled = readOnlyFromProps || formInitializing
const editor = useMemo(() => {
let CreatedEditor = withEnterBreakOut(withHistory(withReact(createEditor())))
@@ -179,7 +166,7 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
path={path}
schemaPath={schemaPath}
>
<RenderComponent mappedComponent={Element} />
{Element}
</ElementProvider>
)
@@ -212,7 +199,7 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
result={result}
schemaPath={schemaPath}
>
<RenderComponent mappedComponent={Leaf} />
{Leaf}
</LeafProvider>
)
}
@@ -320,9 +307,9 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
width,
}}
>
<FieldLabel Label={Label} {...(labelProps || {})} field={field} />
{Label || <FieldLabel label={label} required={required} />}
<div className={`${baseClass}__wrap`}>
<FieldError CustomError={Error} field={field} path={path} {...(errorProps || {})} />
{Error}
<Slate
editor={editor}
key={JSON.stringify({ initialValue, path })} // makes sure slate is completely re-rendered when initialValue changes, bypassing the slate-internal value memoization. That way, external changes to the form will update the editor
@@ -350,7 +337,7 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
path={path}
schemaPath={schemaPath}
>
<RenderComponent mappedComponent={Button} />
{Button}
</ElementButtonProvider>
)
}
@@ -368,7 +355,7 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
path={path}
schemaPath={schemaPath}
>
<RenderComponent mappedComponent={Button} />
{Button}
</LeafButtonProvider>
)
}
@@ -453,7 +440,7 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
</div>
</div>
</Slate>
<FieldDescription Description={Description} field={field} {...(descriptionProps || {})} />
{Description}
</div>
</div>
)

View File

@@ -1,9 +1,9 @@
import type { MappedComponent } from 'payload'
import type { ClientField } from 'payload'
import type { EnabledFeatures } from './types.js'
export const createFeatureMap = (
richTextComponentMap: Map<string, MappedComponent>,
richTextComponentMap: Map<string, ClientField[] | React.ReactNode>,
): EnabledFeatures => {
const features: EnabledFeatures = {
elements: {},
@@ -12,6 +12,9 @@ export const createFeatureMap = (
}
for (const [key, value] of richTextComponentMap) {
if (Array.isArray(value)) {
continue // We only wanna process react nodes here
}
if (key.startsWith('leaf.button') || key.startsWith('leaf.component.')) {
const leafName = key.replace('leaf.button.', '').replace('leaf.component.', '')

View File

@@ -2,8 +2,13 @@
import type { FormState } from 'payload'
import { useConfig, useDrawerSlug, useFieldProps, useModal, useTranslation } from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import {
useDocumentInfo,
useDrawerSlug,
useModal,
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import { reduceFieldsToValues } from 'payload/shared'
import React, { Fragment, useState } from 'react'
import { Editor, Range, Transforms } from 'slate'
@@ -55,23 +60,23 @@ const insertLink = (editor, fields) => {
ReactEditor.focus(editor)
}
export const LinkButton: React.FC = () => {
export const LinkButton: React.FC<{
schemaPath: string
}> = ({ schemaPath }) => {
const { fieldProps } = useElementButton()
const [initialState, setInitialState] = useState<FormState>({})
const { t } = useTranslation()
const editor = useSlate()
const { config } = useConfig()
const { getFormState } = useServerFunctions()
const { collectionSlug, docPermissions, getDocPreferences, globalSlug } = useDocumentInfo()
const { closeModal, openModal } = useModal()
const drawerSlug = useDrawerSlug('rich-text-link')
const { schemaPath } = useFieldProps()
const {
field: { richTextComponentMap },
} = fieldProps
const { componentMap } = fieldProps
const fields = richTextComponentMap.get(linkFieldsSchemaPath)
const fields = componentMap[linkFieldsSchemaPath]
return (
<Fragment>
@@ -91,13 +96,17 @@ export const LinkButton: React.FC = () => {
}
const { state } = await getFormState({
apiRoute: config.routes.api,
body: {
data,
operation: 'update',
schemaPath: `${schemaPath}.${linkFieldsSchemaPath}`,
},
serverURL: config.serverURL,
collectionSlug,
data,
docPermissions,
docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug,
operation: 'update',
renderAllFields: true,
schemaPath: [...schemaPath.split('.'), ...linkFieldsSchemaPath.split('.')].join(
'.',
),
})
setInitialState(state)
@@ -119,6 +128,7 @@ export const LinkButton: React.FC = () => {
closeModal(drawerSlug)
}}
initialState={initialState}
schemaPath={schemaPath}
/>
</Fragment>
)

View File

@@ -6,17 +6,16 @@ import {
Button,
Popup,
Translation,
useAuth,
useConfig,
useDocumentInfo,
useDrawerSlug,
useLocale,
useModal,
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import { deepCopyObject, reduceFieldsToValues } from 'payload/shared'
import React, { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Editor, Node, Transforms } from 'slate'
import { ReactEditor, useSlate } from 'slate-react'
@@ -65,21 +64,20 @@ export const LinkElement = () => {
const fieldMapPath = `${schemaPath}.${linkFieldsSchemaPath}`
const {
field: { richTextComponentMap },
} = fieldProps
const fields = richTextComponentMap.get(linkFieldsSchemaPath)
const { componentMap } = fieldProps
const fields = componentMap[linkFieldsSchemaPath]
const { id, collectionSlug, docPermissions, getDocPreferences, globalSlug } = useDocumentInfo()
const editor = useSlate()
const { config } = useConfig()
const { user } = useAuth()
const { code: locale } = useLocale()
const { i18n, t } = useTranslation()
const { closeModal, openModal, toggleModal } = useModal()
const [renderModal, setRenderModal] = useState(false)
const [renderPopup, setRenderPopup] = useState(false)
const [initialState, setInitialState] = useState<FormState>({})
const { id, collectionSlug } = useDocumentInfo()
const { getFormState } = useServerFunctions()
const drawerSlug = useDrawerSlug('rich-text-link')
@@ -101,13 +99,15 @@ export const LinkElement = () => {
}
const { state } = await getFormState({
apiRoute: config.routes.api,
body: {
data,
operation: 'update',
schemaPath: fieldMapPath,
},
serverURL: config.serverURL,
collectionSlug,
data,
docPermissions,
docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug,
operation: 'update',
renderAllFields: true,
schemaPath: fieldMapPath ?? '',
})
setInitialState(state)
@@ -116,7 +116,20 @@ export const LinkElement = () => {
if (renderModal) {
void awaitInitialState()
}
}, [renderModal, element, user, locale, t, collectionSlug, config, id, fieldMapPath])
}, [
renderModal,
element,
locale,
t,
collectionSlug,
config,
id,
fieldMapPath,
getFormState,
globalSlug,
getDocPreferences,
docPermissions,
])
return (
<span className={baseClass} {...attributes}>
@@ -135,6 +148,7 @@ export const LinkElement = () => {
setRenderModal(false)
}}
initialState={initialState}
schemaPath={schemaPath}
/>
)}
<Popup

View File

@@ -4,17 +4,16 @@ import type { FormProps } from '@payloadcms/ui'
import {
Drawer,
EditDepthProvider,
Form,
FormSubmit,
RenderFields,
useConfig,
useDocumentInfo,
useEditDepth,
useFieldProps,
useHotkey,
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import React, { useCallback, useRef } from 'react'
import type { Props } from './types.js'
@@ -29,45 +28,57 @@ export const LinkDrawer: React.FC<Props> = ({
fields,
handleModalSubmit,
initialState,
schemaPath,
}) => {
const { t } = useTranslation()
const { schemaPath } = useFieldProps()
const fieldMapPath = `${schemaPath}.${linkFieldsSchemaPath}`
const { id } = useDocumentInfo()
const { config } = useConfig()
const { id, collectionSlug, docPermissions, getDocPreferences, globalSlug } = useDocumentInfo()
const { getFormState } = useServerFunctions()
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
const { state } = await getFormState({
apiRoute: config.routes.api,
body: {
id,
formState: prevFormState,
operation: 'update',
schemaPath: fieldMapPath,
},
serverURL: config.serverURL,
id,
collectionSlug,
docPermissions,
docPreferences: await getDocPreferences(),
formState: prevFormState,
globalSlug,
operation: 'update',
schemaPath: fieldMapPath ?? '',
})
return state
},
[config.routes.api, config.serverURL, fieldMapPath, id],
[getFormState, id, collectionSlug, getDocPreferences, docPermissions, globalSlug, fieldMapPath],
)
return (
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink')}>
<Form
beforeSubmit={[onChange]}
disableValidationOnSubmit
initialState={initialState}
onChange={[onChange]}
onSubmit={handleModalSubmit}
>
<RenderFields fields={fields} forceRender path="" readOnly={false} schemaPath="" />
<LinkSubmit />
</Form>
</Drawer>
<EditDepthProvider>
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink')}>
<Form
beforeSubmit={[onChange]}
disableValidationOnSubmit
initialState={initialState}
onChange={[onChange]}
onSubmit={handleModalSubmit}
>
<RenderFields
fields={fields}
forceRender
parentIndexPath=""
parentPath={''}
parentSchemaPath=""
permissions={docPermissions.fields}
readOnly={false}
/>
<LinkSubmit />
</Form>
</Drawer>
</EditDepthProvider>
)
}

View File

@@ -6,4 +6,5 @@ export type Props = {
readonly handleClose: () => void
readonly handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
readonly initialState?: FormState
readonly schemaPath: string
}

View File

@@ -1,7 +1,5 @@
'use client'
import type { FormFieldBase, MappedComponent } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
@@ -27,12 +25,7 @@ const initialParams = {
depth: 0,
}
type Props = {
name: string
richTextComponentMap: Map<string, MappedComponent>
} & FormFieldBase
const RelationshipElementComponent: React.FC<Props> = () => {
const RelationshipElementComponent: React.FC = () => {
const {
attributes,
children,
@@ -194,7 +187,7 @@ const RelationshipElementComponent: React.FC<Props> = () => {
)
}
export const RelationshipElement = (props: Props): React.ReactNode => {
export const RelationshipElement = (props: any): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props}>
<RelationshipElementComponent {...props} />

View File

@@ -6,17 +6,17 @@ import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
Drawer,
EditDepthProvider,
Form,
FormSubmit,
RenderFields,
useAuth,
useConfig,
useDocumentInfo,
useLocale,
useModal,
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import { deepCopyObject } from 'payload/shared'
import React, { useCallback, useEffect, useState } from 'react'
import { Transforms } from 'slate'
@@ -40,16 +40,16 @@ export const UploadDrawer: React.FC<{
const { i18n, t } = useTranslation()
const { code: locale } = useLocale()
const { user } = useAuth()
const { closeModal } = useModal()
const { id, collectionSlug } = useDocumentInfo()
const { id, collectionSlug, docPermissions, getDocPreferences, globalSlug } = useDocumentInfo()
const { getFormState } = useServerFunctions()
const [initialState, setInitialState] = useState({})
const {
field: { richTextComponentMap },
} = fieldProps
const { componentMap } = fieldProps
const relatedFieldSchemaPath = `${uploadFieldsSchemaPath}.${relatedCollection.slug}`
const fields = richTextComponentMap.get(relatedFieldSchemaPath)
const fields = componentMap[relatedFieldSchemaPath]
const { config } = useConfig()
@@ -72,15 +72,16 @@ export const UploadDrawer: React.FC<{
const awaitInitialState = async () => {
const { state } = await getFormState({
apiRoute: config.routes.api,
body: {
id,
collectionSlug,
data,
operation: 'update',
schemaPath: `${schemaPath}.${uploadFieldsSchemaPath}.${relatedCollection.slug}`,
},
serverURL: config.serverURL,
id,
collectionSlug,
data,
docPermissions,
docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug,
operation: 'update',
renderAllFields: true,
schemaPath: `${schemaPath}.${uploadFieldsSchemaPath}.${relatedCollection.slug}`,
})
setInitialState(state)
@@ -90,56 +91,72 @@ export const UploadDrawer: React.FC<{
}, [
config,
element?.fields,
user,
locale,
t,
collectionSlug,
id,
schemaPath,
relatedCollection.slug,
getFormState,
globalSlug,
getDocPreferences,
docPermissions,
])
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
const { state } = await getFormState({
apiRoute: config.routes.api,
body: {
id,
formState: prevFormState,
operation: 'update',
schemaPath: `${schemaPath}.${uploadFieldsSchemaPath}.${relatedCollection.slug}`,
},
serverURL: config.serverURL,
id,
collectionSlug,
docPermissions,
docPreferences: await getDocPreferences(),
formState: prevFormState,
globalSlug,
operation: 'update',
schemaPath: `${schemaPath}.${uploadFieldsSchemaPath}.${relatedCollection.slug}`,
})
return state
},
[config.routes.api, config.serverURL, relatedCollection.slug, schemaPath, id],
[
getFormState,
id,
collectionSlug,
docPermissions,
getDocPreferences,
globalSlug,
schemaPath,
relatedCollection.slug,
],
)
return (
<Drawer
slug={drawerSlug}
title={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
>
<Form
beforeSubmit={[onChange]}
disableValidationOnSubmit
initialState={initialState}
onChange={[onChange]}
onSubmit={handleUpdateEditData}
<EditDepthProvider>
<Drawer
slug={drawerSlug}
title={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
>
<RenderFields
fields={Array.isArray(fields) ? fields : []}
path=""
readOnly={false}
schemaPath=""
/>
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
</Drawer>
<Form
beforeSubmit={[onChange]}
disableValidationOnSubmit
initialState={initialState}
onChange={[onChange]}
onSubmit={handleUpdateEditData}
>
<RenderFields
fields={Array.isArray(fields) ? fields : []}
parentIndexPath=""
parentPath=""
parentSchemaPath=""
permissions={{}}
readOnly={false}
/>
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
</Drawer>
</EditDepthProvider>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import type { ClientCollectionConfig, FormFieldBase } from 'payload'
import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
@@ -32,12 +32,7 @@ const initialParams = {
depth: 0,
}
type Props = {
name: string
richTextComponentMap: Map<string, React.ReactNode>
} & FormFieldBase
const UploadElementComponent: React.FC<{ enabledCollectionSlugs?: string[] } & Props> = ({
const UploadElementComponent: React.FC<{ enabledCollectionSlugs?: string[] }> = ({
enabledCollectionSlugs,
}) => {
const {
@@ -137,7 +132,7 @@ const UploadElementComponent: React.FC<{ enabledCollectionSlugs?: string[] } & P
)
const relatedFieldSchemaPath = `${uploadFieldsSchemaPath}.${relatedCollection.slug}`
const customFieldsMap = fieldProps.field.richTextComponentMap.get(relatedFieldSchemaPath)
const customFieldsMap = fieldProps.componentMap[relatedFieldSchemaPath]
return (
<div
@@ -221,7 +216,7 @@ const UploadElementComponent: React.FC<{ enabledCollectionSlugs?: string[] } & P
)
}
export const UploadElement = (props: Props): React.ReactNode => {
export const UploadElement = (props: any): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props} uploads>
<UploadElementComponent {...props} />

View File

@@ -1,11 +1,12 @@
'use client'
import { RenderComponent, ShimmerEffect, useClientFunctions, useFieldProps } from '@payloadcms/ui'
import { ShimmerEffect, useClientFunctions } from '@payloadcms/ui'
import React, { lazy, Suspense, useEffect, useState } from 'react'
import type { RichTextPlugin, SlateFieldProps } from '../types.js'
import type { EnabledFeatures } from './types.js'
import { SlatePropsProvider } from '../utilities/SlatePropsProvider.js'
import { createFeatureMap } from './createFeatureMap.js'
const RichTextEditor = lazy(() =>
@@ -15,16 +16,13 @@ const RichTextEditor = lazy(() =>
)
export const RichTextField: React.FC<SlateFieldProps> = (props) => {
const {
field: { richTextComponentMap },
} = props
const { componentMap, schemaPath } = props
const { schemaPath } = useFieldProps()
const clientFunctions = useClientFunctions()
const [hasLoadedPlugins, setHasLoadedPlugins] = useState(false)
const [features] = useState<EnabledFeatures>(() => {
return createFeatureMap(richTextComponentMap as any)
return createFeatureMap(new Map(Object.entries(componentMap)))
})
const [plugins, setPlugins] = useState<RichTextPlugin[]>([])
@@ -48,27 +46,25 @@ export const RichTextField: React.FC<SlateFieldProps> = (props) => {
if (!hasLoadedPlugins) {
return (
<React.Fragment>
<SlatePropsProvider schemaPath={schemaPath}>
{Array.isArray(features.plugins) &&
features.plugins.map((Plugin, i) => {
return (
<React.Fragment key={i}>
<RenderComponent mappedComponent={Plugin} />
</React.Fragment>
)
return <React.Fragment key={i}>{Plugin}</React.Fragment>
})}
</React.Fragment>
</SlatePropsProvider>
)
}
return (
<Suspense fallback={<ShimmerEffect height="35vh" />}>
<RichTextEditor
{...props}
elements={features.elements}
leaves={features.leaves}
plugins={plugins}
/>
<SlatePropsProvider schemaPath={schemaPath}>
<RichTextEditor
{...props}
elements={features.elements}
leaves={features.leaves}
plugins={plugins}
/>
</SlatePropsProvider>
</Suspense>
)
}

View File

@@ -0,0 +1,203 @@
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import {
type ClientComponentProps,
type ClientField,
createClientFields,
deepCopyObjectSimple,
type Field,
type RichTextFieldClient,
type ServerComponentProps,
} from 'payload'
import React from 'react'
import type { AdapterArguments, RichTextCustomElement, RichTextCustomLeaf } from '../types.js'
// eslint-disable-next-line payload/no-imports-from-exports-dir
import { RichTextField } from '../exports/client/index.js'
import { elements as elementTypes } from '../field/elements/index.js'
import { defaultLeaves as leafTypes } from '../field/leaves/index.js'
import { linkFieldsSchemaPath } from './elements/link/shared.js'
import { uploadFieldsSchemaPath } from './elements/upload/shared.js'
export const RscEntrySlateField: React.FC<
{
args: AdapterArguments
} & ClientComponentProps &
ServerComponentProps
> = ({
args,
clientField,
forceRender,
i18n,
indexPath,
parentPath,
parentSchemaPath,
path,
payload,
readOnly,
renderedBlocks,
schemaPath,
}) => {
const componentMap: Map<string, ClientField[] | React.ReactNode> = new Map()
const clientProps = {
schemaPath,
}
;(args?.admin?.leaves || Object.values(leafTypes)).forEach((leaf) => {
let leafObject: RichTextCustomLeaf
if (typeof leaf === 'object' && leaf !== null) {
leafObject = leaf
} else if (typeof leaf === 'string' && leafTypes[leaf]) {
leafObject = leafTypes[leaf]
}
if (leafObject) {
const LeafButton = leafObject.Button
const LeafComponent = leafObject.Leaf
componentMap.set(
`leaf.button.${leafObject.name}`,
<RenderServerComponent
clientProps={clientProps}
Component={LeafButton}
importMap={payload.importMap}
/>,
)
componentMap.set(
`leaf.component.${leafObject.name}`,
<RenderServerComponent
clientProps={clientProps}
Component={LeafComponent}
importMap={payload.importMap}
/>,
)
if (Array.isArray(leafObject.plugins)) {
leafObject.plugins.forEach((Plugin, i) => {
componentMap.set(
`leaf.plugin.${leafObject.name}.${i}`,
<RenderServerComponent
clientProps={clientProps}
Component={Plugin}
importMap={payload.importMap}
/>,
)
})
}
}
})
;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => {
let element: RichTextCustomElement
if (typeof el === 'object' && el !== null) {
element = el
} else if (typeof el === 'string' && elementTypes[el]) {
element = elementTypes[el]
}
if (element) {
const ElementButton = element.Button
const ElementComponent = element.Element
if (ElementButton) {
componentMap.set(
`element.button.${element.name}`,
<RenderServerComponent
clientProps={clientProps}
Component={ElementButton}
importMap={payload.importMap}
/>,
)
}
componentMap.set(
`element.component.${element.name}`,
<RenderServerComponent
clientProps={clientProps}
Component={ElementComponent}
importMap={payload.importMap}
/>,
)
if (Array.isArray(element.plugins)) {
element.plugins.forEach((Plugin, i) => {
componentMap.set(
`element.plugin.${element.name}.${i}`,
<RenderServerComponent
clientProps={clientProps}
Component={Plugin}
importMap={payload.importMap}
/>,
)
})
}
switch (element.name) {
case 'link': {
let clientFields = deepCopyObjectSimple(
args.admin?.link?.fields,
) as unknown as ClientField[]
clientFields = createClientFields({
clientFields,
defaultIDType: payload.config.db.defaultIDType,
fields: args.admin?.link?.fields as Field[],
i18n,
})
componentMap.set(linkFieldsSchemaPath, clientFields)
break
}
case 'upload': {
const uploadEnabledCollections = payload.config.collections.filter(
({ admin: { enableRichTextRelationship, hidden }, upload }) => {
if (hidden === true) {
return false
}
return enableRichTextRelationship && Boolean(upload) === true
},
)
uploadEnabledCollections.forEach((collection) => {
if (args?.admin?.upload?.collections[collection.slug]?.fields) {
let clientFields = deepCopyObjectSimple(
args?.admin?.upload?.collections[collection.slug]?.fields,
) as unknown as ClientField[]
clientFields = createClientFields({
clientFields,
defaultIDType: payload.config.db.defaultIDType,
fields: args?.admin?.upload?.collections[collection.slug]?.fields,
i18n,
})
componentMap.set(`${uploadFieldsSchemaPath}.${collection.slug}`, clientFields)
}
})
break
}
case 'relationship':
break
}
}
})
return (
<RichTextField
componentMap={Object.fromEntries(componentMap)}
field={clientField as RichTextFieldClient}
forceRender={forceRender}
indexPath={indexPath}
parentPath={parentPath}
parentSchemaPath={parentSchemaPath}
path={path}
readOnly={readOnly}
renderedBlocks={renderedBlocks}
schemaPath={schemaPath}
/>
)
}

View File

@@ -1,23 +1,21 @@
import type { MappedComponent } from 'payload'
import type { RichTextPlugin, SlateFieldProps } from '../types.js'
export type EnabledFeatures = {
elements: {
[name: string]: {
Button: MappedComponent
Element: MappedComponent
Button: React.ReactNode
Element: React.ReactNode
name: string
}
}
leaves: {
[name: string]: {
Button: MappedComponent
Leaf: MappedComponent
Button: React.ReactNode
Leaf: React.ReactNode
name: string
}
}
plugins: MappedComponent[]
plugins: React.ReactNode[]
}
export type LoadedSlateFieldProps = {

View File

@@ -1,142 +0,0 @@
import type { ClientField, Field, MappedComponent, RichTextGenerateComponentMap } from 'payload'
import { createClientFields } from '@payloadcms/ui/utilities/createClientConfig'
import { deepCopyObjectSimple } from 'payload'
import type { AdapterArguments, RichTextCustomElement, RichTextCustomLeaf } from './types.js'
import { elements as elementTypes } from './field/elements/index.js'
import { linkFieldsSchemaPath } from './field/elements/link/shared.js'
import { uploadFieldsSchemaPath } from './field/elements/upload/shared.js'
import { defaultLeaves as leafTypes } from './field/leaves/index.js'
export const getGenerateComponentMap =
(args: AdapterArguments): RichTextGenerateComponentMap =>
({ createMappedComponent, i18n, importMap, payload }) => {
const componentMap: Map<string, ClientField[] | MappedComponent> = new Map()
;(args?.admin?.leaves || Object.values(leafTypes)).forEach((leaf) => {
let leafObject: RichTextCustomLeaf
if (typeof leaf === 'object' && leaf !== null) {
leafObject = leaf
} else if (typeof leaf === 'string' && leafTypes[leaf]) {
leafObject = leafTypes[leaf]
}
if (leafObject) {
const LeafButton = leafObject.Button
const LeafComponent = leafObject.Leaf
componentMap.set(
`leaf.button.${leafObject.name}`,
createMappedComponent(LeafButton, undefined, undefined, 'slate-LeafButton'),
)
componentMap.set(
`leaf.component.${leafObject.name}`,
createMappedComponent(LeafComponent, undefined, undefined, 'slate-LeafComponent'),
)
if (Array.isArray(leafObject.plugins)) {
leafObject.plugins.forEach((Plugin, i) => {
componentMap.set(
`leaf.plugin.${leafObject.name}.${i}`,
createMappedComponent(Plugin, undefined, undefined, 'slate-LeafPlugin'),
)
})
}
}
})
;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => {
let element: RichTextCustomElement
if (typeof el === 'object' && el !== null) {
element = el
} else if (typeof el === 'string' && elementTypes[el]) {
element = elementTypes[el]
}
if (element) {
const ElementButton = element.Button
const ElementComponent = element.Element
if (ElementButton) {
componentMap.set(
`element.button.${element.name}`,
createMappedComponent(ElementButton, undefined, undefined, 'slate-ElementButton'),
)
}
componentMap.set(
`element.component.${element.name}`,
createMappedComponent(ElementComponent, undefined, undefined, 'slate-ElementComponent'),
)
if (Array.isArray(element.plugins)) {
element.plugins.forEach((Plugin, i) => {
componentMap.set(
`element.plugin.${element.name}.${i}`,
createMappedComponent(Plugin, undefined, undefined, 'slate-ElementPlugin'),
)
})
}
switch (element.name) {
case 'link': {
let clientFields = deepCopyObjectSimple(
args.admin?.link?.fields,
) as unknown as ClientField[]
clientFields = createClientFields({
clientFields,
createMappedComponent,
fields: args.admin?.link?.fields as Field[],
i18n,
importMap,
payload,
})
componentMap.set(linkFieldsSchemaPath, clientFields)
break
}
case 'upload': {
const uploadEnabledCollections = payload.config.collections.filter(
({ admin: { enableRichTextRelationship, hidden }, upload }) => {
if (hidden === true) {
return false
}
return enableRichTextRelationship && Boolean(upload) === true
},
)
uploadEnabledCollections.forEach((collection) => {
if (args?.admin?.upload?.collections[collection.slug]?.fields) {
let clientFields = deepCopyObjectSimple(
args?.admin?.upload?.collections[collection.slug]?.fields,
) as unknown as ClientField[]
clientFields = createClientFields({
clientFields,
createMappedComponent,
fields: args?.admin?.upload?.collections[collection.slug]?.fields,
i18n,
importMap,
payload,
})
componentMap.set(`${uploadFieldsSchemaPath}.${collection.slug}`, clientFields)
}
})
break
}
case 'relationship':
break
}
}
})
return componentMap
}

View File

@@ -1,5 +1,7 @@
import type { Field, RichTextAdapter } from 'payload'
import { traverseFields } from '@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields'
import type { AdapterArguments, RichTextCustomElement } from './types.js'
import { elements as elementTypes } from './field/elements/index.js'
@@ -8,7 +10,7 @@ import { uploadFieldsSchemaPath } from './field/elements/upload/shared.js'
export const getGenerateSchemaMap =
(args: AdapterArguments): RichTextAdapter['generateSchemaMap'] =>
({ config, schemaMap, schemaPath }) => {
({ config, i18n, schemaMap, schemaPath }) => {
;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => {
let element: RichTextCustomElement
@@ -21,10 +23,21 @@ export const getGenerateSchemaMap =
if (element) {
switch (element.name) {
case 'link': {
schemaMap.set(
`${schemaPath}.${linkFieldsSchemaPath}`,
args.admin?.link?.fields as Field[],
)
if (args.admin?.link?.fields) {
schemaMap.set(`${schemaPath}.${linkFieldsSchemaPath}`, {
fields: args.admin?.link?.fields as Field[],
})
// generate schema map entries for sub-fields using traverseFields
traverseFields({
config,
fields: args.admin?.link?.fields as Field[],
i18n,
parentIndexPath: '',
parentSchemaPath: `${schemaPath}.${linkFieldsSchemaPath}`,
schemaMap,
})
}
break
}
@@ -42,10 +55,19 @@ export const getGenerateSchemaMap =
uploadEnabledCollections.forEach((collection) => {
if (args?.admin?.upload?.collections[collection.slug]?.fields) {
schemaMap.set(
`${schemaPath}.${uploadFieldsSchemaPath}.${collection.slug}`,
args?.admin?.upload?.collections[collection.slug]?.fields,
)
schemaMap.set(`${schemaPath}.${uploadFieldsSchemaPath}.${collection.slug}`, {
fields: args?.admin?.upload?.collections[collection.slug]?.fields,
})
// generate schema map entries for sub-fields using traverseFields
traverseFields({
config,
fields: args?.admin?.upload?.collections[collection.slug]?.fields,
i18n,
parentIndexPath: '',
parentSchemaPath: `${schemaPath}.${uploadFieldsSchemaPath}.${collection.slug}`,
schemaMap,
})
}
})

View File

@@ -47,16 +47,16 @@ export function slateEditor(
}
return {
CellComponent: '@payloadcms/richtext-slate/client#RichTextCell',
FieldComponent: '@payloadcms/richtext-slate/client#RichTextField',
generateComponentMap: {
path: '@payloadcms/richtext-slate/generateComponentMap#getGenerateComponentMap',
serverProps: args,
CellComponent: '@payloadcms/richtext-slate/rsc#RscEntrySlateCell',
FieldComponent: {
path: '@payloadcms/richtext-slate/rsc#RscEntrySlateField',
serverProps: {
args,
},
},
generateImportMap: ({ addToImportMap }) => {
addToImportMap('@payloadcms/richtext-slate/client#RichTextCell')
addToImportMap('@payloadcms/richtext-slate/client#RichTextField')
addToImportMap('@payloadcms/richtext-slate/generateComponentMap#getGenerateComponentMap')
addToImportMap('@payloadcms/richtext-slate/rsc#RscEntrySlateCell')
addToImportMap('@payloadcms/richtext-slate/rsc#RscEntrySlateField')
Object.values(leafTypes).forEach((leaf) => {
if (leaf.Button) {
addToImportMap(leaf.Button)

View File

@@ -1,4 +1,10 @@
import type { Field, PayloadComponent, RichTextFieldClientProps, SanitizedConfig } from 'payload'
import type {
ClientField,
Field,
PayloadComponent,
RichTextFieldClientProps,
SanitizedConfig,
} from 'payload'
import type { Editor } from 'slate'
export type TextNode = { [x: string]: unknown; text: string }
@@ -70,4 +76,8 @@ export type AdapterArguments = {
}
}
export type SlateFieldProps = RichTextFieldClientProps<any[], AdapterArguments, AdapterArguments>
export type SlateFieldProps = {
componentMap: {
[x: string]: ClientField[] | React.ReactNode
}
} & RichTextFieldClientProps<any[], AdapterArguments, AdapterArguments>

View File

@@ -0,0 +1,27 @@
'use client'
import React, { createContext, type ReactNode, useContext } from 'react'
interface SlateProps {
schemaPath: string
}
const SlatePropsContext = createContext<SlateProps | undefined>(undefined)
export function SlatePropsProvider({
children,
schemaPath,
}: {
children: ReactNode
schemaPath: string
}) {
return <SlatePropsContext.Provider value={{ schemaPath }}>{children}</SlatePropsContext.Provider>
}
export function useSlateProps() {
const context = useContext(SlatePropsContext)
if (!context) {
throw new Error('useSlateProps must be used within SlatePropsProvider')
}
return context
}

View File

@@ -1,11 +1,13 @@
import type { Editor } from 'slate'
import { useAddClientFunction, useFieldProps } from '@payloadcms/ui'
import { useAddClientFunction } from '@payloadcms/ui'
import { useSlateProps } from './SlatePropsProvider.js'
type Plugin = (editor: Editor) => Editor
export const useSlatePlugin = (key: string, plugin: Plugin) => {
const { schemaPath } = useFieldProps()
const { schemaPath } = useSlateProps()
useAddClientFunction(`slatePlugin.${schemaPath}.${key}`, plugin)
}