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 0e1e2ffc5c5..42a64c250a1 100644 --- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx @@ -34,7 +34,7 @@ import { type CollapsedPreferences, type FormState, } from 'payload' -import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from 'payload/shared' +import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared' import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { v4 as uuid } from 'uuid' @@ -49,13 +49,16 @@ import { useDrawerSubmit, } from '../../../../utilities/fieldsDrawer/useDrawerSubmit.js' import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js' +import { + getCachedFormStateIfDataMatches, + reduceFormStateToBlockData, +} from '../getCachedFormStateIfDataMatches.js' import { $isBlockNode } from '../nodes/BlocksNode.js' import { type BlockCollapsibleWithErrorProps, BlockContent, useBlockComponentContext, } from './BlockContent.js' -import { removeEmptyArrayValues } from './removeEmptyArrayValues.js' export type BlockComponentProps = BlockFields> = { /** @@ -128,41 +131,40 @@ export const BlockComponent: React.FC = (props) => { const isEditable = useLexicalEditable() const blockType = formData.blockType + const formDataRef = useRef(formData) + formDataRef.current = formData const { getFormState } = useServerFunctions() const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${blockType}.fields` const [initialState, setInitialState] = React.useState(() => { // Initial form state that was calculated server-side. May have stale values - const cachedFormState = initialLexicalFormState?.[formData.id]?.formState + const cachedState = initialLexicalFormState?.[formData.id] + const cachedFormState = cachedState?.formState if (!cachedFormState) { return false } - // Merge current formData values into the cached form state - // This ensures that when the component remounts (e.g., due to view changes), we don't lose user edits - const mergedState = Object.fromEntries( - Object.entries(cachedFormState).map(([fieldName, fieldState]) => [ - fieldName, - fieldName in formData - ? { - ...fieldState, - initialValue: formData[fieldName], - value: formData[fieldName], - } - : fieldState, - ]), - ) + const matchingCachedState = getCachedFormStateIfDataMatches({ + cachedFormState, + cachedSchemaPath: cachedState.schemaPath, + currentSchemaPath: schemaFieldsPath, + formData, + }) + if (!matchingCachedState) { + return false + } // Manually add blockName, as it's not part of cachedFormState - mergedState.blockName = { - initialValue: formData.blockName, - passesCondition: true, - valid: true, - value: formData.blockName, + return { + ...matchingCachedState, + blockName: { + initialValue: formData.blockName, + passesCondition: true, + valid: true, + value: formData.blockName, + }, } - - return mergedState }) const hasMounted = useRef(false) @@ -271,9 +273,8 @@ export const BlockComponent: React.FC = (props) => { value: formData.blockName, } - const newFormStateData: BlockFields = reduceFieldsToValues( + const newFormStateData = reduceFormStateToBlockData( deepCopyObjectSimpleWithoutReactComponents(state, { excludeFiles: true }), - true, ) as BlockFields // Things like default values may come back from the server => update the node with the new data @@ -351,10 +352,15 @@ export const BlockComponent: React.FC = (props) => { const controller = new AbortController() onChangeAbortControllerRef.current = controller + const blockData = reduceFormStateToBlockData( + deepCopyObjectSimpleWithoutReactComponents(prevFormState, { excludeFiles: true }), + formDataRef.current, + ) const { state: newFormState } = await getFormState({ id, collectionSlug, + data: blockData, docPermissions: { fields: true, }, @@ -364,7 +370,7 @@ export const BlockComponent: React.FC = (props) => { }), formState: prevFormState, globalSlug, - initialBlockFormState: prevFormState, + initialBlockData: blockData, operation: 'update', readOnly: !isEditable, renderAllFields: submit ? true : false, @@ -380,11 +386,9 @@ export const BlockComponent: React.FC = (props) => { newFormState.blockName = prevFormState.blockName } - const newFormStateData: BlockFields = reduceFieldsToValues( - removeEmptyArrayValues({ - fields: deepCopyObjectSimpleWithoutReactComponents(newFormState, { excludeFiles: true }), - }), - true, + const newFormStateData = reduceFormStateToBlockData( + deepCopyObjectSimpleWithoutReactComponents(newFormState, { excludeFiles: true }), + blockData, ) as BlockFields setTimeout(() => { @@ -764,14 +768,18 @@ export const BlockComponent: React.FC = (props) => { fields={clientBlock?.fields ?? []} initialState={initialState} onChange={[onChange]} - onSubmit={(formState, newData) => { + onSubmit={(formState) => { // This is only called when form is submitted from drawer - usually only the case if the block has a custom Block component + const newData = reduceFormStateToBlockData( + formState, + formDataRef.current, + ) as BlockFields newData.blockType = blockType editor.update( () => { const node = $getNodeByKey(nodeKey) if (node && $isBlockNode(node)) { - node.setFields(newData as BlockFields, true) + node.setFields(newData, true) } }, // Without this, the outer editor's reconciler resets DOM selection diff --git a/packages/richtext-lexical/src/features/blocks/client/component/removeEmptyArrayValues.ts b/packages/richtext-lexical/src/features/blocks/client/component/removeEmptyArrayValues.ts deleted file mode 100644 index 182755b750a..00000000000 --- a/packages/richtext-lexical/src/features/blocks/client/component/removeEmptyArrayValues.ts +++ /dev/null @@ -1,18 +0,0 @@ -'use client' -import type { FormState } from 'payload' - -/** - * By default, if an array field is empty, it will be included in the form state with a value of 0. - * We do not need this behavior here, By setting `disableFormData` to true, we can prevent the field from being included in the form state - * like that. - * @param fields form state - */ -export function removeEmptyArrayValues({ fields }: { fields: FormState }): FormState { - for (const key in fields) { - const field = fields[key] - if (Array.isArray(field?.rows) && 'value' in field) { - field.disableFormData = true - } - } - return fields -} 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 28a6983f541..38b7ac18b4d 100644 --- a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { BlocksFieldClient, ClientBlock, Data, FormState } from 'payload' +import type { BlocksFieldClient, ClientBlock, FormState } from 'payload' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalEditable } from '@lexical/react/useLexicalEditable' @@ -26,7 +26,7 @@ import { $getNodeByKey, SKIP_DOM_SELECTION_TAG } from 'lexical' import './index.css' import '../../../../utilities/fieldsDrawer/index.css' -import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from 'payload/shared' +import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared' import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react' import { v4 as uuid } from 'uuid' @@ -40,6 +40,10 @@ import { useDrawerSubmit, } from '../../../../utilities/fieldsDrawer/useDrawerSubmit.js' import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js' +import { + getCachedFormStateIfDataMatches, + reduceFormStateToBlockData, +} from '../getCachedFormStateIfDataMatches.js' import { $isInlineBlockNode } from '../nodes/InlineBlocksNode.js' export type InlineBlockComponentProps< @@ -90,28 +94,25 @@ export const InlineBlockComponent: React.FC(() => { // Initial form state that was calculated server-side. May have stale values - const cachedFormState = initialLexicalFormState?.[formData.id]?.formState + const cachedState = initialLexicalFormState?.[formData.id] + const cachedFormState = cachedState?.formState if (!cachedFormState) { return false } - // Merge current formData values into the cached form state - // This ensures that when the component remounts (e.g., due to view changes), we don't lose user edits - return Object.fromEntries( - Object.entries(cachedFormState).map(([fieldName, fieldState]) => [ - fieldName, - fieldName in formData - ? { - ...fieldState, - initialValue: formData[fieldName], - value: formData[fieldName], - } - : fieldState, - ]), - ) + return getCachedFormStateIfDataMatches({ + cachedFormState, + cachedSchemaPath: cachedState.schemaPath, + currentSchemaPath: schemaFieldsPath, + formData, + }) }) const hasMounted = useRef(false) @@ -191,7 +192,7 @@ export const InlineBlockComponent: React.FC { @@ -251,7 +251,6 @@ export const InlineBlockComponent: React.FC update the node with the new data @@ -324,10 +322,15 @@ export const InlineBlockComponent: React.FC { - newData.blockType = formData.blockType + (formState: FormState) => { + const newData = reduceFormStateToBlockData(formState, formDataRef.current) + newData.blockType = blockType editor.update( () => { @@ -411,7 +415,7 @@ export const InlineBlockComponent: React.FC { - onFormSubmit(formState, data) + onSubmit={(formState) => { + onFormSubmit(formState) toggleDrawer() }} uuid={formUuid} diff --git a/packages/richtext-lexical/src/features/blocks/client/getCachedFormStateIfDataMatches.spec.ts b/packages/richtext-lexical/src/features/blocks/client/getCachedFormStateIfDataMatches.spec.ts new file mode 100644 index 00000000000..3280cb3f9e7 --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/client/getCachedFormStateIfDataMatches.spec.ts @@ -0,0 +1,401 @@ +import type { Data, FormState } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { + getCachedFormStateIfDataMatches, + reduceFormStateToBlockData, +} from './getCachedFormStateIfDataMatches.js' + +describe('getCachedFormStateIfDataMatches', () => { + it('should reuse cached form state when its values match the current block data', () => { + const cachedFormState: FormState = { + id: { initialValue: 'block-1', value: 'block-1' }, + images: { + disableFormData: true, + initialValue: 1, + rows: [{ id: 'row-1' }], + value: 1, + }, + 'images.0.id': { initialValue: 'row-1', value: 'row-1' }, + 'images.0.image': { initialValue: 'image-1', value: 'image-1' }, + title: { initialValue: 'Gallery', value: 'Gallery' }, + } + const formData: Data = { + blockName: 'Gallery block', + blockType: 'gallery', + id: 'block-1', + images: [{ id: 'row-1', image: 'image-1' }], + title: 'Gallery', + } + + expect(getCachedFormStateIfDataMatches({ cachedFormState, formData })).toBe(cachedFormState) + }) + + it('should rebuild form state when a simple array row was added', () => { + const cachedFormState: FormState = { + images: { + disableFormData: true, + initialValue: 1, + rows: [{ id: 'row-1' }], + value: 1, + }, + 'images.0.id': { initialValue: 'row-1', value: 'row-1' }, + 'images.0.image': { initialValue: 'image-1', value: 'image-1' }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: { + images: [ + { id: 'row-1', image: 'image-1' }, + { id: 'row-2', image: 'image-2' }, + ], + }, + }), + ).toBe(false) + }) + + it('should rebuild form state for a first nested row without a cached row template', () => { + const cachedFormState: FormState = { + sections: { + disableFormData: true, + initialValue: 1, + rows: [{ blockType: 'gallery', id: 'section-1' }], + value: 1, + }, + 'sections.0.blockType': { initialValue: 'gallery', value: 'gallery' }, + 'sections.0.id': { initialValue: 'section-1', value: 'section-1' }, + 'sections.0.items': { initialValue: 0, rows: [], value: 0 }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: { + sections: [ + { + blockType: 'gallery', + id: 'section-1', + items: [{ id: 'item-1', label: 'First item' }], + }, + ], + }, + }), + ).toBe(false) + }) + + it('should rebuild form state when an object-valued row field lost a key', () => { + const cachedFormState: FormState = { + rows: { + disableFormData: true, + initialValue: 1, + rows: [{ id: 'row-1' }], + value: 1, + }, + 'rows.0.id': { initialValue: 'row-1', value: 'row-1' }, + 'rows.0.settings': { + initialValue: { keep: true, removed: 'stale' }, + value: { keep: true, removed: 'stale' }, + }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: { rows: [{ id: 'row-1', settings: { keep: true } }] }, + }), + ).toBe(false) + }) + + it('should rebuild form state when a scalar value changed', () => { + const validate = () => true + const cachedFormState: FormState = { + title: { + initialValue: 'Before save', + passesCondition: true, + valid: true, + validate, + value: 'Before save', + }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: { title: 'After save' }, + }), + ).toBe(false) + }) + + it('should rebuild form state when values inside existing rows changed', () => { + const cachedFormState: FormState = { + items: { + customComponents: { RowLabel: 'row-label' as never }, + disableFormData: true, + initialValue: 1, + rows: [{ id: 'row-1' }], + value: 1, + }, + 'items.0.id': { initialValue: 'row-1', value: 'row-1' }, + 'items.0.label': { + passesCondition: true, + initialValue: 'Before save', + value: 'Before save', + }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: { items: [{ id: 'row-1', label: 'After save' }] }, + }), + ).toBe(false) + }) + + it('should rebuild form state when a top-level field is no longer present', () => { + const cachedFormState: FormState = { + images: { + disableFormData: true, + initialValue: 1, + rows: [{ id: 'row-1' }], + value: 1, + }, + 'images.0.id': { initialValue: 'row-1', value: 'row-1' }, + 'images.0.image': { initialValue: 'image-1', value: 'image-1' }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: {}, + }), + ).toBe(false) + }) + + it('should rebuild form state when current relationship data needs schema normalization', () => { + const cachedFormState: FormState = { + image: { initialValue: 'image-1', value: 'image-1' }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: { + image: { + filename: 'example.jpg', + id: 'image-1', + }, + }, + }), + ).toBe(false) + }) + + it.each([ + { + formData: {}, + name: 'omitted', + }, + { + formData: { items: [] }, + name: 'an empty array', + }, + ])('should reuse cached form state when an empty row field is $name', ({ formData }) => { + const cachedFormState: FormState = { + items: { + initialValue: 0, + rows: [], + value: 0, + }, + } + + expect(getCachedFormStateIfDataMatches({ cachedFormState, formData })).toBe(cachedFormState) + }) + + it('should reuse cached form state when an unset optional field is omitted', () => { + const cachedFormState: FormState = { + title: { initialValue: undefined, value: undefined }, + } + + expect(getCachedFormStateIfDataMatches({ cachedFormState, formData: {} })).toBe(cachedFormState) + }) + + it('should omit empty row counts and unset values when converting form state to block data', () => { + expect( + reduceFormStateToBlockData({ + items: { + initialValue: 0, + rows: [], + value: 0, + }, + sections: { + initialValue: 1, + rows: [{ id: 'section-1' }], + value: 1, + }, + 'sections.0.id': { initialValue: 'section-1', value: 'section-1' }, + 'sections.0.items': { + initialValue: 0, + rows: [], + value: 0, + }, + title: { initialValue: undefined, value: undefined }, + }), + ).toEqual({ + sections: [{ id: 'section-1' }], + }) + }) + + it('should preserve a null array when converting form state to block data', () => { + expect( + reduceFormStateToBlockData({ + items: { + rows: [], + }, + }), + ).toEqual({ items: null }) + }) + + it('should preserve a null array after the client records an empty row count', () => { + expect( + reduceFormStateToBlockData( + { + items: { + rows: [], + value: 0, + }, + }, + { items: null }, + ), + ).toEqual({ items: null }) + }) + + it('should allow a null array to be changed to an empty array', () => { + expect( + reduceFormStateToBlockData( + { + items: { + isModified: true, + rows: [], + value: 0, + }, + }, + { items: null }, + ), + ).toEqual({}) + }) + + it('should keep a null-to-empty array change after a sibling field changes', () => { + expect( + reduceFormStateToBlockData( + { + items: { + disableFormData: false, + rows: [], + value: 0, + }, + title: { + isModified: true, + value: 'After', + }, + }, + { items: null, title: 'Before' }, + ), + ).toEqual({ title: 'After' }) + }) + + it.each([ + { + cachedRow: { id: 'row-1' }, + currentRow: { id: 'row-2', label: 'After save' }, + name: 'row identity changed', + }, + { + cachedRow: { blockType: 'callout', id: 'row-1' }, + currentRow: { blockType: 'quote', id: 'row-1', label: 'After save' }, + name: 'block type changed', + }, + { + cachedRow: { id: 'row-1' }, + currentRow: { label: 'After save' }, + name: 'row identity disappeared', + }, + { + cachedRow: { blockType: 'callout', id: 'row-1' }, + currentRow: { id: 'row-1', label: 'After save' }, + name: 'block type disappeared', + }, + ])('should rebuild form state when the $name', ({ cachedRow, currentRow }) => { + expect( + getCachedFormStateIfDataMatches({ + cachedFormState: { + items: { initialValue: 1, rows: [cachedRow], value: 1 }, + 'items.0.label': { initialValue: 'Before save', value: 'Before save' }, + }, + formData: { items: [currentRow] }, + }), + ).toBe(false) + }) + + it.each([ + { + cachedArrayState: { rows: [] }, + currentValue: [], + name: 'null to empty', + }, + { + cachedArrayState: { initialValue: 0, rows: [], value: 0 }, + currentValue: null, + name: 'empty to null', + }, + ])( + 'should rebuild form state for a $name array transition', + ({ cachedArrayState, currentValue }) => { + expect( + getCachedFormStateIfDataMatches({ + cachedFormState: { items: cachedArrayState }, + formData: { items: currentValue }, + }), + ).toBe(false) + }, + ) + + it('should ignore blockType and blockName when determining whether cached values match', () => { + const cachedFormState: FormState = { + blockName: { initialValue: 'Old block name', value: 'Old block name' }, + id: { initialValue: 'block-1', value: 'block-1' }, + title: { initialValue: 'Gallery', value: 'Gallery' }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + formData: { + blockName: 'Current block name', + blockType: 'gallery', + id: 'block-1', + title: 'Gallery', + }, + }), + ).toBe(cachedFormState) + }) + + it('should rebuild form state when the cached schema path differs', () => { + const cachedFormState: FormState = { + title: { initialValue: 'Shared title', value: 'Shared title' }, + } + + expect( + getCachedFormStateIfDataMatches({ + cachedFormState, + cachedSchemaPath: 'lexical.blocks.callout.fields', + currentSchemaPath: 'lexical.blocks.quote.fields', + formData: { + blockType: 'quote', + title: 'Shared title', + }, + }), + ).toBe(false) + }) +}) diff --git a/packages/richtext-lexical/src/features/blocks/client/getCachedFormStateIfDataMatches.ts b/packages/richtext-lexical/src/features/blocks/client/getCachedFormStateIfDataMatches.ts new file mode 100644 index 00000000000..d9a89bcdba4 --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/client/getCachedFormStateIfDataMatches.ts @@ -0,0 +1,145 @@ +import type { Data, FormState } from 'payload' + +import { dequal } from 'dequal/lite' +import { reduceFieldsToValues } from 'payload/shared' + +export const getCachedFormStateIfDataMatches = ({ + cachedFormState, + cachedSchemaPath, + currentSchemaPath, + formData, +}: { + cachedFormState: FormState + cachedSchemaPath?: string + currentSchemaPath?: string + formData: Data +}): false | FormState => { + if (cachedSchemaPath !== currentSchemaPath) { + return false + } + + const cachedData = reduceFormStateToBlockData(cachedFormState) + const currentData = normalizeData({ + data: formData, + rowFieldPaths: getRowFieldPaths(cachedFormState), + }) + + return dequal(withoutSystemBlockFields(cachedData), withoutSystemBlockFields(currentData)) + ? cachedFormState + : false +} + +export const reduceFormStateToBlockData = (formState: FormState, currentData?: Data): Data => { + const formStateWithoutRowCounts = Object.fromEntries( + Object.entries(formState).map(([path, fieldState]) => { + if (!fieldState || !Array.isArray(fieldState.rows)) { + return [path, fieldState] + } + + if ( + fieldState.value === undefined || + (fieldState.disableFormData !== false && + fieldState.isModified !== true && + fieldState.rows.length === 0 && + getValueAtPath(currentData, path) === null) + ) { + return [path, { ...fieldState, value: null }] + } + + return [ + path, + fieldState.value === null ? fieldState : { ...fieldState, disableFormData: true }, + ] + }), + ) as FormState + + return normalizeData({ + data: reduceFieldsToValues(formStateWithoutRowCounts, true), + rowFieldPaths: new Set(), + }) +} + +const getValueAtPath = (data: Data | undefined, path: string): unknown => + path.split('.').reduce((value, pathSegment) => { + if (!value || typeof value !== 'object') { + return undefined + } + + return (value as Record)[pathSegment] + }, data) + +const getRowFieldPaths = (formState: FormState): Set => + new Set( + Object.entries(formState) + .filter( + ([, fieldState]) => + Array.isArray(fieldState?.rows) && + fieldState.value !== null && + fieldState.value !== undefined, + ) + .map(([path]) => path), + ) + +const normalizeData = ({ + data, + parentPath = '', + rowFieldPaths, +}: { + data: Data + parentPath?: string + rowFieldPaths: Set +}): Data => { + const normalizedData: Data = {} + + for (const [key, value] of Object.entries(data)) { + const path = parentPath ? `${parentPath}.${key}` : key + const normalizedValue = normalizeValue({ parentPath: path, rowFieldPaths, value }) + + if (normalizedValue !== undefined) { + normalizedData[key] = normalizedValue + } + } + + return normalizedData +} + +const normalizeValue = ({ + parentPath, + rowFieldPaths, + value, +}: { + parentPath: string + rowFieldPaths: Set + value: unknown +}): unknown => { + if ( + value === undefined || + (Array.isArray(value) && value.length === 0 && rowFieldPaths.has(parentPath)) + ) { + return undefined + } + + if (Array.isArray(value)) { + return value.map((item, index) => + item && typeof item === 'object' && !Array.isArray(item) + ? normalizeData({ + data: item as Data, + parentPath: `${parentPath}.${index}`, + rowFieldPaths, + }) + : item, + ) + } + + if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + return normalizeData({ data: value as Data, parentPath, rowFieldPaths }) + } + + return value +} + +const withoutSystemBlockFields = ({ + blockName: _blockName, + blockType: _blockType, + ...data +}: Data): Data => data diff --git a/packages/richtext-lexical/src/utilities/buildInitialState.ts b/packages/richtext-lexical/src/utilities/buildInitialState.ts index bc1b30f363d..524a4192a74 100644 --- a/packages/richtext-lexical/src/utilities/buildInitialState.ts +++ b/packages/richtext-lexical/src/utilities/buildInitialState.ts @@ -19,6 +19,7 @@ export type InitialLexicalFormState = { [nodeID: string]: { [key: string]: any formState?: FormState + schemaPath?: string } } @@ -96,6 +97,7 @@ export async function buildInitialState({ } initialState[id].formState = formStateResult + initialState[id].schemaPath = schemaFieldsPath if (node.type === 'block') { const currentFieldPreferences = context.preferences?.fields?.[context.field.name] diff --git a/packages/ui/src/forms/NullifyField/index.spec.ts b/packages/ui/src/forms/NullifyField/index.spec.ts new file mode 100644 index 00000000000..8cc9a275f0c --- /dev/null +++ b/packages/ui/src/forms/NullifyField/index.spec.ts @@ -0,0 +1,52 @@ +import type { FormState } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { fieldReducer } from '../Form/fieldReducer.js' +import { buildNullifyLocaleFieldUpdate } from './index.js' + +describe('buildNullifyLocaleFieldUpdate', () => { + it('should retain the local empty-array marker after a sibling field changes', () => { + const initialState: FormState = { + items: { + rows: [], + }, + title: { + value: 'Before', + }, + } + + const stateWithoutFallback = fieldReducer( + initialState, + buildNullifyLocaleFieldUpdate({ + fieldValue: null, + path: 'items', + useFallback: false, + }), + ) + const stateWithSiblingChange = fieldReducer(stateWithoutFallback, { + path: 'title', + type: 'UPDATE', + value: 'After', + }) + + expect(stateWithSiblingChange.items).toMatchObject({ + disableFormData: false, + rows: [], + value: 0, + }) + }) + + it('should clear the local empty-array marker when fallback is enabled', () => { + expect( + buildNullifyLocaleFieldUpdate({ + fieldValue: 0, + path: 'items', + useFallback: true, + }), + ).toMatchObject({ + disableFormData: undefined, + value: null, + }) + }) +}) diff --git a/packages/ui/src/forms/NullifyField/index.tsx b/packages/ui/src/forms/NullifyField/index.tsx index 33811ef89d4..f144f506858 100644 --- a/packages/ui/src/forms/NullifyField/index.tsx +++ b/packages/ui/src/forms/NullifyField/index.tsx @@ -19,6 +19,21 @@ type NullifyLocaleFieldProps = { readonly readOnly?: boolean } +export const buildNullifyLocaleFieldUpdate = ({ + fieldValue, + path, + useFallback, +}: { + fieldValue: NullifyLocaleFieldProps['fieldValue'] + path: string + useFallback: boolean +}) => ({ + type: 'UPDATE' as const, + disableFormData: useFallback ? undefined : false, + path, + value: useFallback ? null : fieldValue || 0, +}) + export const NullifyLocaleField: React.FC = ({ fieldValue, localized, @@ -47,11 +62,7 @@ export const NullifyLocaleField: React.FC = ({ const onChange = () => { const useFallback = !checked - dispatchFields({ - type: 'UPDATE', - path, - value: useFallback ? null : fieldValue || 0, - }) + dispatchFields(buildNullifyLocaleFieldUpdate({ fieldValue, path, useFallback })) setModified(true) setChecked(useFallback) } diff --git a/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts index 600e08e12f4..647e796fc21 100644 --- a/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts +++ b/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts @@ -2,6 +2,7 @@ import { buildEditorState, type DefaultNodeTypes, type RichTextNodes, + type SerializedBlockNode, type SerializedInlineBlockNode, } from '@payloadcms/richtext-lexical' import { expect, type Page, test } from '@playwright/test' @@ -9,10 +10,15 @@ import path from 'path' import { fileURLToPath } from 'url' import type { PayloadTestSDK } from '../../../../__helpers/shared/sdk/index.js' -import type { Config, InlineBlockWithSelect } from '../../../payload-types.js' +import type { + Config, + InlineBlockWithSelect, + MyBlock, + MyInlineBlock, +} from '../../../payload-types.js' import { assertNetworkRequests } from '../../../../__helpers/e2e/assertNetworkRequests.js' -import { saveDocAndAssert } from '../../../../__helpers/e2e/helpers.js' +import { changeLocale, saveDocAndAssert } from '../../../../__helpers/e2e/helpers.js' import { AdminUrlUtil } from '../../../../__helpers/shared/adminUrlUtil.js' import { reInitializeDB } from '../../../../__helpers/shared/clearAndSeed/reInitializeDB.js' import { initPayloadE2ENoConfig } from '../../../../__helpers/shared/initPayloadE2ENoConfig.js' @@ -267,6 +273,171 @@ describe('Lexical Fully Featured - database', () => { ) }) + test('should preserve null arrays when regular and inline block fields change', async ({ + page, + }) => { + const doc = await payload.create({ + collection: lexicalFullyFeaturedSlug, + data: { + richText: buildEditorState({ + nodes: [ + { + type: 'block', + fields: { + id: 'regular-block', + blockType: 'myBlock', + items: null, + someText: 'Regular before', + }, + format: '', + version: 2, + }, + { + type: 'block', + fields: { + id: 'empty-array-block', + blockType: 'myBlock', + items: null, + someText: 'Empty array block', + }, + format: '', + version: 2, + }, + { + type: 'inlineBlock', + fields: { + id: 'inline-block', + blockType: 'myInlineBlock', + items: null, + someText: 'Inline before', + }, + version: 1, + }, + ], + }), + }, + }) + + await page.goto(url.edit(doc.id)) + await expect(lexical.editor.first()).toBeVisible() + + const regularBlocks = lexical.editor.locator('.LexicalEditorTheme__block-myBlock') + const regularBlock = regularBlocks.nth(0) + await regularBlock.locator('#field-someText').fill('Regular after') + + const inlineBlock = lexical.editor.locator('.LexicalEditorTheme__inlineBlock').first() + await inlineBlock.locator('.LexicalEditorTheme__inlineBlock__container').click() + await expect(lexical.drawer).toBeVisible() + await lexical.drawer.locator('#field-someText').fill('Inline after') + await lexical.drawer.getByText('Save changes').click() + await expect(lexical.drawer).toBeHidden() + + const emptyArrayBlock = regularBlocks.nth(1) + const itemsField = emptyArrayBlock.locator('#field-items') + const emptyFormStateResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(`/admin/collections/${lexicalFullyFeaturedSlug}/`), + ) + await itemsField.getByRole('button', { name: 'Add Item' }).click() + await expect(itemsField.locator('.array-field__row')).toHaveCount(1) + + await itemsField.locator('#items-row-0 .array-actions__button').click() + await page.locator('.popup__content .array-actions__remove').click() + await emptyArrayBlock.locator('#field-someText').fill('Empty array after') + await emptyFormStateResponsePromise + await expect(itemsField.locator('.array-field__row')).toHaveCount(0) + + const updateRequestPromise = page.waitForRequest( + (request) => + request.method() === 'PATCH' && request.url().includes(`/api/${lexicalFullyFeaturedSlug}/`), + ) + await saveDocAndAssert(page) + + const updateRequest = await updateRequestPromise + const serializedUpdateData = updateRequest + .postData() + ?.match(/name="_payload"\r\n\r\n(.*?)\r\n--/s)?.[1] + expect(serializedUpdateData).toBeDefined() + const updateData = JSON.parse(serializedUpdateData as string) + const savedNodes = updateData.richText.root.children as FullyFeaturedNode[] + const savedRegularBlock = savedNodes.find( + (node) => node.type === 'block' && node.fields.id === 'regular-block', + ) as SerializedBlockNode | undefined + const savedEmptyArrayBlock = savedNodes.find( + (node) => node.type === 'block' && node.fields.id === 'empty-array-block', + ) as SerializedBlockNode | undefined + const savedParagraph = savedNodes.find((node) => node.type === 'paragraph') + const savedInlineBlock = savedParagraph?.children.find( + (node) => node.type === 'inlineBlock' && node.fields.blockType === 'myInlineBlock', + ) as SerializedInlineBlockNode | undefined + + expect(savedRegularBlock?.fields.items).toBeNull() + expect(savedEmptyArrayBlock?.fields.items).toBeUndefined() + expect(savedInlineBlock?.fields.items).toBeNull() + }) + + test('should keep a localized block array empty after fallback is disabled', async ({ page }) => { + const doc = await payload.create({ + collection: lexicalFullyFeaturedSlug, + data: { + richText: buildEditorState({ + nodes: [ + { + type: 'block', + fields: { + id: 'localized-empty-array-block', + blockType: 'myBlock', + items: null, + someText: 'Before', + }, + format: '', + version: 2, + }, + ], + }), + }, + }) + + await page.goto(url.edit(doc.id)) + await expect(lexical.editor.first()).toBeVisible() + await changeLocale(page, 'es') + + const block = lexical.editor.locator('.LexicalEditorTheme__block-myBlock') + const itemsField = block.locator('#field-items') + const fallbackCheckbox = itemsField.locator('input[type="checkbox"]') + await expect(fallbackCheckbox).toBeChecked() + + const formStateResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(`/admin/collections/${lexicalFullyFeaturedSlug}/`), + ) + await fallbackCheckbox.click() + await expect(fallbackCheckbox).not.toBeChecked() + await block.locator('#field-someText').fill('After') + await formStateResponsePromise + + const updateRequestPromise = page.waitForRequest( + (request) => + request.method() === 'PATCH' && request.url().includes(`/api/${lexicalFullyFeaturedSlug}/`), + ) + await saveDocAndAssert(page) + + const updateRequest = await updateRequestPromise + const serializedUpdateData = updateRequest + .postData() + ?.match(/name="_payload"\r\n\r\n(.*?)\r\n--/s)?.[1] + expect(serializedUpdateData).toBeDefined() + const updateData = JSON.parse(serializedUpdateData as string) + const savedNodes = updateData.richText.root.children as FullyFeaturedNode[] + const savedBlock = savedNodes.find( + (node) => node.type === 'block' && node.fields.id === 'localized-empty-array-block', + ) as SerializedBlockNode | undefined + + expect(savedBlock?.fields.items).toBeUndefined() + }) + test('ensure block name can be saved and loaded', async ({ page }) => { await lexical.slashCommand('myblock') await expect(lexical.editor.locator('.LexicalEditorTheme__block')).toBeVisible() diff --git a/test/lexical/collections/_LexicalFullyFeatured/index.ts b/test/lexical/collections/_LexicalFullyFeatured/index.ts index 0c442bcdfb1..308215b15ae 100644 --- a/test/lexical/collections/_LexicalFullyFeatured/index.ts +++ b/test/lexical/collections/_LexicalFullyFeatured/index.ts @@ -1,4 +1,4 @@ -import type { CollectionConfig } from 'payload' +import type { CollectionAfterReadHook, CollectionConfig } from 'payload' import { BlocksFeature, @@ -13,8 +13,42 @@ import { import { lexicalFullyFeaturedSlug } from '../../slugs.js' +type RegressionBlockNode = { + children?: RegressionBlockNode[] + fields?: Record +} + +const returnNullItemsForBlockStateRegression: CollectionAfterReadHook = ({ doc }) => { + const setNullItems = (nodes: RegressionBlockNode[]) => { + for (const node of nodes) { + if ( + node.fields?.id === 'regular-block' || + node.fields?.id === 'empty-array-block' || + node.fields?.id === 'localized-empty-array-block' || + node.fields?.id === 'inline-block' + ) { + node.fields.items = null + } + + if (node.children) { + setNullItems(node.children) + } + } + } + + const nodes = doc.richText?.root?.children as RegressionBlockNode[] | undefined + if (nodes) { + setNullItems(nodes) + } + + return doc +} + export const LexicalFullyFeatured: CollectionConfig = { slug: lexicalFullyFeaturedSlug, + hooks: { + afterRead: [returnNullItemsForBlockStateRegression], + }, labels: { singular: 'Lexical Fully Featured', plural: 'Lexical Fully Featured', @@ -75,6 +109,17 @@ export const LexicalFullyFeatured: CollectionConfig = { name: 'someText', type: 'text', }, + { + name: 'items', + type: 'array', + localized: true, + fields: [ + { + name: 'label', + type: 'text', + }, + ], + }, ], }, { @@ -95,6 +140,16 @@ export const LexicalFullyFeatured: CollectionConfig = { name: 'someText', type: 'text', }, + { + name: 'items', + type: 'array', + fields: [ + { + name: 'label', + type: 'text', + }, + ], + }, ], }, { diff --git a/test/lexical/payload-types.ts b/test/lexical/payload-types.ts index 83e9741147d..e63ad437f53 100644 --- a/test/lexical/payload-types.ts +++ b/test/lexical/payload-types.ts @@ -3352,6 +3352,12 @@ export interface MyBlock { id: string; blockType: 'myBlock'; someText?: string | null; + items?: + | { + label?: string | null; + id?: string | null; + }[] + | null; blockName?: string | null; } /** @@ -3380,6 +3386,12 @@ export interface MyInlineBlock { id: string; blockType: 'myInlineBlock'; someText?: string | null; + items?: + | { + label?: string | null; + id?: string | null; + }[] + | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -4185,4 +4197,4 @@ export interface SerializedTableCellNode extends SerializedLexicalEle declare module 'payload' { // @ts-ignore export interface GeneratedTypes extends Config {} -} \ No newline at end of file +}