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 <alessio@gravili.de>
This commit is contained in:
Jarrod Flesch
2025-02-10 16:49:06 -05:00
committed by GitHub
parent 5dadccea39
commit fde526e07f
10 changed files with 200 additions and 16 deletions

View File

@@ -87,8 +87,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
const { getFormState } = useServerFunctions()
const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${formData.blockType}.fields`
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(
initialLexicalFormState?.[formData.id]?.formState
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(() => {
return initialLexicalFormState?.[formData.id]?.formState
? {
...initialLexicalFormState?.[formData.id]?.formState,
blockName: {
@@ -98,11 +98,20 @@ export const BlockComponent: React.FC<Props> = (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<React.ReactNode | undefined>(
@@ -148,6 +157,22 @@ export const BlockComponent: React.FC<Props> = (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> = (props) => {
schemaFieldsPath,
id,
formData,
editor,
nodeKey,
initialState,
collectionSlug,
globalSlug,

View File

@@ -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> = (props) => {
const firstTimeDrawer = useRef(false)
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(
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<React.ReactNode | undefined>(
@@ -176,6 +185,22 @@ export const InlineBlockComponent: React.FC<Props> = (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> = (props) => {
}
}, [
getFormState,
editor,
nodeKey,
schemaFieldsPath,
id,
formData,

View File

@@ -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 (
<div className={classes} key={pathWithEditDepth} style={styles}>

View File

@@ -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

View File

@@ -38,6 +38,7 @@ export const mergeServerFormState = ({
if (acceptValues) {
serverPropsToAccept.push('value')
serverPropsToAccept.push('initialValue')
}
for (const [path, newFieldState] of Object.entries(existingState)) {

View File

@@ -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

View File

@@ -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 (
<button id={`clear-lexical-${fieldName}`} onClick={clearState} type="button">
Clear State
</button>
)
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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"],