fix(richtext-lexical): Blocks: Nested Blocks having incorrect initial data (e.g. missing rows property) (#3638)

* fix(richtext-lexical): Blocks: Sub-Blocks having incorrect initial data (e.g. missing rows property)

* chore: remove unnecessary comment
This commit is contained in:
Alessio Gravili
2023-10-13 18:39:34 +02:00
committed by GitHub
parent 0dab68b336
commit c47632dc1d
12 changed files with 624 additions and 18 deletions

View File

@@ -8,6 +8,9 @@ import getSiblingData from './getSiblingData'
import reduceFieldsToValues from './reduceFieldsToValues'
import { flattenRows, separateRows } from './rows'
/**
* Reducer which modifies the form field state (all the current data of the fields in the form). When called using dispatch, it will return a new state object.
*/
export function fieldReducer(state: Fields, action: FieldAction): Fields {
switch (action.type) {
case 'REPLACE_STATE': {

View File

@@ -50,12 +50,15 @@ import reduceFieldsToValues from './reduceFieldsToValues'
const baseClass = 'form'
const Form: React.FC<Props> = (props) => {
const { id, collection, getDocPreferences, global } = useDocumentInfo()
const {
action,
children,
className,
disableSuccessStatus,
disabled,
fields: fieldsFromProps = collection?.fields || global?.fields,
handleResponse,
initialData, // values only, paths are required as key - form should build initial state as convenience
initialState, // fully formed initial field state
@@ -71,7 +74,6 @@ const Form: React.FC<Props> = (props) => {
const { code: locale } = useLocale()
const { i18n, t } = useTranslation('general')
const { refreshCookie, user } = useAuth()
const { id, collection, getDocPreferences, global } = useDocumentInfo()
const operation = useOperation()
const config = useConfig()
@@ -90,6 +92,10 @@ const Form: React.FC<Props> = (props) => {
if (initialState) initialFieldState = initialState
const fieldsReducer = useReducer(fieldReducer, {}, () => initialFieldState)
/**
* `fields` is the current, up-to-date state/data of all fields in the form. It can be modified by using dispatchFields,
* which calls the fieldReducer, which then updates the state.
*/
const [fields, dispatchFields] = fieldsReducer
contextRef.current.fields = fields
@@ -169,7 +175,7 @@ const Form: React.FC<Props> = (props) => {
if (typeof field.validate === 'function') {
let valueToValidate = field.value
if (Array.isArray(field.rows)) {
if (field?.rows && Array.isArray(field.rows)) {
valueToValidate = contextRef.current.getDataByPath(path)
}
@@ -440,7 +446,7 @@ const Form: React.FC<Props> = (props) => {
const getRowSchemaByPath = React.useCallback(
({ blockType, path }: { blockType?: string; path: string }) => {
const rowConfig = traverseRowConfigs({
fieldConfig: collection?.fields || global?.fields,
fieldConfig: fieldsFromProps,
path,
})
const rowFieldConfigs = buildFieldSchemaMap(rowConfig)
@@ -448,10 +454,11 @@ const Form: React.FC<Props> = (props) => {
const fieldKey = pathSegments.at(-1)
return rowFieldConfigs.get(blockType ? `${fieldKey}.${blockType}` : fieldKey)
},
[traverseRowConfigs, collection?.fields, global?.fields],
[traverseRowConfigs, fieldsFromProps],
)
// Array/Block row manipulation
// Array/Block row manipulation. This is called when, for example, you add a new block to a blocks field.
// The block data is saved in the rows property of the state, which is modified updated here.
const addFieldRow: Context['addFieldRow'] = useCallback(
async ({ data, path, rowIndex }) => {
const preferences = await getDocPreferences()

View File

@@ -2,7 +2,12 @@ import type React from 'react'
import type { Dispatch } from 'react'
import type { User } from '../../../../auth/types'
import type { Condition, Field as FieldConfig, Validate } from '../../../../fields/config/types'
import type {
Condition,
Field,
Field as FieldConfig,
Validate,
} from '../../../../fields/config/types'
export type Row = {
blockType?: string
@@ -41,6 +46,12 @@ export type Props = {
className?: string
disableSuccessStatus?: boolean
disabled?: boolean
/**
* By default, the form will get the field schema (not data) from the current document. If you pass this in, you can override that behavior.
* This is very useful for sub-forms, where the form's field schema is not necessarily the field schema of the current document (e.g. for the Blocks
* feature of the Lexical Rich Text field)
*/
fields?: Field[]
handleResponse?: (res: Response) => void
initialData?: Data
initialState?: Fields

View File

@@ -17,10 +17,21 @@ const intersectionObserverOptions = {
rootMargin: '1000px',
}
// If you send `fields` through, it will render those fields explicitly
// Otherwise, it will reduce your fields using the other provided props
// This is so that we can conditionally render fields before reducing them, if desired
// See the sidebar in '../collections/Edit/Default/index.tsx' for an example
/**
* If you send `fields` through, it will render those fields explicitly
* Otherwise, it will reduce your fields using the other provided props
* This is so that we can conditionally render fields before reducing them, if desired
* See the sidebar in '../collections/Edit/Default/index.tsx' for an example
*
* The state/data for the fields it renders is not managed by this component. Instead, every component it renders has
* their own handling of their own value, usually through the useField hook. This hook will get the field's value
* from the Form the field is in, using the field's path.
*
* Thus, if you would like to set the value of a field you render here, you must do so in the Form that contains the field, or in the
* Field component itself.
*
* All this component does is render the field's Field Components, and pass them the props they need to function.
**/
const RenderFields: React.FC<Props> = (props) => {
const { className, fieldTypes, forceRender, margins } = props

View File

@@ -118,7 +118,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
let valueToValidate = value
if (Array.isArray(field.rows)) {
if (field?.rows && Array.isArray(field.rows)) {
valueToValidate = getDataByPath(path)
}
@@ -138,7 +138,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
}
}
validateField()
void validateField()
},
150,
[

View File

@@ -24,6 +24,11 @@ type Props = {
nodeKey: string
}
/**
* The actual content of the Block. This should be INSIDE a Form component,
* scoped to the block. All format operations in here are thus scoped to the block's form, and
* not the whole document.
*/
export const BlockContent: React.FC<Props> = (props) => {
const { baseClass, block, field, fields, nodeKey } = props
const { i18n } = useTranslation()

View File

@@ -1,14 +1,20 @@
import { type ElementFormatType } from 'lexical'
import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms'
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { type BlockFields } from '../nodes/BlocksNode'
const baseClass = 'lexical-block'
import type { Data } from 'payload/types'
import { useConfig } from 'payload/components/utilities'
import {
buildStateFromSchema,
useConfig,
useDocumentInfo,
useLocale,
} from 'payload/components/utilities'
import { sanitizeFields } from 'payload/config'
import { useTranslation } from 'react-i18next'
import type { BlocksFeatureProps } from '..'
@@ -43,13 +49,49 @@ export const BlockComponent: React.FC<Props> = (props) => {
validRelationships,
})
const initialDataRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
const initialStateRef = React.useRef<Data>(buildInitialState(fields.data || {})) // 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('general')
const { code: locale } = useLocale()
const { getDocPreferences } = useDocumentInfo()
// initialState State
const [initialState, setInitialState] = React.useState<Data>(null)
useEffect(() => {
async function buildInitialState() {
const preferences = await getDocPreferences()
const stateFromSchema = await buildStateFromSchema({
config,
data: fields.data,
fieldSchema: block.fields,
locale,
operation: 'update',
preferences,
t,
})
// 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)
setInitialState({
...initialStateRef?.current,
...stateFromSchema,
})
}
void buildInitialState()
}, [setInitialState, config, block, locale, getDocPreferences, t]) // do not add fields here, it causes an endless loop
// Memoized Form JSX
const formContent = useMemo(() => {
return (
block && (
<Form initialState={initialDataRef?.current} submitted={submitted}>
block &&
initialState && (
<Form fields={block.fields} initialState={initialState} submitted={submitted}>
<BlockContent
baseClass={baseClass}
block={block}
@@ -60,7 +102,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Form>
)
)
}, [block, field, nodeKey, submitted])
}, [block, field, nodeKey, submitted, initialState])
return <div className={baseClass}>{formContent}</div>
}

View File

@@ -0,0 +1,108 @@
import type { Block } from '../../../../packages/payload/src/fields/config/types'
import { lexicalEditor } from '../../../../packages/richtext-lexical/src'
export const TextBlock: Block = {
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
slug: 'text',
}
export const UploadAndRichTextBlock: Block = {
fields: [
{
name: 'upload',
type: 'upload',
relationTo: 'uploads',
required: true,
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
},
],
slug: 'uploadAndRichText',
}
export const RelationshipBlock: Block = {
fields: [
{
name: 'rel',
type: 'relationship',
relationTo: 'uploads',
required: true,
},
],
slug: 'relationshipBlock',
}
export const SelectFieldBlock: Block = {
fields: [
{
name: 'select',
type: 'select',
options: [
{
label: 'Option 1',
value: 'option1',
},
{
label: 'Option 2',
value: 'option2',
},
{
label: 'Option 3',
value: 'option3',
},
{
label: 'Option 4',
value: 'option4',
},
{
label: 'Option 5',
value: 'option5',
},
],
},
],
slug: 'select',
}
export const SubBlockBlock: Block = {
slug: 'subBlock',
fields: [
{
name: 'subBlocks',
type: 'blocks',
blocks: [
{
slug: 'contentBlock',
fields: [
{
name: 'richText',
type: 'richText',
required: true,
editor: lexicalEditor(),
},
],
},
{
slug: 'textArea',
fields: [
{
name: 'content',
type: 'textarea',
required: true,
},
],
},
],
},
],
}

View File

@@ -0,0 +1,320 @@
import { loremIpsum } from './loremIpsum'
export function generateLexicalRichText() {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: "Hello, I'm a rich text field.",
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: 'center',
indent: 0,
type: 'heading',
version: 1,
tag: 'h1',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'I can do all kinds of fun stuff like ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'render links',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 1,
fields: {
url: 'https://payloadcms.com',
newTab: true,
linkType: 'custom',
},
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ', ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link to relationships',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 1,
fields: {
url: 'https://',
doc: {
value: {
id: '{{ARRAY_DOC_ID}}',
},
relationTo: 'array-fields',
},
newTab: false,
linkType: 'internal',
},
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ', and store nested relationship fields:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'relationship',
version: 1,
value: {
id: '{{TEXT_DOC_ID}}',
},
relationTo: 'text-fields',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'You can build your own elements, too.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: "It's built with Lexical",
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'It stores content as JSON so you can use it wherever you need',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: "It's got a great editing experience for non-technical users",
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'bullet',
start: 1,
tag: 'ul',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'And a whole lot more.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'upload',
version: 1,
relationTo: 'uploads',
value: {
id: '{{UPLOAD_DOC_ID}}',
},
fields: {
caption: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
...[...Array(4)].map(() => ({
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: loremIpsum,
type: 'text',
version: 1,
},
],
})),
],
direction: 'ltr',
},
},
},
},
{
children: [],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
},
}
}

View File

@@ -0,0 +1,87 @@
import type { CollectionConfig } from '../../../../packages/payload/src/collections/config/types'
import {
BlocksFeature,
LinkFeature,
TreeviewFeature,
UploadFeature,
lexicalEditor,
} from '../../../../packages/richtext-lexical/src'
import {
RelationshipBlock,
SelectFieldBlock,
SubBlockBlock,
TextBlock,
UploadAndRichTextBlock,
} from './blocks'
import { generateLexicalRichText } from './generateLexicalRichText'
export const LexicalFields: CollectionConfig = {
slug: 'lexical-fields',
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'richTextLexicalCustomFields',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeviewFeature(),
LinkFeature({
fields: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
BlocksFeature({
blocks: [
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
SubBlockBlock,
],
}),
],
}),
},
],
}
export const lexicalRichTextDoc = {
title: 'Rich Text',
richTextLexicalCustomFields: generateLexicalRichText(),
}

View File

@@ -0,0 +1,2 @@
export const loremIpsum =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.'

View File

@@ -14,6 +14,7 @@ import DateFields, { dateDoc } from './collections/Date'
import GroupFields, { groupDoc } from './collections/Group'
import IndexedFields from './collections/Indexed'
import JSONFields, { jsonDoc } from './collections/JSON'
import { LexicalFields } from './collections/Lexical'
import NumberFields, { numberDoc } from './collections/Number'
import PointFields, { pointDoc } from './collections/Point'
import RadioFields, { radiosDoc } from './collections/Radio'
@@ -41,6 +42,7 @@ export default buildConfigWithDefaults({
}),
},
collections: [
LexicalFields,
{
slug: 'users',
auth: true,
@@ -147,6 +149,14 @@ export default buildConfigWithDefaults({
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, formattedTextID),
)
const lexicalRichTextDocWithRelId = JSON.parse(
JSON.stringify(richTextDoc)
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, formattedID)
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, formattedJPGID)
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, formattedTextID),
)
await payload.create({ collection: 'lexical-fields', data: lexicalRichTextDocWithRelId })
const richTextDocWithRelationship = { ...richTextDocWithRelId }
await payload.create({ collection: 'rich-text-fields', data: richTextBulletsDocWithRelId })