Skip to content

Commit fde526e

Browse files
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 #10820 --------- Co-authored-by: Alessio Gravili <alessio@gravili.de>
1 parent 5dadcce commit fde526e

File tree

10 files changed

+200
-16
lines changed

10 files changed

+200
-16
lines changed

packages/richtext-lexical/src/features/blocks/client/component/index.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
8787
const { getFormState } = useServerFunctions()
8888
const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${formData.blockType}.fields`
8989

90-
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(
91-
initialLexicalFormState?.[formData.id]?.formState
90+
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(() => {
91+
return initialLexicalFormState?.[formData.id]?.formState
9292
? {
9393
...initialLexicalFormState?.[formData.id]?.formState,
9494
blockName: {
@@ -98,11 +98,20 @@ export const BlockComponent: React.FC<Props> = (props) => {
9898
value: formData.blockName,
9999
},
100100
}
101-
: false,
102-
)
101+
: false
102+
})
103103

104+
const hasMounted = useRef(false)
105+
const prevCacheBuster = useRef(cacheBuster)
104106
useEffect(() => {
105-
setInitialState(false)
107+
if (hasMounted.current) {
108+
if (prevCacheBuster.current !== cacheBuster) {
109+
setInitialState(false)
110+
}
111+
prevCacheBuster.current = cacheBuster
112+
} else {
113+
hasMounted.current = true
114+
}
106115
}, [cacheBuster])
107116

108117
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
@@ -148,6 +157,22 @@ export const BlockComponent: React.FC<Props> = (props) => {
148157
value: formData.blockName,
149158
}
150159

160+
const newFormStateData: BlockFields = reduceFieldsToValues(
161+
deepCopyObjectSimpleWithoutReactComponents(state),
162+
true,
163+
) as BlockFields
164+
165+
// Things like default values may come back from the server => update the node with the new data
166+
editor.update(() => {
167+
const node = $getNodeByKey(nodeKey)
168+
if (node && $isBlockNode(node)) {
169+
const newData = newFormStateData
170+
newData.blockType = formData.blockType
171+
172+
node.setFields(newData, true)
173+
}
174+
})
175+
151176
setInitialState(state)
152177
setCustomLabel(state._components?.customComponents?.BlockLabel)
153178
setCustomBlock(state._components?.customComponents?.Block)
@@ -166,6 +191,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
166191
schemaFieldsPath,
167192
id,
168193
formData,
194+
editor,
195+
nodeKey,
169196
initialState,
170197
collectionSlug,
171198
globalSlug,

packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { $getNodeByKey } from 'lexical'
2727

2828
import './index.scss'
2929

30-
import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
30+
import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from 'payload/shared'
3131
import { v4 as uuid } from 'uuid'
3232

3333
import type { InlineBlockFields } from '../../server/nodes/InlineBlocksNode.js'
@@ -86,11 +86,20 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
8686
const firstTimeDrawer = useRef(false)
8787

8888
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(
89-
initialLexicalFormState?.[formData.id]?.formState,
89+
() => initialLexicalFormState?.[formData.id]?.formState,
9090
)
9191

92+
const hasMounted = useRef(false)
93+
const prevCacheBuster = useRef(cacheBuster)
9294
useEffect(() => {
93-
setInitialState(false)
95+
if (hasMounted.current) {
96+
if (prevCacheBuster.current !== cacheBuster) {
97+
setInitialState(false)
98+
}
99+
prevCacheBuster.current = cacheBuster
100+
} else {
101+
hasMounted.current = true
102+
}
94103
}, [cacheBuster])
95104

96105
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
@@ -176,6 +185,22 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
176185
})
177186

178187
if (state) {
188+
const newFormStateData: InlineBlockFields = reduceFieldsToValues(
189+
deepCopyObjectSimpleWithoutReactComponents(state),
190+
true,
191+
) as InlineBlockFields
192+
193+
// Things like default values may come back from the server => update the node with the new data
194+
editor.update(() => {
195+
const node = $getNodeByKey(nodeKey)
196+
if (node && $isInlineBlockNode(node)) {
197+
const newData = newFormStateData
198+
newData.blockType = formData.blockType
199+
200+
node.setFields(newData, true)
201+
}
202+
})
203+
179204
setInitialState(state)
180205
setCustomLabel(state['_components']?.customComponents?.BlockLabel)
181206
setCustomBlock(state['_components']?.customComponents?.Block)
@@ -191,6 +216,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
191216
}
192217
}, [
193218
getFormState,
219+
editor,
220+
nodeKey,
194221
schemaFieldsPath,
195222
id,
196223
formData,

packages/richtext-lexical/src/field/Field.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@ import {
88
FieldLabel,
99
RenderCustomComponent,
1010
useEditDepth,
11+
useEffectEvent,
1112
useField,
1213
} from '@payloadcms/ui'
1314
import { mergeFieldStyles } from '@payloadcms/ui/shared'
1415
import React, { useCallback, useEffect, useMemo, useState } from 'react'
1516
import { ErrorBoundary } from 'react-error-boundary'
1617

1718
import type { SanitizedClientEditorConfig } from '../lexical/config/types.js'
18-
import type { LexicalRichTextFieldProps } from '../types.js'
1919

2020
import '../lexical/theme/EditorTheme.scss'
2121
import './bundled.css'
2222
import './index.scss'
23+
24+
import type { LexicalRichTextFieldProps } from '../types.js'
25+
2326
import { LexicalProvider } from '../lexical/LexicalProvider.js'
2427

2528
const baseClass = 'rich-text-lexical'
@@ -126,14 +129,30 @@ const RichTextComponent: React.FC<
126129

127130
const styles = useMemo(() => mergeFieldStyles(field), [field])
128131

129-
useEffect(() => {
130-
if (JSON.stringify(initialValue) !== JSON.stringify(prevInitialValueRef.current)) {
131-
prevInitialValueRef.current = initialValue
132-
if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) {
132+
const handleInitialValueChange = useEffectEvent(
133+
(initialValue: SerializedEditorState | undefined) => {
134+
// Object deep equality check here, as re-mounting the editor if
135+
// the new value is the same as the old one is not necessary
136+
if (
137+
prevValueRef.current !== value &&
138+
JSON.stringify(prevValueRef.current) !== JSON.stringify(value)
139+
) {
140+
prevInitialValueRef.current = initialValue
141+
prevValueRef.current = value
133142
setRerenderProviderKey(new Date())
134143
}
144+
},
145+
)
146+
147+
useEffect(() => {
148+
// Needs to trigger for object reference changes - otherwise,
149+
// reacting to the same initial value change twice will cause
150+
// the second change to be ignored, even though the value has changed.
151+
// That's because initialValue is not kept up-to-date
152+
if (!Object.is(initialValue, prevInitialValueRef.current)) {
153+
handleInitialValueChange(initialValue)
135154
}
136-
}, [initialValue, value])
155+
}, [initialValue])
137156

138157
return (
139158
<div className={classes} key={pathWithEditDepth} style={styles}>

packages/ui/src/exports/client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export { useIntersect } from '../../hooks/useIntersect.js'
1818
export { usePayloadAPI } from '../../hooks/usePayloadAPI.js'
1919
export { useResize } from '../../hooks/useResize.js'
2020
export { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
21+
export { useEffectEvent } from '../../hooks/useEffectEvent.js'
22+
2123
export { useUseTitleField } from '../../hooks/useUseAsTitle.js'
2224

2325
// elements

packages/ui/src/forms/Form/mergeServerFormState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const mergeServerFormState = ({
3838

3939
if (acceptValues) {
4040
serverPropsToAccept.push('value')
41+
serverPropsToAccept.push('initialValue')
4142
}
4243

4344
for (const [path, newFieldState] of Object.entries(existingState)) {

test/fields/collections/Lexical/blocks.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ export const FilterOptionsBlock: Block = {
4747
type: 'relationship',
4848
relationTo: 'text-fields',
4949
filterOptions: ({ siblingData }) => {
50-
console.log('SD', siblingData)
5150
// @ts-expect-error
5251
if (!siblingData?.groupText) {
5352
return true
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client'
2+
3+
import type { SerializedParagraphNode, SerializedTextNode } from '@payloadcms/richtext-lexical'
4+
5+
import { useForm } from '@payloadcms/ui'
6+
import React from 'react'
7+
8+
export const ClearState = ({ fieldName }: { fieldName: string }) => {
9+
const { dispatchFields, fields } = useForm()
10+
11+
const clearState = React.useCallback(() => {
12+
const newState = {
13+
root: {
14+
type: 'root',
15+
children: [
16+
{
17+
type: 'paragraph',
18+
children: [
19+
{
20+
type: 'text',
21+
detail: 0,
22+
format: 0,
23+
mode: 'normal',
24+
style: '',
25+
text: '',
26+
version: 1,
27+
} as SerializedTextNode,
28+
],
29+
direction: 'ltr',
30+
format: '',
31+
indent: 0,
32+
textFormat: 0,
33+
textStyle: '',
34+
version: 1,
35+
} as SerializedParagraphNode,
36+
],
37+
direction: 'ltr',
38+
format: '',
39+
indent: 0,
40+
version: 1,
41+
},
42+
}
43+
dispatchFields({
44+
type: 'REPLACE_STATE',
45+
state: {
46+
...fields,
47+
[fieldName]: {
48+
...fields[fieldName],
49+
initialValue: newState,
50+
value: newState,
51+
},
52+
},
53+
})
54+
}, [dispatchFields, fields, fieldName])
55+
56+
return (
57+
<button id={`clear-lexical-${fieldName}`} onClick={clearState} type="button">
58+
Clear State
59+
</button>
60+
)
61+
}

test/fields/collections/Lexical/e2e/main/e2e.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,40 @@ describe('lexicalMain', () => {
269269
})
270270
})
271271

272+
test('should be able to externally mutate editor state', async () => {
273+
await navigateToLexicalFields()
274+
const richTextField = page.locator('.rich-text-lexical').nth(1).locator('.editor-scroller') // first
275+
await expect(richTextField).toBeVisible()
276+
await richTextField.click() // Use click, because focus does not work
277+
await page.keyboard.type('some text')
278+
const spanInEditor = richTextField.locator('span').first()
279+
await expect(spanInEditor).toHaveText('some text')
280+
await saveDocAndAssert(page)
281+
await page.locator('#clear-lexical-lexicalSimple').click()
282+
await expect(spanInEditor).not.toBeAttached()
283+
})
284+
285+
// This test ensures that the second state clear change is respected too, even though
286+
// initialValue is stale and equal to the previous state change result value-wise
287+
test('should be able to externally mutate editor state twice', async () => {
288+
await navigateToLexicalFields()
289+
const richTextField = page.locator('.rich-text-lexical').nth(1).locator('.editor-scroller') // first
290+
await expect(richTextField).toBeVisible()
291+
await richTextField.click() // Use click, because focus does not work
292+
await page.keyboard.type('some text')
293+
const spanInEditor = richTextField.locator('span').first()
294+
await expect(spanInEditor).toHaveText('some text')
295+
await saveDocAndAssert(page)
296+
await page.locator('#clear-lexical-lexicalSimple').click()
297+
await expect(spanInEditor).not.toBeAttached()
298+
299+
await richTextField.click()
300+
await page.keyboard.type('some text')
301+
await expect(spanInEditor).toHaveText('some text')
302+
await page.locator('#clear-lexical-lexicalSimple').click()
303+
await expect(spanInEditor).not.toBeAttached()
304+
})
305+
272306
test('should be able to bold text using floating select toolbar', async () => {
273307
await navigateToLexicalFields()
274308
const richTextField = page.locator('.rich-text-lexical').nth(2) // second

test/fields/collections/Lexical/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,20 @@ export const LexicalFields: CollectionConfig = {
321321
],
322322
}),
323323
},
324+
{
325+
type: 'ui',
326+
name: 'clearLexicalState',
327+
admin: {
328+
components: {
329+
Field: {
330+
path: '/collections/Lexical/components/ClearState.js#ClearState',
331+
clientProps: {
332+
fieldName: 'lexicalSimple',
333+
},
334+
},
335+
},
336+
},
337+
},
324338
{
325339
name: 'lexicalWithBlocks',
326340
type: 'richText',

tsconfig.base.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
}
3232
],
3333
"paths": {
34-
"@payload-config": ["./test/admin/config.ts"],
34+
"@payload-config": ["./test/_community/config.ts"],
3535
"@payloadcms/live-preview": ["./packages/live-preview/src"],
3636
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
3737
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],

0 commit comments

Comments
 (0)