PluginProbe ʕ •ᴥ•ʔ
Secure Custom Fields / trunk
Secure Custom Fields vtrunk
6.9.1 6.9.0 6.8.9 6.8.7 6.8.8 6.8.6 6.8.4 6.8.5 trunk 6.4.0-beta1 6.4.0-beta2 6.4.1 6.4.1-beta3 6.4.1-beta4 6.4.1-beta5 6.4.1-beta6 6.4.1-beta7 6.4.2 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.6.0 6.7.0 6.7.1 6.8.0 6.8.1 6.8.2 6.8.3
secure-custom-fields / assets / src / js / pro / blocks-v3 / components / block-form.js
secure-custom-fields / assets / src / js / pro / blocks-v3 / components Last commit date
block-edit.js 3 weeks ago block-form.js 2 months ago block-placeholder.js 6 months ago block-preview.js 6 months ago block-toolbar-fields.js 2 months ago error-boundary.js 2 months ago inline-editing-toolbar.js 6 months ago jsx-parser.js 2 months ago popover-wrapper.js 2 months ago
block-form.js
305 lines
1 /**
2 * BlockForm Component
3 * Renders the ACF fields form inside a block and handles form changes, validation, and remounting
4 */
5
6 import { useState, useEffect, useRef } from '@wordpress/element';
7 import { lockPostSaving, unlockPostSaving } from '../utils/post-locking';
8
9 /**
10 * BlockForm component
11 * Manages ACF field forms within Gutenberg blocks, including validation and change detection
12 *
13 * @param {Object} props - Component props
14 * @param {jQuery} props.$ - jQuery instance
15 * @param {string} props.clientId - Block client ID
16 * @param {string} props.blockFormHtml - HTML markup for the ACF form
17 * @param {Function} props.onMount - Callback when the form mounts
18 * @param {Function} props.onChange - Callback when form data changes
19 * @param {Array} props.validationErrors - Array of validation error objects
20 * @param {boolean} props.showValidationErrors - Whether to display validation errors
21 * @param {React.RefObject} props.acfFormRef - Ref to the form container element
22 * @param {boolean} props.userHasInteractedWithForm - Whether user has interacted with the form
23 * @param {Object} props.attributes - Block attributes
24 * @returns {JSX.Element} - Rendered form component
25 */
26 export const BlockForm = ( {
27 $,
28 clientId,
29 blockFormHtml,
30 onMount,
31 onChange,
32 validationErrors,
33 showValidationErrors,
34 acfFormRef,
35 userHasInteractedWithForm,
36 attributes,
37 hideFieldsInSidebar,
38 } ) => {
39 const [ formHtml, setFormHtml ] = useState( blockFormHtml );
40 const [ pendingChange, setPendingChange ] = useState( false );
41 const debounceTimer = useRef( null );
42 const [ userInteracted, setUserInteracted ] = useState( false );
43 const [ hasRemounted, setHasRemounted ] = useState( false );
44
45 // Trigger onChange when there's a pending change
46 useEffect( () => {
47 if (
48 pendingChange &&
49 ( userHasInteractedWithForm || userInteracted )
50 ) {
51 onChange( pendingChange );
52 setPendingChange( false );
53 }
54 }, [
55 pendingChange,
56 userHasInteractedWithForm,
57 userInteracted,
58 setPendingChange,
59 onChange,
60 ] );
61
62 // Update form HTML when blockFormHtml prop changes
63 useEffect( () => {
64 if ( ! formHtml && blockFormHtml ) {
65 setFormHtml( blockFormHtml );
66 }
67 }, [ blockFormHtml ] );
68
69 // Handle validation errors
70 useEffect( () => {
71 if ( ! acfFormRef?.current ) return;
72
73 const validator = acf.getBlockFormValidator(
74 $( acfFormRef.current ).find( '.acf-block-fields' )
75 );
76
77 validator.clearErrors();
78 validator.set( 'notice', null );
79
80 acf.doAction( 'blocks/validation/pre_apply', validationErrors );
81
82 if ( validationErrors ) {
83 if ( showValidationErrors ) {
84 lockPostSaving( clientId );
85 validator.$el.find( '.acf-notice' ).remove();
86 validator.addErrors( validationErrors );
87 validator.showErrors( 'after' );
88 }
89 } else {
90 // Handle successful validation
91 if (
92 validator.$el.find( '.acf-notice' ).length > 0 &&
93 showValidationErrors
94 ) {
95 validator.$el.find( '.acf-notice' ).remove();
96 validator.addErrors( [
97 { message: acf.__( 'Validation successful' ) },
98 ] );
99 validator.showErrors( 'after' );
100 validator.get( 'notice' ).update( {
101 type: 'success',
102 text: acf.__( 'Validation successful' ),
103 timeout: 1000,
104 } );
105 validator.set( 'notice', null );
106
107 setTimeout( () => {
108 validator.$el.find( '.acf-notice' ).remove();
109 }, 1001 );
110
111 const noticeDispatch = wp.data.dispatch( 'core/notices' );
112
113 /**
114 * Recursively checks for ACF errors in blocks
115 * @param {Array} blocks - Array of block objects
116 * @returns {Promise<boolean>} - True if error found
117 */
118 function checkForErrors( blocks ) {
119 return new Promise( function ( resolve ) {
120 blocks.forEach( ( block ) => {
121 if ( block.innerBlocks.length > 0 ) {
122 checkForErrors( block.innerBlocks ).then(
123 ( hasError ) => {
124 if ( hasError ) return resolve( true );
125 }
126 );
127 }
128
129 if (
130 block.attributes.hasAcfError &&
131 block.clientId !== clientId
132 ) {
133 return resolve( true );
134 }
135 } );
136 return resolve( false );
137 } );
138 }
139
140 checkForErrors(
141 wp.data.select( 'core/block-editor' ).getBlocks()
142 ).then( ( hasError ) => {
143 if ( hasError ) {
144 noticeDispatch.createErrorNotice(
145 acf.__(
146 'An ACF Block on this page requires attention before you can save.'
147 ),
148 { id: 'acf-blocks-validation', isDismissible: true }
149 );
150 } else {
151 noticeDispatch.removeNotice( 'acf-blocks-validation' );
152 }
153 } );
154 }
155
156 unlockPostSaving( clientId );
157 }
158
159 acf.doAction( 'blocks/validation/post_apply', validationErrors );
160 }, [ validationErrors, clientId, showValidationErrors ] );
161
162 // Handle form remounting and change detection
163 useEffect( () => {
164 if ( ! acfFormRef?.current || ! formHtml ) return;
165
166 acf.debug( 'Remounting ACF Form' );
167
168 const formElement = acfFormRef.current;
169 const $form = $( formElement );
170 let isActive = true;
171
172 if ( ! hasRemounted ) {
173 acf.debug( 'Remounting ACF Form' );
174 acf.doAction( 'remount', $form );
175 onMount?.( $form );
176 setHasRemounted( true );
177 }
178
179 const handleChange = () => {
180 onChange( $form );
181 };
182
183 const scheduleChange = () => {
184 if ( ! isActive ) return;
185
186 const inputs = formElement.querySelectorAll( 'input, textarea' );
187 const selects = formElement.querySelectorAll( 'select' );
188
189 inputs.forEach( ( input ) => {
190 input.removeEventListener( 'input', handleChange );
191 input.addEventListener( 'input', handleChange );
192 input.removeEventListener( 'change', handleChange );
193 input.addEventListener( 'change', handleChange );
194 } );
195
196 selects.forEach( ( select ) => {
197 select.removeEventListener( 'change', handleChange );
198 select.addEventListener( 'change', handleChange );
199 } );
200
201 clearTimeout( debounceTimer.current );
202 debounceTimer.current = setTimeout( () => {
203 if ( isActive ) {
204 setPendingChange( $form );
205 }
206 }, 300 );
207 };
208
209 // Observe DOM changes to detect field additions/removals
210 const domObserver = new MutationObserver( scheduleChange );
211
212 // Observe iframe content changes (for WYSIWYG editors)
213 const iframeObserver = new MutationObserver( () => {
214 if ( isActive ) {
215 setUserInteracted( true );
216 scheduleChange();
217 }
218 } );
219
220 const observerConfig = {
221 attributes: true,
222 childList: true,
223 subtree: true,
224 characterData: true,
225 };
226
227 domObserver.observe( formElement, observerConfig );
228
229 // Watch for changes in iframes (WYSIWYG fields)
230 [ ...formElement.querySelectorAll( 'iframe' ) ].forEach( ( iframe ) => {
231 if ( iframe && iframe.contentDocument ) {
232 const iframeBody = iframe.contentDocument.body;
233 if ( iframeBody ) {
234 iframeObserver.observe( iframeBody, observerConfig );
235 }
236 }
237 } );
238
239 // Attach event listeners to form inputs
240 formElement
241 .querySelectorAll( 'input, textarea' )
242 .forEach( ( input ) => {
243 input.addEventListener( 'input', handleChange );
244 input.addEventListener( 'change', handleChange );
245 } );
246
247 formElement.querySelectorAll( 'select' ).forEach( ( select ) => {
248 select.addEventListener( 'change', handleChange );
249 } );
250
251 if ( userHasInteractedWithForm ) {
252 clearTimeout( debounceTimer.current );
253 debounceTimer.current = setTimeout( () => {
254 if ( isActive ) {
255 setPendingChange( $form );
256 }
257 }, 300 );
258 }
259
260 // Cleanup function
261 return () => {
262 isActive = false;
263 domObserver.disconnect();
264 iframeObserver.disconnect();
265 clearTimeout( debounceTimer.current );
266
267 if ( formElement ) {
268 formElement
269 .querySelectorAll( 'input, textarea' )
270 .forEach( ( input ) => {
271 input.removeEventListener( 'input', handleChange );
272 input.removeEventListener( 'change', handleChange );
273 } );
274
275 formElement
276 .querySelectorAll( 'select' )
277 .forEach( ( select ) => {
278 select.removeEventListener( 'change', handleChange );
279 } );
280 }
281 };
282 }, [
283 acfFormRef,
284 attributes,
285 formHtml,
286 onMount,
287 userHasInteractedWithForm,
288 ] );
289
290 return (
291 <div
292 ref={ acfFormRef }
293 className="acf-block-component acf-block-panel"
294 style={ { display: hideFieldsInSidebar ? 'none' : null } }
295 dangerouslySetInnerHTML={ {
296 __html: acf.applyFilters(
297 'blocks/form/render',
298 formHtml,
299 true
300 ),
301 } }
302 />
303 );
304 };
305