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-page-block-blocks.js

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

431 lines 17.3 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 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