From fde526e07f652fbd508a47a9a65b3b516639ba5e Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:49:06 -0500 Subject: [PATCH] fix: set initialValues alongside values during onSuccess (#10825) ### What? Initial values should be set from the server when `acceptValues` is true. ### Why? This is needed since we take the values from the server after a successful form submission. ### How? Add `initialValue` into `serverPropsToAccept` when `acceptValues` is true. Fixes https://github.com/payloadcms/payload/issues/10820 --------- Co-authored-by: Alessio Gravili --- .../blocks/client/component/index.tsx | 37 +++++++++-- .../blocks/client/componentInline/index.tsx | 33 +++++++++- packages/richtext-lexical/src/field/Field.tsx | 31 ++++++++-- packages/ui/src/exports/client/index.ts | 2 + .../ui/src/forms/Form/mergeServerFormState.ts | 1 + test/fields/collections/Lexical/blocks.ts | 1 - .../Lexical/components/ClearState.tsx | 61 +++++++++++++++++++ .../collections/Lexical/e2e/main/e2e.spec.ts | 34 +++++++++++ test/fields/collections/Lexical/index.ts | 14 +++++ tsconfig.base.json | 2 +- 10 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 test/fields/collections/Lexical/components/ClearState.tsx diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx index 15c2dd545..485bdbc2b 100644 --- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx @@ -87,8 +87,8 @@ export const BlockComponent: React.FC = (props) => { const { getFormState } = useServerFunctions() const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${formData.blockType}.fields` - const [initialState, setInitialState] = React.useState( - initialLexicalFormState?.[formData.id]?.formState + const [initialState, setInitialState] = React.useState(() => { + return initialLexicalFormState?.[formData.id]?.formState ? { ...initialLexicalFormState?.[formData.id]?.formState, blockName: { @@ -98,11 +98,20 @@ export const BlockComponent: React.FC = (props) => { value: formData.blockName, }, } - : false, - ) + : false + }) + const hasMounted = useRef(false) + const prevCacheBuster = useRef(cacheBuster) useEffect(() => { - setInitialState(false) + if (hasMounted.current) { + if (prevCacheBuster.current !== cacheBuster) { + setInitialState(false) + } + prevCacheBuster.current = cacheBuster + } else { + hasMounted.current = true + } }, [cacheBuster]) const [CustomLabel, setCustomLabel] = React.useState( @@ -148,6 +157,22 @@ export const BlockComponent: React.FC = (props) => { value: formData.blockName, } + const newFormStateData: BlockFields = reduceFieldsToValues( + deepCopyObjectSimpleWithoutReactComponents(state), + true, + ) as BlockFields + + // Things like default values may come back from the server => update the node with the new data + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if (node && $isBlockNode(node)) { + const newData = newFormStateData + newData.blockType = formData.blockType + + node.setFields(newData, true) + } + }) + setInitialState(state) setCustomLabel(state._components?.customComponents?.BlockLabel) setCustomBlock(state._components?.customComponents?.Block) @@ -166,6 +191,8 @@ export const BlockComponent: React.FC = (props) => { schemaFieldsPath, id, formData, + editor, + nodeKey, initialState, collectionSlug, globalSlug, diff --git a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx index 918ba19ba..0d3170d7c 100644 --- a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx @@ -27,7 +27,7 @@ import { $getNodeByKey } from 'lexical' import './index.scss' -import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared' +import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from 'payload/shared' import { v4 as uuid } from 'uuid' import type { InlineBlockFields } from '../../server/nodes/InlineBlocksNode.js' @@ -86,11 +86,20 @@ export const InlineBlockComponent: React.FC = (props) => { const firstTimeDrawer = useRef(false) const [initialState, setInitialState] = React.useState( - initialLexicalFormState?.[formData.id]?.formState, + () => initialLexicalFormState?.[formData.id]?.formState, ) + const hasMounted = useRef(false) + const prevCacheBuster = useRef(cacheBuster) useEffect(() => { - setInitialState(false) + if (hasMounted.current) { + if (prevCacheBuster.current !== cacheBuster) { + setInitialState(false) + } + prevCacheBuster.current = cacheBuster + } else { + hasMounted.current = true + } }, [cacheBuster]) const [CustomLabel, setCustomLabel] = React.useState( @@ -176,6 +185,22 @@ export const InlineBlockComponent: React.FC = (props) => { }) if (state) { + const newFormStateData: InlineBlockFields = reduceFieldsToValues( + deepCopyObjectSimpleWithoutReactComponents(state), + true, + ) as InlineBlockFields + + // Things like default values may come back from the server => update the node with the new data + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if (node && $isInlineBlockNode(node)) { + const newData = newFormStateData + newData.blockType = formData.blockType + + node.setFields(newData, true) + } + }) + setInitialState(state) setCustomLabel(state['_components']?.customComponents?.BlockLabel) setCustomBlock(state['_components']?.customComponents?.Block) @@ -191,6 +216,8 @@ export const InlineBlockComponent: React.FC = (props) => { } }, [ getFormState, + editor, + nodeKey, schemaFieldsPath, id, formData, diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx index 4088dbf32..98a4fbeb7 100644 --- a/packages/richtext-lexical/src/field/Field.tsx +++ b/packages/richtext-lexical/src/field/Field.tsx @@ -8,6 +8,7 @@ import { FieldLabel, RenderCustomComponent, useEditDepth, + useEffectEvent, useField, } from '@payloadcms/ui' import { mergeFieldStyles } from '@payloadcms/ui/shared' @@ -15,11 +16,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { ErrorBoundary } from 'react-error-boundary' import type { SanitizedClientEditorConfig } from '../lexical/config/types.js' -import type { LexicalRichTextFieldProps } from '../types.js' import '../lexical/theme/EditorTheme.scss' import './bundled.css' import './index.scss' + +import type { LexicalRichTextFieldProps } from '../types.js' + import { LexicalProvider } from '../lexical/LexicalProvider.js' const baseClass = 'rich-text-lexical' @@ -126,14 +129,30 @@ const RichTextComponent: React.FC< const styles = useMemo(() => mergeFieldStyles(field), [field]) - useEffect(() => { - if (JSON.stringify(initialValue) !== JSON.stringify(prevInitialValueRef.current)) { - prevInitialValueRef.current = initialValue - if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) { + const handleInitialValueChange = useEffectEvent( + (initialValue: SerializedEditorState | undefined) => { + // Object deep equality check here, as re-mounting the editor if + // the new value is the same as the old one is not necessary + if ( + prevValueRef.current !== value && + JSON.stringify(prevValueRef.current) !== JSON.stringify(value) + ) { + prevInitialValueRef.current = initialValue + prevValueRef.current = value setRerenderProviderKey(new Date()) } + }, + ) + + useEffect(() => { + // Needs to trigger for object reference changes - otherwise, + // reacting to the same initial value change twice will cause + // the second change to be ignored, even though the value has changed. + // That's because initialValue is not kept up-to-date + if (!Object.is(initialValue, prevInitialValueRef.current)) { + handleInitialValueChange(initialValue) } - }, [initialValue, value]) + }, [initialValue]) return (
diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 1983f2d7a..e865121e1 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -18,6 +18,8 @@ export { useIntersect } from '../../hooks/useIntersect.js' export { usePayloadAPI } from '../../hooks/usePayloadAPI.js' export { useResize } from '../../hooks/useResize.js' export { useThrottledEffect } from '../../hooks/useThrottledEffect.js' +export { useEffectEvent } from '../../hooks/useEffectEvent.js' + export { useUseTitleField } from '../../hooks/useUseAsTitle.js' // elements diff --git a/packages/ui/src/forms/Form/mergeServerFormState.ts b/packages/ui/src/forms/Form/mergeServerFormState.ts index 08873e87b..3f21ab8bc 100644 --- a/packages/ui/src/forms/Form/mergeServerFormState.ts +++ b/packages/ui/src/forms/Form/mergeServerFormState.ts @@ -38,6 +38,7 @@ export const mergeServerFormState = ({ if (acceptValues) { serverPropsToAccept.push('value') + serverPropsToAccept.push('initialValue') } for (const [path, newFieldState] of Object.entries(existingState)) { diff --git a/test/fields/collections/Lexical/blocks.ts b/test/fields/collections/Lexical/blocks.ts index 706aa6bef..5210a1e82 100644 --- a/test/fields/collections/Lexical/blocks.ts +++ b/test/fields/collections/Lexical/blocks.ts @@ -47,7 +47,6 @@ export const FilterOptionsBlock: Block = { type: 'relationship', relationTo: 'text-fields', filterOptions: ({ siblingData }) => { - console.log('SD', siblingData) // @ts-expect-error if (!siblingData?.groupText) { return true diff --git a/test/fields/collections/Lexical/components/ClearState.tsx b/test/fields/collections/Lexical/components/ClearState.tsx new file mode 100644 index 000000000..05fc25393 --- /dev/null +++ b/test/fields/collections/Lexical/components/ClearState.tsx @@ -0,0 +1,61 @@ +'use client' + +import type { SerializedParagraphNode, SerializedTextNode } from '@payloadcms/richtext-lexical' + +import { useForm } from '@payloadcms/ui' +import React from 'react' + +export const ClearState = ({ fieldName }: { fieldName: string }) => { + const { dispatchFields, fields } = useForm() + + const clearState = React.useCallback(() => { + const newState = { + root: { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: '', + version: 1, + } as SerializedTextNode, + ], + direction: 'ltr', + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + version: 1, + } as SerializedParagraphNode, + ], + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + } + dispatchFields({ + type: 'REPLACE_STATE', + state: { + ...fields, + [fieldName]: { + ...fields[fieldName], + initialValue: newState, + value: newState, + }, + }, + }) + }, [dispatchFields, fields, fieldName]) + + return ( + + ) +} diff --git a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts index abd2fcee5..64027d9fc 100644 --- a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts @@ -269,6 +269,40 @@ describe('lexicalMain', () => { }) }) + test('should be able to externally mutate editor state', async () => { + await navigateToLexicalFields() + const richTextField = page.locator('.rich-text-lexical').nth(1).locator('.editor-scroller') // first + await expect(richTextField).toBeVisible() + await richTextField.click() // Use click, because focus does not work + await page.keyboard.type('some text') + const spanInEditor = richTextField.locator('span').first() + await expect(spanInEditor).toHaveText('some text') + await saveDocAndAssert(page) + await page.locator('#clear-lexical-lexicalSimple').click() + await expect(spanInEditor).not.toBeAttached() + }) + + // This test ensures that the second state clear change is respected too, even though + // initialValue is stale and equal to the previous state change result value-wise + test('should be able to externally mutate editor state twice', async () => { + await navigateToLexicalFields() + const richTextField = page.locator('.rich-text-lexical').nth(1).locator('.editor-scroller') // first + await expect(richTextField).toBeVisible() + await richTextField.click() // Use click, because focus does not work + await page.keyboard.type('some text') + const spanInEditor = richTextField.locator('span').first() + await expect(spanInEditor).toHaveText('some text') + await saveDocAndAssert(page) + await page.locator('#clear-lexical-lexicalSimple').click() + await expect(spanInEditor).not.toBeAttached() + + await richTextField.click() + await page.keyboard.type('some text') + await expect(spanInEditor).toHaveText('some text') + await page.locator('#clear-lexical-lexicalSimple').click() + await expect(spanInEditor).not.toBeAttached() + }) + test('should be able to bold text using floating select toolbar', async () => { await navigateToLexicalFields() const richTextField = page.locator('.rich-text-lexical').nth(2) // second diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts index e8fe5ef50..97e3360f4 100644 --- a/test/fields/collections/Lexical/index.ts +++ b/test/fields/collections/Lexical/index.ts @@ -321,6 +321,20 @@ export const LexicalFields: CollectionConfig = { ], }), }, + { + type: 'ui', + name: 'clearLexicalState', + admin: { + components: { + Field: { + path: '/collections/Lexical/components/ClearState.js#ClearState', + clientProps: { + fieldName: 'lexicalSimple', + }, + }, + }, + }, + }, { name: 'lexicalWithBlocks', type: 'richText', diff --git a/tsconfig.base.json b/tsconfig.base.json index c461af5dc..ffd7ec771 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/admin/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],