PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.2 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 All 37 releases
mlsimport / includes / standalone / class-mlsimport-standalone-block.php

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

569 lines 21.5 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 // Generate the browser-side Property List schema from the same
208 // canonical filter list as item_list_attributes(). Otherwise a
209 // PHP-supported preset can render when injected in code but be
210 // discarded when Gutenberg saves it (issue #313).
211 'itemListFilterKeys' => array_values(
212 array_filter(
213 Mlsimport_Standalone_Shortcodes::filter_keys(),
214 static function ( string $key ): bool {
215 return 'limit' !== $key;
216 }
217 )
218 ),
219 'blocks' => array(
220 array(
221 'name' => self::NAME,
222 'title' => __( 'MLS Listings', 'mlsimport' ),
223 'description' => __( 'Display imported MLS listings in a filterable grid.', 'mlsimport' ),
224 'category' => 'widgets',
225 'layout' => false,
226 'kind' => 'listings',
227 ),
228 array(
229 'name' => self::HALF_MAP_NAME,
230 'title' => __( 'Half Map', 'mlsimport' ),
231 'description' => __( 'Filterable MLS listings beside a live, viewport-clustered map.', 'mlsimport' ),
232 'category' => 'mlsimport-real-estate',
233 'layout' => true,
234 'kind' => 'half-map',
235 ),
236 array(
237 'name' => self::RESULTS_NAME,
238 'title' => __( 'Search Results', 'mlsimport' ),
239 'description' => __( 'MLS search results seeded from the search form, with an optional refine bar.', 'mlsimport' ),
240 'category' => 'mlsimport-real-estate',
241 'layout' => false,
242 'kind' => 'results',
243 ),
244 array(
245 'name' => self::SLIDER_NAME,
246 'title' => __( 'Property Slider', 'mlsimport' ),
247 'description' => __( 'Selected MLS listings as a swipeable carousel, with an initial filter.', 'mlsimport' ),
248 'category' => 'mlsimport-real-estate',
249 'layout' => false,
250 'kind' => 'slider',
251 ),
252 array(
253 'name' => self::ITEM_LIST_NAME,
254 'title' => __( 'Property List', 'mlsimport' ),
255 'description' => __( 'A filterable MLS listings grid with a search bar and pagination.', 'mlsimport' ),
256 'category' => 'mlsimport-real-estate',
257 'layout' => false,
258 'kind' => 'item-list',
259 ),
260 array(
261 'name' => self::MAP_NAME,
262 'title' => __( 'Map with Listings', 'mlsimport' ),
263 'description' => __( 'A map of MLS listings matching an initial filter (or an explicit set).', 'mlsimport' ),
264 'category' => 'mlsimport-real-estate',
265 'layout' => false,
266 'kind' => 'map',
267 ),
268 ),
269 )
270 );
271 }
272
273 /**
274 * The taxonomy filter controls for the initial-filter inspector: one entry per
275 * standalone taxonomy field, each carrying its attribute key, label and term
276 * options. Option values follow the field's own convention — term name for the
277 * fast-column taxonomies, slug for the term-join ones — so a chosen value drops
278 * straight into the matching block attribute the render path already understands.
279 *
280 * Public because the Elementor Property List widget builds the same initial-filter
281 * pickers from this one list, so the two builders can never offer different terms.
282 *
283 * @return array<int,array{key:string,label:string,options:array<int,array{label:string,value:string}>}>
284 */
285 public static function taxonomy_options(): array {
286 if ( ! function_exists( 'get_terms' ) ) {
287 return array();
288 }
289 $out = array();
290 // One control per catalog field that is a real, registered taxonomy field.
291 foreach ( Mlsimport_Page_Block_Search_Fields::catalog() as $key ) {
292 $def = Mlsimport_Page_Block_Search_Fields::definition( $key );
293 // Skip non-taxonomy fields and any taxonomy that isn't registered.
294 if ( null === $def || 'taxonomy' !== $def['group'] || ! taxonomy_exists( $def['tax'] ) ) {
295 continue;
296 }
297 // Pull every term (including empty ones) so the editor lists all options.
298 $terms = get_terms(
299 array(
300 'taxonomy' => $def['tax'],
301 'hide_empty' => false,
302 )
303 );
304 if ( is_wp_error( $terms ) || empty( $terms ) ) {
305 continue;
306 }
307 // Build { label, value } options; the value follows the field's own
308 // convention (slug for term-join fields, term name for fast-column ones).
309 $options = array();
310 foreach ( $terms as $term ) {
311 $value = 'slug' === $def['value'] ? (string) $term->slug : (string) $term->name;
312 $options[] = array( 'label' => (string) $term->name, 'value' => $value );
313 }
314 $out[] = array(
315 'key' => $key,
316 'label' => (string) $def['label'],
317 'options' => $options,
318 );
319 }
320 return $out;
321 }
322
323 /**
324 * Map block attributes to filter args and render the grid (render_callback). The
325 * search_fields attribute is display config (the per-field on/off toggles), not a
326 * filter key, so it is injected after atts_to_args (which keeps only filter keys).
327 *
328 * @param array $attributes Block attributes.
329 * @return string
330 */
331 public static function render( $attributes ): string {
332 $attributes = (array) $attributes;
333 $args = Mlsimport_Standalone_Shortcodes::atts_to_args( $attributes );
334 if ( isset( $attributes['search_fields'] ) && '' !== $attributes['search_fields'] ) {
335 $args['search_fields'] = (string) $attributes['search_fields'];
336 }
337 // fields_per_row is search-form display config (columns per row), not a filter
338 // key, so it is injected after atts_to_args (which keeps only filter keys).
339 if ( isset( $attributes['fields_per_row'] ) && '' !== $attributes['fields_per_row'] ) {
340 $args['fields_per_row'] = (string) $attributes['fields_per_row'];
341 }
342 return Mlsimport_Standalone_Render::render_grid( $args );
343 }
344
345 /**
346 * Render the Half Map block through the page-block dispatcher, so its markup,
347 * assets and hooks match the shortcode and Elementor widget exactly. The block
348 * attributes (filter presets + search_fields + map_side + height) map 1:1 to the
349 * dispatcher args.
350 *
351 * @param array $attributes Block attributes.
352 * @return string
353 */
354 public static function render_half_map( $attributes ): string {
355 return mlsimport_render_page_block( 'half_map', (array) $attributes );
356 }
357
358 /**
359 * Render the Search Results block through the page-block dispatcher, so its
360 * markup, assets and hooks match the shortcode and Elementor widget exactly. The
361 * dispatcher's mlsimport_page_block_results reads the GET search; these attributes
362 * are only the refine-bar display config.
363 *
364 * @param array $attributes Block attributes.
365 * @return string
366 */
367 public static function render_results( $attributes ): string {
368 return mlsimport_render_page_block( 'property_list_filters', (array) $attributes );
369 }
370
371 /**
372 * Render the Content Slider block through the page-block dispatcher, so its markup,
373 * assets and hooks match the shortcode and Elementor widget exactly. The block
374 * attributes (initial-filter presets + how-many + explicit IDs) map 1:1 to the
375 * dispatcher args; the slider render maps them through the same atts->args path.
376 *
377 * @param array $attributes Block attributes.
378 * @return string
379 */
380 public static function render_slider( $attributes ): string {
381 return mlsimport_render_page_block( 'content_slider', (array) $attributes );
382 }
383
384 /**
385 * Render the Property List block through the page-block dispatcher, so its markup,
386 * assets and hooks match the shortcode and Elementor widget exactly. The block
387 * attributes (initial-filter presets + per-page + show-bar + fields-per-row +
388 * search_fields) map 1:1 to the dispatcher args.
389 *
390 * @param array $attributes Block attributes.
391 * @return string
392 */
393 public static function render_item_list( $attributes ): string {
394 return mlsimport_render_page_block( 'item_list', (array) $attributes );
395 }
396
397 /**
398 * Render the Map with Listings block through the page-block dispatcher, so its markup,
399 * assets and hooks match the shortcode and Elementor widget exactly. The block
400 * attributes (initial-filter presets + how-many + explicit IDs) map 1:1 to the
401 * dispatcher args.
402 *
403 * @param array $attributes Block attributes.
404 * @return string
405 */
406 public static function render_map( $attributes ): string {
407 return mlsimport_render_page_block( 'map_listings', (array) $attributes );
408 }
409
410 /**
411 * Content Slider attribute schema — every filter key as an optional string (the
412 * "Initial filter" panel) plus an explicit-IDs list. limit defaults to 6 (the
413 * "How many" control), matching the manifest's slider count default.
414 *
415 * @return array
416 */
417 private static function slider_attributes(): array {
418 // One optional string attribute per filter key; limit defaults to the slider count.
419 $attributes = array();
420 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
421 $attributes[ $key ] = array(
422 'type' => 'string',
423 'default' => 'limit' === $key ? '6' : '',
424 );
425 }
426 $attributes['ids'] = array(
427 'type' => 'string',
428 'default' => '',
429 );
430 return $attributes;
431 }
432
433 /**
434 * Property List attribute schema — every filter key as an optional string (so the
435 * Initial filter panel presets and any shortcode-set filter validate) plus the
436 * search-bar config: per-page (count), show-bar, fields-per-row and the per-field
437 * toggle state (search_fields). Per-page is `count`, so the raw `limit` key is dropped.
438 *
439 * @return array
440 */
441 private static function item_list_attributes(): array {
442 $attributes = array();
443 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
444 if ( 'limit' === $key ) {
445 continue;
446 }
447 $attributes[ $key ] = array( 'type' => 'string', 'default' => '' );
448 }
449 $attributes['count'] = array( 'type' => 'string', 'default' => '12' );
450 $attributes['show_filter_bar'] = array( 'type' => 'string', 'default' => '1' );
451 $attributes['fields_per_row'] = array( 'type' => 'string', 'default' => '4' );
452 $attributes['search_fields'] = array( 'type' => 'string', 'default' => '' );
453 return $attributes;
454 }
455
456 /**
457 * Map with Listings attribute schema — every filter key as an optional string (so the
458 * Initial filter panel presets and any shortcode-set filter validate). count and ids
459 * stay registered so a shortcode-set value (or a pre-existing saved block) still
460 * validates, but both default empty: the editor exposes no How-many / IDs / Order
461 * control, because a map plots every match at its coordinates. A non-empty count would
462 * inject a pointless limit into the map's data-filters (bounds() ignores it and
463 * map_payload() strips it anyway), so it defaults to '' — the map shows them all.
464 *
465 * @return array
466 */
467 private static function map_attributes(): array {
468 $attributes = array();
469 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
470 if ( 'limit' === $key || 'page' === $key ) {
471 continue;
472 }
473 $attributes[ $key ] = array( 'type' => 'string', 'default' => '' );
474 }
475 $attributes['count'] = array( 'type' => 'string', 'default' => '' );
476 $attributes['ids'] = array( 'type' => 'string', 'default' => '' );
477 return $attributes;
478 }
479
480 /**
481 * Search Results attribute schema — only the refine-bar display config (the
482 * filters themselves come from the request, not saved attributes). Mirrors the
483 * manifest defaults so an unconfigured block shows the bar, 4-up, all fields.
484 *
485 * @return array
486 */
487 private static function results_attributes(): array {
488 return array(
489 'count' => array( 'type' => 'string', 'default' => '12' ),
490 'show_filter_bar' => array( 'type' => 'string', 'default' => '1' ),
491 'fields_per_row' => array( 'type' => 'string', 'default' => '4' ),
492 'search_fields' => array( 'type' => 'string', 'default' => '' ),
493 );
494 }
495
496 /**
497 * Block attribute schema — every filter key as an optional string, plus
498 * search_fields (the per-field on/off toggle state: a comma list of the search
499 * fields to show; empty = show all). limit defaults to 12 (the "Properties to
500 * display" control), matching the render-grid per-page default.
501 *
502 * @return array
503 */
504 private static function attributes(): array {
505 $attributes = array();
506 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
507 $attributes[ $key ] = array(
508 'type' => 'string',
509 'default' => 'limit' === $key ? '12' : '',
510 );
511 }
512 $attributes['search_fields'] = array(
513 'type' => 'string',
514 'default' => '',
515 );
516 // Search-bar columns (the "Search fields per row" control); 4-up by default for
517 // the full-width listings bar. Half Map overrides this to 3 (its narrow pane).
518 $attributes['fields_per_row'] = array(
519 'type' => 'string',
520 'default' => '4',
521 );
522 return $attributes;
523 }
524
525 /**
526 * Half Map attribute schema — the listings schema plus the two Layout-panel
527 * controls (map_side, height). Defaults mirror the manifest so an unconfigured
528 * block renders a right-side, full-height map.
529 *
530 * @return array
531 */
532 private static function half_map_attributes(): array {
533 $attributes = self::attributes();
534 $attributes['fields_per_row'] = array(
535 'type' => 'string',
536 'default' => '3',
537 );
538 $attributes['map_side'] = array(
539 'type' => 'string',
540 'default' => 'right',
541 );
542 $attributes['height'] = array(
543 'type' => 'string',
544 'default' => '100vh',
545 );
546 return $attributes;
547 }
548
549 /**
550 * The search form's toggleable fields in render order: the keyword box, every
551 * searchable catalog field, then the sort control — each as { key, label } so the
552 * editor can build one on/off toggle per field. Mirrors search-form.php.
553 *
554 * @return array<int,array{key:string,label:string}>
555 */
556 private static function search_fields(): array {
557 $labels = array_merge(
558 array( 'keywords' => __( 'Keywords', 'mlsimport' ) ),
559 Mlsimport_Page_Block_Search_Fields::labels(),
560 array( 'sort' => __( 'Sort by', 'mlsimport' ) )
561 );
562 $fields = array();
563 foreach ( $labels as $key => $label ) {
564 $fields[] = array( 'key' => $key, 'label' => $label );
565 }
566 return $fields;
567 }
568 }
569