diff --git a/assets/src/js/pro/blocks-v3/components/block-edit.js b/assets/src/js/pro/blocks-v3/components/block-edit.js index bf373a11..7830c3ff 100644 --- a/assets/src/js/pro/blocks-v3/components/block-edit.js +++ b/assets/src/js/pro/blocks-v3/components/block-edit.js @@ -169,6 +169,22 @@ function deserializeGoogleMapValues( $form, serializedData, clientId ) { } ); } +/** + * Serializes the block form into the JSON string stored as theSerializedAcfData. + * + * @param {Object} $form The block form jQuery element. + * @param {string} clientId The block client ID. + * @return {string|null} JSON string, or null when the form has no data. + */ +function serializeBlockForm( $form, clientId ) { + const serializedData = acf.serialize( $form, `acf-block_${ clientId }` ); + if ( ! serializedData ) { + return null; + } + deserializeGoogleMapValues( $form, serializedData, clientId ); + return JSON.stringify( serializedData ); +} + /** * Main BlockEdit component wrapper * Manages block data fetching and initial setup @@ -264,6 +280,27 @@ export const BlockEdit = ( props ) => { const acfFormRef = useRef( null ); const previewRef = useRef( null ); const debounceRef = useRef( null ); + // Serialized form data before the user changed anything. + const initialFormDataRef = useRef( null ); + const hasWrittenFormDataRef = useRef( false ); + + /** + * Stores the form's current data as its loaded state, until the first real edit is written + * + * @param {jQuery} $form - The block form + */ + const captureInitialFormData = useCallback( + ( $form ) => { + if ( hasWrittenFormDataRef.current ) { + return; + } + const serializedData = serializeBlockForm( $form, clientId ); + if ( serializedData ) { + initialFormDataRef.current = serializedData; + } + }, + [ clientId ] + ); // Initialize acf.blockEdit namespace for jsx-parser to use if ( ! acf.blockEdit ) { @@ -414,9 +451,10 @@ export const BlockEdit = ( props ) => { * @returns {string} - MD5 hash of serialized attributes */ function generateAttributesHash( attrs, ctx ) { - delete attrs.hasAcfError; - attrs._acf_context = sortObjectKeys( ctx ); - return md5( JSON.stringify( sortObjectKeys( attrs ) ) ); + // Work on a copy, attrs can be the block editor store's own object. + const { hasAcfError, ...hashAttrs } = attrs; + hashAttrs._acf_context = sortObjectKeys( ctx ); + return md5( JSON.stringify( sortObjectKeys( hashAttrs ) ) ); } /** @@ -579,10 +617,18 @@ export const BlockEdit = ( props ) => { // Update hasAcfError attribute based on validation errors useEffect( () => { - setAttributes( - validationErrors ? { hasAcfError: true } : { hasAcfError: false } - ); - }, [ validationErrors, setAttributes ] ); + const hasError = !! validationErrors; + if ( hasError === !! attributes.hasAcfError ) { + return; + } + + // hasAcfError is not saved with the block, so this must not mark the post as changed. + // Send only this key, so the next real edit is not merged into this change. + wp.data + .dispatch( 'core/block-editor' ) + .__unstableMarkNextChangeAsNotPersistent(); + setAttributes( { hasAcfError: hasError } ); + }, [ validationErrors, attributes.hasAcfError, setAttributes ] ); // Listen for validation error events from other blocks useEffect( () => { @@ -631,18 +677,29 @@ export const BlockEdit = ( props ) => { } ); } + // Stored data is keyed by field name and the form by field key, so on load + // the form is compared with its own first state instead. if ( theSerializedAcfData === - JSON.stringify( attributesWithoutError.data ) + JSON.stringify( attributesWithoutError.data ) || + ( ! hasWrittenFormDataRef.current && + theSerializedAcfData === initialFormDataRef.current ) ) { + // Send the form values even when they are not stored, so the server + // validates them. A new block has no stored data to validate. return void fetchBlockData( { - theAttributes: attributesWithoutError, + theAttributes: { + ...attributesWithoutError, + data: parsedData, + }, theClientId: clientId, theContext: context, isSelected: isSelected, } ); } + hasWrittenFormDataRef.current = true; + // Use original attributes (with hasAcfError) when updating const updatedAttributes = { ...attributes, @@ -714,6 +771,7 @@ export const BlockEdit = ( props ) => { showValidationErrors={ showValidationErrors } theSerializedAcfData={ theSerializedAcfData } setTheSerializedAcfData={ setTheSerializedAcfData } + captureInitialFormData={ captureInitialFormData } acfFormRef={ acfFormRef } blockFormHtml={ blockFormHtml } blockPreviewHtml={ blockPreviewHtml } @@ -770,6 +828,7 @@ function BlockEditInner( props ) { showValidationErrors, theSerializedAcfData, setTheSerializedAcfData, + captureInitialFormData, acfFormRef, blockFormHtml, blockPreviewHtml, @@ -1005,14 +1064,12 @@ function BlockEditInner( props ) { setContentEditableChangeInProgress( false ); fieldInput.value = content; - const $form = $( acfFormRef?.current ); - const serializedData = acf.serialize( - $form, - `acf-block_${ clientId }` + const serializedData = serializeBlockForm( + $( acfFormRef?.current ), + clientId ); if ( serializedData ) { - deserializeGoogleMapValues( $form, serializedData, clientId ); - setTheSerializedAcfData( JSON.stringify( serializedData ) ); + setTheSerializedAcfData( serializedData ); } else { setUserHasInteractedWithForm( false ); } @@ -1251,20 +1308,15 @@ function BlockEditInner( props ) { $={ $ } clientId={ clientId } blockFormHtml={ blockFormHtml } + onMount={ captureInitialFormData } + onUntouchedChange={ captureInitialFormData } onChange={ function ( $form ) { - const serializedData = acf.serialize( + const serializedData = serializeBlockForm( $form, - `acf-block_${ clientId }` + clientId ); if ( serializedData ) { - deserializeGoogleMapValues( - $form, - serializedData, - clientId - ); - setTheSerializedAcfData( - JSON.stringify( serializedData ) - ); + setTheSerializedAcfData( serializedData ); } } } validationErrors={ validationErrors } diff --git a/assets/src/js/pro/blocks-v3/components/block-form.js b/assets/src/js/pro/blocks-v3/components/block-form.js index f5b22527..444155aa 100644 --- a/assets/src/js/pro/blocks-v3/components/block-form.js +++ b/assets/src/js/pro/blocks-v3/components/block-form.js @@ -16,6 +16,7 @@ import { lockPostSaving, unlockPostSaving } from '../utils/post-locking'; * @param {string} props.blockFormHtml - HTML markup for the ACF form * @param {Function} props.onMount - Callback when the form mounts * @param {Function} props.onChange - Callback when form data changes + * @param {Function} props.onUntouchedChange - Callback when the form changes before any user interaction * @param {Array} props.validationErrors - Array of validation error objects * @param {boolean} props.showValidationErrors - Whether to display validation errors * @param {React.RefObject} props.acfFormRef - Ref to the form container element @@ -29,6 +30,7 @@ export const BlockForm = ( { blockFormHtml, onMount, onChange, + onUntouchedChange, validationErrors, showValidationErrors, acfFormRef, @@ -39,17 +41,24 @@ export const BlockForm = ( { const [ formHtml, setFormHtml ] = useState( blockFormHtml ); const [ pendingChange, setPendingChange ] = useState( false ); const debounceTimer = useRef( null ); + const reportedUntouchedChange = useRef( null ); const [ userInteracted, setUserInteracted ] = useState( false ); const [ hasRemounted, setHasRemounted ] = useState( false ); // Trigger onChange when there's a pending change useEffect( () => { - if ( - pendingChange && - ( userHasInteractedWithForm || userInteracted ) - ) { + if ( ! pendingChange ) { + return; + } + + if ( userHasInteractedWithForm || userInteracted ) { onChange( pendingChange ); setPendingChange( false ); + } else if ( reportedUntouchedChange.current !== pendingChange ) { + // No interaction yet, so this is the form setting itself up. + // Report it once, not on every render, so a later edit is never taken as setup. + reportedUntouchedChange.current = pendingChange; + onUntouchedChange?.( pendingChange ); } }, [ pendingChange, @@ -57,6 +66,7 @@ export const BlockForm = ( { userInteracted, setPendingChange, onChange, + onUntouchedChange, ] ); // Update form HTML when blockFormHtml prop changes @@ -177,6 +187,8 @@ export const BlockForm = ( { } const handleChange = () => { + // Edits like a context menu paste fire no click or keydown first. + setUserInteracted( true ); onChange( $form ); }; diff --git a/tests/js/blocks-v3/block-edit.test.js b/tests/js/blocks-v3/block-edit.test.js index 1b866922..4e48a45d 100644 --- a/tests/js/blocks-v3/block-edit.test.js +++ b/tests/js/blocks-v3/block-edit.test.js @@ -8,7 +8,7 @@ /* global acf, wp, MouseEvent */ import React from 'react'; -import { render, screen, act, waitFor } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import '@testing-library/jest-dom'; // Mock md5 @@ -40,14 +40,19 @@ jest.mock( } ) ); +// Latest props passed to BlockForm, so tests can call its callbacks. +let mockBlockFormProps = null; jest.mock( '../../../assets/src/js/pro/blocks-v3/components/block-form', () => ( { - BlockForm: ( { blockFormHtml } ) => ( -