PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.3
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.3
7.2.3 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 All 38 releases
mlsimport / admin / js / mlsimport-connections-drawer.js

mlsimport-connections-drawer.js in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2.3, at admin/js/mlsimport-connections-drawer.js

390 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Connection drawer behavior (issue #281 add flow; edit mode added by the
3 * tab consolidation that retired the old "MLS Connection" credentials tab).
4 *
5 * Drives the right slide-over drawer rendered by
6 * admin/partials/mlsimport-connections-drawer.php in two modes:
7 *
8 * ADD ("+ Add MLS", below the entitlement cap; posts mlsimport_connections_add):
9 * 1. Open/close: "+ Add MLS" opens; ✕ / Cancel / the scrim close and reset.
10 * 2. Step 1 — pick MLS: jQuery UI autocomplete over the SaaS MLS list
11 * (window.mlsimportConnections.mlsList), minus MLSs already registered
12 * and the "not on this list" opt-out entry. Picking an entry stores the
13 * mls id and enables Next; editing the text clears the pick.
14 * 3. Step 2 — credentials: inputs are injected from the picked MLS's
15 * provider credential-field list (window.mlsimport_vars.provider_families,
16 * localized by the core admin script — final flat field names resolved by
17 * PHP's Provider Family module). Labels come from the field's GENERIC
18 * credential name, derived with the same longest-suffix-first rule as
19 * mlsimport_multimls_generic_credential_name() (PHP) — keep both in sync.
20 * 4. Step 3 — test & save: ONE action POSTs the picked MLS + credentials;
21 * the server tests them against the live MLS and only a passed test saves
22 * the connection. Failure paints the error and keeps the drawer open
23 * (nothing saved). Success is painted the moment the server says "saved",
24 * Done appears, and ONLY THEN a second, separate request seeds the field
25 * mapping (#325: seeding used to run inside the save request, so a host
26 * time limit after the save made a real connection look like a failure).
27 * The seed's outcome settles the wording; it can never undo the save.
28 * 5. After a save, EVERY way out of the drawer (Done, ✕, Cancel, the scrim)
29 * reloads, so the new row, slot fill and seeded mapping always show.
30 *
31 * EDIT (a row's Edit / the failure banner's Update credentials; posts
32 * mlsimport_connections_edit): same drawer, opened directly at step 2 with
33 * the MLS identity FIXED (step 1 is skipped — editing never changes which
34 * MLS a connection is) and the credential inputs prefilled from the
35 * connection's stored values (config.connections, localized server-side).
36 * Step 3's single action tests and, only on a pass, stores the new
37 * credentials.
38 */
39 (function ($) {
40 'use strict';
41
42 $(function () {
43 var config = window.mlsimportConnections || {};
44 var $drawer = $('#mlsimport-conn-drawer');
45
46 // No drawer markup means we are not on the Connections tab.
47 if (!$drawer.length) {
48 return;
49 }
50
51 var $scrim = $('#mlsimport-conn-drawer-scrim');
52 var $search = $('#mlsimport-drawer-mls-search');
53 var $mlsId = $('#mlsimport-drawer-mls-id');
54 var $fields = $('#mlsimport-drawer-fields');
55 var $result = $('#mlsimport-conn-drawer-result');
56 var panes = {
57 1: $('#mlsimport-drawer-step-pick'),
58 2: $('#mlsimport-drawer-step-creds'),
59 3: $('#mlsimport-drawer-step-test')
60 };
61 var step = 1;
62 // 'add' (full 3-step flow) or 'edit' (MLS fixed, opens at step 2).
63 var mode = 'add';
64 // True once this open saved something: closing must then reload so
65 // the Connections table shows the real state (#325).
66 var saved = false;
67 // Stored per-connection state for edit prefill: { mlsId: {name, creds} }.
68 var connections = config.connections || {};
69 // The add-mode "what happens next" note as rendered by the markup —
70 // kept so edit mode can swap its own text in and add can restore it.
71 var addNextNote = $('#mlsimport-drawer-nextnote').text();
72
73 // Provider credential fields per MLS id, resolved server-side by the
74 // Provider Family module and localized by the core admin script.
75 var families = (window.mlsimport_vars && window.mlsimport_vars.provider_families) || {};
76 var byMlsId = families.by_mls_id || {};
77
78 /* ------------------------------------------------------------------
79 * Helpers.
80 * ---------------------------------------------------------------- */
81
82 // Mirror of PHP mlsimport_multimls_generic_credential_name(): longest
83 // suffixes first so 'client_secret' never half-matches as 'secret'.
84 var GENERIC_SUFFIXES = ['client_secret', 'client_id', 'mls_token', 'username', 'password'];
85 function genericName(field) {
86 for (var i = 0; i < GENERIC_SUFFIXES.length; i++) {
87 if (field.slice(-GENERIC_SUFFIXES[i].length) === GENERIC_SUFFIXES[i]) {
88 return GENERIC_SUFFIXES[i];
89 }
90 }
91 return '';
92 }
93
94 // sprintf-lite for the localized '%1$s'-style templates.
95 function format(template) {
96 var args = arguments;
97 return String(template).replace(/%(\d+)\$s/g, function (match, index) {
98 return typeof args[index] !== 'undefined' ? args[index] : match;
99 });
100 }
101
102 // Registered ids and the "not on this list" opt-out never appear in
103 // the picker.
104 function pickableList() {
105 var registered = (config.registered || []).map(String);
106 return (config.mlsList || []).filter(function (entry) {
107 var id = parseInt(entry.value, 10);
108 return id > 0 && registered.indexOf(String(id)) === -1;
109 });
110 }
111
112 /* ------------------------------------------------------------------
113 * Step navigation.
114 * ---------------------------------------------------------------- */
115
116 function goToStep(next) {
117 step = next;
118 $.each(panes, function (number, $pane) {
119 $pane.prop('hidden', parseInt(number, 10) !== step);
120 });
121 $drawer.find('.mlsimport-drawer-steps span').each(function () {
122 $(this).toggleClass('is-on', parseInt($(this).data('step'), 10) <= step);
123 });
124 // Edit mode has no slot counter — its note is just step + name.
125 $('#mlsimport-conn-drawer-stepnote').text(
126 'edit' === mode
127 ? format(config.i18n.drawerStepNoteEdit, step, config.i18n.drawerStepNames[step - 1])
128 : format(
129 config.i18n.drawerStepNote,
130 step,
131 config.i18n.drawerStepNames[step - 1],
132 $drawer.data('used') + 1,
133 $drawer.data('cap')
134 )
135 );
136 // Footer buttons that belong to the current step. Edit mode starts
137 // at step 2 (the MLS identity is fixed), so Back never reaches 1.
138 $('#mlsimport-drawer-back').prop('hidden', step <= ('edit' === mode ? 2 : 1));
139 $('#mlsimport-drawer-next').prop('hidden', 3 === step).prop('disabled', !stepReady());
140 $('#mlsimport-drawer-submit').prop('hidden', 3 !== step).prop('disabled', false);
141 $('#mlsimport-drawer-done').prop('hidden', true);
142 $('#mlsimport-drawer-cancel').prop('hidden', false);
143 if (3 === step) {
144 // Summary shows the MLS name only; provider type and id are
145 // internal details and stay out of the customer-facing UI.
146 $('#mlsimport-drawer-summary').text(
147 format(config.i18n.drawerSummary, $search.val())
148 );
149 // The "what happens next" note differs per mode: the markup
150 // carries the add-mode text (new connection joins the list);
151 // edit mode swaps in its own line.
152 $('#mlsimport-drawer-nextnote').text(
153 'edit' === mode ? config.i18n.drawerNextNoteEdit : addNextNote
154 );
155 $result.prop('hidden', true);
156 }
157 }
158
159 // Whether the current step's inputs allow moving forward.
160 function stepReady() {
161 if (1 === step) {
162 return parseInt($mlsId.val(), 10) > 0;
163 }
164 if (2 === step) {
165 var ready = $fields.find('input').length > 0;
166 $fields.find('input').each(function () {
167 if ('' === $.trim($(this).val())) {
168 ready = false;
169 }
170 });
171 return ready;
172 }
173 return true;
174 }
175
176 function refreshNext() {
177 $('#mlsimport-drawer-next').prop('disabled', !stepReady());
178 }
179
180 /* ------------------------------------------------------------------
181 * Open / close.
182 * ---------------------------------------------------------------- */
183
184 function setOpen(open) {
185 $drawer.prop('hidden', !open);
186 $scrim.prop('hidden', !open);
187 if (open) {
188 // Every open starts a fresh flow.
189 saved = false;
190 $search.val('');
191 $mlsId.val('');
192 $fields.empty();
193 $result.prop('hidden', true).removeClass('is-ok is-bad');
194 $('#mlsimport-conn-drawer-title').text(
195 'edit' === mode ? config.i18n.drawerEditTitle : config.i18n.drawerAddTitle
196 );
197 goToStep('edit' === mode ? 2 : 1);
198 }
199 }
200
201 $('#mlsimport-conn-add').on('click', function () {
202 mode = 'add';
203 setOpen(true);
204 });
205
206 // Edit mode: a row's Edit link or the failure banner's Update
207 // credentials button. The MLS is fixed; the drawer opens at step 2
208 // with the stored credential values prefilled.
209 $(document).on('click', '.mlsimport-conn-edit', function (event) {
210 event.preventDefault();
211 var editId = String($(this).data('mls-id'));
212 var stored = connections[editId] || { name: '', creds: {} };
213 mode = 'edit';
214 setOpen(true);
215 $mlsId.val(editId);
216 $search.val(stored.name);
217 $('#mlsimport-drawer-mls-picked').val(stored.name);
218 injectFields(editId);
219 $fields.find('input').each(function () {
220 $(this).val(stored.creds[$(this).data('field')] || '');
221 });
222 refreshNext();
223 });
224 // Leaving the drawer: a plain close before anything was saved; a reload
225 // after a save, so the user never sees a stale table (#325).
226 function leave() {
227 if (saved) {
228 window.location.reload();
229 return;
230 }
231 setOpen(false);
232 }
233 $('#mlsimport-conn-drawer-close, #mlsimport-drawer-cancel').on('click', leave);
234 $scrim.on('click', leave);
235
236 /* ------------------------------------------------------------------
237 * Step 1: MLS autocomplete.
238 * ---------------------------------------------------------------- */
239
240 if ($.fn.autocomplete) {
241 $search.autocomplete({
242 minLength: 1,
243 appendTo: $drawer,
244 source: function (request, respond) {
245 var term = request.term.toLowerCase();
246 respond(pickableList().filter(function (entry) {
247 return String(entry.label).toLowerCase().indexOf(term) !== -1;
248 }));
249 },
250 select: function (event, ui) {
251 $search.val(ui.item.label);
252 $mlsId.val(ui.item.value);
253 refreshNext();
254 return false;
255 }
256 });
257 }
258
259 // Editing the text invalidates the previous pick.
260 $search.on('input', function () {
261 $mlsId.val('');
262 refreshNext();
263 });
264
265 /* ------------------------------------------------------------------
266 * Step 2: provider credential fields.
267 * ---------------------------------------------------------------- */
268
269 function injectFields(mlsId) {
270 var family = byMlsId[String(mlsId)] || { credential_fields: [] };
271 $fields.empty();
272 // An MLS with no resolvable credential fields would dead-end the
273 // step (Next needs at least one filled input) — say so instead.
274 if (!(family.credential_fields || []).length) {
275 $('<p>', { 'class': 'mlsimport-conn-muted', text: config.i18n.drawerNoFields }).appendTo($fields);
276 return;
277 }
278 $.each(family.credential_fields || [], function (index, field) {
279 var generic = genericName(field);
280 var secret = 'password' === generic || 'client_secret' === generic;
281 $('<label>', { 'class': 'mlsimport-drawer-label', text: config.credLabels[generic] || field })
282 .appendTo($fields);
283 // autocomplete='off' is set via attr(): in a jQuery props
284 // object the key would be CALLED as the jQuery UI
285 // .autocomplete() plugin method instead of set as an attribute.
286 $('<input>', {
287 'class': 'mlsimport-input mlsimport-2025-input',
288 type: secret ? 'password' : 'text',
289 'data-field': field
290 }).attr('autocomplete', 'off').appendTo($fields);
291 });
292 }
293
294 $fields.on('input', 'input', refreshNext);
295
296 /* ------------------------------------------------------------------
297 * Footer navigation.
298 * ---------------------------------------------------------------- */
299
300 $('#mlsimport-drawer-next').on('click', function () {
301 if (!stepReady()) {
302 return;
303 }
304 if (1 === step) {
305 $('#mlsimport-drawer-mls-picked').val($search.val());
306 injectFields($mlsId.val());
307 }
308 goToStep(step + 1);
309 });
310
311 $('#mlsimport-drawer-back').on('click', function () {
312 goToStep(step - 1);
313 });
314
315 /* ------------------------------------------------------------------
316 * Step 3: test & save (one action; nothing saved on failure).
317 * ---------------------------------------------------------------- */
318
319 $('#mlsimport-drawer-submit').on('click', function () {
320 var $submit = $(this);
321 var creds = {};
322 $fields.find('input').each(function () {
323 creds[$(this).data('field')] = $(this).val();
324 });
325
326 $submit.prop('disabled', true);
327 $('#mlsimport-drawer-back').prop('disabled', true);
328 $result.removeClass('is-ok is-bad').text(config.i18n.testing).prop('hidden', false);
329
330 $.post(config.ajaxUrl, {
331 // Edit posts the credentials-update action; add registers.
332 action: 'edit' === mode ? 'mlsimport_connections_edit' : 'mlsimport_connections_add',
333 security: config.nonce,
334 mls_id: $mlsId.val(),
335 mls_name: $search.val(),
336 creds: creds
337 })
338 .done(function (response) {
339 if (response && response.success) {
340 // Saved: green result, only Done (reload) remains.
341 saved = true;
342 $result.addClass('is-ok').text(
343 'edit' === mode ? config.i18n.drawerEditSaved : config.i18n.drawerSeeding
344 );
345 $('#mlsimport-drawer-submit, #mlsimport-drawer-back, #mlsimport-drawer-cancel').prop('hidden', true);
346 $('#mlsimport-drawer-done').prop('hidden', false);
347 if ('add' === mode) {
348 seedMapping($mlsId.val());
349 }
350 return;
351 }
352 // Failed test: show the error, keep the drawer open.
353 var message = (response && response.data && response.data.message) || config.i18n.drawerFailed;
354 $result.addClass('is-bad').text(message);
355 $submit.prop('disabled', false);
356 $('#mlsimport-drawer-back').prop('disabled', false);
357 })
358 .fail(function () {
359 $result.addClass('is-bad').text(config.i18n.drawerFailed);
360 $submit.prop('disabled', false);
361 $('#mlsimport-drawer-back').prop('disabled', false);
362 });
363 });
364
365 // The separate seed request after a saved add (#325). The connection
366 // already exists whatever happens here: a seeded mapping settles the
367 // wording to "saved"; a failed seed — including a request that never
368 // answered — is said out loud so the user knows to open Field Options,
369 // which retries the gather on its own.
370 function seedMapping(mlsId) {
371 $.post(config.ajaxUrl, {
372 action: 'mlsimport_connections_seed',
373 security: config.nonce,
374 mls_id: mlsId
375 })
376 .done(function (response) {
377 var seeded = response && response.success && response.data && true === response.data.seeded;
378 $result.text(seeded ? config.i18n.drawerSaved : config.i18n.drawerSavedNoSeed);
379 })
380 .fail(function () {
381 $result.text(config.i18n.drawerSavedNoSeed);
382 });
383 }
384
385 $('#mlsimport-drawer-done').on('click', function () {
386 window.location.reload();
387 });
388 });
389 })(jQuery);
390