| 1 |
/** |
| 2 |
* Editor registration for the standalone page blocks. |
| 3 |
* |
| 4 |
* Plain ES5, no build step. Loops the localized MLSImportPageBlocks manifest and |
| 5 |
* registers one dynamic block per page block. Each block renders server-side via |
| 6 |
* ServerSideRender (the PHP render_callback is the single source of markup) and |
| 7 |
* builds its inspector controls from the localized arg schema, so adding a block |
| 8 |
* in PHP needs no change here. |
| 9 |
*/ |
| 10 |
( function ( blocks, element, components, blockEditor, serverSideRender ) { |
| 11 |
// Bail out if the block API or the localized manifest isn't available |
| 12 |
if ( ! blocks || ! window.MLSImportPageBlocks ) { |
| 13 |
return; |
| 14 |
} |
| 15 |
|
| 16 |
// Local aliases for the WP editor primitives used below |
| 17 |
var el = element.createElement; |
| 18 |
var InspectorControls = blockEditor.InspectorControls; |
| 19 |
var useBlockProps = blockEditor.useBlockProps; |
| 20 |
// Wrap PanelBody so every inspector panel carries the mlsimport-block-inspector |
| 21 |
// class, scoping the shared "2025" admin styling (mlsimport-block-editor.css) to |
| 22 |
// MLSImport block panels only, without restyling core/other-plugin inspectors. |
| 23 |
var RawPanelBody = components.PanelBody; |
| 24 |
var PanelBody = RawPanelBody ? function ( props ) { |
| 25 |
var next = Object.assign( {}, props ); |
| 26 |
next.className = ( next.className ? next.className + ' ' : '' ) + 'mlsimport-block-inspector'; |
| 27 |
return el( RawPanelBody, next ); |
| 28 |
} : RawPanelBody; |
| 29 |
var TextControl = components.TextControl; |
| 30 |
var TextareaControl = components.TextareaControl; |
| 31 |
var SelectControl = components.SelectControl; |
| 32 |
var ToggleControl = components.ToggleControl; |
| 33 |
var FormTokenField = components.FormTokenField; |
| 34 |
var Button = components.Button; |
| 35 |
var BaseControl = components.BaseControl; |
| 36 |
var ColorPalette = components.ColorPalette; |
| 37 |
var ColorIndicator = components.ColorIndicator; |
| 38 |
var Dropdown = components.Dropdown; |
| 39 |
var MediaUpload = blockEditor.MediaUpload; |
| 40 |
var MediaUploadCheck = blockEditor.MediaUploadCheck; |
| 41 |
var iconUrl = window.MLSImportPageBlocksIcon || ''; |
| 42 |
|
| 43 |
// Block toolbar/menu icon: the localized image URL, else a Dashicon fallback. |
| 44 |
// The PNG is 19x14, so it keeps its natural size (width/height auto beats the |
| 45 |
// width=24 height=24 attributes wp.components.Icon clones onto it) — a forced |
| 46 |
// square stretched it to 24x24 and blurred it. |
| 47 |
var icon = iconUrl |
| 48 |
? el( 'img', { src: iconUrl, alt: '', style: { width: 'auto', height: 'auto' } } ) |
| 49 |
: 'admin-home'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Build a Gutenberg attribute schema from the localized field list. |
| 53 |
* |
| 54 |
* The declared attribute type MUST match what PHP declares, or values fail |
| 55 |
* schema validation at render time and fall back to their default. |
| 56 |
* |
| 57 |
* @param {Array} fields - Field descriptors (key, type, default, ...). |
| 58 |
* @return {Object} Attribute schema keyed by field key. |
| 59 |
*/ |
| 60 |
function buildAttributes( fields ) { |
| 61 |
var attrs = {}; |
| 62 |
fields.forEach( function ( f ) { |
| 63 |
if ( f.type === 'number' ) { |
| 64 |
attrs[ f.key ] = { type: 'number', 'default': f['default'] !== '' ? Number( f['default'] ) : 0 }; |
| 65 |
} else if ( f.type === 'toggle' ) { |
| 66 |
attrs[ f.key ] = { type: 'boolean', 'default': !! f['default'] }; |
| 67 |
} else if ( f.type === 'repeater' ) { |
| 68 |
// A repeater stores an array of rows. It MUST be declared 'array' (matching |
| 69 |
// the PHP attribute schema) or the editor serializes the rows into a |
| 70 |
// string-typed attribute that never round-trips — the rows are lost on reload. |
| 71 |
attrs[ f.key ] = { type: 'array', 'default': Array.isArray( f['default'] ) ? f['default'] : [] }; |
| 72 |
} else { |
| 73 |
// Includes 'multiselect': like every taxonomy filter, it stores a comma |
| 74 |
// list of term ids as a string (the FormTokenField serializes to it). |
| 75 |
attrs[ f.key ] = { type: 'string', 'default': String( f['default'] || '' ) }; |
| 76 |
} |
| 77 |
} ); |
| 78 |
return attrs; |
| 79 |
} |
| 80 |
|
| 81 |
// One control for a single value (used by top-level fields and repeater rows). |
| 82 |
/** |
| 83 |
* Render the appropriate inspector control for a single field value. |
| 84 |
* |
| 85 |
* @param {Object} f - Field descriptor (type, label, options, ...). |
| 86 |
* @param {*} value - Current value. |
| 87 |
* @param {Function} onChange - Called with the new value on edit. |
| 88 |
* @param {string} reactKey - React key for the control element. |
| 89 |
* @return {Object} A wp.element control element. |
| 90 |
*/ |
| 91 |
function inputControl( f, value, onChange, reactKey ) { |
| 92 |
if ( f.type === 'multiselect' && FormTokenField ) { |
| 93 |
// The SAME control the Property List "Initial filter" taxonomy pickers use |
| 94 |
// (taxControl in mlsimport-standalone-block.js): a FormTokenField whose |
| 95 |
// tokens show term NAMES while the stored attribute is a comma list of term |
| 96 |
// IDs. f.options is an { id: name } object localized from PHP; map both ways |
| 97 |
// so a saved value round-trips to its label and a chosen label saves its id. |
| 98 |
var opts = f.options || {}; |
| 99 |
var valToLabel = {}; |
| 100 |
var labelToVal = {}; |
| 101 |
var suggestions = Object.keys( opts ).map( function ( id ) { |
| 102 |
valToLabel[ id ] = opts[ id ]; |
| 103 |
labelToVal[ opts[ id ] ] = id; |
| 104 |
return opts[ id ]; |
| 105 |
} ); |
| 106 |
var tokens = ( value ? String( value ).split( ',' ) : [] ).map( function ( v ) { |
| 107 |
v = v.trim(); |
| 108 |
return valToLabel[ v ] || v; |
| 109 |
} ).filter( Boolean ); |
| 110 |
return el( FormTokenField, { |
| 111 |
key: reactKey, |
| 112 |
label: f.label, |
| 113 |
value: tokens, |
| 114 |
suggestions: suggestions, |
| 115 |
__experimentalExpandOnFocus: true, |
| 116 |
onChange: function ( picked ) { |
| 117 |
var ids = picked.map( function ( t ) { |
| 118 |
return Object.prototype.hasOwnProperty.call( labelToVal, t ) ? labelToVal[ t ] : t; |
| 119 |
} ); |
| 120 |
onChange( ids.join( ',' ) ); |
| 121 |
} |
| 122 |
} ); |
| 123 |
} |
| 124 |
// Fixed-choice field: render a dropdown from the { value: label } options |
| 125 |
if ( f.type === 'select' && f.options ) { |
| 126 |
var options = Object.keys( f.options ).map( function ( key ) { |
| 127 |
return { value: key, label: f.options[ key ] }; |
| 128 |
} ); |
| 129 |
return el( SelectControl, { key: reactKey, label: f.label, value: value, options: options, onChange: onChange } ); |
| 130 |
} |
| 131 |
// Colour swatch. The attribute stores the plain CSS colour string PHP prints |
| 132 |
// inline, so an unset colour is '' and the stylesheet's own default wins. |
| 133 |
// |
| 134 |
// Collapsed into a one-line swatch + value button rather than an always-open |
| 135 |
// ColorPalette: the palette grid, its "Custom" toggle and the hex readout take |
| 136 |
// most of the inspector, and this block has six other controls under it. The |
| 137 |
// palette itself is unchanged — it just lives in the popover now. |
| 138 |
if ( f.type === 'color' && ColorPalette && Dropdown ) { |
| 139 |
return el( BaseControl, { key: reactKey, label: f.label }, |
| 140 |
el( Dropdown, { |
| 141 |
contentClassName: 'mlsimport-color-popover', |
| 142 |
popoverProps: { placement: 'left-start' }, |
| 143 |
renderToggle: function ( toggle ) { |
| 144 |
return el( Button, { |
| 145 |
variant: 'secondary', |
| 146 |
onClick: toggle.onToggle, |
| 147 |
'aria-expanded': toggle.isOpen, |
| 148 |
style: { display: 'flex', alignItems: 'center', gap: '8px', width: '100%' } |
| 149 |
}, |
| 150 |
ColorIndicator ? el( ColorIndicator, { colorValue: value || 'transparent' } ) : null, |
| 151 |
// An empty value is not "no colour" but "whatever the stylesheet |
| 152 |
// already uses", so it reads as Default rather than as blank. |
| 153 |
el( 'span', null, value ? value : 'Default' ) |
| 154 |
); |
| 155 |
}, |
| 156 |
renderContent: function () { |
| 157 |
return el( ColorPalette, { |
| 158 |
value: value || '', |
| 159 |
onChange: function ( c ) { |
| 160 |
onChange( c || '' ); |
| 161 |
} |
| 162 |
} ); |
| 163 |
} |
| 164 |
} ) |
| 165 |
); |
| 166 |
} |
| 167 |
// Media picker. The attribute stores the image URL (a string), not the |
| 168 |
// attachment id, so the render fn can print it without a second lookup and |
| 169 |
// the Elementor side — which hands back { url, id } — normalises to the same. |
| 170 |
if ( f.type === 'media' && MediaUpload && MediaUploadCheck ) { |
| 171 |
return el( BaseControl, { key: reactKey, label: f.label }, |
| 172 |
el( MediaUploadCheck, null, |
| 173 |
el( MediaUpload, { |
| 174 |
allowedTypes: [ 'image' ], |
| 175 |
value: value, |
| 176 |
onSelect: function ( media ) { |
| 177 |
onChange( media && media.url ? media.url : '' ); |
| 178 |
}, |
| 179 |
render: function ( open ) { |
| 180 |
return el( 'div', null, |
| 181 |
value ? el( 'img', { src: value, style: { maxWidth: '48px', display: 'block', marginBottom: '6px' } } ) : null, |
| 182 |
el( Button, { variant: 'secondary', onClick: open.open }, value ? 'Replace' : 'Select image' ), |
| 183 |
value ? el( Button, { isDestructive: true, isSmall: true, onClick: function () { onChange( '' ); } }, 'Remove' ) : null |
| 184 |
); |
| 185 |
} |
| 186 |
} ) |
| 187 |
) |
| 188 |
); |
| 189 |
} |
| 190 |
if ( f.type === 'toggle' ) { |
| 191 |
// Store a real BOOLEAN, because that is what the attribute is declared as |
| 192 |
// (buildAttributes below, and Mlsimport_Page_Block_Blocks::attributes in PHP). |
| 193 |
// |
| 194 |
// This used to write the STRINGS 'yes' / '' — and a string in a boolean-typed |
| 195 |
// attribute never reaches the render callback at all. WP_Block_Type:: |
| 196 |
// prepare_attributes_for_render() validates every attribute against its schema, |
| 197 |
// UNSETS anything that fails, and then fills the default back in. So the value |
| 198 |
// was silently discarded and the default restored on every render: switching a |
| 199 |
// toggle OFF did nothing whatsoever. That is every toggle in every generic page |
| 200 |
// block — Show filter bar, Featured agents only, Show job title, Show office name, |
| 201 |
// Hide terms with no listings, Show listing count, Display as auto grid. |
| 202 |
// |
| 203 |
// The PHP side already normalises with (string) — (string) true === '1', |
| 204 |
// (string) false === '' — so booleans are what it wants, and the legacy 'yes'/'1' |
| 205 |
// strings a shortcode may still pass keep working. |
| 206 |
return el( ToggleControl, { key: reactKey, label: f.label, checked: value === true || value === 'yes' || value === '1', onChange: function ( c ) { |
| 207 |
onChange( !! c ); |
| 208 |
} } ); |
| 209 |
} |
| 210 |
// Textarea: a multi-line input, so a long comma list (e.g. many post IDs) |
| 211 |
// is fully visible while editing. |
| 212 |
if ( f.type === 'textarea' && TextareaControl ) { |
| 213 |
return el( TextareaControl, { |
| 214 |
key: reactKey, |
| 215 |
label: f.label, |
| 216 |
value: value === undefined || value === null ? '' : value, |
| 217 |
rows: 5, |
| 218 |
onChange: function ( v ) { |
| 219 |
onChange( v ); |
| 220 |
} |
| 221 |
} ); |
| 222 |
} |
| 223 |
// Default: a text (or numeric) input; numbers are coerced back to Number |
| 224 |
return el( TextControl, { |
| 225 |
key: reactKey, |
| 226 |
label: f.label, |
| 227 |
type: f.type === 'number' ? 'number' : 'text', |
| 228 |
value: value === undefined || value === null ? '' : value, |
| 229 |
onChange: function ( v ) { |
| 230 |
onChange( f.type === 'number' ? Number( v ) : v ); |
| 231 |
} |
| 232 |
} ); |
| 233 |
} |
| 234 |
|
| 235 |
// A repeater: an editable list of rows, each row a control per sub-field. |
| 236 |
/** |
| 237 |
* Render a repeater field: an add/remove list of rows, one control per |
| 238 |
* sub-field, writing the whole array back to the block attribute. |
| 239 |
* |
| 240 |
* @param {Object} f - Repeater field descriptor (with nested .fields). |
| 241 |
* @param {Object} props - Gutenberg edit() props (attributes, setAttributes). |
| 242 |
* @return {Object} A wp.element repeater element. |
| 243 |
*/ |
| 244 |
function repeaterControl( f, props ) { |
| 245 |
// Current rows (default to empty) and the per-row sub-field descriptors |
| 246 |
var rows = Array.isArray( props.attributes[ f.key ] ) ? props.attributes[ f.key ] : []; |
| 247 |
var subs = f.fields || []; |
| 248 |
|
| 249 |
// Persist a new rows array back to the block attribute |
| 250 |
function commit( next ) { |
| 251 |
var update = {}; |
| 252 |
update[ f.key ] = next; |
| 253 |
props.setAttributes( update ); |
| 254 |
} |
| 255 |
// Immutably update one cell (row i, sub-field key) and commit |
| 256 |
function setCell( i, key, val ) { |
| 257 |
var copy = rows.map( function ( r, idx ) { |
| 258 |
if ( idx !== i ) { |
| 259 |
return r; |
| 260 |
} |
| 261 |
// Clone the target row so state stays immutable, then set the changed cell |
| 262 |
var row = {}; |
| 263 |
Object.keys( r || {} ).forEach( function ( k ) { row[ k ] = r[ k ]; } ); |
| 264 |
row[ key ] = val; |
| 265 |
return row; |
| 266 |
} ); |
| 267 |
commit( copy ); |
| 268 |
} |
| 269 |
// Append a new row pre-filled with each sub-field's default |
| 270 |
function addRow() { |
| 271 |
var def = {}; |
| 272 |
subs.forEach( function ( sf ) { def[ sf.key ] = sf['default']; } ); |
| 273 |
commit( rows.concat( [ def ] ) ); |
| 274 |
} |
| 275 |
// Drop the row at index i |
| 276 |
function removeRow( i ) { |
| 277 |
commit( rows.filter( function ( _, idx ) { return idx !== i; } ) ); |
| 278 |
} |
| 279 |
// Move the row at `from` to sit at `to`, keeping every other row's relative |
| 280 |
// order. The rendered form follows this array order directly, so reordering |
| 281 |
// here IS the field order on the front end — no separate order attribute. |
| 282 |
function moveRow( from, to ) { |
| 283 |
if ( from === to || from < 0 || to < 0 || from >= rows.length || to >= rows.length ) { |
| 284 |
return; |
| 285 |
} |
| 286 |
var next = rows.slice(); |
| 287 |
var moved = next.splice( from, 1 )[ 0 ]; |
| 288 |
next.splice( to, 0, moved ); |
| 289 |
commit( next ); |
| 290 |
} |
| 291 |
|
| 292 |
// Build one bordered row block per row: a control per sub-field plus a Remove |
| 293 |
// button. The row itself is the drag source — drag it onto another row to put |
| 294 |
// it there. The moved index travels in dataTransfer rather than in a closure |
| 295 |
// variable, because a re-render between dragstart and drop would otherwise |
| 296 |
// leave a stale index behind. |
| 297 |
var rowEls = rows.map( function ( row, i ) { |
| 298 |
var cells = subs.map( function ( sf ) { |
| 299 |
return inputControl( sf, row ? row[ sf.key ] : sf['default'], function ( v ) { |
| 300 |
setCell( i, sf.key, v ); |
| 301 |
}, sf.key ); |
| 302 |
} ); |
| 303 |
cells.push( el( 'div', { key: '__actions', style: { display: 'flex', alignItems: 'center', gap: '4px', marginTop: '8px' } }, |
| 304 |
el( Button, { |
| 305 |
key: '__up', |
| 306 |
isSmall: true, |
| 307 |
variant: 'tertiary', |
| 308 |
disabled: i === 0, |
| 309 |
'aria-label': 'Move up', |
| 310 |
onClick: function () { moveRow( i, i - 1 ); } |
| 311 |
}, '↑' ), |
| 312 |
el( Button, { |
| 313 |
key: '__down', |
| 314 |
isSmall: true, |
| 315 |
variant: 'tertiary', |
| 316 |
disabled: i === rows.length - 1, |
| 317 |
'aria-label': 'Move down', |
| 318 |
onClick: function () { moveRow( i, i + 1 ); } |
| 319 |
}, '↓' ), |
| 320 |
el( Button, { |
| 321 |
key: '__rm', |
| 322 |
isDestructive: true, |
| 323 |
isSmall: true, |
| 324 |
onClick: function () { removeRow( i ); } |
| 325 |
}, 'Remove' ) |
| 326 |
) ); |
| 327 |
return el( 'div', { |
| 328 |
key: i, |
| 329 |
className: 'mlsimport-repeater-row', |
| 330 |
draggable: true, |
| 331 |
onDragStart: function ( e ) { |
| 332 |
e.dataTransfer.setData( 'text/plain', String( i ) ); |
| 333 |
e.dataTransfer.effectAllowed = 'move'; |
| 334 |
}, |
| 335 |
onDragOver: function ( e ) { e.preventDefault(); }, |
| 336 |
onDrop: function ( e ) { |
| 337 |
e.preventDefault(); |
| 338 |
var from = parseInt( e.dataTransfer.getData( 'text/plain' ), 10 ); |
| 339 |
if ( ! isNaN( from ) ) { |
| 340 |
moveRow( from, i ); |
| 341 |
} |
| 342 |
}, |
| 343 |
style: { border: '1px solid #e0e0e0', borderRadius: '4px', padding: '10px', marginBottom: '10px', cursor: 'move' } |
| 344 |
}, cells ); |
| 345 |
} ); |
| 346 |
|
| 347 |
// Wrap the label, the rows, and the "+ add" button |
| 348 |
return el( 'div', { key: f.key, className: 'mlsimport-repeater' }, |
| 349 |
el( 'p', { key: '__lbl' }, el( 'strong', null, f.label ) ), |
| 350 |
rowEls, |
| 351 |
// Full-width and solid: it is the panel's one primary action, and a small |
| 352 |
// outline button floating under a stack of bordered rows reads as part of |
| 353 |
// the last row rather than as "add another". |
| 354 |
el( Button, { |
| 355 |
key: '__add', |
| 356 |
variant: 'primary', |
| 357 |
onClick: addRow, |
| 358 |
style: { width: '100%', justifyContent: 'center' } |
| 359 |
}, '+ ' + f.label ) |
| 360 |
); |
| 361 |
} |
| 362 |
|
| 363 |
/** |
| 364 |
* Dispatch to the right control for a top-level field and wire it to setAttributes. |
| 365 |
* |
| 366 |
* @param {Object} f - Field descriptor. |
| 367 |
* @param {Object} props - Gutenberg edit() props. |
| 368 |
* @return {Object} A wp.element control element. |
| 369 |
*/ |
| 370 |
function controlFor( f, props ) { |
| 371 |
// Repeaters manage their own array attribute |
| 372 |
if ( f.type === 'repeater' ) { |
| 373 |
return repeaterControl( f, props ); |
| 374 |
} |
| 375 |
// Scalar field: read the attribute and write edits straight back |
| 376 |
return inputControl( f, props.attributes[ f.key ], function ( v ) { |
| 377 |
var update = {}; |
| 378 |
update[ f.key ] = v; |
| 379 |
props.setAttributes( update ); |
| 380 |
}, f.key ); |
| 381 |
} |
| 382 |
|
| 383 |
// Register one dynamic block per entry in the localized manifest |
| 384 |
window.MLSImportPageBlocks.forEach( function ( block ) { |
| 385 |
var blockName = 'mlsimport/' + block.slug; |
| 386 |
var fields = block.args || []; |
| 387 |
|
| 388 |
blocks.registerBlockType( blockName, { |
| 389 |
apiVersion: 2, |
| 390 |
title: block.title, |
| 391 |
icon: icon, |
| 392 |
category: 'mlsimport-real-estate', |
| 393 |
attributes: buildAttributes( fields ), |
| 394 |
// Editor view: inspector controls + a non-interactive server-rendered preview |
| 395 |
edit: function ( props ) { |
| 396 |
// apiVersion 2 requires the edit root to carry useBlockProps(), or the |
| 397 |
// editor never builds the selectable block wrapper (block unselectable, |
| 398 |
// so its InspectorControls — which only show on selection — never appear). |
| 399 |
var blockProps = useBlockProps ? useBlockProps() : {}; |
| 400 |
var inspector = el( |
| 401 |
InspectorControls, |
| 402 |
{ key: 'inspector' }, |
| 403 |
el( |
| 404 |
PanelBody, |
| 405 |
{ title: block.title, initialOpen: true }, |
| 406 |
fields.map( function ( f ) { |
| 407 |
return controlFor( f, props ); |
| 408 |
} ) |
| 409 |
) |
| 410 |
); |
| 411 |
// Wrap the preview so its links/forms can't be clicked: the card markup |
| 412 |
// is a full anchor, and an inline SSR preview would otherwise navigate |
| 413 |
// the editor away (to the property permalink / site root) instead of |
| 414 |
// selecting the block. pointer-events:none lets the click fall through |
| 415 |
// to the block wrapper so the block selects normally. |
| 416 |
var preview = el( 'div', { key: 'preview', style: { pointerEvents: 'none' } }, |
| 417 |
el( serverSideRender, { |
| 418 |
block: blockName, |
| 419 |
attributes: props.attributes |
| 420 |
} ) |
| 421 |
); |
| 422 |
return el( 'div', blockProps, inspector, preview ); |
| 423 |
}, |
| 424 |
// Dynamic block: markup comes from PHP, so nothing is saved to post content |
| 425 |
save: function () { |
| 426 |
return null; |
| 427 |
} |
| 428 |
} ); |
| 429 |
} ); |
| 430 |
} )( window.wp.blocks, window.wp.element, window.wp.components, window.wp.blockEditor, window.wp.serverSideRender ); |
| 431 |
|