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 / includes / standalone / class-mlsimport-standalone-block.php

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

557 lines 21.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Standalone (theme_id 990) Gutenberg block (M7).
4 *
5 * A dynamic, server-rendered block: its render_callback maps the block
6 * attributes through the same atts->args + render_grid path as the shortcode,
7 * so block / shortcode / widget all emit identical output. (The editor-side
8 * inspector UI is a separate JS build; this is the render contract.)
9 *
10 * @package Mlsimport
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 require_once __DIR__ . '/class-mlsimport-standalone-render.php';
18 require_once __DIR__ . '/class-mlsimport-standalone-shortcodes.php';
19 require_once __DIR__ . '/class-mlsimport-page-block-search-fields.php';
20 require_once __DIR__ . '/page-block-registry.php';
21
22 /**
23 * Registers and renders the mlsimport/listings and mlsimport/half-map blocks.
24 *
25 * Both share the rich listings inspector (filter presets + per-field toggles +
26 * "properties to display"). Half Map is owned here — not by the generic page-block
27 * adapter (Mlsimport_Page_Block_Blocks, which skips it) — so it reuses that
28 * inspector instead of one raw text input per filter key. Its markup still comes
29 * from the page-block dispatcher, so block / shortcode / Elementor stay identical.
30 */
31 class Mlsimport_Standalone_Block {
32
33 const NAME = 'mlsimport/listings';
34
35 /** The Half Map block: the listings inspector + a Layout panel (map side, height). */
36 const HALF_MAP_NAME = 'mlsimport/half-map';
37
38 /** The Search Results block: a trimmed listings inspector (per-page, show-bar,
39 * fields-per-row, per-field toggles). Owned here — not the generic page-block
40 * adapter — so it reuses those toggles instead of a raw search_fields text input. */
41 const RESULTS_NAME = 'mlsimport/property-list-filters';
42
43 /** The Content Slider block: a Slider panel (how many + explicit IDs) + the same
44 * rich "Initial filter" panel as the Half Map (taxonomy dropdowns, price, beds/baths,
45 * order). Owned here so it reuses that panel instead of one raw text input per key. */
46 const SLIDER_NAME = 'mlsimport/content-slider';
47
48 /** The Property List block: the rich listings inspector (Settings + Initial filter +
49 * per-field toggles) minus the map Layout panel. Owned here — not the generic page-block
50 * adapter — so it reuses those toggles instead of raw text / comma-list inputs. */
51 const ITEM_LIST_NAME = 'mlsimport/item-list';
52
53 /** The Map with Listings block: a Settings panel (how many + explicit IDs) + the same
54 * rich Initial filter panel the Half Map and Property List expose. Owned here — not the
55 * generic page-block adapter — so it reuses those dropdowns instead of raw text inputs. */
56 const MAP_NAME = 'mlsimport/map-listings';
57
58 /** Editor script handle (registered at init, localized on editor assets). */
59 const EDITOR_HANDLE = 'mlsimport-standalone-block';
60
61 /**
62 * Register the dynamic blocks (idempotent).
63 *
64 * @return void
65 */
66 public static function register(): void {
67 if ( ! function_exists( 'register_block_type' ) ) {
68 return;
69 }
70
71 $editor_script = self::register_editor_script();
72
73 if ( ! WP_Block_Type_Registry::get_instance()->is_registered( self::NAME ) ) {
74 register_block_type(
75 self::NAME,
76 array(
77 'render_callback' => array( __CLASS__, 'render' ),
78 'attributes' => self::attributes(),
79 'editor_script' => $editor_script,
80 )
81 );
82 }
83
84 if ( ! WP_Block_Type_Registry::get_instance()->is_registered( self::HALF_MAP_NAME ) ) {
85 register_block_type(
86 self::HALF_MAP_NAME,
87 array(
88 'render_callback' => array( __CLASS__, 'render_half_map' ),
89 'attributes' => self::half_map_attributes(),
90 'editor_script' => $editor_script,
91 )
92 );
93 }
94
95 if ( ! WP_Block_Type_Registry::get_instance()->is_registered( self::RESULTS_NAME ) ) {
96 register_block_type(
97 self::RESULTS_NAME,
98 array(
99 'render_callback' => array( __CLASS__, 'render_results' ),
100 'attributes' => self::results_attributes(),
101 'editor_script' => $editor_script,
102 )
103 );
104 }
105
106 if ( ! WP_Block_Type_Registry::get_instance()->is_registered( self::SLIDER_NAME ) ) {
107 register_block_type(
108 self::SLIDER_NAME,
109 array(
110 'render_callback' => array( __CLASS__, 'render_slider' ),
111 'attributes' => self::slider_attributes(),
112 'editor_script' => $editor_script,
113 )
114 );
115 }
116
117 if ( ! WP_Block_Type_Registry::get_instance()->is_registered( self::ITEM_LIST_NAME ) ) {
118 register_block_type(
119 self::ITEM_LIST_NAME,
120 array(
121 'render_callback' => array( __CLASS__, 'render_item_list' ),
122 'attributes' => self::item_list_attributes(),
123 'editor_script' => $editor_script,
124 )
125 );
126 }
127
128 if ( ! WP_Block_Type_Registry::get_instance()->is_registered( self::MAP_NAME ) ) {
129 register_block_type(
130 self::MAP_NAME,
131 array(
132 'render_callback' => array( __CLASS__, 'render_map' ),
133 'attributes' => self::map_attributes(),
134 'editor_script' => $editor_script,
135 )
136 );
137 }
138
139 // The taxonomy-term options are only needed in the editor, so localize them
140 // there (and not on every front-end request, where get_terms would be dead work).
141 add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'localize_editor' ) );
142 }
143
144 /**
145 * Register the editor script that JS-registers this dynamic block so it
146 * appears in the inserter carrying the same brand icon as the Import Tasks
147 * menu. Returns the script handle (or '' if scripts are unavailable).
148 *
149 * @return string
150 */
151 private static function register_editor_script(): string {
152 if ( ! function_exists( 'wp_register_script' ) ) {
153 return '';
154 }
155 $url = defined( 'MLSIMPORT_PLUGIN_URL' ) ? MLSIMPORT_PLUGIN_URL : plugin_dir_url( dirname( __DIR__ ) . '/mlsimport.php' );
156 // Version by file mtime so a JS edit busts the editor cache even when the
157 // plugin version constant is unchanged (see Mlsimport_Page_Block_Blocks).
158 $path = defined( 'MLSIMPORT_PLUGIN_PATH' ) ? MLSIMPORT_PLUGIN_PATH . 'admin/js/mlsimport-standalone-block.js' : '';
159 $ver = ( $path && file_exists( $path ) ) ? (string) filemtime( $path ) : ( defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '1' );
160
161 wp_register_script(
162 self::EDITOR_HANDLE,
163 $url . 'admin/js/mlsimport-standalone-block.js',
164 array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-server-side-render' ),
165 $ver,
166 true
167 );
168 return self::EDITOR_HANDLE;
169 }
170
171 /**
172 * Attach the editor config to the block script (editor context only). Carries the
173 * block name, brand icon, the search-field toggle catalog and — for the initial
174 * filter inspector — the term options of every standalone taxonomy.
175 *
176 * @return void
177 */
178 public static function localize_editor(): void {
179 if ( ! function_exists( 'wp_localize_script' ) ) {
180 return;
181 }
182 $url = defined( 'MLSIMPORT_PLUGIN_URL' ) ? MLSIMPORT_PLUGIN_URL : plugin_dir_url( dirname( __DIR__ ) . '/mlsimport.php' );
183
184 // Re-skin MLSImport block inspector controls to the shared "2025" admin design
185 // system (matches the settings page / field_options tab). Scoped by the
186 // .mlsimport-block-inspector class the PanelBody wrapper adds, so it only
187 // touches MLSImport panels. mtime-versioned so CSS edits bust the editor cache.
188 if ( function_exists( 'wp_enqueue_style' ) ) {
189 $css_path = defined( 'MLSIMPORT_PLUGIN_PATH' ) ? MLSIMPORT_PLUGIN_PATH . 'admin/css/mlsimport-block-editor.css' : '';
190 $css_ver = ( $css_path && file_exists( $css_path ) ) ? (string) filemtime( $css_path ) : ( defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : '1' );
191 wp_enqueue_style(
192 'mlsimport-block-editor',
193 $url . 'admin/css/mlsimport-block-editor.css',
194 array( 'wp-components' ),
195 $css_ver
196 );
197 }
198
199 wp_localize_script(
200 self::EDITOR_HANDLE,
201 'MLSImportBlock',
202 array(
203 'name' => self::NAME,
204 'iconUrl' => $url . 'img/mlsimport_menu.png',
205 'searchFields' => self::search_fields(),
206 'taxonomies' => self::taxonomy_options(),
207 'blocks' => array(
208 array(
209 'name' => self::NAME,
210 'title' => __( 'MLS Listings', 'mlsimport' ),
211 'description' => __( 'Display imported MLS listings in a filterable grid.', 'mlsimport' ),
212 'category' => 'widgets',
213 'layout' => false,
214 'kind' => 'listings',
215 ),
216 array(
217 'name' => self::HALF_MAP_NAME,
218 'title' => __( 'Half Map', 'mlsimport' ),
219 'description' => __( 'Filterable MLS listings beside a live, viewport-clustered map.', 'mlsimport' ),
220 'category' => 'mlsimport-real-estate',
221 'layout' => true,
222 'kind' => 'half-map',
223 ),
224 array(
225 'name' => self::RESULTS_NAME,
226 'title' => __( 'Search Results', 'mlsimport' ),
227 'description' => __( 'MLS search results seeded from the search form, with an optional refine bar.', 'mlsimport' ),
228 'category' => 'mlsimport-real-estate',
229 'layout' => false,
230 'kind' => 'results',
231 ),
232 array(
233 'name' => self::SLIDER_NAME,
234 'title' => __( 'Property Slider', 'mlsimport' ),
235 'description' => __( 'Selected MLS listings as a swipeable carousel, with an initial filter.', 'mlsimport' ),
236 'category' => 'mlsimport-real-estate',
237 'layout' => false,
238 'kind' => 'slider',
239 ),
240 array(
241 'name' => self::ITEM_LIST_NAME,
242 'title' => __( 'Property List', 'mlsimport' ),
243 'description' => __( 'A filterable MLS listings grid with a search bar and pagination.', 'mlsimport' ),
244 'category' => 'mlsimport-real-estate',
245 'layout' => false,
246 'kind' => 'item-list',
247 ),
248 array(
249 'name' => self::MAP_NAME,
250 'title' => __( 'Map with Listings', 'mlsimport' ),
251 'description' => __( 'A map of MLS listings matching an initial filter (or an explicit set).', 'mlsimport' ),
252 'category' => 'mlsimport-real-estate',
253 'layout' => false,
254 'kind' => 'map',
255 ),
256 ),
257 )
258 );
259 }
260
261 /**
262 * The taxonomy filter controls for the initial-filter inspector: one entry per
263 * standalone taxonomy field, each carrying its attribute key, label and term
264 * options. Option values follow the field's own convention — term name for the
265 * fast-column taxonomies, slug for the term-join ones — so a chosen value drops
266 * straight into the matching block attribute the render path already understands.
267 *
268 * Public because the Elementor Property List widget builds the same initial-filter
269 * pickers from this one list, so the two builders can never offer different terms.
270 *
271 * @return array<int,array{key:string,label:string,options:array<int,array{label:string,value:string}>}>
272 */
273 public static function taxonomy_options(): array {
274 if ( ! function_exists( 'get_terms' ) ) {
275 return array();
276 }
277 $out = array();
278 // One control per catalog field that is a real, registered taxonomy field.
279 foreach ( Mlsimport_Page_Block_Search_Fields::catalog() as $key ) {
280 $def = Mlsimport_Page_Block_Search_Fields::definition( $key );
281 // Skip non-taxonomy fields and any taxonomy that isn't registered.
282 if ( null === $def || 'taxonomy' !== $def['group'] || ! taxonomy_exists( $def['tax'] ) ) {
283 continue;
284 }
285 // Pull every term (including empty ones) so the editor lists all options.
286 $terms = get_terms(
287 array(
288 'taxonomy' => $def['tax'],
289 'hide_empty' => false,
290 )
291 );
292 if ( is_wp_error( $terms ) || empty( $terms ) ) {
293 continue;
294 }
295 // Build { label, value } options; the value follows the field's own
296 // convention (slug for term-join fields, term name for fast-column ones).
297 $options = array();
298 foreach ( $terms as $term ) {
299 $value = 'slug' === $def['value'] ? (string) $term->slug : (string) $term->name;
300 $options[] = array( 'label' => (string) $term->name, 'value' => $value );
301 }
302 $out[] = array(
303 'key' => $key,
304 'label' => (string) $def['label'],
305 'options' => $options,
306 );
307 }
308 return $out;
309 }
310
311 /**
312 * Map block attributes to filter args and render the grid (render_callback). The
313 * search_fields attribute is display config (the per-field on/off toggles), not a
314 * filter key, so it is injected after atts_to_args (which keeps only filter keys).
315 *
316 * @param array $attributes Block attributes.
317 * @return string
318 */
319 public static function render( $attributes ): string {
320 $attributes = (array) $attributes;
321 $args = Mlsimport_Standalone_Shortcodes::atts_to_args( $attributes );
322 if ( isset( $attributes['search_fields'] ) && '' !== $attributes['search_fields'] ) {
323 $args['search_fields'] = (string) $attributes['search_fields'];
324 }
325 // fields_per_row is search-form display config (columns per row), not a filter
326 // key, so it is injected after atts_to_args (which keeps only filter keys).
327 if ( isset( $attributes['fields_per_row'] ) && '' !== $attributes['fields_per_row'] ) {
328 $args['fields_per_row'] = (string) $attributes['fields_per_row'];
329 }
330 return Mlsimport_Standalone_Render::render_grid( $args );
331 }
332
333 /**
334 * Render the Half Map block through the page-block dispatcher, so its markup,
335 * assets and hooks match the shortcode and Elementor widget exactly. The block
336 * attributes (filter presets + search_fields + map_side + height) map 1:1 to the
337 * dispatcher args.
338 *
339 * @param array $attributes Block attributes.
340 * @return string
341 */
342 public static function render_half_map( $attributes ): string {
343 return mlsimport_render_page_block( 'half_map', (array) $attributes );
344 }
345
346 /**
347 * Render the Search Results block through the page-block dispatcher, so its
348 * markup, assets and hooks match the shortcode and Elementor widget exactly. The
349 * dispatcher's mlsimport_page_block_results reads the GET search; these attributes
350 * are only the refine-bar display config.
351 *
352 * @param array $attributes Block attributes.
353 * @return string
354 */
355 public static function render_results( $attributes ): string {
356 return mlsimport_render_page_block( 'property_list_filters', (array) $attributes );
357 }
358
359 /**
360 * Render the Content Slider block through the page-block dispatcher, so its markup,
361 * assets and hooks match the shortcode and Elementor widget exactly. The block
362 * attributes (initial-filter presets + how-many + explicit IDs) map 1:1 to the
363 * dispatcher args; the slider render maps them through the same atts->args path.
364 *
365 * @param array $attributes Block attributes.
366 * @return string
367 */
368 public static function render_slider( $attributes ): string {
369 return mlsimport_render_page_block( 'content_slider', (array) $attributes );
370 }
371
372 /**
373 * Render the Property List block through the page-block dispatcher, so its markup,
374 * assets and hooks match the shortcode and Elementor widget exactly. The block
375 * attributes (initial-filter presets + per-page + show-bar + fields-per-row +
376 * search_fields) map 1:1 to the dispatcher args.
377 *
378 * @param array $attributes Block attributes.
379 * @return string
380 */
381 public static function render_item_list( $attributes ): string {
382 return mlsimport_render_page_block( 'item_list', (array) $attributes );
383 }
384
385 /**
386 * Render the Map with Listings block through the page-block dispatcher, so its markup,
387 * assets and hooks match the shortcode and Elementor widget exactly. The block
388 * attributes (initial-filter presets + how-many + explicit IDs) map 1:1 to the
389 * dispatcher args.
390 *
391 * @param array $attributes Block attributes.
392 * @return string
393 */
394 public static function render_map( $attributes ): string {
395 return mlsimport_render_page_block( 'map_listings', (array) $attributes );
396 }
397
398 /**
399 * Content Slider attribute schema — every filter key as an optional string (the
400 * "Initial filter" panel) plus an explicit-IDs list. limit defaults to 6 (the
401 * "How many" control), matching the manifest's slider count default.
402 *
403 * @return array
404 */
405 private static function slider_attributes(): array {
406 // One optional string attribute per filter key; limit defaults to the slider count.
407 $attributes = array();
408 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
409 $attributes[ $key ] = array(
410 'type' => 'string',
411 'default' => 'limit' === $key ? '6' : '',
412 );
413 }
414 $attributes['ids'] = array(
415 'type' => 'string',
416 'default' => '',
417 );
418 return $attributes;
419 }
420
421 /**
422 * Property List attribute schema — every filter key as an optional string (so the
423 * Initial filter panel presets and any shortcode-set filter validate) plus the
424 * search-bar config: per-page (count), show-bar, fields-per-row and the per-field
425 * toggle state (search_fields). Per-page is `count`, so the raw `limit` key is dropped.
426 *
427 * @return array
428 */
429 private static function item_list_attributes(): array {
430 $attributes = array();
431 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
432 if ( 'limit' === $key ) {
433 continue;
434 }
435 $attributes[ $key ] = array( 'type' => 'string', 'default' => '' );
436 }
437 $attributes['count'] = array( 'type' => 'string', 'default' => '12' );
438 $attributes['show_filter_bar'] = array( 'type' => 'string', 'default' => '1' );
439 $attributes['fields_per_row'] = array( 'type' => 'string', 'default' => '4' );
440 $attributes['search_fields'] = array( 'type' => 'string', 'default' => '' );
441 return $attributes;
442 }
443
444 /**
445 * Map with Listings attribute schema — every filter key as an optional string (so the
446 * Initial filter panel presets and any shortcode-set filter validate). count and ids
447 * stay registered so a shortcode-set value (or a pre-existing saved block) still
448 * validates, but both default empty: the editor exposes no How-many / IDs / Order
449 * control, because a map plots every match at its coordinates. A non-empty count would
450 * inject a pointless limit into the map's data-filters (bounds() ignores it and
451 * map_payload() strips it anyway), so it defaults to '' — the map shows them all.
452 *
453 * @return array
454 */
455 private static function map_attributes(): array {
456 $attributes = array();
457 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
458 if ( 'limit' === $key || 'page' === $key ) {
459 continue;
460 }
461 $attributes[ $key ] = array( 'type' => 'string', 'default' => '' );
462 }
463 $attributes['count'] = array( 'type' => 'string', 'default' => '' );
464 $attributes['ids'] = array( 'type' => 'string', 'default' => '' );
465 return $attributes;
466 }
467
468 /**
469 * Search Results attribute schema — only the refine-bar display config (the
470 * filters themselves come from the request, not saved attributes). Mirrors the
471 * manifest defaults so an unconfigured block shows the bar, 4-up, all fields.
472 *
473 * @return array
474 */
475 private static function results_attributes(): array {
476 return array(
477 'count' => array( 'type' => 'string', 'default' => '12' ),
478 'show_filter_bar' => array( 'type' => 'string', 'default' => '1' ),
479 'fields_per_row' => array( 'type' => 'string', 'default' => '4' ),
480 'search_fields' => array( 'type' => 'string', 'default' => '' ),
481 );
482 }
483
484 /**
485 * Block attribute schema — every filter key as an optional string, plus
486 * search_fields (the per-field on/off toggle state: a comma list of the search
487 * fields to show; empty = show all). limit defaults to 12 (the "Properties to
488 * display" control), matching the render-grid per-page default.
489 *
490 * @return array
491 */
492 private static function attributes(): array {
493 $attributes = array();
494 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
495 $attributes[ $key ] = array(
496 'type' => 'string',
497 'default' => 'limit' === $key ? '12' : '',
498 );
499 }
500 $attributes['search_fields'] = array(
501 'type' => 'string',
502 'default' => '',
503 );
504 // Search-bar columns (the "Search fields per row" control); 4-up by default for
505 // the full-width listings bar. Half Map overrides this to 3 (its narrow pane).
506 $attributes['fields_per_row'] = array(
507 'type' => 'string',
508 'default' => '4',
509 );
510 return $attributes;
511 }
512
513 /**
514 * Half Map attribute schema — the listings schema plus the two Layout-panel
515 * controls (map_side, height). Defaults mirror the manifest so an unconfigured
516 * block renders a right-side, full-height map.
517 *
518 * @return array
519 */
520 private static function half_map_attributes(): array {
521 $attributes = self::attributes();
522 $attributes['fields_per_row'] = array(
523 'type' => 'string',
524 'default' => '3',
525 );
526 $attributes['map_side'] = array(
527 'type' => 'string',
528 'default' => 'right',
529 );
530 $attributes['height'] = array(
531 'type' => 'string',
532 'default' => '100vh',
533 );
534 return $attributes;
535 }
536
537 /**
538 * The search form's toggleable fields in render order: the keyword box, every
539 * searchable catalog field, then the sort control — each as { key, label } so the
540 * editor can build one on/off toggle per field. Mirrors search-form.php.
541 *
542 * @return array<int,array{key:string,label:string}>
543 */
544 private static function search_fields(): array {
545 $labels = array_merge(
546 array( 'keywords' => __( 'Keywords', 'mlsimport' ) ),
547 Mlsimport_Page_Block_Search_Fields::labels(),
548 array( 'sort' => __( 'Sort by', 'mlsimport' ) )
549 );
550 $fields = array();
551 foreach ( $labels as $key => $label ) {
552 $fields[] = array( 'key' => $key, 'label' => $label );
553 }
554 return $fields;
555 }
556 }
557