| 1 |
/** |
| 2 |
* Editor registration for the standalone `mlsimport/listings` and |
| 3 |
* `mlsimport/half-map` dynamic blocks. |
| 4 |
* |
| 5 |
* Both are server-rendered (render_callback in PHP) and share one inspector: a |
| 6 |
* "properties to display" control, an Initial filter panel, and one on/off toggle |
| 7 |
* per search field (the form fields are localized from PHP so this list never |
| 8 |
* drifts from search-form.php). The Half Map block adds a Layout panel (map side + |
| 9 |
* height). Blocks are localized from PHP, so the half-map reuses the listings |
| 10 |
* inspector instead of the generic page-block adapter's raw text inputs. No build |
| 11 |
* step: plain wp.element.createElement, no JSX. |
| 12 |
*/ |
| 13 |
( function ( wp, cfg ) { |
| 14 |
if ( ! wp || ! wp.blocks || ! wp.element ) { |
| 15 |
return; |
| 16 |
} |
| 17 |
|
| 18 |
var el = wp.element.createElement; |
| 19 |
var components = wp.components || {}; |
| 20 |
var blockEditor = wp.blockEditor || {}; |
| 21 |
var InspectorControls = blockEditor.InspectorControls; |
| 22 |
var useBlockProps = blockEditor.useBlockProps; |
| 23 |
// Wrap PanelBody so every inspector panel carries the mlsimport-block-inspector |
| 24 |
// class. That class scopes the shared "2025" admin styling (mlsimport-block-editor.css) |
| 25 |
// to MLSImport block panels only, without restyling core/other-plugin inspectors. |
| 26 |
var RawPanelBody = components.PanelBody; |
| 27 |
var PanelBody = RawPanelBody ? function ( props ) { |
| 28 |
var next = Object.assign( {}, props ); |
| 29 |
next.className = ( next.className ? next.className + ' ' : '' ) + 'mlsimport-block-inspector'; |
| 30 |
return el( RawPanelBody, next ); |
| 31 |
} : RawPanelBody; |
| 32 |
var TextControl = components.TextControl; |
| 33 |
var ToggleControl = components.ToggleControl; |
| 34 |
var SelectControl = components.SelectControl; |
| 35 |
var FormTokenField = components.FormTokenField; |
| 36 |
var name = ( cfg && cfg.name ) || 'mlsimport/listings'; |
| 37 |
var iconUrl = cfg && cfg.iconUrl; |
| 38 |
var searchFields = ( cfg && cfg.searchFields ) || []; |
| 39 |
var taxonomies = ( cfg && cfg.taxonomies ) || []; |
| 40 |
var allKeys = searchFields.map( function ( f ) { return f.key; } ); |
| 41 |
|
| 42 |
// Friendly sort presets -> the orderby/order attribute pair the render path uses. |
| 43 |
var SORT_OPTIONS = [ |
| 44 |
{ label: 'Default', value: '', orderby: '', order: '' }, |
| 45 |
{ label: 'Newest', value: 'list_date|desc', orderby: 'list_date', order: 'desc' }, |
| 46 |
{ label: 'Oldest', value: 'list_date|asc', orderby: 'list_date', order: 'asc' }, |
| 47 |
{ label: 'Price (low to high)', value: 'price|asc', orderby: 'price', order: 'asc' }, |
| 48 |
{ label: 'Price (high to low)', value: 'price|desc', orderby: 'price', order: 'desc' }, |
| 49 |
{ label: 'Bedrooms (most first)', value: 'bedrooms|desc', orderby: 'bedrooms', order: 'desc' } |
| 50 |
]; |
| 51 |
|
| 52 |
// Same image as the admin sidebar menu icon. The PNG is 19x14, so it is left at |
| 53 |
// its natural size (width/height auto beats the width=24 height=24 attributes |
| 54 |
// wp.components.Icon clones onto it) — forcing a square stretched and blurred it. |
| 55 |
var icon = iconUrl |
| 56 |
? el( 'img', { src: iconUrl, alt: '', style: { width: 'auto', height: 'auto' } } ) |
| 57 |
: 'admin-home'; |
| 58 |
|
| 59 |
// The enabled set for the current attribute value. Empty search_fields means |
| 60 |
// every field shows (the default); otherwise only the listed keys are on. |
| 61 |
function enabledKeys( value ) { |
| 62 |
if ( ! value ) { |
| 63 |
return allKeys.slice(); |
| 64 |
} |
| 65 |
var picked = String( value ).split( ',' ).map( function ( k ) { return k.trim(); } ); |
| 66 |
return allKeys.filter( function ( k ) { return picked.indexOf( k ) !== -1; } ); |
| 67 |
} |
| 68 |
|
| 69 |
// Flip one field on/off and serialize back to search_fields, preserving catalog |
| 70 |
// order. Every field on -> '' (the default = show all); every field off -> the |
| 71 |
// 'none' marker (an explicit empty form, distinct from the default); otherwise |
| 72 |
// the comma list. enabledKeys() round-trips all three (the marker matches no key). |
| 73 |
function toggleField( props, key, on ) { |
| 74 |
var current = enabledKeys( props.attributes.search_fields ); |
| 75 |
var next = allKeys.filter( function ( k ) { |
| 76 |
if ( k === key ) { |
| 77 |
return on; |
| 78 |
} |
| 79 |
return current.indexOf( k ) !== -1; |
| 80 |
} ); |
| 81 |
var value = ''; |
| 82 |
if ( next.length === 0 ) { |
| 83 |
value = 'none'; |
| 84 |
} else if ( next.length !== allKeys.length ) { |
| 85 |
value = next.join( ',' ); |
| 86 |
} |
| 87 |
props.setAttributes( { search_fields: value } ); |
| 88 |
} |
| 89 |
|
| 90 |
// Split a stored comma list into trimmed, non-empty values. |
| 91 |
function splitList( value ) { |
| 92 |
return value ? String( value ).split( ',' ).map( function ( v ) { return v.trim(); } ).filter( Boolean ) : []; |
| 93 |
} |
| 94 |
|
| 95 |
// One taxonomy multi-select: tokens show term names, but the stored attribute |
| 96 |
// holds the field's own value (term name or slug). Map both ways so an existing |
| 97 |
// value round-trips to its label and a chosen label saves back as its value. |
| 98 |
function taxControl( props, tax ) { |
| 99 |
if ( ! FormTokenField ) { |
| 100 |
return null; |
| 101 |
} |
| 102 |
var valToLabel = {}; |
| 103 |
var labelToVal = {}; |
| 104 |
var suggestions = tax.options.map( function ( o ) { |
| 105 |
valToLabel[ o.value ] = o.label; |
| 106 |
labelToVal[ o.label ] = o.value; |
| 107 |
return o.label; |
| 108 |
} ); |
| 109 |
var tokens = splitList( props.attributes[ tax.key ] ).map( function ( v ) { |
| 110 |
return valToLabel[ v ] || v; |
| 111 |
} ); |
| 112 |
return el( FormTokenField, { |
| 113 |
key: tax.key, |
| 114 |
label: tax.label, |
| 115 |
value: tokens, |
| 116 |
suggestions: suggestions, |
| 117 |
__experimentalExpandOnFocus: true, |
| 118 |
onChange: function ( picked ) { |
| 119 |
var values = picked.map( function ( t ) { |
| 120 |
return Object.prototype.hasOwnProperty.call( labelToVal, t ) ? labelToVal[ t ] : t; |
| 121 |
} ); |
| 122 |
var attr = {}; |
| 123 |
attr[ tax.key ] = values.join( ',' ); |
| 124 |
props.setAttributes( attr ); |
| 125 |
} |
| 126 |
} ); |
| 127 |
} |
| 128 |
|
| 129 |
// A min/max number pair writing two attributes (e.g. price_min/price_max). |
| 130 |
function rangeControl( props, label, minKey, maxKey ) { |
| 131 |
if ( ! TextControl ) { |
| 132 |
return null; |
| 133 |
} |
| 134 |
// Each half sits in its own flex:1 wrapper: without it the two TextControls |
| 135 |
// size to their content and the pair comes out lopsided (and can wrap). |
| 136 |
function field( key, ph ) { |
| 137 |
return el( |
| 138 |
'div', |
| 139 |
{ key: key, style: { flex: '1', minWidth: 0 } }, |
| 140 |
el( TextControl, { |
| 141 |
label: ph, |
| 142 |
type: 'number', |
| 143 |
min: 0, |
| 144 |
value: props.attributes[ key ] || '', |
| 145 |
onChange: function ( v ) { |
| 146 |
var attr = {}; |
| 147 |
attr[ key ] = v === '' ? '' : String( parseInt( v, 10 ) || '' ); |
| 148 |
props.setAttributes( attr ); |
| 149 |
} |
| 150 |
} ) |
| 151 |
); |
| 152 |
} |
| 153 |
return el( |
| 154 |
'div', |
| 155 |
{ key: minKey, className: 'mlsimport-range' }, |
| 156 |
el( 'p', { style: { margin: '0 0 4px' } }, label ), |
| 157 |
el( 'div', { style: { display: 'flex', gap: '8px' } }, field( minKey, 'Min' ), field( maxKey, 'Max' ) ) |
| 158 |
); |
| 159 |
} |
| 160 |
|
| 161 |
// A single number attribute (beds, baths — used as a minimum). |
| 162 |
// |
| 163 |
// Parsed as a FLOAT, not an int: baths are half-values in every real MLS feed |
| 164 |
// ("2.5 baths"), and the query filters them as one (bathrooms >= %f). parseInt |
| 165 |
// silently truncated 2.5 to 2, so the control could not express what the filter |
| 166 |
// already supported. Beds are unaffected — the WHERE-builder casts them to int. |
| 167 |
function numberControl( props, key, label ) { |
| 168 |
if ( ! TextControl ) { |
| 169 |
return null; |
| 170 |
} |
| 171 |
return el( TextControl, { |
| 172 |
key: key, |
| 173 |
label: label, |
| 174 |
type: 'number', |
| 175 |
min: 0, |
| 176 |
step: 'any', |
| 177 |
value: props.attributes[ key ] || '', |
| 178 |
onChange: function ( v ) { |
| 179 |
var attr = {}; |
| 180 |
attr[ key ] = v === '' ? '' : String( parseFloat( v ) || '' ); |
| 181 |
props.setAttributes( attr ); |
| 182 |
} |
| 183 |
} ); |
| 184 |
} |
| 185 |
|
| 186 |
// The sort preset select, reading/writing the orderby + order attribute pair. |
| 187 |
function sortControl( props ) { |
| 188 |
if ( ! SelectControl ) { |
| 189 |
return null; |
| 190 |
} |
| 191 |
var current = ''; |
| 192 |
SORT_OPTIONS.forEach( function ( o ) { |
| 193 |
if ( o.orderby === ( props.attributes.orderby || '' ) && o.order === ( props.attributes.order || '' ) ) { |
| 194 |
current = o.value; |
| 195 |
} |
| 196 |
} ); |
| 197 |
return el( SelectControl, { |
| 198 |
key: 'sort', |
| 199 |
label: 'Order', |
| 200 |
value: current, |
| 201 |
options: SORT_OPTIONS.map( function ( o ) { return { label: o.label, value: o.value }; } ), |
| 202 |
onChange: function ( v ) { |
| 203 |
var match = SORT_OPTIONS.filter( function ( o ) { return o.value === v; } )[ 0 ] || SORT_OPTIONS[ 0 ]; |
| 204 |
props.setAttributes( { orderby: match.orderby, order: match.order } ); |
| 205 |
} |
| 206 |
} ); |
| 207 |
} |
| 208 |
|
| 209 |
// opts.noSort drops the Order control (a map has no pin order); opts.open expands |
| 210 |
// the panel on load (used when it is the ONLY panel, so the inspector isn't blank). |
| 211 |
function initialFilterPanel( props, title, opts ) { |
| 212 |
opts = opts || {}; |
| 213 |
var children = taxonomies.map( function ( tax ) { return taxControl( props, tax ); } ); |
| 214 |
children.push( rangeControl( props, 'Price', 'price_min', 'price_max' ) ); |
| 215 |
children.push( numberControl( props, 'beds', 'Min beds' ) ); |
| 216 |
children.push( numberControl( props, 'baths', 'Min baths' ) ); |
| 217 |
// Agent post ID — narrows the set to that agent's listings (the mlsimport_list_agent_id |
| 218 |
// link the agent profile uses). Empty (or 0) means every agent. |
| 219 |
children.push( numberControl( props, 'agent', 'Agent (post ID)' ) ); |
| 220 |
if ( ! opts.noSort ) { |
| 221 |
children.push( sortControl( props ) ); |
| 222 |
} |
| 223 |
return el( |
| 224 |
PanelBody, |
| 225 |
{ key: 'initial-filter', title: title || 'Initial filter', initialOpen: !! opts.open }, |
| 226 |
children |
| 227 |
); |
| 228 |
} |
| 229 |
|
| 230 |
// Layout panel — only the Half Map block has a map to place and a height to set; |
| 231 |
// the plain listings grid has neither, so this panel is added only when asked. |
| 232 |
function layoutPanel( props ) { |
| 233 |
return el( |
| 234 |
PanelBody, |
| 235 |
{ key: 'layout', title: 'Layout', initialOpen: false }, |
| 236 |
SelectControl ? el( SelectControl, { |
| 237 |
key: 'map_side', |
| 238 |
label: 'Map side', |
| 239 |
value: props.attributes.map_side || 'right', |
| 240 |
options: [ { label: 'Right', value: 'right' }, { label: 'Left', value: 'left' } ], |
| 241 |
onChange: function ( v ) { props.setAttributes( { map_side: v } ); } |
| 242 |
} ) : null, |
| 243 |
TextControl ? el( TextControl, { |
| 244 |
key: 'height', |
| 245 |
label: 'Height (CSS, e.g. 100vh)', |
| 246 |
value: props.attributes.height || '', |
| 247 |
onChange: function ( v ) { props.setAttributes( { height: v } ); } |
| 248 |
} ) : null |
| 249 |
); |
| 250 |
} |
| 251 |
|
| 252 |
// Search Results panel — the trimmed listings controls: per-page, a Show filter |
| 253 |
// bar toggle (off renders results only, the bar hidden), and fields-per-row. The |
| 254 |
// filters themselves come from the request, so there is no Initial filter panel. |
| 255 |
function resultsPanel( props, title ) { |
| 256 |
var showBar = props.attributes.show_filter_bar; |
| 257 |
return el( |
| 258 |
PanelBody, |
| 259 |
{ key: 'results', title: title || 'Search results', initialOpen: true }, |
| 260 |
TextControl ? el( TextControl, { |
| 261 |
key: 'count', |
| 262 |
label: 'Properties per page', |
| 263 |
type: 'number', |
| 264 |
min: 1, |
| 265 |
value: props.attributes.count || '', |
| 266 |
onChange: function ( v ) { |
| 267 |
props.setAttributes( { count: v === '' ? '' : String( parseInt( v, 10 ) || '' ) } ); |
| 268 |
} |
| 269 |
} ) : null, |
| 270 |
ToggleControl ? el( ToggleControl, { |
| 271 |
key: 'show_filter_bar', |
| 272 |
label: 'Show filter bar', |
| 273 |
checked: showBar !== '' && showBar !== '0', |
| 274 |
onChange: function ( on ) { props.setAttributes( { show_filter_bar: on ? '1' : '' } ); } |
| 275 |
} ) : null, |
| 276 |
SelectControl ? el( SelectControl, { |
| 277 |
key: 'fields_per_row', |
| 278 |
label: 'Search fields per row', |
| 279 |
value: props.attributes.fields_per_row || '4', |
| 280 |
options: [ |
| 281 |
{ label: '3', value: '3' }, |
| 282 |
{ label: '4', value: '4' }, |
| 283 |
{ label: '5', value: '5' }, |
| 284 |
{ label: '6', value: '6' } |
| 285 |
], |
| 286 |
onChange: function ( v ) { props.setAttributes( { fields_per_row: v } ); } |
| 287 |
} ) : null |
| 288 |
); |
| 289 |
} |
| 290 |
|
| 291 |
// Content Slider panel — the carousel's own controls: how many slides (limit) and |
| 292 |
// an optional explicit, ordered ID list (overrides the initial filter when set). |
| 293 |
// The filters themselves live in the shared Initial filter panel below it. |
| 294 |
function sliderPanel( props ) { |
| 295 |
return el( |
| 296 |
PanelBody, |
| 297 |
{ key: 'slider', title: 'Slider', initialOpen: true }, |
| 298 |
TextControl ? el( TextControl, { |
| 299 |
key: 'limit', |
| 300 |
label: 'How many', |
| 301 |
type: 'number', |
| 302 |
min: 1, |
| 303 |
value: props.attributes.limit || '', |
| 304 |
onChange: function ( v ) { |
| 305 |
props.setAttributes( { limit: v === '' ? '' : String( parseInt( v, 10 ) || '' ) } ); |
| 306 |
} |
| 307 |
} ) : null, |
| 308 |
TextControl ? el( TextControl, { |
| 309 |
key: 'ids', |
| 310 |
label: 'Explicit property IDs (comma list)', |
| 311 |
value: props.attributes.ids || '', |
| 312 |
onChange: function ( v ) { props.setAttributes( { ids: v } ); } |
| 313 |
} ) : null |
| 314 |
); |
| 315 |
} |
| 316 |
|
| 317 |
function inspector( props, block ) { |
| 318 |
if ( ! InspectorControls || ! PanelBody ) { |
| 319 |
return null; |
| 320 |
} |
| 321 |
var hasLayout = !! ( block && block.layout ); |
| 322 |
var isResults = !! ( block && block.kind === 'results' ); |
| 323 |
var isSlider = !! ( block && block.kind === 'slider' ); |
| 324 |
var isItemList = !! ( block && block.kind === 'item-list' ); |
| 325 |
var isMap = !! ( block && block.kind === 'map' ); |
| 326 |
var enabled = enabledKeys( props.attributes.search_fields ); |
| 327 |
|
| 328 |
// Content Slider: its own Slider panel + the shared Initial filter panel (the |
| 329 |
// same taxonomy dropdowns / price / beds / order the Half Map exposes). No search |
| 330 |
// bar, so no per-field toggle panel. |
| 331 |
if ( isSlider ) { |
| 332 |
return el( InspectorControls, { key: 'inspector' }, [ sliderPanel( props ), initialFilterPanel( props ) ] ); |
| 333 |
} |
| 334 |
|
| 335 |
// Map with Listings: ONLY the shared Initial filter panel — the filter that |
| 336 |
// decides which listings become pins. A map plots every match at its coordinates, |
| 337 |
// so there is no page size ("How many" never caps the query map — map_payload |
| 338 |
// strips limit/page), no order (pins have no sequence) and no explicit-ID surface. |
| 339 |
// The panel opens on load since it is the sole panel. Matches the Elementor widget. |
| 340 |
if ( isMap ) { |
| 341 |
return el( InspectorControls, { key: 'inspector' }, [ initialFilterPanel( props, 'Initial filters', { noSort: true, open: true } ) ] ); |
| 342 |
} |
| 343 |
|
| 344 |
var fieldsPanel = el( |
| 345 |
PanelBody, |
| 346 |
{ key: 'fields', title: 'Search fields', initialOpen: false }, |
| 347 |
searchFields.map( function ( f ) { |
| 348 |
return ToggleControl ? el( ToggleControl, { |
| 349 |
key: f.key, |
| 350 |
label: f.label, |
| 351 |
checked: enabled.indexOf( f.key ) !== -1, |
| 352 |
onChange: function ( on ) { toggleField( props, f.key, on ); } |
| 353 |
} ) : null; |
| 354 |
} ) |
| 355 |
); |
| 356 |
|
| 357 |
// Property List: the Settings panel (per-page, show-bar, fields-per-row) + the |
| 358 |
// Initial filter presets + the per-field toggles. The same rich inspector the |
| 359 |
// Half Map uses, minus the map Layout panel. |
| 360 |
if ( isItemList ) { |
| 361 |
return el( InspectorControls, { key: 'inspector' }, [ resultsPanel( props, 'Settings' ), initialFilterPanel( props, 'Initial filters' ), fieldsPanel ] ); |
| 362 |
} |
| 363 |
|
| 364 |
// Results block: just the trimmed panel + the per-field toggles. |
| 365 |
if ( isResults ) { |
| 366 |
return el( InspectorControls, { key: 'inspector' }, [ resultsPanel( props ), fieldsPanel ] ); |
| 367 |
} |
| 368 |
|
| 369 |
var listingsPanel = el( |
| 370 |
PanelBody, |
| 371 |
{ key: 'listings', title: 'Listings', initialOpen: true }, |
| 372 |
TextControl ? el( TextControl, { |
| 373 |
key: 'limit', |
| 374 |
label: 'Properties to display', |
| 375 |
type: 'number', |
| 376 |
min: 1, |
| 377 |
value: props.attributes.limit || '', |
| 378 |
onChange: function ( v ) { |
| 379 |
props.setAttributes( { limit: v === '' ? '' : String( parseInt( v, 10 ) || '' ) } ); |
| 380 |
} |
| 381 |
} ) : null, |
| 382 |
SelectControl ? el( SelectControl, { |
| 383 |
key: 'fields_per_row', |
| 384 |
label: 'Search fields per row', |
| 385 |
value: props.attributes.fields_per_row || ( hasLayout ? '3' : '4' ), |
| 386 |
options: [ |
| 387 |
{ label: '3', value: '3' }, |
| 388 |
{ label: '4', value: '4' }, |
| 389 |
{ label: '5', value: '5' }, |
| 390 |
{ label: '6', value: '6' } |
| 391 |
], |
| 392 |
onChange: function ( v ) { props.setAttributes( { fields_per_row: v } ); } |
| 393 |
} ) : null |
| 394 |
); |
| 395 |
|
| 396 |
var panels = [ listingsPanel, initialFilterPanel( props ), fieldsPanel ]; |
| 397 |
if ( hasLayout ) { |
| 398 |
panels.push( layoutPanel( props ) ); |
| 399 |
} |
| 400 |
return el( InspectorControls, { key: 'inspector' }, panels ); |
| 401 |
} |
| 402 |
|
| 403 |
// Every attribute the inspector writes must be declared here so the editor |
| 404 |
// serializes it and ServerSideRender forwards it to the PHP render_callback. The |
| 405 |
// Half Map block adds map_side/height (its Layout panel); the plain grid omits them. |
| 406 |
function buildAttributes( block ) { |
| 407 |
var hasLayout = !! ( block && block.layout ); |
| 408 |
|
| 409 |
// Search Results saves only the refine-bar display config — no initial-filter |
| 410 |
// presets (the filters come from the request). Mirrors results_attributes() in PHP. |
| 411 |
if ( block && block.kind === 'results' ) { |
| 412 |
return { |
| 413 |
count: { type: 'string', 'default': '12' }, |
| 414 |
show_filter_bar: { type: 'string', 'default': '1' }, |
| 415 |
fields_per_row: { type: 'string', 'default': '4' }, |
| 416 |
search_fields: { type: 'string', 'default': '' } |
| 417 |
}; |
| 418 |
} |
| 419 |
|
| 420 |
// Content Slider saves the initial-filter presets the Slider + Initial filter |
| 421 |
// panels write (how-many, explicit IDs, price/beds/baths, order, and one attribute |
| 422 |
// per taxonomy) — no search-bar config. Mirrors slider_attributes() in PHP. |
| 423 |
if ( block && block.kind === 'slider' ) { |
| 424 |
var sliderAttrs = { |
| 425 |
limit: { type: 'string', 'default': '6' }, |
| 426 |
ids: { type: 'string', 'default': '' }, |
| 427 |
price_min: { type: 'string', 'default': '' }, |
| 428 |
price_max: { type: 'string', 'default': '' }, |
| 429 |
beds: { type: 'string', 'default': '' }, |
| 430 |
baths: { type: 'string', 'default': '' }, |
| 431 |
agent: { type: 'string', 'default': '' }, |
| 432 |
orderby: { type: 'string', 'default': '' }, |
| 433 |
order: { type: 'string', 'default': '' } |
| 434 |
}; |
| 435 |
taxonomies.forEach( function ( tax ) { |
| 436 |
sliderAttrs[ tax.key ] = { type: 'string', 'default': '' }; |
| 437 |
} ); |
| 438 |
return sliderAttrs; |
| 439 |
} |
| 440 |
|
| 441 |
// Property List saves the search-bar config (per-page, show-bar, fields-per-row, |
| 442 |
// per-field toggles) AND the initial-filter presets. Mirrors item_list_attributes() in PHP. |
| 443 |
if ( block && block.kind === 'item-list' ) { |
| 444 |
var itemAttrs = { |
| 445 |
count: { type: 'string', 'default': '12' }, |
| 446 |
show_filter_bar: { type: 'string', 'default': '1' }, |
| 447 |
fields_per_row: { type: 'string', 'default': '4' }, |
| 448 |
search_fields: { type: 'string', 'default': '' }, |
| 449 |
price_min: { type: 'string', 'default': '' }, |
| 450 |
price_max: { type: 'string', 'default': '' }, |
| 451 |
beds: { type: 'string', 'default': '' }, |
| 452 |
baths: { type: 'string', 'default': '' }, |
| 453 |
agent: { type: 'string', 'default': '' }, |
| 454 |
orderby: { type: 'string', 'default': '' }, |
| 455 |
order: { type: 'string', 'default': '' } |
| 456 |
}; |
| 457 |
taxonomies.forEach( function ( tax ) { itemAttrs[ tax.key ] = { type: 'string', 'default': '' }; } ); |
| 458 |
return itemAttrs; |
| 459 |
} |
| 460 |
|
| 461 |
// Map with Listings saves the map selection controls (how many, explicit IDs) AND |
| 462 |
// the initial-filter presets. Mirrors map_attributes() in PHP. |
| 463 |
if ( block && block.kind === 'map' ) { |
| 464 |
var mapAttrs = { |
| 465 |
count: { type: 'string', 'default': '6' }, |
| 466 |
ids: { type: 'string', 'default': '' }, |
| 467 |
price_min: { type: 'string', 'default': '' }, |
| 468 |
price_max: { type: 'string', 'default': '' }, |
| 469 |
beds: { type: 'string', 'default': '' }, |
| 470 |
baths: { type: 'string', 'default': '' }, |
| 471 |
agent: { type: 'string', 'default': '' }, |
| 472 |
orderby: { type: 'string', 'default': '' }, |
| 473 |
order: { type: 'string', 'default': '' } |
| 474 |
}; |
| 475 |
taxonomies.forEach( function ( tax ) { mapAttrs[ tax.key ] = { type: 'string', 'default': '' }; } ); |
| 476 |
return mapAttrs; |
| 477 |
} |
| 478 |
|
| 479 |
var attributes = { |
| 480 |
limit: { type: 'string', 'default': '12' }, |
| 481 |
search_fields: { type: 'string', 'default': '' }, |
| 482 |
// Search-bar columns: 4-up for the full-width listings bar, 3-up for the |
| 483 |
// Half Map's narrow pane. Mirrors the PHP attribute defaults. |
| 484 |
fields_per_row: { type: 'string', 'default': hasLayout ? '3' : '4' }, |
| 485 |
price_min: { type: 'string', 'default': '' }, |
| 486 |
price_max: { type: 'string', 'default': '' }, |
| 487 |
beds: { type: 'string', 'default': '' }, |
| 488 |
baths: { type: 'string', 'default': '' }, |
| 489 |
agent: { type: 'string', 'default': '' }, |
| 490 |
orderby: { type: 'string', 'default': '' }, |
| 491 |
order: { type: 'string', 'default': '' } |
| 492 |
}; |
| 493 |
taxonomies.forEach( function ( tax ) { |
| 494 |
attributes[ tax.key ] = { type: 'string', 'default': '' }; |
| 495 |
} ); |
| 496 |
if ( hasLayout ) { |
| 497 |
attributes.map_side = { type: 'string', 'default': 'right' }; |
| 498 |
attributes.height = { type: 'string', 'default': '100vh' }; |
| 499 |
} |
| 500 |
return attributes; |
| 501 |
} |
| 502 |
|
| 503 |
// The blocks this script registers. Both share the listings inspector; the Half |
| 504 |
// Map adds a Layout panel. Localized from PHP; a single-block fallback keeps the |
| 505 |
// listings block working if the config predates the blocks array. |
| 506 |
var blocks = ( cfg && cfg.blocks ) || [ |
| 507 |
{ name: name, title: 'MLS Listings', description: 'Display imported MLS listings in a filterable grid.', category: 'widgets', layout: false } |
| 508 |
]; |
| 509 |
|
| 510 |
blocks.forEach( function ( block ) { |
| 511 |
wp.blocks.registerBlockType( block.name, { |
| 512 |
apiVersion: 2, |
| 513 |
title: block.title, |
| 514 |
description: block.description, |
| 515 |
category: block.category || 'widgets', |
| 516 |
icon: icon, |
| 517 |
attributes: buildAttributes( block ), |
| 518 |
edit: function ( props ) { |
| 519 |
// apiVersion 2 requires useBlockProps() on the edit root, or the block has |
| 520 |
// no selectable wrapper in the editor (and its inspector never shows). |
| 521 |
var blockProps = useBlockProps ? useBlockProps() : {}; |
| 522 |
// pointer-events:none on the preview lets a click fall through to the block |
| 523 |
// wrapper (so the block selects) instead of following a listing card's link |
| 524 |
// and navigating the editor away. |
| 525 |
var preview = wp.serverSideRender |
| 526 |
? el( 'div', { key: 'preview', style: { pointerEvents: 'none' } }, el( wp.serverSideRender, { block: block.name, attributes: props.attributes } ) ) |
| 527 |
: el( 'p', { key: 'preview' }, block.title ); |
| 528 |
return el( 'div', blockProps, inspector( props, block ), preview ); |
| 529 |
}, |
| 530 |
// Dynamic block — output comes from the server render_callback. |
| 531 |
save: function () { |
| 532 |
return null; |
| 533 |
}, |
| 534 |
} ); |
| 535 |
} ); |
| 536 |
} )( window.wp, window.MLSImportBlock ); |
| 537 |
|