diff --git a/packages/payload/src/admin/components/forms/Form/fieldReducer.ts b/packages/payload/src/admin/components/forms/Form/fieldReducer.ts index 43e3f00ee..a2a665f27 100644 --- a/packages/payload/src/admin/components/forms/Form/fieldReducer.ts +++ b/packages/payload/src/admin/components/forms/Form/fieldReducer.ts @@ -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': { diff --git a/packages/payload/src/admin/components/forms/Form/index.tsx b/packages/payload/src/admin/components/forms/Form/index.tsx index 11442e4df..344164de9 100644 --- a/packages/payload/src/admin/components/forms/Form/index.tsx +++ b/packages/payload/src/admin/components/forms/Form/index.tsx @@ -50,12 +50,15 @@ import reduceFieldsToValues from './reduceFieldsToValues' const baseClass = 'form' const Form: React.FC = (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) => { 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) => { 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) => { 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) => { 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) => { 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() diff --git a/packages/payload/src/admin/components/forms/Form/types.ts b/packages/payload/src/admin/components/forms/Form/types.ts index 242b0c4c8..28bfcaade 100644 --- a/packages/payload/src/admin/components/forms/Form/types.ts +++ b/packages/payload/src/admin/components/forms/Form/types.ts @@ -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 diff --git a/packages/payload/src/admin/components/forms/RenderFields/index.tsx b/packages/payload/src/admin/components/forms/RenderFields/index.tsx index f99d064aa..e300c446f 100644 --- a/packages/payload/src/admin/components/forms/RenderFields/index.tsx +++ b/packages/payload/src/admin/components/forms/RenderFields/index.tsx @@ -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) => { const { className, fieldTypes, forceRender, margins } = props diff --git a/packages/payload/src/admin/components/forms/useField/index.tsx b/packages/payload/src/admin/components/forms/useField/index.tsx index 5cdefc80f..32232bc0a 100644 --- a/packages/payload/src/admin/components/forms/useField/index.tsx +++ b/packages/payload/src/admin/components/forms/useField/index.tsx @@ -118,7 +118,7 @@ const useField = (options: Options): FieldType => { let valueToValidate = value - if (Array.isArray(field.rows)) { + if (field?.rows && Array.isArray(field.rows)) { valueToValidate = getDataByPath(path) } @@ -138,7 +138,7 @@ const useField = (options: Options): FieldType => { } } - validateField() + void validateField() }, 150, [ diff --git a/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx b/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx index 351a30c8b..dab2bd9cb 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx +++ b/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx @@ -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) => { const { baseClass, block, field, fields, nodeKey } = props const { i18n } = useTranslation() diff --git a/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx b/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx index 5c43ae8c3..08ba9bb4f 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx +++ b/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx @@ -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) => { validRelationships, }) - const initialDataRef = React.useRef(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(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(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 && ( -
+ block && + initialState && ( + = (props) => { ) ) - }, [block, field, nodeKey, submitted]) + }, [block, field, nodeKey, submitted, initialState]) return
{formContent}
} diff --git a/test/fields/collections/Lexical/blocks.ts b/test/fields/collections/Lexical/blocks.ts new file mode 100644 index 000000000..ddc80b9a7 --- /dev/null +++ b/test/fields/collections/Lexical/blocks.ts @@ -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, + }, + ], + }, + ], + }, + ], +} diff --git a/test/fields/collections/Lexical/generateLexicalRichText.ts b/test/fields/collections/Lexical/generateLexicalRichText.ts new file mode 100644 index 000000000..f98bbbbf9 --- /dev/null +++ b/test/fields/collections/Lexical/generateLexicalRichText.ts @@ -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', + }, + } +} diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts new file mode 100644 index 000000000..33b3bd858 --- /dev/null +++ b/test/fields/collections/Lexical/index.ts @@ -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(), +} diff --git a/test/fields/collections/Lexical/loremIpsum.ts b/test/fields/collections/Lexical/loremIpsum.ts new file mode 100644 index 000000000..d43b0805d --- /dev/null +++ b/test/fields/collections/Lexical/loremIpsum.ts @@ -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.' diff --git a/test/fields/config.ts b/test/fields/config.ts index 1ed2e9858..7c58f78cb 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -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 })