PluginProbe
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor / 4.0.3
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor v4.0.3
4.0.3 4.0.2 4.0.1 4.0.0 3.16.6 3.16.5 3.16.4 3.16.3 3.16.2 3.16.1 3.16.0 3.15.9 3.9.9 3.9.5 3.9.6 3.9.7 3.9.8 1.1.7 1.1.8 1.1.9 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 All 341 releases
profile-builder / form-builder / blocks / src / components / ExistingFieldsPanel.js

ExistingFieldsPanel.js in User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor 4.0.3, at form-builder/blocks/src/components/ExistingFieldsPanel.js

1,486 lines 65.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __, _n, sprintf } from '@wordpress/i18n';
2 import {
3 Button,
4 PanelBody,
5 Notice,
6 SearchControl,
7 Modal,
8 Spinner,
9 Tooltip,
10 __experimentalToggleGroupControl as ToggleGroupControl,
11 __experimentalToggleGroupControlOptionIcon as ToggleGroupControlOptionIcon,
12 } from '@wordpress/components';
13 import { useDispatch, useSelect, useRegistry, select as dataSelect, dispatch as dataDispatch } from '@wordpress/data';
14 import { createBlock, getBlockType } from '@wordpress/blocks';
15 import { registerPlugin } from '@wordpress/plugins';
16 import { PluginSidebar } from '@wordpress/edit-post';
17 import { BlockEditorProvider, BlockList, BlockIcon } from '@wordpress/block-editor';
18 import { createPortal, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from '@wordpress/element';
19 import apiFetch from '@wordpress/api-fetch';
20 import { FieldSettingsSlot } from './FieldSettingsSlotFill';
21 import {
22 scheduleAttributePersist,
23 captureInitial,
24 registerSnapshotSync,
25 registerWriteBack,
26 forget,
27 } from '../lib/persistFieldAttributes';
28 import { makeWriteBackHandler } from '../lib/writeBackHandler';
29 import { createPubsubStore } from '../lib/createPubsubStore';
30 import {
31 getExistingFieldsSnapshot,
32 setExistingFieldsSnapshot,
33 useExistingFieldsSnapshot,
34 } from '../lib/existingFieldsSnapshot';
35 import { removeBlocksWithUndo } from '../lib/removeBlocksWithUndo';
36 import { REPEATER_BLOCK_NAME } from '../lib/repeaterBlockName';
37 import {
38 miniInserterAllowsExistingFields,
39 subscribeMiniInserterTarget,
40 useMiniInserterTarget,
41 } from '../lib/miniInserterTarget';
42
43 const HIJACK_TAB_SELECTOR = '[role="tab"][id$="-media"]';
44 const HIJACK_CLASS = 'wppb-fb-existing-fields-hijacked';
45 const INJECT_CLASS = 'wppb-fb-existing-fields-injected';
46 // Hides Existing Fields tab when destination can't take an existing field.
47 const NO_EXISTING_FIELDS_CLASS = 'wppb-fb-no-existing-fields';
48 const HIJACK_TAB_LABEL = __( 'Existing Fields', 'profile-builder' );
49
50 // Field Settings PluginSidebar (registerPlugin slug + name).
51 const SIDEBAR_PLUGIN = 'wppb-fb-existing-fields-sidebar';
52 const SIDEBAR_NAME = 'field-settings';
53 const SIDEBAR_TARGET = `${ SIDEBAR_PLUGIN }/${ SIDEBAR_NAME }`;
54
55 // Form Settings complementary area (store name uses slash; DOM id uses colon).
56 const DOCUMENT_SIDEBAR = 'edit-post/document';
57
58 // Try edit-post / editor / interface open APIs across Gutenberg versions.
59 const openSidebar = ( identifier ) => {
60 const tries = [
61 [ 'core/edit-post.openGeneralSidebar', () => dataDispatch( 'core/edit-post' ) && dataDispatch( 'core/edit-post' ).openGeneralSidebar( identifier ) ],
62 [ 'core/editor.openGeneralSidebar', () => dataDispatch( 'core/editor' ) && dataDispatch( 'core/editor' ).openGeneralSidebar( identifier ) ],
63 [ 'core/interface.enableComplementaryArea', () => dataDispatch( 'core/interface' ) && dataDispatch( 'core/interface' ).enableComplementaryArea( 'core/edit-post', identifier ) ],
64 ];
65 const errors = [];
66 for ( const [ label, fn ] of tries ) {
67 try { const r = fn(); if ( r !== undefined && r !== false ) return; } catch ( e ) { errors.push( `${ label }: ${ e?.message ?? e }` ); }
68 }
69 if ( errors.length === tries.length ) {
70 // eslint-disable-next-line no-console
71 console.warn( '[wppb-fb] openSidebar: all dispatch targets failed for', identifier, errors );
72 }
73 };
74
75 const TAB_RENAMES = [
76 { selector: '[role="tab"][id$="-blocks"]', label: __( 'New Fields', 'profile-builder' ) },
77 { selector: '[role="tab"][id$="-patterns"]', label: __( 'Examples', 'profile-builder' ) },
78 { selector: HIJACK_TAB_SELECTOR, label: HIJACK_TAB_LABEL },
79 ];
80
81 const renameTab = ( tab, label ) => {
82 if ( ! tab || tab.dataset.wppbRenamed === label ) return;
83 const spans = tab.querySelectorAll( 'span' );
84 let renamed = false;
85 for ( const span of spans ) {
86 if ( span.children.length === 0 && span.textContent.trim().length > 0 ) {
87 span.textContent = label;
88 renamed = true;
89 break;
90 }
91 }
92 if ( ! renamed ) {
93 const span = document.createElement( 'span' );
94 span.textContent = label;
95 tab.appendChild( span );
96 }
97 if ( tab.hasAttribute( 'aria-label' ) ) {
98 tab.setAttribute( 'aria-label', label );
99 }
100 tab.dataset.wppbRenamed = label;
101 };
102
103 const ensurePanelTarget = ( panel ) => {
104 panel.classList.add( HIJACK_CLASS );
105 let injected = panel.querySelector( `.${ INJECT_CLASS }` );
106 if ( ! injected ) {
107 injected = document.createElement( 'div' );
108 injected.className = INJECT_CLASS;
109 panel.appendChild( injected );
110 }
111 return injected;
112 };
113
114 // DFS walk of PB blocks (parent before children).
115 const walkPbCanvasBlocks = ( blocks, visit ) => {
116 for ( const b of blocks ) {
117 if ( b.name && b.name.startsWith( 'profile-builder/' ) ) visit( b );
118 if ( b.innerBlocks && b.innerBlocks.length ) walkPbCanvasBlocks( b.innerBlocks, visit );
119 }
120 };
121
122 // Index canvas by field id. Map order is DFS (card sort).
123 const collectCanvasFields = ( blocks, map ) => {
124 walkPbCanvasBlocks( blocks, ( b ) => {
125 if ( b.attributes && b.attributes.id ) {
126 map.set( Number( b.attributes.id ), { clientId: b.clientId, attributes: b.attributes } );
127 }
128 } );
129 };
130
131 // Card selection shared across portal list + PluginSidebar.
132 // parentMetaName routes sub-field VirtualFieldEdit / persister scope.
133 const selectionStore = createPubsubStore(
134 { id: null, parentMetaName: null },
135 ( a, b ) => a.id === b.id && a.parentMetaName === b.parentMetaName,
136 );
137 const setSelectedCard = ( id, parentMetaName = null ) => {
138 selectionStore.set( { id, parentMetaName } );
139 };
140 const useSelectedCard = () =>
141 useSyncExternalStore( selectionStore.subscribe, selectionStore.get );
142
143 // Shared existing-fields snapshot (lib/existingFieldsSnapshot).
144 const setSnapshot = setExistingFieldsSnapshot;
145 const useSnapshot = useExistingFieldsSnapshot;
146
147 const flattenSnapshot = ( entries, out = new Map() ) => {
148 for ( const e of entries ) {
149 out.set( e.id, e );
150 if ( e.innerBlocks && e.innerBlocks.length ) flattenSnapshot( e.innerBlocks, out );
151 }
152 return out;
153 };
154
155 // Bridge `mandatoryTypes` only — empty if missing (server still enforces delete).
156 const MANDATORY_DEFAULT_TYPES = new Set(
157 ( window.wppbFb && Array.isArray( window.wppbFb.mandatoryTypes ) )
158 ? window.wppbFb.mandatoryTypes
159 : []
160 );
161
162 // Live canvas clientIds for a manage-fields row id (Remove/Delete scrub).
163 const canvasClientIdsForFieldId = ( fieldId ) => {
164 const target = Number( fieldId );
165 const out = [];
166 walkPbCanvasBlocks( dataSelect( 'core/block-editor' ).getBlocks(), ( b ) => {
167 if ( b.attributes && b.attributes.id && Number( b.attributes.id ) === target ) {
168 out.push( b.clientId );
169 }
170 } );
171 return out;
172 };
173
174 // Required when type declares the attr and value is 'Yes'.
175 const supportsRequired = ( blockName ) => {
176 const type = blockName ? getBlockType( blockName ) : null;
177 return !! ( type && type.attributes && type.attributes.required );
178 };
179 const isRequiredField = ( field ) =>
180 supportsRequired( field.blockName ) &&
181 String( ( field.attributes && field.attributes.required ) || '' ).toLowerCase() === 'yes';
182
183 // Card title + required asterisk (shared by top-level and sub-field cards).
184 const FieldTitle = ( { field, children } ) => (
185 <div className="wppb-fb-existing-fields__title">
186 { field.title || __( '(Untitled)', 'profile-builder' ) }
187 { isRequiredField( field ) && (
188 <span
189 className="wppb-fb-existing-fields__required"
190 title={ __( 'Required field', 'profile-builder' ) }
191 >
192 *
193 </span>
194 ) }
195 { children }
196 </div>
197 );
198
199 // Field Type / Meta Name / ID rows (shared card body).
200 const FieldMeta = ( { field } ) => (
201 <>
202 <div className="wppb-fb-existing-fields__row">
203 <span className="wppb-fb-existing-fields__label">{ __( 'Field Type:', 'profile-builder' ) }</span>
204 <span className="wppb-fb-existing-fields__type">{ field.fieldType }</span>
205 </div>
206 <div className="wppb-fb-existing-fields__row">
207 <span className="wppb-fb-existing-fields__label">{ __( 'Meta Name:', 'profile-builder' ) }</span>
208 <span className="wppb-fb-existing-fields__meta">{ field.metaName || '' }</span>
209 </div>
210 <div className="wppb-fb-existing-fields__row">
211 <span className="wppb-fb-existing-fields__label">{ __( 'ID:', 'profile-builder' ) }</span>
212 <span className="wppb-fb-existing-fields__id">{ field.id }</span>
213 </div>
214 </>
215 );
216
217 const FILTER_ALL = 'all';
218 const FILTER_IN = 'in';
219 const FILTER_NOTIN = 'notin';
220
221 // Inline SVG; fill via style — button CSS overrides fill="none".
222 const FilterAllIcon = () => (
223 <svg width="20" height="20" viewBox="0 0 24 24" style={ { fill: 'none' } } aria-hidden="true" focusable="false">
224 <line x1="4" y1="7" x2="20" y2="7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
225 <line x1="4" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
226 <line x1="4" y1="17" x2="20" y2="17" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
227 </svg>
228 );
229 const FilterInFormIcon = () => (
230 <svg width="20" height="20" viewBox="0 0 24 24" style={ { fill: 'none' } } aria-hidden="true" focusable="false">
231 <circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.6" />
232 <path d="M8.5 12l2.5 2.5 4.5-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
233 </svg>
234 );
235 const FilterNotInFormIcon = () => (
236 <svg width="20" height="20" viewBox="0 0 24 24" style={ { fill: 'none' } } aria-hidden="true" focusable="false">
237 <circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.6" />
238 <path d="M12 8.5v7M8.5 12h7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
239 </svg>
240 );
241
242 // Scroll card into view below sticky search/filter toolbar.
243 const scrollCardIntoView = ( card ) => {
244 const scroller = card.closest( '.block-editor-tabbed-sidebar__tabpanel' );
245 if ( ! scroller ) {
246 card.scrollIntoView( { block: 'nearest' } );
247 return;
248 }
249 const toolbar = scroller.querySelector( '.wppb-fb-existing-fields__toolbar' );
250 const stickyH = toolbar ? toolbar.offsetHeight : 0;
251 const cardRect = card.getBoundingClientRect();
252 const scrRect = scroller.getBoundingClientRect();
253 const margin = 8;
254 const topGap = cardRect.top - ( scrRect.top + stickyH ); // < 0 → hidden above (behind the sticky toolbar)
255 const bottomGap = cardRect.bottom - scrRect.bottom; // > 0 → hidden below the fold
256 if ( topGap < 0 ) {
257 scroller.scrollTop += topGap - margin;
258 } else if ( bottomGap > 0 ) {
259 scroller.scrollTop += bottomGap + margin;
260 }
261 };
262
263 // Dismiss mini-inserter popover via Escape (Dropdown useDialog).
264 const closeEnclosingPopover = ( el ) => {
265 const popover = el && el.closest( '.components-popover' );
266 if ( ! popover ) return;
267 popover.dispatchEvent(
268 new KeyboardEvent( 'keydown', { key: 'Escape', keyCode: 27, bubbles: true, cancelable: true } )
269 );
270 };
271
272 // Existing Fields list. inMiniInserter → insert at published target; no save refetch.
273 const ExistingFieldsList = ( { inMiniInserter = false } ) => {
274 const {
275 insertBlock,
276 updateBlockAttributes,
277 selectBlock,
278 clearSelectedBlock,
279 removeBlocks,
280 __unstableMarkNextChangeAsNotPersistent,
281 } = useDispatch( 'core/block-editor' );
282
283 const snapshot = useSnapshot();
284 const miniInserterTarget = useMiniInserterTarget();
285 const selectedCard = useSelectedCard();
286 const selectedCardId = selectedCard.id;
287 const selectedCardParentMeta = selectedCard.parentMetaName;
288 const [ deletingId, setDeletingId ] = useState( null );
289 const [ searchTerm, setSearchTerm ] = useState( '' );
290 const [ filterMode, setFilterMode ] = useState( FILTER_ALL );
291
292 // Debounced search for filter/sort.
293 const [ deferredTerm, setDeferredTerm ] = useState( '' );
294 useEffect( () => {
295 const t = setTimeout( () => setDeferredTerm( searchTerm ), 150 );
296 return () => clearTimeout( t );
297 }, [ searchTerm ] );
298
299 // Delete confirmation modal (+ cross-form usage lookup).
300 const [ pendingDelete, setPendingDelete ] = useState( null );
301 const [ usage, setUsage ] = useState( { loading: false, forms: null, error: false } );
302 const [ deleteError, setDeleteError ] = useState( null );
303 // Ignore stale usage responses when another delete was opened.
304 const pendingDeleteReqRef = useRef( null );
305
306 // Refetch after real save (new ids / sanitized meta-names).
307 const { isSaving, isAutosaving, selectedBlockClientId, selectedBlockAttrs, currentPostId } = useSelect( ( select ) => {
308 const editor = select( 'core/editor' );
309 const blocks = select( 'core/block-editor' );
310 const selectedId = blocks.getSelectedBlockClientId();
311 const block = selectedId ? blocks.getBlock( selectedId ) : null;
312 return {
313 isSaving: editor.isSavingPost(),
314 isAutosaving: editor.isAutosavingPost(),
315 selectedBlockClientId: selectedId,
316 selectedBlockAttrs: block ? block.attributes : null,
317 currentPostId: editor.getCurrentPostId(),
318 };
319 }, [] );
320
321 // Canvas block selection wins over card-only selection.
322 const selectedBlockFieldId = ( selectedBlockClientId && selectedBlockAttrs && selectedBlockAttrs.id )
323 ? Number( selectedBlockAttrs.id )
324 : null;
325 useEffect( () => {
326 if ( selectedBlockFieldId !== null && selectedCardId !== null ) {
327 setSelectedCard( null );
328 }
329 }, [ selectedBlockFieldId, selectedCardId ] );
330
331 const effectiveSelectedId = selectedBlockFieldId !== null ? selectedBlockFieldId : selectedCardId;
332
333 useEffect( () => {
334 // Docked sidebar only — don't scroll a mini inserter.
335 if ( inMiniInserter ) return undefined;
336 if ( effectiveSelectedId === null || effectiveSelectedId === undefined ) return undefined;
337
338 // Retry ~30 frames: offsetParent null may mean not laid out yet.
339 let frame = 0;
340 let tries = 0;
341 const MAX_TRIES = 30;
342 const attempt = () => {
343 const card = document.querySelector(
344 '.wppb-fb-existing-fields:not(.is-mini) .wppb-fb-existing-fields__card.is-selected'
345 );
346 if ( card && card.offsetParent !== null ) {
347 scrollCardIntoView( card );
348 return;
349 }
350 if ( ++tries >= MAX_TRIES ) return;
351 frame = window.requestAnimationFrame( attempt );
352 };
353 frame = window.requestAnimationFrame( attempt );
354 return () => window.cancelAnimationFrame( frame );
355 }, [ effectiveSelectedId, inMiniInserter ] );
356
357 const wasRealSaving = useRef( false );
358 useEffect( () => {
359 // Docked copy only — avoid duplicate refetch from an open mini inserter.
360 if ( inMiniInserter ) return;
361 const realSavingNow = isSaving && ! isAutosaving;
362 if ( wasRealSaving.current && ! isSaving ) {
363 apiFetch( { path: '/wppb/v1/existing-fields' } )
364 .then( ( data ) => {
365 if ( ! data || ! Array.isArray( data.fields ) ) return;
366 setSnapshot( data.fields );
367
368 // Write sanitized server values back onto canvas blocks.
369 const byId = flattenSnapshot( data.fields );
370 walkPbCanvasBlocks( dataSelect( 'core/block-editor' ).getBlocks(), ( b ) => {
371 if ( ! b.attributes || ! b.attributes.id ) return;
372 const field = byId.get( Number( b.attributes.id ) );
373 if ( ! field || b.attributes[ 'meta-name' ] === field.metaName ) return;
374 if ( typeof __unstableMarkNextChangeAsNotPersistent === 'function' ) {
375 __unstableMarkNextChangeAsNotPersistent();
376 }
377 updateBlockAttributes( b.clientId, { 'meta-name': field.metaName } );
378 } );
379 } )
380 .catch( () => { /* keep prior snapshot */ } );
381 }
382 wasRealSaving.current = realSavingNow;
383 }, [ isSaving, isAutosaving ] );
384
385 const canvasBlocks = useSelect( ( select ) => select( 'core/block-editor' ).getBlocks(), [] );
386
387 // Merge live title/meta-name over snapshot; index by field id.
388 const { fields, canvasById, canvasOrder } = useMemo( () => {
389 const canvas = new Map();
390 collectCanvasFields( canvasBlocks, canvas );
391
392 // DFS canvas order drives card / sub-field sort.
393 const order = new Map();
394 let pos = 0;
395 for ( const id of canvas.keys() ) order.set( id, pos++ );
396
397 const merged = snapshot.map( ( field ) => {
398 const live = canvas.get( field.id );
399 if ( ! live ) return field;
400 const { id: _ignored, ...liveAttrs } = live.attributes;
401 return {
402 ...field,
403 title: liveAttrs[ 'field-title' ] !== undefined ? liveAttrs[ 'field-title' ] : field.title,
404 metaName: liveAttrs[ 'meta-name' ] !== undefined ? liveAttrs[ 'meta-name' ] : field.metaName,
405 attributes: { ...field.attributes, ...liveAttrs },
406 };
407 } );
408
409 return { fields: merged, canvasById: canvas, canvasOrder: order };
410 }, [ canvasBlocks, snapshot ] );
411
412 // Rebuild Repeater innerBlocks recursively for insert.
413 const buildBlockFromEntry = ( entry ) => {
414 const inner = ( entry.innerBlocks || [] ).map( buildBlockFromEntry );
415 return createBlock(
416 entry.blockName,
417 { id: entry.id, ...entry.attributes },
418 inner
419 );
420 };
421
422 const onInsert = ( e, field ) => {
423 e.stopPropagation();
424 const button = e.currentTarget;
425 // Mini inserter: insert at published destination (clientId = before).
426 let rootClientId;
427 let index;
428 if ( inMiniInserter ) {
429 rootClientId = miniInserterTarget.rootClientId;
430 if ( miniInserterTarget.clientId && ! miniInserterTarget.isAppender ) {
431 const at = dataSelect( 'core/block-editor' ).getBlockIndex( miniInserterTarget.clientId );
432 if ( at >= 0 ) index = at;
433 }
434 }
435 insertBlock( buildBlockFromEntry( field ), index, rootClientId );
436 if ( inMiniInserter ) closeEnclosingPopover( button );
437 };
438
439 // Remove from this form only; snackbar Undo (global undo is off).
440 const onRemove = ( e, field ) => {
441 e.stopPropagation();
442 const clientIds = canvasClientIdsForFieldId( field.id );
443 if ( clientIds.length > 0 ) {
444 removeBlocksWithUndo( clientIds, {
445 select: dataSelect,
446 dispatch: dataDispatch,
447 message: sprintf(
448 // translators: %s: field title.
449 __( '%s removed from this form.', 'profile-builder' ),
450 field.title || field.fieldType
451 ),
452 } );
453 }
454 };
455
456 // Delete step 1: confirm + cross-form usage lookup.
457 const onDeleteClick = ( e, field ) => {
458 e.stopPropagation();
459 setDeleteError( null );
460 setPendingDelete( field );
461 pendingDeleteReqRef.current = field.id; // mark the in-flight lookup
462 setUsage( { loading: true, forms: null, error: false } );
463 apiFetch( { path: `/wppb/v1/existing-fields/${ field.id }/usage` } )
464 .then( ( data ) => {
465 if ( pendingDeleteReqRef.current !== field.id ) return; // stale — a newer delete opened / cancelled
466 const forms = ( data && Array.isArray( data.forms ) ) ? data.forms : [];
467 setUsage( { loading: false, forms, error: false } );
468 } )
469 .catch( () => {
470 if ( pendingDeleteReqRef.current !== field.id ) return; // stale
471 setUsage( { loading: false, forms: [], error: true } );
472 } );
473 };
474
475 // Delete cancel (no-op if delete in flight).
476 const cancelDelete = () => {
477 if ( deletingId !== null ) return;
478 pendingDeleteReqRef.current = null; // invalidate any in-flight lookup
479 setPendingDelete( null );
480 setUsage( { loading: false, forms: null, error: false } );
481 setDeleteError( null );
482 };
483
484 // Delete step 2: DELETE endpoint + scrub canvas blocks.
485 const confirmDelete = () => {
486 const field = pendingDelete;
487 if ( ! field ) return;
488
489 setDeleteError( null );
490 setDeletingId( field.id );
491
492 // Drop queued PUTs first so upsert can't recreate the row.
493 forget( 'top', field.id );
494
495 apiFetch( {
496 path: `/wppb/v1/existing-fields/${ field.id }`,
497 method: 'DELETE',
498 } )
499 .then( () => {
500 const next = getExistingFieldsSnapshot().filter( ( entry ) => entry.id !== field.id );
501 setSnapshot( next );
502
503 // Scrub canvas blocks so save doesn't recreate the row.
504 const clientIds = canvasClientIdsForFieldId( field.id );
505 if ( clientIds.length > 0 ) {
506 removeBlocks( clientIds );
507 }
508
509 if ( selectedCardId === field.id ) {
510 setSelectedCard( null );
511 }
512
513 setPendingDelete( null );
514 setUsage( { loading: false, forms: null, error: false } );
515 } )
516 .catch( ( err ) => {
517 const msg = ( err && err.message ) || __( 'Failed to delete the field.', 'profile-builder' );
518 setDeleteError( msg );
519 } )
520 .finally( () => setDeletingId( null ) );
521 };
522
523 // Card click: select canvas block or card-only; open Field Settings.
524 const onCardClick = ( field, parentMetaName = null ) => {
525 const live = canvasById.get( field.id );
526 if ( live && live.clientId ) {
527 selectBlock( live.clientId );
528 setSelectedCard( null );
529 } else {
530 clearSelectedBlock();
531 setSelectedCard( field.id, parentMetaName );
532 }
533 openSidebar( SIDEBAR_TARGET );
534 };
535
536 const normalizedQuery = deferredTerm.trim().toLowerCase();
537
538 // Filter/sort: query match (incl. sub-fields); in-form first in canvas order.
539 const visibleFields = useMemo( () => {
540 const matchesQuery = ( field ) => {
541 if ( ! normalizedQuery ) return true;
542 const haystacks = [ field.title, field.fieldType, field.metaName ];
543 if ( Array.isArray( field.innerBlocks ) ) {
544 for ( const sub of field.innerBlocks ) {
545 haystacks.push( sub.title, sub.fieldType, sub.metaName );
546 }
547 }
548 return haystacks.some(
549 ( value ) => typeof value === 'string' && value.toLowerCase().includes( normalizedQuery )
550 );
551 };
552 const matchesFilter = ( field ) => {
553 if ( filterMode === FILTER_IN ) return canvasById.has( field.id );
554 if ( filterMode === FILTER_NOTIN ) return ! canvasById.has( field.id );
555 return true;
556 };
557 return fields
558 .filter( ( field ) => matchesQuery( field ) && matchesFilter( field ) )
559 .sort( ( a, b ) => {
560 const aIn = canvasById.has( a.id );
561 const bIn = canvasById.has( b.id );
562 if ( aIn && bIn ) return canvasOrder.get( a.id ) - canvasOrder.get( b.id );
563 return ( bIn ? 1 : 0 ) - ( aIn ? 1 : 0 );
564 } );
565 }, [ fields, canvasById, canvasOrder, normalizedQuery, filterMode ] );
566
567 // Sub-field card: selectable, not independently insertable/deletable.
568 const renderSubFieldCard = ( subField, parentMetaName ) => {
569 const isUsed = canvasById.has( subField.id );
570 const isSelected = effectiveSelectedId === subField.id;
571 const classes = [
572 'wppb-fb-existing-fields__card',
573 'is-subfield',
574 isUsed ? 'is-in-form' : '',
575 isSelected ? 'is-selected' : '',
576 ].filter( Boolean ).join( ' ' );
577 return (
578 <li
579 key={ subField.id }
580 className={ classes }
581 role="button"
582 tabIndex={ 0 }
583 aria-pressed={ isSelected || undefined }
584 onClick={ ( e ) => {
585 e.stopPropagation();
586 onCardClick( subField, parentMetaName );
587 } }
588 onKeyDown={ ( e ) => {
589 if ( e.key === 'Enter' || e.key === ' ' ) {
590 e.preventDefault();
591 e.stopPropagation();
592 onCardClick( subField, parentMetaName );
593 }
594 } }
595 >
596 <div className="wppb-fb-existing-fields__body">
597 <FieldTitle field={ subField } />
598 <FieldMeta field={ subField } />
599 </div>
600 </li>
601 );
602 };
603
604 return (
605 <>
606 { /* Sticky search + filter toolbar. */ }
607 <div className="wppb-fb-existing-fields__toolbar">
608 <SearchControl
609 __nextHasNoMarginBottom
610 className="block-editor-inserter__search"
611 value={ searchTerm }
612 onChange={ setSearchTerm }
613 label={ __( 'Search', 'profile-builder' ) }
614 placeholder={ __( 'Search', 'profile-builder' ) }
615 />
616 { /* Padding inset so isBlock control aligns with search. */ }
617 <div className="wppb-fb-existing-fields__filter">
618 <ToggleGroupControl
619 __nextHasNoMarginBottom
620 isBlock
621 hideLabelFromVision
622 label={ __( 'Filter fields', 'profile-builder' ) }
623 value={ filterMode }
624 onChange={ ( value ) => setFilterMode( value || FILTER_ALL ) }
625 >
626 <ToggleGroupControlOptionIcon
627 value={ FILTER_ALL }
628 icon={ FilterAllIcon }
629 label={ __( 'All fields', 'profile-builder' ) }
630 />
631 <ToggleGroupControlOptionIcon
632 value={ FILTER_IN }
633 icon={ FilterInFormIcon }
634 label={ __( 'In this form', 'profile-builder' ) }
635 />
636 <ToggleGroupControlOptionIcon
637 value={ FILTER_NOTIN }
638 icon={ FilterNotInFormIcon }
639 label={ __( 'Not in this form', 'profile-builder' ) }
640 />
641 </ToggleGroupControl>
642 </div>
643 </div>
644 { visibleFields.length === 0 ? (
645 /* Match New Fields empty-state styling. */
646 <div className="block-editor-inserter__no-results">
647 <p>
648 { normalizedQuery
649 ? __( 'No results found.', 'profile-builder' )
650 : filterMode === FILTER_IN
651 ? __( 'No fields are in this form yet.', 'profile-builder' )
652 : filterMode === FILTER_NOTIN
653 ? __( 'All fields are already in this form.', 'profile-builder' )
654 : __( 'No existing fields found.', 'profile-builder' ) }
655 </p>
656 </div>
657 ) : (
658 <ul className="wppb-fb-existing-fields__list">
659 { visibleFields.map( ( field ) => {
660 const isUsed = canvasById.has( field.id );
661 const isSelected = effectiveSelectedId === field.id;
662 const isRepeater = field.fieldType === 'Repeater';
663 const subFields = Array.isArray( field.innerBlocks ) ? field.innerBlocks : [];
664 const hasSubFields = isRepeater && field.metaName && subFields.length > 0;
665 const subFieldCount = subFields.length;
666 // Order in-form Repeater sub-cards to match canvas.
667 const orderedSubFields = ( isUsed && subFieldCount )
668 ? [ ...subFields ].sort( ( a, b ) =>
669 ( canvasOrder.has( a.id ) ? canvasOrder.get( a.id ) : Infinity ) -
670 ( canvasOrder.has( b.id ) ? canvasOrder.get( b.id ) : Infinity )
671 )
672 : subFields;
673 // Expand sub-cards when parent Repeater is selected or in-form.
674 const subFieldIds = subFields.map( ( s ) => s.id );
675 const selectedSubFieldOnCanvas = selectedBlockFieldId !== null && subFieldIds.includes( selectedBlockFieldId );
676 const selectedSubFieldCard = selectedCardParentMeta && selectedCardParentMeta === field.metaName;
677 const isExpanded = hasSubFields && ( isSelected || selectedSubFieldOnCanvas || selectedSubFieldCard );
678 const classes = [
679 'wppb-fb-existing-fields__card',
680 isRepeater ? 'is-repeater' : '',
681 isExpanded ? 'is-expanded' : '',
682 isUsed ? 'is-in-form' : '',
683 isSelected ? 'is-selected' : '',
684 ].filter( Boolean ).join( ' ' );
685 return (
686 <li
687 key={ field.id }
688 className={ classes }
689 >
690 <div
691 className="wppb-fb-existing-fields__card-main"
692 role="button"
693 tabIndex={ 0 }
694 aria-pressed={ isSelected || undefined }
695 aria-expanded={ hasSubFields ? isExpanded : undefined }
696 onClick={ () => onCardClick( field ) }
697 onKeyDown={ ( e ) => {
698 if ( e.key === 'Enter' || e.key === ' ' ) {
699 e.preventDefault();
700 onCardClick( field );
701 }
702 } }
703 >
704 <div className="wppb-fb-existing-fields__body">
705 <div className="wppb-fb-existing-fields__header">
706 <FieldTitle field={ field }>
707 { isRepeater && subFieldCount > 0 && (
708 <span
709 className="wppb-fb-existing-fields__subfield-count"
710 title={ sprintf(
711 /* translators: %d: number of sub-fields */
712 _n( '%d sub-field', '%d sub-fields', subFieldCount, 'profile-builder' ),
713 subFieldCount
714 ) }
715 >
716 { subFieldCount }
717 </span>
718 ) }
719 </FieldTitle>
720 { /* Actions live in the title row, revealed on
721 hover / selection (CSS). Delete (red text)
722 sits left of the Insert/Remove outlined button. */ }
723 <div className="wppb-fb-existing-fields__actions">
724 { /* Hover tooltips convey what the button
725 labels don't: Remove is form-scoped and
726 reversible, Delete is global and permanent,
727 Insert reuses this field rather than
728 creating a new one. `text` is the hover
729 description; the field-specific `aria-label`
730 stays on the button as the accessible name. */ }
731 { /* No Delete in the mini inserter: that
732 popover is for finding and inserting, and
733 a global, permanent, un-undoable action
734 doesn't belong one stray click away from
735 Insert on a card list this dense. Gated in
736 JS rather than CSS-hidden so the popover
737 carries no invisible-but-focusable
738 destructive button. Deleting a field is
739 the docked panel's job. */ }
740 { ! inMiniInserter && ! MANDATORY_DEFAULT_TYPES.has( field.fieldType ) && (
741 <Tooltip text={ __( 'Permanently delete this field from every form. This cannot be undone.', 'profile-builder' ) }>
742 <Button
743 variant="tertiary"
744 size="small"
745 isDestructive
746 isBusy={ deletingId === field.id }
747 disabled={ deletingId !== null }
748 onClick={ ( e ) => onDeleteClick( e, field ) }
749 aria-label={
750 /* translators: %s: field title */
751 __( 'Delete %s', 'profile-builder' ).replace( '%s', field.title || field.fieldType )
752 }
753 >
754 { __( 'Delete', 'profile-builder' ) }
755 </Button>
756 </Tooltip>
757 ) }
758 { /* In form → Remove (scrubs the block from
759 this form), except mandatory default types
760 (Username / E-mail / Password) which show no
761 button. Not in form → Insert. */ }
762 { ! isUsed ? (
763 <Tooltip text={ __( 'Add this existing field to the current form.', 'profile-builder' ) }>
764 <Button
765 variant="secondary"
766 size="small"
767 onClick={ ( e ) => onInsert( e, field ) }
768 aria-label={
769 /* translators: %s: field title */
770 __( 'Insert %s', 'profile-builder' ).replace( '%s', field.title || field.fieldType )
771 }
772 >
773 { __( 'Insert', 'profile-builder' ) }
774 </Button>
775 </Tooltip>
776 ) : ( ! MANDATORY_DEFAULT_TYPES.has( field.fieldType ) && (
777 <Tooltip text={ __( 'Remove this field from this form only. It stays available to add again and is not deleted.', 'profile-builder' ) }>
778 <Button
779 variant="secondary"
780 size="small"
781 onClick={ ( e ) => onRemove( e, field ) }
782 aria-label={
783 /* translators: %s: field title */
784 __( 'Remove %s from this form', 'profile-builder' ).replace( '%s', field.title || field.fieldType )
785 }
786 >
787 { __( 'Remove', 'profile-builder' ) }
788 </Button>
789 </Tooltip>
790 ) ) }
791 </div>
792 </div>
793 <FieldMeta field={ field } />
794 </div>
795 </div>
796 { isExpanded && (
797 <ul className="wppb-fb-existing-fields__subfields">
798 { orderedSubFields.map( ( sub ) => renderSubFieldCard( sub, field.metaName ) ) }
799 </ul>
800 ) }
801 </li>
802 );
803 } ) }
804 </ul>
805 ) }
806 { pendingDelete && (
807 <DeleteConfirmModal
808 field={ pendingDelete }
809 usage={ usage }
810 currentPostId={ currentPostId }
811 isDeleting={ deletingId !== null }
812 error={ deleteError }
813 onCancel={ cancelDelete }
814 onConfirm={ confirmDelete }
815 />
816 ) }
817 </>
818 );
819 };
820
821 // Delete confirmation modal with cross-form usage warning.
822 const DeleteConfirmModal = ( { field, usage, currentPostId, isDeleting, error, onCancel, onConfirm } ) => {
823 const label = field.title || field.fieldType;
824 const otherForms = ( usage.forms || [] ).filter(
825 ( f ) => Number( f.id ) !== Number( currentPostId )
826 );
827
828 return (
829 <Modal
830 title={ __( 'Delete field', 'profile-builder' ) }
831 className="wppb-fb-delete-confirm"
832 onRequestClose={ onCancel }
833 shouldCloseOnClickOutside={ ! isDeleting }
834 shouldCloseOnEsc={ ! isDeleting }
835 >
836 <p className="wppb-fb-delete-confirm__message">
837 { sprintf(
838 /* translators: %s: field title or type */
839 __( 'Delete the field “%s”? This removes it from every form that uses it and cannot be undone.', 'profile-builder' ),
840 label
841 ) }
842 </p>
843
844 { usage.loading && (
845 <p className="wppb-fb-delete-confirm__checking">
846 <Spinner />
847 { __( 'Checking which other forms use this field…', 'profile-builder' ) }
848 </p>
849 ) }
850
851 { ! usage.loading && otherForms.length > 0 && (
852 /* Alert-style callout (custom div, not a Gutenberg <Notice>, for
853 full control of the amber treatment: cream fill, thick left
854 rail, bold lead-in, divided list of the OTHER forms the field
855 lives on). Keeps the `__usage` hook for tests. */
856 <div className="wppb-fb-delete-confirm__usage" role="alert">
857 <p className="wppb-fb-delete-confirm__usage-lead">
858 <strong>
859 { sprintf(
860 /* translators: %d: number of other forms */
861 _n(
862 'This field is used on %d other form.',
863 'This field is used on %d other forms.',
864 otherForms.length,
865 'profile-builder'
866 ),
867 otherForms.length
868 ) }
869 </strong>
870 { ' ' }
871 { __( 'Deleting it will also remove it from:', 'profile-builder' ) }
872 </p>
873 <ul className="wppb-fb-delete-confirm__forms">
874 { otherForms.map( ( f ) => (
875 <li key={ f.id }>{ f.title }</li>
876 ) ) }
877 </ul>
878 </div>
879 ) }
880
881 { ! usage.loading && usage.error && (
882 <Notice status="warning" isDismissible={ false }>
883 { __( 'Could not check which other forms use this field. It will still be removed from every form on delete.', 'profile-builder' ) }
884 </Notice>
885 ) }
886
887 { error && (
888 <Notice status="error" isDismissible={ false }>
889 { error }
890 </Notice>
891 ) }
892
893 <div className="wppb-fb-delete-confirm__actions">
894 <Button
895 variant="tertiary"
896 onClick={ onCancel }
897 disabled={ isDeleting }
898 >
899 { __( 'Cancel', 'profile-builder' ) }
900 </Button>
901 <Button
902 variant="primary"
903 isDestructive
904 className="wppb-fb-delete-confirm__confirm"
905 onClick={ onConfirm }
906 isBusy={ isDeleting }
907 disabled={ isDeleting || usage.loading }
908 >
909 { __( 'Delete field', 'profile-builder' ) }
910 </Button>
911 </div>
912 </Modal>
913 );
914 };
915
916 // Portal Existing Fields into every Media tab (docked + mini inserters).
917 const ExistingFieldsPanel = () => {
918 const postType = useSelect(
919 ( select ) => select( 'core/editor' ).getCurrentPostType(),
920 []
921 );
922 const [ targets, setTargets ] = useState( [] );
923 const isOurPostType = [ 'wppb-rf-cpt', 'wppb-epf-cpt' ].includes( postType );
924
925 // Ref mirrors targets for the observer effect (avoids stale closure).
926 const targetsRef = useRef( [] );
927 const applyTargets = useCallback( ( next ) => {
928 const prev = targetsRef.current;
929 if ( prev.length === next.length && prev.every( ( el, i ) => el === next[ i ] ) ) return;
930 targetsRef.current = next;
931 setTargets( next );
932 }, [] );
933
934 useEffect( () => {
935 if ( ! isOurPostType ) return undefined;
936
937 const sync = () => {
938 for ( const { selector, label } of TAB_RENAMES ) {
939 for ( const tab of document.querySelectorAll( selector ) ) {
940 renameTab( tab, label );
941 }
942 }
943
944 const next = [];
945 for ( const mediaTab of document.querySelectorAll( HIJACK_TAB_SELECTOR ) ) {
946 // Reorder: place Existing Fields (the hijacked Media tab) before
947 // Examples (Patterns) — within THIS instance's tab strip only.
948 const patternsTab = mediaTab.parentNode
949 ? mediaTab.parentNode.querySelector( '[role="tab"][id$="-patterns"]' )
950 : null;
951 if (
952 patternsTab &&
953 patternsTab.parentNode === mediaTab.parentNode &&
954 ( patternsTab.compareDocumentPosition( mediaTab ) & Node.DOCUMENT_POSITION_FOLLOWING )
955 ) {
956 patternsTab.parentNode.insertBefore( mediaTab, patternsTab );
957 }
958
959 const panelId = mediaTab.getAttribute( 'aria-controls' );
960 const panel = panelId ? document.getElementById( panelId ) : null;
961 if ( ! panel ) continue;
962
963 // Claim the panel immediately (hijack class + target div) so the
964 // real Media-tab content is hidden from the first frame...
965 const injected = ensurePanelTarget( panel );
966
967 const popover = panel.closest( '.block-editor-inserter__popover' );
968
969 // Mark popovers that can't take existing fields (CSS hides the tab).
970 if ( popover ) {
971 const allowed = miniInserterAllowsExistingFields();
972 popover.classList.toggle( NO_EXISTING_FIELDS_CLASS, ! allowed );
973 if ( ! allowed ) continue;
974 }
975
976 // Lazy-mount card list in mini inserter (docked always mounts).
977 if ( popover && panel.hasAttribute( 'hidden' ) ) {
978 continue;
979 }
980 next.push( injected );
981 }
982 applyTargets( next );
983 };
984
985 // Coalesce a burst of Gutenberg DOM mutations into a single sync per
986 // animation frame. Undebounced, the observer ran sync (several
987 // querySelectors) on every mutation batch on the typing→paint path (INP).
988 let frame = 0;
989 const scheduleSync = () => {
990 if ( frame ) return;
991 frame = window.requestAnimationFrame( () => {
992 frame = 0;
993 sync();
994 } );
995 };
996
997 sync();
998 const observer = new MutationObserver( scheduleSync );
999 // Observe document.body — mini inserter popovers portal outside the skeleton.
1000 observer.observe( document.body, {
1001 attributeFilter: [ 'hidden' ],
1002 attributes: true,
1003 childList: true,
1004 subtree: true,
1005 } );
1006 // Re-sync when destination store changes (may race popover mount).
1007 const unsubscribeTarget = subscribeMiniInserterTarget( scheduleSync );
1008
1009 return () => {
1010 observer.disconnect();
1011 unsubscribeTarget();
1012 if ( frame ) window.cancelAnimationFrame( frame );
1013 };
1014 }, [ isOurPostType, applyTargets ] );
1015
1016 if ( ! isOurPostType || targets.length === 0 ) return null;
1017
1018 return targets.map( ( target, i ) => {
1019 // A target inside an inserter popover is a mini inserter: its Insert
1020 // must land in the container that opened it, and inserting should
1021 // dismiss the popover (the docked sidebar list stays open, as before).
1022 const inPopover = !! target.closest( '.block-editor-inserter__popover' );
1023 // Key off the owning tab panel's id (`tabs-N-media-view`), not the array
1024 // index: the popover instance comes and goes, and an index key would let
1025 // React reuse the docked list's subtree for it (and vice versa).
1026 const key = ( target.parentElement && target.parentElement.id ) || `wppb-fb-existing-fields-${ i }`;
1027 return createPortal(
1028 <div className={ `wppb-fb-existing-fields${ inPopover ? ' is-mini' : '' }` }>
1029 <ExistingFieldsList inMiniInserter={ inPopover } />
1030 </div>,
1031 target,
1032 key
1033 );
1034 } );
1035 };
1036
1037 registerPlugin( 'wppb-fb-existing-fields', { render: ExistingFieldsPanel } );
1038
1039 // PluginSidebar for cards with no canvas block (VirtualFieldEdit).
1040
1041 // Select the virtual block in the inner BlockEditorProvider registry.
1042 const SelectVirtualBlock = ( { clientId } ) => {
1043 const registry = useRegistry();
1044 useEffect( () => {
1045 if ( ! clientId ) return;
1046 registry.dispatch( 'core/block-editor' ).selectBlock( clientId );
1047 }, [ clientId, registry ] );
1048 return null;
1049 };
1050
1051 // Write-back sanitized values into the virtual block.
1052 const VirtualWriteBackBridge = ( { clientId } ) => {
1053 const registry = useRegistry();
1054 useEffect( () => {
1055 if ( ! clientId ) return undefined;
1056 const innerSelect = registry.select( 'core/block-editor' );
1057 const innerDispatch = registry.dispatch( 'core/block-editor' );
1058 const handler = makeWriteBackHandler( {
1059 getBlock: ( id ) => innerSelect.getBlock( id ),
1060 updateBlockAttributes: innerDispatch.updateBlockAttributes,
1061 markNonPersistent: typeof innerDispatch.__unstableMarkNextChangeAsNotPersistent === 'function'
1062 ? innerDispatch.__unstableMarkNextChangeAsNotPersistent
1063 : undefined,
1064 } );
1065 const unreg = registerWriteBack( clientId, handler( clientId ) );
1066 return unreg;
1067 }, [ clientId, registry ] );
1068 return null;
1069 };
1070
1071 // Build the hidden BlockEditorProvider tree for a card (sub-field wraps Repeater).
1072 const buildInitialBlocks = ( field, scope, parentMetaName, parentRepeaterId ) => {
1073 try {
1074 const blockType = getBlockType( field.blockName );
1075 if ( ! blockType ) return { blocks: [], targetClientId: null };
1076
1077 const buildInner = ( entry ) => createBlock(
1078 entry.blockName,
1079 { id: entry.id, ...( entry.attributes || {} ) },
1080 ( entry.innerBlocks || [] ).map( buildInner )
1081 );
1082 const childInner = ( field.innerBlocks || [] ).map( buildInner );
1083 const fieldBlock = createBlock(
1084 field.blockName,
1085 {
1086 id: field.id,
1087 'field-title': field.title,
1088 'meta-name': field.metaName,
1089 ...( field.attributes || {} ),
1090 },
1091 childInner
1092 );
1093
1094 if ( scope === 'top' ) {
1095 return { blocks: [ fieldBlock ], targetClientId: fieldBlock.clientId };
1096 }
1097
1098 // Sub-field: wrap in a synthetic Repeater ancestor.
1099 const repeaterAttrs = {
1100 id: parentRepeaterId > 0 ? parentRepeaterId : 0,
1101 'meta-name': parentMetaName,
1102 };
1103 const wrapper = createBlock( REPEATER_BLOCK_NAME, repeaterAttrs, [ fieldBlock ] );
1104 return { blocks: [ wrapper ], targetClientId: fieldBlock.clientId };
1105 } catch ( e ) {
1106 // Catch createBlock errors so a bad row doesn't kill the panel.
1107 console.warn(
1108 '[wppb-fb] buildInitialBlocks: createBlock failed for',
1109 { blockName: field?.blockName, fieldType: field?.fieldType, id: field?.id, scope, parentMetaName },
1110 e
1111 );
1112 return { blocks: [], targetClientId: null };
1113 }
1114 };
1115
1116 // Resolve the target sub-field block inside the wrapper for sub-field mode.
1117 const findBlockByClientId = ( blocks, targetClientId ) => {
1118 for ( const b of blocks ) {
1119 if ( b.clientId === targetClientId ) return b;
1120 if ( b.innerBlocks && b.innerBlocks.length ) {
1121 const hit = findBlockByClientId( b.innerBlocks, targetClientId );
1122 if ( hit ) return hit;
1123 }
1124 }
1125 return null;
1126 };
1127
1128 const VirtualFieldEdit = ( { field, scope = 'top', parentMetaName = null, parentRepeaterId = 0, onPatch } ) => {
1129 const persistScope = scope === 'sub' ? `sub:${ parentMetaName }` : 'top';
1130
1131 // Build the initial in-memory tree once per (field, scope). useMemo recreates
1132 // on swap so a fresh clientId pair gets a clean baseline. Sub mode wraps the
1133 // field in a synthetic Repeater ancestor — see buildInitialBlocks.
1134 const { initialBlocks, initialTargetClientId } = useMemo( () => {
1135 const built = buildInitialBlocks( field, scope, parentMetaName, parentRepeaterId );
1136 return { initialBlocks: built.blocks, initialTargetClientId: built.targetClientId };
1137 }, [ field.id, field.blockName, scope, parentMetaName, parentRepeaterId ] );
1138
1139 const [ blocks, setBlocks ] = useState( initialBlocks );
1140 const targetClientIdRef = useRef( initialTargetClientId );
1141
1142 // Reset blocks when the field / scope changes.
1143 useEffect( () => {
1144 setBlocks( initialBlocks );
1145 targetClientIdRef.current = initialTargetClientId;
1146 }, [ initialBlocks, initialTargetClientId ] );
1147
1148 // Capture initial baseline once per (field, clientId, scope) so the
1149 // persister's first diff doesn't false-positive on the seeded attributes.
1150 const baselineCapturedRef = useRef( null );
1151 useEffect( () => {
1152 if ( blocks.length === 0 || ! targetClientIdRef.current ) return;
1153 const target = findBlockByClientId( blocks, targetClientIdRef.current );
1154 if ( ! target ) return;
1155 const captureKey = persistScope + ':' + field.id + ':' + target.clientId;
1156 if ( baselineCapturedRef.current === captureKey ) return;
1157 captureInitial( persistScope, field.id, target.attributes );
1158 baselineCapturedRef.current = captureKey;
1159 }, [ blocks, field.id, persistScope ] );
1160
1161 const onChange = useCallback( ( newBlocks ) => {
1162 setBlocks( newBlocks );
1163 if ( newBlocks.length === 0 || ! targetClientIdRef.current ) return;
1164 const target = findBlockByClientId( newBlocks, targetClientIdRef.current );
1165 if ( ! target ) return;
1166
1167 scheduleAttributePersist( {
1168 scope: persistScope,
1169 id: field.id,
1170 fieldType: field.fieldType,
1171 clientId: target.clientId,
1172 attributes: target.attributes,
1173 } );
1174
1175 if ( onPatch ) onPatch( target.attributes );
1176 }, [ field.id, field.fieldType, persistScope, onPatch ] );
1177
1178 if ( blocks.length === 0 || ! targetClientIdRef.current ) {
1179 return (
1180 <PanelBody title={ __( 'Field Settings', 'profile-builder' ) }>
1181 <Notice status="warning" isDismissible={ false }>
1182 { __( 'Unable to render settings for this field type.', 'profile-builder' ) }
1183 </Notice>
1184 </PanelBody>
1185 );
1186 }
1187
1188 return (
1189 <div className="wppb-fb-virtual-field-edit" aria-hidden="true">
1190 <BlockEditorProvider
1191 value={ blocks }
1192 onChange={ onChange }
1193 onInput={ onChange }
1194 settings={ {} }
1195 >
1196 <SelectVirtualBlock clientId={ targetClientIdRef.current } />
1197 <VirtualWriteBackBridge clientId={ targetClientIdRef.current } />
1198 <BlockList />
1199 </BlockEditorProvider>
1200 </div>
1201 );
1202 };
1203
1204 // Register snapshot sync once so PUTs update the card list.
1205 registerSnapshotSync( ( fieldEntry, scope ) => {
1206 if ( scope === 'top' ) {
1207 const next = getExistingFieldsSnapshot().map( ( entry ) => entry.id === fieldEntry.id ? fieldEntry : entry );
1208 if ( ! next.some( ( e ) => e.id === fieldEntry.id ) ) next.push( fieldEntry );
1209 setSnapshot( next );
1210 return;
1211 }
1212 if ( typeof scope === 'string' && scope.startsWith( 'sub:' ) ) {
1213 const parentMeta = scope.slice( 4 );
1214 const next = getExistingFieldsSnapshot().map( ( entry ) => {
1215 if ( entry.fieldType !== 'Repeater' || entry.metaName !== parentMeta ) return entry;
1216 const inner = Array.isArray( entry.innerBlocks ) ? entry.innerBlocks.slice() : [];
1217 const idx = inner.findIndex( ( s ) => s.id === fieldEntry.id );
1218 if ( idx >= 0 ) inner[ idx ] = fieldEntry;
1219 else inner.push( fieldEntry );
1220 return { ...entry, innerBlocks: inner };
1221 } );
1222 setSnapshot( next );
1223 }
1224 } );
1225
1226 // Lightweight BlockCard stand-in (native pulls unwanted inspector chrome).
1227 const FieldTypeCard = ( { blockType } ) => {
1228 if ( ! blockType ) return null;
1229 return (
1230 <div className="wppb-fb-field-type-card">
1231 <span className="wppb-fb-field-type-card__icon">
1232 <BlockIcon icon={ blockType.icon } showColors />
1233 </span>
1234 <div className="wppb-fb-field-type-card__content">
1235 <h2 className="wppb-fb-field-type-card__title">{ blockType.title }</h2>
1236 { blockType.description && (
1237 <span className="wppb-fb-field-type-card__description">{ blockType.description }</span>
1238 ) }
1239 </div>
1240 </div>
1241 );
1242 };
1243
1244 const FieldSettingsSidebarContents = () => {
1245 const { id: selectedCardId, parentMetaName: selectedCardParentMeta } = useSelectedCard();
1246 const snapshot = useSnapshot();
1247
1248 // In-form: canvas Edit already fills the slot; skip VirtualFieldEdit.
1249 const selectedBlockName = useSelect( ( select ) => {
1250 const blocks = select( 'core/block-editor' );
1251 const id = blocks.getSelectedBlockClientId();
1252 const block = id ? blocks.getBlock( id ) : null;
1253 return block ? block.name : null;
1254 }, [] );
1255 const isPbBlockSelected = !! ( selectedBlockName && selectedBlockName.startsWith( 'profile-builder/' ) );
1256
1257 // Resolve card entry (+ parent Repeater for sub-fields).
1258 const { field, parentRepeater } = useMemo( () => {
1259 if ( selectedCardId === null ) return { field: null, parentRepeater: null };
1260 if ( selectedCardParentMeta ) {
1261 const parent = snapshot.find( ( entry ) =>
1262 entry.fieldType === 'Repeater' && entry.metaName === selectedCardParentMeta
1263 ) || null;
1264 if ( ! parent ) return { field: null, parentRepeater: null };
1265 const sub = ( parent.innerBlocks || [] ).find( ( s ) => s.id === selectedCardId ) || null;
1266 return { field: sub, parentRepeater: parent };
1267 }
1268 const top = snapshot.find( ( entry ) => entry.id === selectedCardId ) || null;
1269 return { field: top, parentRepeater: null };
1270 }, [ selectedCardId, selectedCardParentMeta, snapshot ] );
1271
1272 // Block selected on canvas — the block's edit() Fill already targets
1273 // FieldSettingsSlot.
1274 if ( isPbBlockSelected ) {
1275 const blockType = selectedBlockName ? getBlockType( selectedBlockName ) : null;
1276 return (
1277 <>
1278 <FieldTypeCard blockType={ blockType } />
1279 <FieldSettingsSlot bubblesVirtually />
1280 </>
1281 );
1282 }
1283
1284 // No card and no block selected.
1285 if ( ! field ) {
1286 return (
1287 <PanelBody title={ __( 'Field Settings', 'profile-builder' ) }>
1288 <p>{ __( 'Select a field from the Existing Fields list or click a field block on the canvas to edit its settings.', 'profile-builder' ) }</p>
1289 </PanelBody>
1290 );
1291 }
1292
1293 // Not-in-form card: VirtualFieldEdit + per-edit persist.
1294 const cardBlockType = field.blockName ? getBlockType( field.blockName ) : null;
1295 const isSubFieldEdit = !! selectedCardParentMeta && !! parentRepeater;
1296 const hint = isSubFieldEdit
1297 ? sprintf(
1298 /* translators: %s: parent Repeater title or meta-name */
1299 __( 'Sub-field of Repeater "%s". Edits here update the global sub-field definition.', 'profile-builder' ),
1300 ( parentRepeater.title || parentRepeater.metaName )
1301 )
1302 : __( 'This field is not in the current form. Edits here update the global field definition and apply wherever the field is used.', 'profile-builder' );
1303
1304 return (
1305 <>
1306 <FieldTypeCard blockType={ cardBlockType } />
1307 <p className="wppb-fb-field-settings__hint">
1308 { hint }
1309 </p>
1310 <VirtualFieldEdit
1311 field={ field }
1312 scope={ isSubFieldEdit ? 'sub' : 'top' }
1313 parentMetaName={ isSubFieldEdit ? selectedCardParentMeta : null }
1314 parentRepeaterId={ isSubFieldEdit && parentRepeater ? parentRepeater.id : 0 }
1315 />
1316 <FieldSettingsSlot bubblesVirtually />
1317 </>
1318 );
1319 };
1320
1321 // Pin complementary area to Field Settings when a PB block/card is selected;
1322 // otherwise Document sidebar. Never closable.
1323 const FieldSettingsAutoOpener = () => {
1324 const { selectedBlockClientId, selectedBlockName } = useSelect( ( select ) => {
1325 const blocks = select( 'core/block-editor' );
1326 const id = blocks.getSelectedBlockClientId();
1327 const block = id ? blocks.getBlock( id ) : null;
1328 return {
1329 selectedBlockClientId: id,
1330 selectedBlockName: block ? block.name : null,
1331 };
1332 }, [] );
1333
1334 const { id: selectedCardId } = useSelectedCard();
1335
1336 const isPbBlockSelected = !! ( selectedBlockClientId && selectedBlockName && selectedBlockName.startsWith( 'profile-builder/' ) );
1337 const isPbCardSelected = selectedCardId !== null && selectedCardId !== undefined;
1338 const isPbSelected = isPbBlockSelected || isPbCardSelected;
1339
1340 // The complementary area is a pure function of selection.
1341 const desiredSidebar = isPbSelected ? SIDEBAR_TARGET : DOCUMENT_SIDEBAR;
1342
1343 const activeSidebar = useSelect( ( select ) => {
1344 const ed = select( 'core/edit-post' );
1345 return ed && typeof ed.getActiveGeneralSidebarName === 'function'
1346 ? ed.getActiveGeneralSidebarName()
1347 : null;
1348 }, [] );
1349
1350 useEffect( () => {
1351 if ( activeSidebar === desiredSidebar ) return;
1352
1353 // Microtask: win race against core's complementary-area reset.
1354 queueMicrotask( () => {
1355 const ed = dataSelect( 'core/edit-post' );
1356 const active = ed && typeof ed.getActiveGeneralSidebarName === 'function'
1357 ? ed.getActiveGeneralSidebarName()
1358 : null;
1359 if ( active !== desiredSidebar ) openSidebar( desiredSidebar );
1360 } );
1361 }, [ desiredSidebar, activeSidebar ] );
1362
1363 return null;
1364 };
1365
1366 // Auto-switch to Existing Fields when an in-form canvas block is clicked.
1367 const activateExistingFieldsTab = () => {
1368 const tab = document.querySelector( HIJACK_TAB_SELECTOR );
1369 if ( ! tab ) return;
1370 // Don't fight the user's own tab choice / avoid a redundant click.
1371 if ( tab.getAttribute( 'aria-selected' ) === 'true' ) return;
1372 tab.click();
1373 };
1374
1375 const collectPbClientIds = ( blocks, out ) => {
1376 for ( const b of blocks ) {
1377 if ( b.name && b.name.startsWith( 'profile-builder/' ) ) out.add( b.clientId );
1378 if ( b.innerBlocks && b.innerBlocks.length ) collectPbClientIds( b.innerBlocks, out );
1379 }
1380 return out;
1381 };
1382
1383 const ExistingFieldsTabAutoSwitch = () => {
1384 const selectedPbClientId = useSelect( ( select ) => {
1385 const be = select( 'core/block-editor' );
1386 const id = be.getSelectedBlockClientId();
1387 const block = id ? be.getBlock( id ) : null;
1388 return ( block && block.name && block.name.startsWith( 'profile-builder/' ) ) ? id : null;
1389 }, [] );
1390
1391 const canvasBlocks = useSelect( ( select ) => select( 'core/block-editor' ).getBlocks(), [] );
1392
1393 // PB-block clientIds known as of the last canvas snapshot. `null` until the
1394 // first canvas effect runs, so a selection already present at page load
1395 // never triggers a switch.
1396 const knownClientIdsRef = useRef( null );
1397
1398 // Selection effect — declared BEFORE the canvas-tracking effect so that when
1399 // a block is just inserted (canvas tree + selection change in one dispatch),
1400 // this runs against the PRE-insert set and correctly skips it.
1401 useEffect( () => {
1402 if ( ! selectedPbClientId ) return;
1403 if ( knownClientIdsRef.current && knownClientIdsRef.current.has( selectedPbClientId ) ) {
1404 activateExistingFieldsTab();
1405 }
1406 }, [ selectedPbClientId ] );
1407
1408 // Keep the known-clientId set current. Runs after the selection effect.
1409 useEffect( () => {
1410 knownClientIdsRef.current = collectPbClientIds( canvasBlocks, new Set() );
1411 }, [ canvasBlocks ] );
1412
1413 return null;
1414 };
1415
1416 // Clear card-only selection on empty-canvas click.
1417 const DeselectCardOnCanvasClick = () => {
1418 const { id: selectedCardId } = useSelectedCard();
1419
1420 useEffect( () => {
1421 if ( selectedCardId === null || selectedCardId === undefined ) return undefined;
1422
1423 const clearIfEmptySpace = ( isIframe ) => ( e ) => {
1424 const t = e.target;
1425 if ( ! t || typeof t.closest !== 'function' ) return;
1426 // A click on an actual block selects it — ExistingFieldsList's
1427 // store-driven effect clears the card then. Leave it alone.
1428 if ( t.closest( '[data-block], .wp-block' ) ) return;
1429 // Inside the iframed canvas, everything that isn't a block is the
1430 // form's empty space. In the non-iframed fallback, restrict to the
1431 // editor writing surface so clicks on surrounding chrome don't deselect.
1432 if ( isIframe || t.closest( '.block-editor-writing-flow, .block-editor-block-list__layout, .editor-styles-wrapper' ) ) {
1433 setSelectedCard( null );
1434 }
1435 };
1436
1437 const detachers = [];
1438 // pointerdown: core padding appender stops mousedown.
1439 const attach = ( doc, isIframe ) => {
1440 if ( ! doc ) return;
1441 const handler = clearIfEmptySpace( isIframe );
1442 doc.addEventListener( 'pointerdown', handler, true );
1443 detachers.push( () => doc.removeEventListener( 'pointerdown', handler, true ) );
1444 };
1445
1446 attach( document, false );
1447 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
1448 if ( iframe ) {
1449 // contentDocument is same-origin (the editor canvas); guard anyway.
1450 try { attach( iframe.contentDocument, true ); } catch ( err ) { /* ignore */ }
1451 }
1452
1453 return () => detachers.forEach( ( off ) => off() );
1454 }, [ selectedCardId ] );
1455
1456 return null;
1457 };
1458
1459 const FieldSettingsSidebar = () => {
1460 const postType = useSelect(
1461 ( select ) => select( 'core/editor' ).getCurrentPostType(),
1462 []
1463 );
1464 const isOurPostType = [ 'wppb-rf-cpt', 'wppb-epf-cpt' ].includes( postType );
1465 if ( ! isOurPostType ) return null;
1466
1467 return (
1468 <>
1469 <FieldSettingsAutoOpener />
1470 <ExistingFieldsTabAutoSwitch />
1471 <DeselectCardOnCanvasClick />
1472 <PluginSidebar
1473 name={ SIDEBAR_NAME }
1474 title={ __( 'Field Settings', 'profile-builder' ) }
1475 icon="forms"
1476 className="wppb-fb-field-settings-sidebar"
1477 isPinnable={ false }
1478 >
1479 <FieldSettingsSidebarContents />
1480 </PluginSidebar>
1481 </>
1482 );
1483 };
1484
1485 registerPlugin( 'wppb-fb-existing-fields-sidebar', { render: FieldSettingsSidebar } );
1486