feat(richtext-lexical): initial working BlocksFeature

This commit is contained in:
Alessio Gravili
2024-02-29 15:46:10 -05:00
parent cccdba57fe
commit 7188cfe85a
23 changed files with 385 additions and 309 deletions

View File

@@ -7,7 +7,6 @@ import { ErrorBoundary } from 'react-error-boundary'
import type { SanitizedClientEditorConfig } from './lexical/config/types' import type { SanitizedClientEditorConfig } from './lexical/config/types'
import { richTextValidateHOC } from '../validate'
import './index.scss' import './index.scss'
import { LexicalProvider } from './lexical/LexicalProvider' import { LexicalProvider } from './lexical/LexicalProvider'
@@ -22,36 +21,24 @@ const RichText: React.FC<
> = (props) => { > = (props) => {
const { const {
name, name,
AfterInput,
BeforeInput,
Description, Description,
Error, Error,
Label, Label,
className, className,
docPreferences,
editorConfig, editorConfig,
fieldMap,
initialSubfieldState,
label,
locale,
localized,
maxLength,
minLength,
path: pathFromProps, path: pathFromProps,
placeholder,
readOnly, readOnly,
required, required,
richTextComponentMap,
rtl,
style, style,
user, validate, // Users can pass in client side validation if they WANT to, but it's not required anymore
validate = richTextValidateHOC({ editorConfig }),
width, width,
} = props } = props
const memoizedValidate = useCallback( const memoizedValidate = useCallback(
(value, validationOptions) => { (value, validationOptions) => {
return validate(value, { ...validationOptions, props, required }) if (typeof validate === 'function') {
return validate(value, { ...validationOptions, props, required })
}
}, },
// Important: do not add props to the dependencies array. // Important: do not add props to the dependencies array.
// This would cause an infinite loop and endless re-rendering. // This would cause an infinite loop and endless re-rendering.

View File

@@ -1,14 +1,15 @@
import type { FormState } from '@payloadcms/ui' import type { SanitizedClientEditorConfig } from '@payloadcms/richtext-lexical'
import type { Block, Data, Field } from 'payload/types' import type { FormFieldBase, FormState } from '@payloadcms/ui'
import type { Data, Field } from 'payload/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { RenderFields } from '@payloadcms/ui'
import { import {
Button, Button,
Collapsible, Collapsible,
ErrorPill, ErrorPill,
Pill, Pill,
RenderFields,
SectionTitle, SectionTitle,
createNestedFieldPath, createNestedFieldPath,
useDocumentInfo, useDocumentInfo,
@@ -19,6 +20,7 @@ import isDeepEqual from 'deep-equal'
import { $getNodeByKey } from 'lexical' import { $getNodeByKey } from 'lexical'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import type { ReducedBlock } from '../../../../../../ui/src/utilities/buildComponentMap/types'
import type { FieldProps } from '../../../../types' import type { FieldProps } from '../../../../types'
import type { BlockFields, BlockNode } from '../nodes/BlocksNode' import type { BlockFields, BlockNode } from '../nodes/BlocksNode'
@@ -26,11 +28,15 @@ import { FormSavePlugin } from './FormSavePlugin'
type Props = { type Props = {
baseClass: string baseClass: string
block: Block field: FormFieldBase & {
field: FieldProps editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
formData: BlockFields formData: BlockFields
formSchema: Field[] formSchema: Field[]
nodeKey: string nodeKey: string
reducedBlock: ReducedBlock
} }
/** /**
@@ -41,12 +47,13 @@ type Props = {
export const BlockContent: React.FC<Props> = (props) => { export const BlockContent: React.FC<Props> = (props) => {
const { const {
baseClass, baseClass,
block: { labels },
field, field,
formData, formData,
formSchema, formSchema,
nodeKey, nodeKey,
reducedBlock: { labels },
} = props } = props
const { i18n } = useTranslation() const { i18n } = useTranslation()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
// Used for saving collapsed to preferences (and gettin' it from there again) // Used for saving collapsed to preferences (and gettin' it from there again)
@@ -92,10 +99,17 @@ export const BlockContent: React.FC<Props> = (props) => {
fullFieldsWithValues: FormState fullFieldsWithValues: FormState
newFormData: Data newFormData: Data
}) => { }) => {
newFormData = {
...newFormData,
id: formData.id, // TODO: Why does form updatee not include theeeeem
blockName: formData.blockName, // TODO: Why does form updatee not include theeeeem
blockType: formData.blockType, // TODO: Why does form updatee not include theeeeem
}
// Recursively remove all undefined values from even being present in formData, as they will // 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 // cause isDeepEqual to return false if, for example, formData has a key that fields.data
// does not have, even if it's undefined. // does not have, even if it's undefined.
// Currently, this happens if a block has another sub-blocks field. Inside of formData, that sub-blocks field has an undefined blockName property. // 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. // Inside of fields.data however, that sub-blocks blockName property does not exist at all.
function removeUndefinedAndNullRecursively(obj: object) { function removeUndefinedAndNullRecursively(obj: object) {
Object.keys(obj).forEach((key) => { Object.keys(obj).forEach((key) => {
@@ -109,6 +123,8 @@ export const BlockContent: React.FC<Props> = (props) => {
removeUndefinedAndNullRecursively(newFormData) removeUndefinedAndNullRecursively(newFormData)
removeUndefinedAndNullRecursively(formData) removeUndefinedAndNullRecursively(formData)
console.log('before saving node data...', newFormData, 'old', formData)
// Only update if the data has actually changed. Otherwise, we may be triggering an unnecessary value change, // 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 // which would trigger the "Leave without saving" dialog unnecessarily
if (!isDeepEqual(formData, newFormData)) { if (!isDeepEqual(formData, newFormData)) {
@@ -120,6 +136,7 @@ export const BlockContent: React.FC<Props> = (props) => {
editor.update(() => { editor.update(() => {
const node: BlockNode = $getNodeByKey(nodeKey) const node: BlockNode = $getNodeByKey(nodeKey)
if (node) { if (node) {
console.log('saving node data...', newFormData)
node.setFields(newFormData as BlockFields) node.setFields(newFormData as BlockFields)
} }
}) })
@@ -163,11 +180,6 @@ export const BlockContent: React.FC<Props> = (props) => {
}) })
}, [editor, nodeKey]) }, [editor, nodeKey])
const fieldSchemaWithPath = formSchema.map((field) => ({
...field,
path: createNestedFieldPath(null, field),
}))
return ( return (
<React.Fragment> <React.Fragment>
<Collapsible <Collapsible
@@ -186,7 +198,7 @@ export const BlockContent: React.FC<Props> = (props) => {
: '[Singular Label]'} : '[Singular Label]'}
</Pill> </Pill>
<SectionTitle path={`${path}blockName`} readOnly={field?.admin?.readOnly} /> <SectionTitle path={`${path}blockName`} readOnly={field?.admin?.readOnly} />
{fieldHasErrors && <ErrorPill count={errorCount} withMessage />} {fieldHasErrors && <ErrorPill count={errorCount} i18n={i18n} withMessage />}
</div> </div>
{editor.isEditable() && ( {editor.isEditable() && (
<Button <Button
@@ -210,16 +222,12 @@ export const BlockContent: React.FC<Props> = (props) => {
onCollapsedChange() onCollapsedChange()
}} }}
> >
[RenderFields] <RenderFields
{/* <RenderFields
className={`${baseClass}__fields`} className={`${baseClass}__fields`}
fieldSchema={fieldSchemaWithPath} fieldMap={Array.isArray(formSchema) ? formSchema : []}
fieldTypes={field.fieldTypes}
forceRender forceRender
margins="small" margins="small"
permissions={field.permissions?.blocks?.[formData?.blockType]?.fields} />
readOnly={field.admin.readOnly}
/> */}
</Collapsible> </Collapsible>
<FormSavePlugin onChange={onFormChange} /> <FormSavePlugin onChange={onFormChange} />

View File

@@ -25,6 +25,7 @@ export const FormSavePlugin: React.FC<Props> = (props) => {
const newFormData = reduceFieldsToValues(fields, true) const newFormData = reduceFieldsToValues(fields, true)
useEffect(() => { useEffect(() => {
console.log('FormSavePlugin', newFormData)
if (onChange) { if (onChange) {
onChange({ fullFieldsWithValues: fields, newFormData }) onChange({ fullFieldsWithValues: fields, newFormData })
} }

View File

@@ -1,133 +1,142 @@
'use client' 'use client'
import { import {
FieldPathProvider,
Form, Form,
type FormProps,
type FormState,
buildInitialState, buildInitialState,
buildStateFromSchema, buildStateFromSchema,
getFormState,
useConfig, useConfig,
useDocumentInfo, useDocumentInfo,
useFieldPath,
useFormSubmitted, useFormSubmitted,
useLocale, useLocale,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import React, { useEffect, useMemo } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { type BlockFields } from '../nodes/BlocksNode' import { type BlockFields } from '../nodes/BlocksNode'
const baseClass = 'lexical-block' const baseClass = 'lexical-block'
import type { Data } from 'payload/types' import type { Data } from 'payload/types'
import { sanitizeFields } from 'payload/config' import type { ReducedBlock } from '../../../../../../ui/src/utilities/buildComponentMap/types'
import type { ClientComponentProps } from '../../types'
import type { BlocksFeatureProps } from '..' import type { BlocksFeatureClientProps } from '../feature.client'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider' import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { transformInputFormSchema } from '../utils/transformInputFormSchema'
import { BlockContent } from './BlockContent' import { BlockContent } from './BlockContent'
import './index.scss' import './index.scss'
type Props = { type Props = {
blockFieldWrapperName: string blockFieldWrapperName: string
children?: React.ReactNode children?: React.ReactNode
/**
* This formData already comes wrapped in blockFieldWrapperName
*/
formData: BlockFields formData: BlockFields
nodeKey?: string nodeKey?: string
/**
* This transformedFormData already comes wrapped in blockFieldWrapperName
*/
transformedFormData: BlockFields
} }
export const BlockComponent: React.FC<Props> = (props) => { export const BlockComponent: React.FC<Props> = (props) => {
const { blockFieldWrapperName, formData, nodeKey } = props const { blockFieldWrapperName, formData, nodeKey } = props
const payloadConfig = useConfig() const config = useConfig()
const submitted = useFormSubmitted() const submitted = useFormSubmitted()
const { id } = useDocumentInfo()
const { schemaPath } = useFieldPath()
const { editorConfig, field: parentLexicalRichTextField } = useEditorConfigContext() const { editorConfig, field: parentLexicalRichTextField } = useEditorConfigContext()
const block = ( const [initialState, setInitialState] = useState<FormState | false>(false)
editorConfig?.resolvedFeatureMap?.get('blocks')?.props as BlocksFeatureProps const {
)?.blocks?.find((block) => block.slug === formData?.blockType) field: { richTextComponentMap },
} = useEditorConfigContext()
const unsanitizedFormSchema = block?.fields || [] console.log('1. Loading node data', formData)
const componentMapRenderedFieldsPath = `feature.blocks.fields.${formData?.blockType}`
const schemaFieldsPath = `${schemaPath}.feature.blocks.${formData?.blockType}`
const reducedBlock: ReducedBlock = (
editorConfig?.resolvedFeatureMap?.get('blocks')
?.clientFeatureProps as ClientComponentProps<BlocksFeatureClientProps>
)?.reducedBlocks?.find((block) => block.slug === formData?.blockType)
const fieldMap = richTextComponentMap.get(componentMapRenderedFieldsPath) // Field Schema
useEffect(() => {
const awaitInitialState = async () => {
const state = await getFormState({
apiRoute: config.routes.api,
body: {
id,
data: JSON.parse(JSON.stringify(formData)),
operation: 'update',
schemaPath: schemaFieldsPath,
},
serverURL: config.serverURL,
}) // Form State
if (state) {
setInitialState(state)
}
}
if (formData) {
void awaitInitialState()
}
}, [config.routes.api, config.serverURL, schemaFieldsPath, id])
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
return await getFormState({
apiRoute: config.routes.api,
body: {
id,
formState: prevFormState,
operation: 'update',
schemaPath: schemaFieldsPath,
},
serverURL: config.serverURL,
})
},
[config.routes.api, config.serverURL, schemaFieldsPath, id],
)
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here // Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
const validRelationships = payloadConfig.collections.map((c) => c.slug) || [] //const formSchema = transformInputFormSchema(fieldMap, blockFieldWrapperName)
const formSchema = transformInputFormSchema(
sanitizeFields({
// @ts-expect-error-next-line TODO: Fix this
config: payloadConfig,
fields: unsanitizedFormSchema,
validRelationships,
}),
blockFieldWrapperName,
)
const initialStateRef = React.useRef<Data>(null) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once const initialStateRef = React.useRef<Data>(null) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
const config = useConfig() console.log('Bloocks initialState', initialState)
const { t } = useTranslation()
const { code: locale } = useLocale()
const { getDocPreferences } = useDocumentInfo()
// initialState State
const [initialState, setInitialState] = React.useState<Data>(null)
useEffect(() => {
async function createInitialState() {
const preferences = await getDocPreferences()
const stateFromSchema = await buildStateFromSchema({
// @ts-expect-error-next-line TODO: Fix this
config,
data: JSON.parse(JSON.stringify(formData)),
fieldSchema: formSchema as any,
locale,
operation: 'create',
preferences,
t,
})
if (!initialStateRef.current) {
// @ts-expect-error-next-line TODO: Fix this
initialStateRef.current = buildInitialState(JSON.parse(JSON.stringify(formData)))
}
// We have to merge the output of buildInitialState (above this useEffect) with the output of buildStateFromSchema.
// That's because the output of buildInitialState provides important properties necessary for THIS block,
// like blockName, blockType and id, while buildStateFromSchema provides the correct output of this block's data,
// e.g. if this block has a sub-block (like the `rows` property)
const consolidatedInitialState = {
...initialStateRef.current,
...stateFromSchema,
}
// We need to delete consolidatedInitialState[blockFieldWrapperName] - it's an unnecessary property.
// It causes issues when we later use reduceFieldsToValues in the FormSavePlugin, because that may
// cause some sub-fields to "use" the wrong value from the blockFieldWrapperName property (which shouldn't be there)
// This fixes the 'should respect row removal in nested array field' fields lexical e2e test
delete consolidatedInitialState[blockFieldWrapperName]
setInitialState(consolidatedInitialState)
}
void createInitialState()
}, [setInitialState, config, locale, getDocPreferences, t]) // do not add formData or formSchema here, it causes an endless loop
// Memoized Form JSX // Memoized Form JSX
const formContent = useMemo(() => { const formContent = useMemo(() => {
return ( return (
block && reducedBlock &&
initialState && ( initialState && (
<Form fields={formSchema} initialState={initialState} submitted={submitted}> <FieldPathProvider path="" schemaPath="">
<BlockContent <Form
baseClass={baseClass} fields={fieldMap}
block={block} initialState={initialState}
field={parentLexicalRichTextField} onChange={[onChange]}
formData={formData} submitted={submitted}
formSchema={formSchema} >
nodeKey={nodeKey} <BlockContent
/> baseClass={baseClass}
</Form> field={parentLexicalRichTextField}
formData={formData}
formSchema={fieldMap}
nodeKey={nodeKey}
reducedBlock={reducedBlock}
/>
</Form>
</FieldPathProvider>
) )
) )
}, [block, parentLexicalRichTextField, nodeKey, submitted, initialState]) }, [fieldMap, parentLexicalRichTextField, nodeKey, submitted, initialState, reducedBlock])
return <div className={baseClass}>{formContent}</div> return <div className={baseClass}>{formContent}</div>
} }

View File

@@ -11,7 +11,8 @@ import {
} from 'lexical' } from 'lexical'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import type { BlocksFeatureProps } from '..' import type { ClientComponentProps } from '../../types'
import type { BlocksFeatureClientProps } from '../feature.client'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider' import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { $createBlockNode } from '../nodes/BlocksNode' import { $createBlockNode } from '../nodes/BlocksNode'
@@ -35,7 +36,7 @@ const insertBlock = ({
editor.dispatchCommand(INSERT_BLOCK_COMMAND, { editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null, id: null,
blockName: '', blockName: '',
blockType: blockType, blockType,
}) })
} else { } else {
editor.update(() => { editor.update(() => {
@@ -45,7 +46,7 @@ const insertBlock = ({
$createBlockNode({ $createBlockNode({
id: null, id: null,
blockName: '', blockName: '',
blockType: blockType, blockType,
}), }),
) )
} }
@@ -68,9 +69,9 @@ export const BlocksDrawerComponent: React.FC = () => {
} }
const addRow = useCallback( const addRow = useCallback(
async (rowIndex: number, blockType: string) => { (rowIndex: number, blockType: string) => {
insertBlock({ insertBlock({
blockType: blockType, blockType,
editor, editor,
replaceNodeKey, replaceNodeKey,
}) })
@@ -83,8 +84,10 @@ export const BlocksDrawerComponent: React.FC = () => {
depth: editDepth, depth: editDepth,
}) })
const blocks = (editorConfig?.resolvedFeatureMap?.get('blocks')?.props as BlocksFeatureProps) const reducedBlocks = (
?.blocks editorConfig?.resolvedFeatureMap?.get('blocks')
?.clientFeatureProps as ClientComponentProps<BlocksFeatureClientProps>
)?.reducedBlocks
useEffect(() => { useEffect(() => {
return editor.registerCommand<{ return editor.registerCommand<{
@@ -104,7 +107,7 @@ export const BlocksDrawerComponent: React.FC = () => {
<BlocksDrawer <BlocksDrawer
addRow={addRow} addRow={addRow}
addRowIndex={0} addRowIndex={0}
blocks={blocks} blocks={reducedBlocks}
drawerSlug={drawerSlug} drawerSlug={drawerSlug}
labels={labels} labels={labels}
/> />

View File

@@ -0,0 +1,65 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import type { ReducedBlock } from '../../../../../ui/src/utilities/buildComponentMap/types'
import type { FeatureProviderProviderClient } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types'
import { BlockIcon } from '../../lexical/ui/icons/Block'
import { createClientComponent } from '../createClientComponent'
import { BlockNode } from './nodes/BlocksNode'
import { BlocksPlugin } from './plugin'
import { INSERT_BLOCK_COMMAND } from './plugin/commands'
export type BlocksFeatureClientProps = {
reducedBlocks: ReducedBlock[]
}
const BlocksFeatureClient: FeatureProviderProviderClient<BlocksFeatureClientProps> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
nodes: [BlockNode],
plugins: [
{
Component: BlocksPlugin,
position: 'normal',
},
],
slashMenu: {
options: [
{
displayName: 'Blocks',
key: 'blocks',
options: [
...props.reducedBlocks.map((block) => {
return new SlashMenuOption('block-' + block.slug, {
Icon: BlockIcon,
displayName: ({ i18n }) => {
if (!block.labels.singular) {
return block.slug
}
return getTranslation(block.labels.singular, i18n)
},
keywords: ['block', 'blocks', block.slug],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
},
})
}),
],
},
],
},
}),
}
}
export const BlocksFeatureClientComponent = createClientComponent(BlocksFeatureClient)

View File

@@ -0,0 +1,103 @@
import type { Block, BlockField, Field } from 'payload/types'
import { baseBlockFields, sanitizeFields } from 'payload/config'
import { fieldsToJSONSchema, formatLabels } from 'payload/utilities'
import type { FeatureProviderProviderServer } from '../types'
import type { BlocksFeatureClientProps } from './feature.client'
import { BlocksFeatureClientComponent } from './feature.client'
import { BlockNode } from './nodes/BlocksNode'
import { blockPopulationPromiseHOC } from './populationPromise'
import { blockValidationHOC } from './validate'
export type BlocksFeatureProps = {
blocks: Block[]
}
export const BlocksFeature: FeatureProviderProviderServer<
BlocksFeatureProps,
BlocksFeatureClientProps
> = (props) => {
// Sanitization taken from payload/src/fields/config/sanitize.ts
if (props?.blocks?.length) {
props.blocks = props.blocks.map((block) => {
return {
...block,
fields: block.fields.concat(baseBlockFields),
labels: !block.labels ? formatLabels(block.slug) : block.labels,
}
})
// unSanitizedBlock.fields are sanitized in the React component and not here.
// That's because we do not have access to the payload config here.
}
// Build clientProps
const clientProps: BlocksFeatureClientProps = {
reducedBlocks: [],
}
for (const block of props.blocks) {
clientProps.reducedBlocks.push({
slug: block.slug,
imageAltText: block.imageAltText,
imageURL: block.imageURL,
labels: block.labels,
subfields: [],
})
}
return {
feature: () => {
return {
ClientComponent: BlocksFeatureClientComponent,
clientFeatureProps: clientProps,
generateSchemaMap: ({ config, props }) => {
const validRelationships = config.collections.map((c) => c.slug) || []
const schemaMap: {
[key: string]: Field[]
} = {}
for (const block of props.blocks) {
const unSanitizedFormSchema = block.fields || []
const formSchema = sanitizeFields({
config,
fields: unSanitizedFormSchema,
validRelationships,
})
schemaMap[block.slug] = formSchema
}
return schemaMap
},
generatedTypes: {
modifyOutputSchema: ({ currentSchema, field, interfaceNameDefinitions }) => {
const blocksField: BlockField = {
name: field?.name + '_lexical_blocks',
type: 'blocks',
blocks: props.blocks,
}
// This is only done so that interfaceNameDefinitions sets those block's interfaceNames.
// we don't actually use the JSON Schema itself in the generated types yet.
fieldsToJSONSchema({}, [blocksField], interfaceNameDefinitions)
return currentSchema
},
},
nodes: [
{
node: BlockNode,
populationPromises: [blockPopulationPromiseHOC(props)],
validations: [blockValidationHOC(props)],
},
],
serverFeatureProps: props,
}
},
key: 'blocks',
serverFeatureProps: props,
}
}

View File

@@ -1,100 +0,0 @@
import type { Block, BlockField } from 'payload/types'
import { getTranslation } from '@payloadcms/translations'
import { baseBlockFields } from 'payload/config'
import { fieldsToJSONSchema, formatLabels } from 'payload/utilities'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types'
import { BlockNode } from './nodes/BlocksNode'
import { INSERT_BLOCK_COMMAND } from './plugin/commands'
import { blockPopulationPromiseHOC } from './populationPromise'
import { blockValidationHOC } from './validate'
export type BlocksFeatureProps = {
blocks: Block[]
}
export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
// Sanitization taken from payload/src/fields/config/sanitize.ts
if (props?.blocks?.length) {
props.blocks = props.blocks.map((block) => {
return {
...block,
fields: block.fields.concat(baseBlockFields),
labels: !block.labels ? formatLabels(block.slug) : block.labels,
}
})
// unsanitizedBlock.fields are sanitized in the React component and not here.
// That's because we do not have access to the payload config here.
}
return {
feature: () => {
return {
generatedTypes: {
modifyOutputSchema: ({ currentSchema, field, interfaceNameDefinitions }) => {
const blocksField: BlockField = {
name: field?.name + '_lexical_blocks',
type: 'blocks',
blocks: props.blocks,
}
// This is only done so that interfaceNameDefinitions sets those block's interfaceNames.
// we don't actually use the JSON Schema itself in the generated types yet.
fieldsToJSONSchema({}, [blocksField], interfaceNameDefinitions)
return currentSchema
},
},
nodes: [
{
type: BlockNode.getType(),
node: BlockNode,
populationPromises: [blockPopulationPromiseHOC(props)],
validations: [blockValidationHOC(props)],
},
],
plugins: [
{
Component: () =>
// @ts-expect-error-next-line
import('./plugin').then((module) => module.BlocksPlugin),
position: 'normal',
},
],
props: props,
slashMenu: {
options: [
{
displayName: 'Blocks',
key: 'blocks',
options: [
...props.blocks.map((block) => {
return new SlashMenuOption('block-' + block.slug, {
Icon: () =>
// @ts-expect-error-next-line
import('../../lexical/ui/icons/Block').then((module) => module.BlockIcon),
displayName: ({ i18n }) => {
// TODO: fix this
// @ts-expect-error-next-line
return getTranslation(block.labels.singular, i18n)
},
keywords: ['block', 'blocks', block.slug],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
},
})
}),
],
},
],
},
}
},
key: 'blocks',
}
}

View File

@@ -96,8 +96,9 @@ export class BlockNode extends DecoratorBlockNode {
return ( return (
<BlockComponent <BlockComponent
blockFieldWrapperName={blockFieldWrapperName} blockFieldWrapperName={blockFieldWrapperName}
formData={transformedFormData} formData={this.getFields()}
nodeKey={this.getKey()} nodeKey={this.getKey()}
transformedFormData={transformedFormData}
/> />
) )
} }

View File

@@ -18,7 +18,7 @@ import { INSERT_BLOCK_COMMAND } from './commands'
export type InsertBlockPayload = Exclude<BlockFields, 'id'> export type InsertBlockPayload = Exclude<BlockFields, 'id'>
export function BlocksPlugin(): JSX.Element | null { export function BlocksPlugin(): React.ReactNode {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {

View File

@@ -2,8 +2,8 @@ import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config' import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.'
import type { PopulationPromise } from '../types' import type { PopulationPromise } from '../types'
import type { BlocksFeatureProps } from './feature.server'
import type { SerializedBlockNode } from './nodes/BlocksNode' import type { SerializedBlockNode } from './nodes/BlocksNode'
import { recurseNestedFields } from '../../../populate/recurseNestedFields' import { recurseNestedFields } from '../../../populate/recurseNestedFields'
@@ -61,7 +61,7 @@ export const blockPopulationPromiseHOC = (
promises, promises,
req, req,
showHiddenFields, showHiddenFields,
// The afterReadPromise gets its data from looking for field.name inside of the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field. // The afterReadPromise gets its data from looking for field.name inside the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field.
siblingDoc: blockFieldData, siblingDoc: blockFieldData,
}) })

View File

@@ -1,31 +1,32 @@
import type { BlocksFeatureProps } from '@payloadcms/richtext-lexical'
import type { Block } from 'payload/types' import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config' import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.'
import type { NodeValidation } from '../types' import type { NodeValidation } from '../types'
import type { SerializedBlockNode } from './nodes/BlocksNode' import type { SerializedBlockNode } from './nodes/BlocksNode'
export const blockValidationHOC = ( export const blockValidationHOC = (
props: BlocksFeatureProps, props: BlocksFeatureProps,
): NodeValidation<SerializedBlockNode> => { ): NodeValidation<SerializedBlockNode> => {
const blockValidation: NodeValidation<SerializedBlockNode> = async ({ const blockValidation: NodeValidation<SerializedBlockNode> = async ({ node, validation }) => {
node,
payloadConfig,
validation,
}) => {
const blockFieldData = node.fields const blockFieldData = node.fields
const blocks: Block[] = props.blocks const blocks: Block[] = props.blocks
const { const {
options: { req }, options: {
req,
req: {
payload: { config },
},
},
} = validation } = validation
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here // Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
const validRelationships = payloadConfig.collections.map((c) => c.slug) || [] const validRelationships = config.collections.map((c) => c.slug) || []
blocks.forEach((block) => { blocks.forEach((block) => {
block.fields = sanitizeFields({ block.fields = sanitizeFields({
config: payloadConfig, config,
fields: block.fields, fields: block.fields,
validRelationships, validRelationships,
}) })
@@ -57,13 +58,10 @@ export const blockValidationHOC = (
const validationResult = await field.validate(fieldValue, { const validationResult = await field.validate(fieldValue, {
...field, ...field,
id: validation.options.id, id: validation.options.id,
config: payloadConfig,
data: fieldValue, data: fieldValue,
operation: validation.options.operation, operation: validation.options.operation,
payload: validation.options.payload, req,
siblingData: validation.options.siblingData, siblingData: validation.options.siblingData,
t: validation.options.t,
user: validation.options.user,
}) })
if (validationResult !== true) { if (validationResult !== true) {

View File

@@ -64,7 +64,7 @@ export const ExtraFieldsUploadDrawer: React.FC<
const fieldSchema = sanitizeFields({ const fieldSchema = sanitizeFields({
// TODO: fix this // TODO: fix this
// @ts-expect-error-next-line // @ts-expect-error-next-line
config: config, config,
fields: fieldSchemaUnsanitized, fields: fieldSchemaUnsanitized,
validRelationships, validRelationships,
}) })
@@ -94,7 +94,7 @@ export const ExtraFieldsUploadDrawer: React.FC<
const fieldSchema = sanitizeFields({ const fieldSchema = sanitizeFields({
// TODO: fix this // TODO: fix this
// @ts-expect-error-next-line // @ts-expect-error-next-line
config: config, config,
fields: fieldSchemaUnsanitized, fields: fieldSchemaUnsanitized,
validRelationships, validRelationships,
}) })
@@ -102,16 +102,12 @@ export const ExtraFieldsUploadDrawer: React.FC<
const awaitInitialState = async () => { const awaitInitialState = async () => {
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
// TODO: fix this
// @ts-expect-error-next-line
config,
data: deepCopyObject(fields || {}), data: deepCopyObject(fields || {}),
fieldSchema, fieldSchema,
locale, locale,
operation: 'update', operation: 'update',
preferences, preferences,
t, req,
user,
}) })
setInitialState(state) setInitialState(state)
} }

View File

@@ -1,3 +1,4 @@
'use client'
import type { LexicalCommand } from 'lexical' import type { LexicalCommand } from 'lexical'
import { createCommand } from 'lexical' import { createCommand } from 'lexical'

View File

@@ -16,7 +16,6 @@ export const BlockQuoteFeature: FeatureProviderProviderServer<undefined, undefin
markdownTransformers: [MarkdownTransformer], markdownTransformers: [MarkdownTransformer],
nodes: [ nodes: [
{ {
type: QuoteNode.getType(),
converters: { converters: {
html: { html: {
converter: async ({ converters, node, parent }) => { converter: async ({ converters, node, parent }) => {

View File

@@ -22,7 +22,7 @@ const baseClass = 'lexical-link-edit-drawer'
export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, stateData }) => { export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, stateData }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { id, getDocPreferences } = useDocumentInfo() const { id } = useDocumentInfo()
const { schemaPath } = useFieldPath() const { schemaPath } = useFieldPath()
const config = useConfig() const config = useConfig()
const [initialState, setInitialState] = useState<FormState | false>(false) const [initialState, setInitialState] = useState<FormState | false>(false)
@@ -37,14 +37,11 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
useEffect(() => { useEffect(() => {
const awaitInitialState = async () => { const awaitInitialState = async () => {
const docPreferences = await getDocPreferences()
const state = await getFormState({ const state = await getFormState({
apiRoute: config.routes.api, apiRoute: config.routes.api,
body: { body: {
id, id,
data: stateData, data: stateData,
docPreferences,
operation: 'update', operation: 'update',
schemaPath: schemaFieldsPath, schemaPath: schemaFieldsPath,
}, },
@@ -57,17 +54,14 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
if (stateData) { if (stateData) {
void awaitInitialState() void awaitInitialState()
} }
}, [config.routes.api, config.serverURL, schemaFieldsPath, getDocPreferences, id, stateData]) }, [config.routes.api, config.serverURL, schemaFieldsPath, id, stateData])
const onChange: FormProps['onChange'][0] = useCallback( const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
const docPreferences = await getDocPreferences() return await getFormState({
return getFormState({
apiRoute: config.routes.api, apiRoute: config.routes.api,
body: { body: {
id, id,
docPreferences,
formState: prevFormState, formState: prevFormState,
operation: 'update', operation: 'update',
schemaPath: schemaFieldsPath, schemaPath: schemaFieldsPath,
@@ -76,7 +70,7 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
}) })
}, },
[config.routes.api, config.serverURL, schemaFieldsPath, getDocPreferences, id], [config.routes.api, config.serverURL, schemaFieldsPath, id],
) )
return ( return (
@@ -89,11 +83,7 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
onChange={[onChange]} onChange={[onChange]}
onSubmit={handleModalSubmit} onSubmit={handleModalSubmit}
> >
<RenderFields <RenderFields fieldMap={Array.isArray(fieldMap) ? fieldMap : []} forceRender />
fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
forceRender
readOnly={false}
/>
<FormSubmit>{t('general:submit')}</FormSubmit> <FormSubmit>{t('general:submit')}</FormSubmit>
</Form> </Form>

View File

@@ -48,9 +48,6 @@ export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & {
fields?: fields?:
| ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[]) | ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[])
| Field[] | Field[]
//someFunction: (something: number) => string
someFunction?: React.FC
} }
export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps, ClientProps> = ( export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps, ClientProps> = (
@@ -64,11 +61,6 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
disabledCollections: props.disabledCollections, disabledCollections: props.disabledCollections,
enabledCollections: props.enabledCollections, enabledCollections: props.enabledCollections,
} as ExclusiveLinkCollectionsProps, } as ExclusiveLinkCollectionsProps,
generateComponentMap: () => {
return {
someFunction: props.someFunction,
}
},
generateSchemaMap: ({ config, props, schemaMap, schemaPath }) => { generateSchemaMap: ({ config, props, schemaMap, schemaPath }) => {
const i18n = initI18n({ config: config.i18n, context: 'client', translations }) const i18n = initI18n({ config: config.i18n, context: 'client', translations })

View File

@@ -50,14 +50,12 @@ export type PopulationPromise<T extends SerializedLexicalNode = SerializedLexica
export type NodeValidation<T extends SerializedLexicalNode = SerializedLexicalNode> = ({ export type NodeValidation<T extends SerializedLexicalNode = SerializedLexicalNode> = ({
node, node,
nodeValidations, nodeValidations,
payloadConfig,
validation, validation,
}: { }: {
node: T node: T
nodeValidations: Map<string, Array<NodeValidation>> nodeValidations: Map<string, Array<NodeValidation>>
payloadConfig: SanitizedConfig
validation: { validation: {
options: ValidateOptions<SerializedEditorState, unknown, RichTextField> options: ValidateOptions<unknown, unknown, RichTextField>
value: SerializedEditorState value: SerializedEditorState
} }
}) => Promise<string | true> | string | true }) => Promise<string | true> | string | true

View File

@@ -232,17 +232,8 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
} }
} }
export { BlocksFeature } from './field/features/Blocks'
export {
$createBlockNode,
$isBlockNode,
type BlockFields,
BlockNode,
type SerializedBlockNode,
} from './field/features/Blocks/nodes/BlocksNode'
export { HeadingFeature } from './field/features/Heading' export { HeadingFeature } from './field/features/Heading'
export { ParagraphFeature } from './field/features/Paragraph' export { ParagraphFeature } from './field/features/Paragraph'
export { RelationshipFeature } from './field/features/Relationship' export { RelationshipFeature } from './field/features/Relationship'
export { export {
$createRelationshipNode, $createRelationshipNode,
@@ -251,10 +242,11 @@ export {
RelationshipNode, RelationshipNode,
type SerializedRelationshipNode, type SerializedRelationshipNode,
} from './field/features/Relationship/nodes/RelationshipNode' } from './field/features/Relationship/nodes/RelationshipNode'
export { UploadFeature } from './field/features/Upload'
export { UploadFeature } from './field/features/Upload'
export type { UploadFeatureProps } from './field/features/Upload' export type { UploadFeatureProps } from './field/features/Upload'
export type { RawUploadPayload } from './field/features/Upload/nodes/UploadNode' export type { RawUploadPayload } from './field/features/Upload/nodes/UploadNode'
export { export {
$createUploadNode, $createUploadNode,
$isUploadNode, $isUploadNode,
@@ -264,6 +256,14 @@ export {
} from './field/features/Upload/nodes/UploadNode' } from './field/features/Upload/nodes/UploadNode'
export { AlignFeature } from './field/features/align/feature.server' export { AlignFeature } from './field/features/align/feature.server'
export { BlockQuoteFeature } from './field/features/blockquote/feature.server' export { BlockQuoteFeature } from './field/features/blockquote/feature.server'
export { BlocksFeature, type BlocksFeatureProps } from './field/features/blocks/feature.server'
export {
$createBlockNode,
$isBlockNode,
type BlockFields,
BlockNode,
type SerializedBlockNode,
} from './field/features/blocks/nodes/BlocksNode'
export { TextDropdownSectionWithEntries } from './field/features/common/floatingSelectToolbarTextDropdownSection' export { TextDropdownSectionWithEntries } from './field/features/common/floatingSelectToolbarTextDropdownSection'
export { export {
HTMLConverterFeature, HTMLConverterFeature,

View File

@@ -1,19 +1,23 @@
import type { SanitizedServerEditorConfig } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from 'lexical' import type { SerializedEditorState } from 'lexical'
import type { RichTextField, Validate } from 'payload/types' import type { RichTextField, Validate } from 'payload/types'
import type { SanitizedEditorConfig } from '../field/lexical/config/types'
import { defaultRichTextValue, defaultRichTextValueV2 } from '../populate/defaultValue' import { defaultRichTextValue, defaultRichTextValueV2 } from '../populate/defaultValue'
import { validateNodes } from './validateNodes' import { validateNodes } from './validateNodes'
export const richTextValidateHOC = ({ editorConfig }: { editorConfig: SanitizedEditorConfig }) => { export const richTextValidateHOC = ({
const richTextValidate: Validate< editorConfig,
SerializedEditorState, }: {
SerializedEditorState, editorConfig: SanitizedServerEditorConfig
unknown, }) => {
RichTextField const richTextValidate: Validate<SerializedEditorState, unknown, unknown, RichTextField> = async (
> = async (value, options) => { value,
const { required, t } = options options,
) => {
const {
req: { t },
required,
} = options
if (required) { if (required) {
if ( if (
@@ -35,7 +39,6 @@ export const richTextValidateHOC = ({ editorConfig }: { editorConfig: SanitizedE
return await validateNodes({ return await validateNodes({
nodeValidations: editorConfig.features.validations, nodeValidations: editorConfig.features.validations,
nodes: rootNodes, nodes: rootNodes,
payloadConfig: options.config,
validation: { validation: {
options, options,
value, value,

View File

@@ -1,5 +1,4 @@
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical' import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
import type { SanitizedConfig } from 'payload/config'
import type { RichTextField, ValidateOptions } from 'payload/types' import type { RichTextField, ValidateOptions } from 'payload/types'
import type { NodeValidation } from '../field/features/types' import type { NodeValidation } from '../field/features/types'
@@ -7,14 +6,12 @@ import type { NodeValidation } from '../field/features/types'
export async function validateNodes({ export async function validateNodes({
nodeValidations, nodeValidations,
nodes, nodes,
payloadConfig,
validation: validationFromProps, validation: validationFromProps,
}: { }: {
nodeValidations: Map<string, Array<NodeValidation>> nodeValidations: Map<string, Array<NodeValidation>>
nodes: SerializedLexicalNode[] nodes: SerializedLexicalNode[]
payloadConfig: SanitizedConfig
validation: { validation: {
options: ValidateOptions<SerializedEditorState, unknown, RichTextField> options: ValidateOptions<unknown, unknown, RichTextField>
value: SerializedEditorState value: SerializedEditorState
} }
}): Promise<string | true> { }): Promise<string | true> {
@@ -30,7 +27,6 @@ export async function validateNodes({
const validationResult = await validation({ const validationResult = await validation({
node, node,
nodeValidations, nodeValidations,
payloadConfig,
validation: validationFromProps, validation: validationFromProps,
}) })
if (validationResult !== true) { if (validationResult !== true) {
@@ -44,7 +40,6 @@ export async function validateNodes({
const childrenValidationResult = await validateNodes({ const childrenValidationResult = await validateNodes({
nodeValidations, nodeValidations,
nodes: node.children as SerializedLexicalNode[], nodes: node.children as SerializedLexicalNode[],
payloadConfig,
validation: validationFromProps, validation: validationFromProps,
}) })
if (childrenValidationResult !== true) { if (childrenValidationResult !== true) {

View File

@@ -38,7 +38,7 @@ export const LinkDrawer: React.FC<Props> = ({
const onChange: FormProps['onChange'][0] = useCallback( const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
return getFormState({ return await getFormState({
apiRoute: config.routes.api, apiRoute: config.routes.api,
body: { body: {
id, id,

View File

@@ -1,3 +1,10 @@
import {
AlignFeature,
BlockQuoteFeature,
BlocksFeature,
LinkFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import path from 'path' import path from 'path'
import type { Config, SanitizedConfig } from '../packages/payload/src/config/types' import type { Config, SanitizedConfig } from '../packages/payload/src/config/types'
@@ -5,7 +12,6 @@ import type { Config, SanitizedConfig } from '../packages/payload/src/config/typ
import { mongooseAdapter } from '../packages/db-mongodb/src' import { mongooseAdapter } from '../packages/db-mongodb/src'
import { postgresAdapter } from '../packages/db-postgres/src' import { postgresAdapter } from '../packages/db-postgres/src'
import { buildConfig as buildPayloadConfig } from '../packages/payload/src/config/build' import { buildConfig as buildPayloadConfig } from '../packages/payload/src/config/build'
import { AlignFeature, LinkFeature, lexicalEditor } from '../packages/richtext-lexical/src'
//import { slateEditor } from '../packages/richtext-slate/src' //import { slateEditor } from '../packages/richtext-slate/src'
// process.env.PAYLOAD_DATABASE = 'postgres' // process.env.PAYLOAD_DATABASE = 'postgres'
@@ -73,7 +79,28 @@ export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<S
}, },
}),*/ }),*/
editor: lexicalEditor({ editor: lexicalEditor({
features: [LinkFeature({}), AlignFeature()], features: [
LinkFeature({}),
AlignFeature(),
BlockQuoteFeature(),
BlocksFeature({
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'textarea',
},
],
},
],
}),
],
}), }),
rateLimit: { rateLimit: {
max: 9999999999, max: 9999999999,