| 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 |
|