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-field-selector.js

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

563 lines 24.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Field Configuration browser controller.
3 *
4 * One controller owns the list view and one ordered mutation queue. Every save
5 * posts four fixed variables (action, nonce, revision, JSON command), applies
6 * the authoritative server result, and advances the revision before the next
7 * command starts. Network interruptions receive three automatic retries. Any
8 * persistent or rejected save pauses later work and leaves the administrator's
9 * visible edits intact until Retry or Reload is chosen.
10 *
11 * No state is published on window and no unload warning is registered.
12 */
13 (function ($) {
14 'use strict';
15
16 /**
17 * Start one isolated controller for every rendered configuration list.
18 *
19 * Settings and onboarding may render the same component on different pages.
20 * Each container receives private revision and queue state, with nothing
21 * shared through window globals.
22 */
23 $(function () {
24 $('.mlsimport-field-selector-container').each(function () {
25 createController($(this));
26 });
27 });
28
29 /**
30 * Create the UI state, event bindings, sortable behavior, and save queue.
31 *
32 * @param {jQuery} $container One rendered Field Configuration interface.
33 */
34 function createController($container) {
35 var state = {
36 revision: parseInt($container.attr('data-revision'), 10) || 0,
37 queue: [],
38 saving: false,
39 paused: false,
40 retryTimer: null
41 };
42 var $body = $container.find('#mlsimport-fields-table-body');
43 var $status = $container.find('.mlsimport-field-save-status');
44 var $search = $container.find('#mlsimport-field-search');
45 var $filter = $container.find('#mlsimport-import-filter');
46 var $sort = $container.find('#mlsimport-field-sort');
47
48 $filter.val($container.attr('data-initial-filter') || 'all');
49
50 /**
51 * Return all current-metadata rows, including locally hidden rows.
52 *
53 * Search and filtering toggle visibility without removing elements, so
54 * this collection remains the complete active ordering boundary.
55 *
56 * @return {jQuery} Active field row collection.
57 */
58 function rows() {
59 return $body.children('.mlsimport-field-row');
60 }
61
62 /**
63 * Find one row without interpolating an MLS key into a CSS selector.
64 *
65 * Comparing data values handles punctuation safely and avoids escaping
66 * provider-specific field names before selector construction.
67 *
68 * @param {string} fieldKey RESO field identifier.
69 * @return {jQuery} Matching row, or an empty collection.
70 */
71 function rowFor(fieldKey) {
72 return rows().filter(function () {
73 return String($(this).attr('data-field-key')) === String(fieldKey);
74 }).first();
75 }
76
77 /**
78 * Recalculate active and selected counts from the current controls.
79 *
80 * Controls may contain authoritative saved values or newer queued edits;
81 * counts intentionally describe what the administrator currently sees.
82 */
83 function updateStats() {
84 var total = rows().length;
85 var selected = rows().find('.mlsimport-import-checkbox:checked').length;
86 $container.find('.mlsimport-total-count').text(total + ' fields total');
87 $container.find('.mlsimport-selected-count').text(selected + ' marked for import');
88 }
89
90 /**
91 * Apply search/filter/sort locally and enable ordering only in the full
92 * custom-order view, where every active row and position is visible.
93 */
94 function applyView() {
95 var search = $.trim($search.val() || '').toLowerCase();
96 var filter = $filter.val() || 'all';
97 var sort = $sort.val() || 'custom_order';
98 var $rows = rows();
99
100 $rows.each(function () {
101 var $row = $(this);
102 // Match the MLS field key, never the whole row text: every row
103 // contains a taxonomy dropdown whose option labels (e.g.
104 // "Property City") would otherwise match terms like "city".
105 var fieldKey = String($row.attr('data-field-key') || '').toLowerCase();
106 var matchesSearch = !search || fieldKey.indexOf(search) !== -1;
107 var checked = $row.find('.mlsimport-import-checkbox').prop('checked');
108 var matchesFilter = filter === 'all' || (filter === 'selected' && checked) || (filter === 'not_selected' && !checked);
109 $row.toggle(matchesSearch && matchesFilter);
110 });
111
112 var sorted = $rows.get().sort(function (left, right) {
113 var $left = $(left);
114 var $right = $(right);
115 var leftValue;
116 var rightValue;
117
118 if (sort === 'custom_order') {
119 return (parseInt($left.attr('data-field-order'), 10) || 0) - (parseInt($right.attr('data-field-order'), 10) || 0);
120 }
121 if (sort.indexOf('label_') === 0) {
122 leftValue = $left.find('.mlsimport-label-input').val() || '';
123 rightValue = $right.find('.mlsimport-label-input').val() || '';
124 } else if (sort.indexOf('postmeta_') === 0) {
125 leftValue = $left.find('.mlsimport-postmeta-input').val() || '';
126 rightValue = $right.find('.mlsimport-postmeta-input').val() || '';
127 } else if (sort.indexOf('category_') === 0) {
128 leftValue = $left.find('.mlsimport-taxonomy-select').val() || '';
129 rightValue = $right.find('.mlsimport-taxonomy-select').val() || '';
130 } else {
131 leftValue = $left.attr('data-field-key') || '';
132 rightValue = $right.attr('data-field-key') || '';
133 }
134
135 leftValue = String(leftValue).toLowerCase();
136 rightValue = String(rightValue).toLowerCase();
137 return leftValue.localeCompare(rightValue) * (sort.slice(-5) === '_desc' ? -1 : 1);
138 });
139 $.each(sorted, function (_, row) {
140 $body.append(row);
141 });
142
143 // Reordering (buttons and drag-and-drop) stays available in every
144 // view: moves are relative commands anchored to the nearest visible
145 // row, which is well-defined within the full stored order no matter
146 // how the list is currently searched, filtered, or sorted.
147 }
148
149 /**
150 * Render the paused/error state and the correct recovery action.
151 *
152 * Routine saving/saved feedback is shown per-control by the inline save
153 * indicators; this status line only carries failures. Stale revisions
154 * require reload because another writer owns newer state. Other
155 * failures keep the head command queued and expose Retry.
156 *
157 * @param {string} kind Visual state: error.
158 * @param {string} message Administrator-facing status text.
159 * @param {string} errorCode Stable module failure code.
160 */
161 function showStatus(kind, message, errorCode) {
162 $status.removeClass('is-saving is-saved is-error').empty().addClass('is-' + kind).text(message || '');
163 if (kind !== 'error') {
164 return;
165 }
166
167 var $button = $('<button type="button" class="button mlsimport-field-save-recovery"></button>');
168 if (errorCode === 'stale_revision') {
169 $button.text(mlsimport_params.messages.reload).on('click', function () {
170 window.location.reload();
171 });
172 } else {
173 $button.text(mlsimport_params.messages.retry).on('click', function () {
174 state.paused = false;
175 processQueue();
176 });
177 }
178 $status.append(' ').append($button);
179 }
180
181 /**
182 * Map one queued command to the row cells that carry its save
183 * indicator: the edited column for set/bulk commands and the
184 * field-name cell for move commands.
185 *
186 * @param {Object} command Compact set, bulk, or move command.
187 * @return {jQuery} Cells to annotate.
188 */
189 function indicatorCells(command) {
190 var cellByProperty = {
191 'import': '.mlsimport-field-import',
192 admin: '.mlsimport-field-admin',
193 label: '.mlsimport-field-label',
194 postmeta: '.mlsimport-field-postmeta',
195 taxonomy: '.mlsimport-field-taxonomy'
196 };
197 var selector = command.type === 'move' ? '.mlsimport-field-name' : cellByProperty[command.property];
198 var fieldKeys = command.type === 'bulk' ? command.fields || [] : [command.field];
199 var $cells = $();
200 $.each(fieldKeys, function (_, fieldKey) {
201 $cells = $cells.add(rowFor(fieldKey).find(selector));
202 });
203 return $cells;
204 }
205
206 /**
207 * Show a spinning save indicator inside each given cell, replacing any
208 * indicator left over from an earlier save of the same control.
209 *
210 * @param {jQuery} $cells Cells returned by indicatorCells().
211 */
212 function addSaveIndicator($cells) {
213 $cells.each(function () {
214 $(this).find('.mlsimport-save-indicator').remove();
215 $(this).append('<span class="mlsimport-save-indicator is-spinning"></span>');
216 });
217 }
218
219 /**
220 * Swap each cell's spinner for a fading success tick or an error cross.
221 *
222 * @param {jQuery} $cells Annotated cells.
223 * @param {boolean} success Whether the save was accepted.
224 */
225 function resolveSaveIndicators($cells, success) {
226 var $indicators = $cells.find('.mlsimport-save-indicator');
227 $indicators.removeClass('is-spinning');
228 if (success) {
229 $indicators.addClass('is-success').html('✓');
230 window.setTimeout(function () {
231 $indicators.fadeOut(500, function () {
232 $(this).remove();
233 });
234 }, 1000);
235 return;
236 }
237 $indicators.addClass('is-error').html('✕');
238 }
239
240 /**
241 * Add one administrator intent to the tail of the ordered queue.
242 *
243 * A fresh network-attempt count belongs to each command. Processing can
244 * start immediately, but never overtakes a currently saving head item.
245 *
246 * @param {Object} command Compact set, bulk, or move command.
247 */
248 function enqueue(command) {
249 state.queue.push({ command: command, networkAttempts: 0 });
250 processQueue();
251 }
252
253 /**
254 * Send only the head command. Later changes wait for its authoritative
255 * result, ensuring each subsequent request uses the returned revision.
256 */
257 function processQueue() {
258 if (state.saving || state.paused || state.queue.length === 0) {
259 return;
260 }
261
262 state.saving = true;
263 $status.removeClass('is-saving is-saved is-error').empty();
264 var item = state.queue[0];
265 var $indicated = indicatorCells(item.command);
266 addSaveIndicator($indicated);
267 $.ajax({
268 url: mlsimport_params.ajax_url,
269 type: 'POST',
270 dataType: 'json',
271 timeout: 20000,
272 data: {
273 action: mlsimport_params.action,
274 security: mlsimport_params.nonce,
275 revision: state.revision,
276 command: JSON.stringify(item.command)
277 }
278 }).done(function (response) {
279 if (!response || !response.success || !response.data || !response.data.success) {
280 resolveSaveIndicators($indicated, false);
281 pauseQueue(response && response.data ? response.data : null, 'The server rejected this Field Configuration change.');
282 return;
283 }
284
285 state.queue.shift();
286 state.revision = parseInt(response.data.revision, 10) || state.revision;
287 $container.attr('data-revision', state.revision);
288 applyResult(response.data);
289 restoreQueuedView();
290 state.saving = false;
291 resolveSaveIndicators($indicated, true);
292 processQueue();
293 }).fail(function (xhr, textStatus) {
294 var isNetworkFailure = xhr.status === 0 || textStatus === 'timeout';
295 if (isNetworkFailure && item.networkAttempts < 3) {
296 item.networkAttempts += 1;
297 state.saving = false;
298 state.retryTimer = window.setTimeout(processQueue, item.networkAttempts * 500);
299 return;
300 }
301
302 var payload = xhr.responseJSON && xhr.responseJSON.data ? xhr.responseJSON.data : null;
303 resolveSaveIndicators($indicated, false);
304 pauseQueue(payload, isNetworkFailure ? 'The network did not recover. Your change is still unsaved.' : 'Field Configuration could not be saved.');
305 });
306 }
307
308 /**
309 * Pause later changes while preserving the current optimistic controls.
310 *
311 * The failed head remains in the queue. Only stale results advance the
312 * locally known revision, and their recovery action is Reload rather than
313 * retrying a command based on superseded state.
314 *
315 * @param {Object|null} payload Failed Field Configuration Result.
316 * @param {string} fallbackMessage Message used for malformed responses.
317 */
318 function pauseQueue(payload, fallbackMessage) {
319 var error = payload && payload.error ? payload.error : {};
320 state.saving = false;
321 state.paused = true;
322 if (payload && payload.revision !== undefined && error.code === 'stale_revision') {
323 state.revision = parseInt(payload.revision, 10) || state.revision;
324 }
325 showStatus('error', error.message || fallbackMessage, error.code || 'persistence_failed');
326 }
327
328 /**
329 * Apply cleaned fields and computed order returned by the PHP module.
330 *
331 * This replaces values from the command that just completed. A separate
332 * overlay immediately reapplies later queued intent so an earlier server
333 * response cannot make unsaved work disappear from the controls.
334 *
335 * @param {Object} result Successful authoritative result.
336 */
337 function applyResult(result) {
338 $.each(result.fields || {}, function (fieldKey, field) {
339 var $row = rowFor(fieldKey);
340 $row.find('.mlsimport-import-checkbox').prop('checked', Number(field.import) === 1);
341 $row.find('.mlsimport-admin-checkbox').prop('checked', Number(field.admin) === 1);
342 $row.find('.mlsimport-label-input').val(field.label || '');
343 $row.find('.mlsimport-postmeta-input').val(field.postmeta || '');
344 $row.find('.mlsimport-taxonomy-select').val(field.taxonomy || '');
345 });
346 $.each(result.order || [], function (_, fieldKey) {
347 $body.append(rowFor(fieldKey));
348 });
349 refreshPositions();
350 updateStats();
351 applyView();
352 }
353
354 /**
355 * Overlay one pending command on the authoritative DOM without saving.
356 *
357 * Set and bulk commands update their controls, including mutually
358 * exclusive mappings. Move commands recreate the pending relative order.
359 * This function never enqueues, sends, or changes the revision.
360 *
361 * @param {Object} command Pending compact command.
362 */
363 function applyPendingCommand(command) {
364 var $targets;
365 var $row;
366 var $anchor;
367
368 if (command.type === 'move') {
369 $row = rowFor(command.field);
370 $anchor = rowFor(command.anchor);
371 if ($row.length && $anchor.length && !$row.is($anchor)) {
372 command.position === 'before' ? $row.insertBefore($anchor) : $row.insertAfter($anchor);
373 }
374 return;
375 }
376
377 $targets = command.type === 'bulk' ? command.fields || [] : [command.field];
378 $.each($targets, function (_, fieldKey) {
379 $row = rowFor(fieldKey);
380 if (command.property === 'import') {
381 $row.find('.mlsimport-import-checkbox').prop('checked', Number(command.value) === 1);
382 } else if (command.property === 'admin') {
383 $row.find('.mlsimport-admin-checkbox').prop('checked', Number(command.value) === 1);
384 } else if (command.property === 'label') {
385 $row.find('.mlsimport-label-input').val(command.value || '');
386 } else if (command.property === 'postmeta') {
387 $row.find('.mlsimport-postmeta-input').val(command.value || '');
388 if ($.trim(command.value || '') !== '') {
389 $row.find('.mlsimport-taxonomy-select').val('');
390 }
391 } else if (command.property === 'taxonomy') {
392 $row.find('.mlsimport-taxonomy-select').val(command.value || '');
393 if ((command.value || '') !== '') {
394 $row.find('.mlsimport-postmeta-input').val('');
395 }
396 }
397 });
398 }
399
400 /**
401 * Restore all later unsaved intent after an authoritative result lands.
402 *
403 * Commands are overlaid in their original queue order so the last edit to
404 * a control remains visible. Positions, counts, and filters are refreshed
405 * once after the complete overlay rather than once per pending command.
406 */
407 function restoreQueuedView() {
408 $.each(state.queue, function (_, item) {
409 applyPendingCommand(item.command);
410 });
411 refreshPositions();
412 updateStats();
413 applyView();
414 }
415
416 /**
417 * Re-index the optimistic DOM after a permitted drag or relative move.
418 *
419 * These positions are presentation state until the server returns its
420 * authoritative order; no complete order array is placed on the wire.
421 */
422 function refreshPositions() {
423 rows().each(function (index) {
424 $(this).attr('data-field-order', index).find('.field-position').text((index + 1) + '. ');
425 });
426 }
427
428 /**
429 * Translate one changed control into a compact one-field command.
430 *
431 * Mapping exclusivity is reflected optimistically before enqueueing. The
432 * authoritative response later replaces normalized text and destinations.
433 */
434 $container.on('change', '.mlsimport-import-checkbox, .mlsimport-admin-checkbox, .mlsimport-label-input, .mlsimport-postmeta-input, .mlsimport-taxonomy-select', function () {
435 var $control = $(this);
436 var $row = $control.closest('.mlsimport-field-row');
437 var property;
438 var value;
439
440 if ($control.hasClass('mlsimport-import-checkbox')) {
441 property = 'import';
442 value = $control.prop('checked') ? 1 : 0;
443 } else if ($control.hasClass('mlsimport-admin-checkbox')) {
444 property = 'admin';
445 value = $control.prop('checked') ? 1 : 0;
446 } else if ($control.hasClass('mlsimport-label-input')) {
447 property = 'label';
448 value = $control.val();
449 } else if ($control.hasClass('mlsimport-postmeta-input')) {
450 property = 'postmeta';
451 value = $control.val();
452 if ($.trim(value) !== '') {
453 $row.find('.mlsimport-taxonomy-select').val('');
454 }
455 } else {
456 property = 'taxonomy';
457 value = $control.val();
458 if (value !== '') {
459 $row.find('.mlsimport-postmeta-input').val('');
460 }
461 }
462
463 enqueue({ type: 'set', field: $row.attr('data-field-key'), property: property, value: value });
464 updateStats();
465 applyView();
466 });
467
468 /**
469 * Change and send only active rows currently visible to the administrator.
470 *
471 * Sending explicit field keys makes the server-side bulk transition
472 * independent of search text, filters, pagination, or POST variable count.
473 */
474 $container.on('click', '#mlsimport-select-all-import, #mlsimport-select-none-import, #mlsimport-select-all-admin, #mlsimport-select-none-admin', function () {
475 var id = this.id;
476 var property = id.indexOf('admin') !== -1 ? 'admin' : 'import';
477 var value = id.indexOf('none') === -1 ? 1 : 0;
478 var selector = property === 'admin' ? '.mlsimport-admin-checkbox' : '.mlsimport-import-checkbox';
479 var $visibleRows = rows().filter(':visible');
480 var fieldKeys = $visibleRows.map(function () { return $(this).attr('data-field-key'); }).get();
481
482 if (fieldKeys.length === 0) {
483 return;
484 }
485 $visibleRows.find(selector).prop('checked', value === 1);
486 enqueue({ type: 'bulk', property: property, fields: fieldKeys, value: value });
487 updateStats();
488 applyView();
489 });
490
491 /**
492 * Convert move buttons into the same relative command as drag-and-drop.
493 *
494 * Anchors are the nearest VISIBLE rows so that, in a filtered view, one
495 * click jumps past hidden neighbors instead of swapping with them
496 * invisibly. The DOM moves immediately, then the queue sends only the
497 * moving field, anchor field, and before/after position.
498 */
499 $container.on('click', '.mlsimport-move-btn', function () {
500 var $row = $(this).closest('.mlsimport-field-row');
501 var $anchor;
502 var position;
503
504 if ($(this).hasClass('mlsimport-move-up')) {
505 $anchor = $row.prevAll('.mlsimport-field-row:visible').first();
506 position = 'before';
507 } else if ($(this).hasClass('mlsimport-move-down')) {
508 $anchor = $row.nextAll('.mlsimport-field-row:visible').first();
509 position = 'after';
510 } else if ($(this).hasClass('mlsimport-move-top')) {
511 $anchor = rows().filter(':visible').first();
512 position = 'before';
513 } else {
514 $anchor = rows().filter(':visible').last();
515 position = 'after';
516 }
517
518 if (!$anchor.length || $anchor.is($row)) {
519 return;
520 }
521 // Keep the clicked button under the cursor: measure its position
522 // before the move, then scroll by its displacement so repeated
523 // clicks walk the row without chasing the button down the page.
524 var buttonTop = $(this).offset().top;
525 position === 'before' ? $row.insertBefore($anchor) : $row.insertAfter($anchor);
526 refreshPositions();
527 window.scrollBy(0, $(this).offset().top - buttonTop);
528 enqueue({ type: 'move', field: $row.attr('data-field-key'), anchor: $anchor.attr('data-field-key'), position: position });
529 });
530
531 /**
532 * Derive one moving/anchor command from a completed sortable interaction.
533 *
534 * Reordering is enabled only for the unfiltered custom-order view, so the
535 * adjacent anchor always belongs to the complete active configuration.
536 */
537 if ($.fn.sortable) {
538 $body.sortable({
539 items: '> .mlsimport-field-row',
540 handle: '.mlsimport-field-name',
541 update: function (_, ui) {
542 var $row = ui.item;
543 var $anchor = $row.prev('.mlsimport-field-row');
544 var position = 'after';
545 if (!$anchor.length) {
546 $anchor = $row.next('.mlsimport-field-row');
547 position = 'before';
548 }
549 refreshPositions();
550 if ($anchor.length) {
551 enqueue({ type: 'move', field: $row.attr('data-field-key'), anchor: $anchor.attr('data-field-key'), position: position });
552 }
553 }
554 });
555 }
556
557 $search.on('input', applyView);
558 $filter.add($sort).on('change', applyView);
559 updateStats();
560 applyView();
561 }
562 })(jQuery);
563