PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / admin / js / mlsimport-standalone-block.js

mlsimport-standalone-block.js in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.1, at admin/js/mlsimport-standalone-block.js

537 lines 21.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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