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

View File

@@ -1,14 +1,15 @@
import type { FormState } from '@payloadcms/ui'
import type { Block, Data, Field } from 'payload/types'
import type { SanitizedClientEditorConfig } from '@payloadcms/richtext-lexical'
import type { FormFieldBase, FormState } from '@payloadcms/ui'
import type { Data, Field } from 'payload/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { getTranslation } from '@payloadcms/translations'
import { RenderFields } from '@payloadcms/ui'
import {
Button,
Collapsible,
ErrorPill,
Pill,
RenderFields,
SectionTitle,
createNestedFieldPath,
useDocumentInfo,
@@ -19,6 +20,7 @@ import isDeepEqual from 'deep-equal'
import { $getNodeByKey } from 'lexical'
import React, { useCallback } from 'react'
import type { ReducedBlock } from '../../../../../../ui/src/utilities/buildComponentMap/types'
import type { FieldProps } from '../../../../types'
import type { BlockFields, BlockNode } from '../nodes/BlocksNode'
@@ -26,11 +28,15 @@ import { FormSavePlugin } from './FormSavePlugin'
type Props = {
baseClass: string
block: Block
field: FieldProps
field: FormFieldBase & {
editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
formData: BlockFields
formSchema: Field[]
nodeKey: string
reducedBlock: ReducedBlock
}
/**
@@ -41,12 +47,13 @@ type Props = {
export const BlockContent: React.FC<Props> = (props) => {
const {
baseClass,
block: { labels },
field,
formData,
formSchema,
nodeKey,
reducedBlock: { labels },
} = props
const { i18n } = useTranslation()
const [editor] = useLexicalComposerContext()
// 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
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
// 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 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.
function removeUndefinedAndNullRecursively(obj: object) {
Object.keys(obj).forEach((key) => {
@@ -109,6 +123,8 @@ export const BlockContent: React.FC<Props> = (props) => {
removeUndefinedAndNullRecursively(newFormData)
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,
// which would trigger the "Leave without saving" dialog unnecessarily
if (!isDeepEqual(formData, newFormData)) {
@@ -120,6 +136,7 @@ export const BlockContent: React.FC<Props> = (props) => {
editor.update(() => {
const node: BlockNode = $getNodeByKey(nodeKey)
if (node) {
console.log('saving node data...', newFormData)
node.setFields(newFormData as BlockFields)
}
})
@@ -163,11 +180,6 @@ export const BlockContent: React.FC<Props> = (props) => {
})
}, [editor, nodeKey])
const fieldSchemaWithPath = formSchema.map((field) => ({
...field,
path: createNestedFieldPath(null, field),
}))
return (
<React.Fragment>
<Collapsible
@@ -186,7 +198,7 @@ export const BlockContent: React.FC<Props> = (props) => {
: '[Singular Label]'}
</Pill>
<SectionTitle path={`${path}blockName`} readOnly={field?.admin?.readOnly} />
{fieldHasErrors && <ErrorPill count={errorCount} withMessage />}
{fieldHasErrors && <ErrorPill count={errorCount} i18n={i18n} withMessage />}
</div>
{editor.isEditable() && (
<Button
@@ -210,16 +222,12 @@ export const BlockContent: React.FC<Props> = (props) => {
onCollapsedChange()
}}
>
[RenderFields]
{/* <RenderFields
<RenderFields
className={`${baseClass}__fields`}
fieldSchema={fieldSchemaWithPath}
fieldTypes={field.fieldTypes}
fieldMap={Array.isArray(formSchema) ? formSchema : []}
forceRender
margins="small"
permissions={field.permissions?.blocks?.[formData?.blockType]?.fields}
readOnly={field.admin.readOnly}
/> */}
/>
</Collapsible>
<FormSavePlugin onChange={onFormChange} />

View File

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

View File

@@ -1,133 +1,142 @@
'use client'
import {
FieldPathProvider,
Form,
type FormProps,
type FormState,
buildInitialState,
buildStateFromSchema,
getFormState,
useConfig,
useDocumentInfo,
useFieldPath,
useFormSubmitted,
useLocale,
useTranslation,
} from '@payloadcms/ui'
import React, { useEffect, useMemo } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { type BlockFields } from '../nodes/BlocksNode'
const baseClass = 'lexical-block'
import type { Data } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '..'
import type { ReducedBlock } from '../../../../../../ui/src/utilities/buildComponentMap/types'
import type { ClientComponentProps } from '../../types'
import type { BlocksFeatureClientProps } from '../feature.client'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { transformInputFormSchema } from '../utils/transformInputFormSchema'
import { BlockContent } from './BlockContent'
import './index.scss'
type Props = {
blockFieldWrapperName: string
children?: React.ReactNode
/**
* This formData already comes wrapped in blockFieldWrapperName
*/
formData: BlockFields
nodeKey?: string
/**
* This transformedFormData already comes wrapped in blockFieldWrapperName
*/
transformedFormData: BlockFields
}
export const BlockComponent: React.FC<Props> = (props) => {
const { blockFieldWrapperName, formData, nodeKey } = props
const payloadConfig = useConfig()
const config = useConfig()
const submitted = useFormSubmitted()
const { id } = useDocumentInfo()
const { schemaPath } = useFieldPath()
const { editorConfig, field: parentLexicalRichTextField } = useEditorConfigContext()
const block = (
editorConfig?.resolvedFeatureMap?.get('blocks')?.props as BlocksFeatureProps
)?.blocks?.find((block) => block.slug === formData?.blockType)
const [initialState, setInitialState] = useState<FormState | false>(false)
const {
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
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
const formSchema = transformInputFormSchema(
sanitizeFields({
// @ts-expect-error-next-line TODO: Fix this
config: payloadConfig,
fields: unsanitizedFormSchema,
validRelationships,
}),
blockFieldWrapperName,
)
//const formSchema = transformInputFormSchema(fieldMap, 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 config = useConfig()
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
console.log('Bloocks initialState', initialState)
// Memoized Form JSX
const formContent = useMemo(() => {
return (
block &&
reducedBlock &&
initialState && (
<Form fields={formSchema} initialState={initialState} submitted={submitted}>
<FieldPathProvider path="" schemaPath="">
<Form
fields={fieldMap}
initialState={initialState}
onChange={[onChange]}
submitted={submitted}
>
<BlockContent
baseClass={baseClass}
block={block}
field={parentLexicalRichTextField}
formData={formData}
formSchema={formSchema}
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>
}

View File

@@ -11,7 +11,8 @@ import {
} from 'lexical'
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 { $createBlockNode } from '../nodes/BlocksNode'
@@ -35,7 +36,7 @@ const insertBlock = ({
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: blockType,
blockType,
})
} else {
editor.update(() => {
@@ -45,7 +46,7 @@ const insertBlock = ({
$createBlockNode({
id: null,
blockName: '',
blockType: blockType,
blockType,
}),
)
}
@@ -68,9 +69,9 @@ export const BlocksDrawerComponent: React.FC = () => {
}
const addRow = useCallback(
async (rowIndex: number, blockType: string) => {
(rowIndex: number, blockType: string) => {
insertBlock({
blockType: blockType,
blockType,
editor,
replaceNodeKey,
})
@@ -83,8 +84,10 @@ export const BlocksDrawerComponent: React.FC = () => {
depth: editDepth,
})
const blocks = (editorConfig?.resolvedFeatureMap?.get('blocks')?.props as BlocksFeatureProps)
?.blocks
const reducedBlocks = (
editorConfig?.resolvedFeatureMap?.get('blocks')
?.clientFeatureProps as ClientComponentProps<BlocksFeatureClientProps>
)?.reducedBlocks
useEffect(() => {
return editor.registerCommand<{
@@ -104,7 +107,7 @@ export const BlocksDrawerComponent: React.FC = () => {
<BlocksDrawer
addRow={addRow}
addRowIndex={0}
blocks={blocks}
blocks={reducedBlocks}
drawerSlug={drawerSlug}
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 (
<BlockComponent
blockFieldWrapperName={blockFieldWrapperName}
formData={transformedFormData}
formData={this.getFields()}
nodeKey={this.getKey()}
transformedFormData={transformedFormData}
/>
)
}

View File

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

View File

@@ -2,8 +2,8 @@ import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.'
import type { PopulationPromise } from '../types'
import type { BlocksFeatureProps } from './feature.server'
import type { SerializedBlockNode } from './nodes/BlocksNode'
import { recurseNestedFields } from '../../../populate/recurseNestedFields'
@@ -61,7 +61,7 @@ export const blockPopulationPromiseHOC = (
promises,
req,
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,
})

View File

@@ -1,31 +1,32 @@
import type { BlocksFeatureProps } from '@payloadcms/richtext-lexical'
import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.'
import type { NodeValidation } from '../types'
import type { SerializedBlockNode } from './nodes/BlocksNode'
export const blockValidationHOC = (
props: BlocksFeatureProps,
): NodeValidation<SerializedBlockNode> => {
const blockValidation: NodeValidation<SerializedBlockNode> = async ({
node,
payloadConfig,
validation,
}) => {
const blockValidation: NodeValidation<SerializedBlockNode> = async ({ node, validation }) => {
const blockFieldData = node.fields
const blocks: Block[] = props.blocks
const {
options: { req },
options: {
req,
req: {
payload: { config },
},
},
} = validation
// 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) => {
block.fields = sanitizeFields({
config: payloadConfig,
config,
fields: block.fields,
validRelationships,
})
@@ -57,13 +58,10 @@ export const blockValidationHOC = (
const validationResult = await field.validate(fieldValue, {
...field,
id: validation.options.id,
config: payloadConfig,
data: fieldValue,
operation: validation.options.operation,
payload: validation.options.payload,
req,
siblingData: validation.options.siblingData,
t: validation.options.t,
user: validation.options.user,
})
if (validationResult !== true) {

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ export const BlockQuoteFeature: FeatureProviderProviderServer<undefined, undefin
markdownTransformers: [MarkdownTransformer],
nodes: [
{
type: QuoteNode.getType(),
converters: {
html: {
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 }) => {
const { t } = useTranslation()
const { id, getDocPreferences } = useDocumentInfo()
const { id } = useDocumentInfo()
const { schemaPath } = useFieldPath()
const config = useConfig()
const [initialState, setInitialState] = useState<FormState | false>(false)
@@ -37,14 +37,11 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
useEffect(() => {
const awaitInitialState = async () => {
const docPreferences = await getDocPreferences()
const state = await getFormState({
apiRoute: config.routes.api,
body: {
id,
data: stateData,
docPreferences,
operation: 'update',
schemaPath: schemaFieldsPath,
},
@@ -57,17 +54,14 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
if (stateData) {
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(
async ({ formState: prevFormState }) => {
const docPreferences = await getDocPreferences()
return getFormState({
return await getFormState({
apiRoute: config.routes.api,
body: {
id,
docPreferences,
formState: prevFormState,
operation: 'update',
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 (
@@ -89,11 +83,7 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
onChange={[onChange]}
onSubmit={handleModalSubmit}
>
<RenderFields
fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
forceRender
readOnly={false}
/>
<RenderFields fieldMap={Array.isArray(fieldMap) ? fieldMap : []} forceRender />
<FormSubmit>{t('general:submit')}</FormSubmit>
</Form>

View File

@@ -48,9 +48,6 @@ export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & {
fields?:
| ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[])
| Field[]
//someFunction: (something: number) => string
someFunction?: React.FC
}
export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps, ClientProps> = (
@@ -64,11 +61,6 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
disabledCollections: props.disabledCollections,
enabledCollections: props.enabledCollections,
} as ExclusiveLinkCollectionsProps,
generateComponentMap: () => {
return {
someFunction: props.someFunction,
}
},
generateSchemaMap: ({ config, props, schemaMap, schemaPath }) => {
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> = ({
node,
nodeValidations,
payloadConfig,
validation,
}: {
node: T
nodeValidations: Map<string, Array<NodeValidation>>
payloadConfig: SanitizedConfig
validation: {
options: ValidateOptions<SerializedEditorState, unknown, RichTextField>
options: ValidateOptions<unknown, unknown, RichTextField>
value: SerializedEditorState
}
}) => 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 { ParagraphFeature } from './field/features/Paragraph'
export { RelationshipFeature } from './field/features/Relationship'
export {
$createRelationshipNode,
@@ -251,10 +242,11 @@ export {
RelationshipNode,
type SerializedRelationshipNode,
} 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 { RawUploadPayload } from './field/features/Upload/nodes/UploadNode'
export {
$createUploadNode,
$isUploadNode,
@@ -264,6 +256,14 @@ export {
} from './field/features/Upload/nodes/UploadNode'
export { AlignFeature } from './field/features/align/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 {
HTMLConverterFeature,

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,10 @@
import {
AlignFeature,
BlockQuoteFeature,
BlocksFeature,
LinkFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import path from 'path'
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 { postgresAdapter } from '../packages/db-postgres/src'
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'
// process.env.PAYLOAD_DATABASE = 'postgres'
@@ -73,7 +79,28 @@ export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<S
},
}),*/
editor: lexicalEditor({
features: [LinkFeature({}), AlignFeature()],
features: [
LinkFeature({}),
AlignFeature(),
BlockQuoteFeature(),
BlocksFeature({
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'textarea',
},
],
},
],
}),
],
}),
rateLimit: {
max: 9999999999,