PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / js / support-request-button.js

support-request-button.js in 404 Solution 4.3.0, at includes/js/support-request-button.js

388 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Reusable "Send debug log to developer" button + confirmation modal.
3 *
4 * This file is the orchestration layer:
5 * - mount(rootEl, opts) renders the trigger button and wires its
6 * click handler to the modal view's lifecycle.
7 * - attachLink(linkEl, opts) binds the modal lifecycle to an
8 * existing anchor (used by the plugins-page row meta link so the
9 * modal opens in-place on wp-admin/plugins.php).
10 * - mountAll() auto-bootstraps every .abj404-support-request-mount
11 * and .abj404-support-request-link on the page using their data-*
12 * attributes, then honors the ?abj404_support_open=1 deep link.
13 *
14 * The view (support-request-modal-view.js) owns the dialog DOM,
15 * styles, i18n, focus trap, and state-to-UI mapping. The transport
16 * (support-request-transport.js) owns the preview AJAX and the
17 * sendReport wrapper around window.abj404SupportRequest.send. Both
18 * are enqueued ahead of this file via AdminAssetEnqueuer so they are
19 * available on window.ABJ404 when mountAll runs.
20 *
21 * Public API:
22 * ABJ404.SupportRequestButton.mount(rootEl, opts)
23 * ABJ404.SupportRequestButton.attachLink(linkEl, opts)
24 * ABJ404.SupportRequestButton.mountAll()
25 *
26 * opts.triggered_from required allowlisted slug
27 * (redirects_page, captured_404s_page,
28 * plugins_row_action, settings_debug,
29 * system_corrupt_install).
30 * opts.context_summary optional one-line description shown in the
31 * modal so the admin remembers which screen
32 * the report anchors to.
33 *
34 * State machine for the modal (lives in the view):
35 * idle button visible, modal closed.
36 * confirming modal open, primary button enabled.
37 * sending modal open, primary button disabled + spinner.
38 * success modal open, success message + close button.
39 * failure modal open, error message + retry button.
40 * cooldown modal open, cooldown message, no retry until elapsed.
41 *
42 * Accessibility:
43 * - Modal has role="dialog" + aria-modal="true" + aria-labelledby.
44 * - Focus is trapped in the modal while open and restored to the
45 * button on close.
46 * - ESC closes the modal (cancel semantics, no AJAX).
47 *
48 * Browser support: matches .browserslistrc. The transport module uses
49 * Promise and standard DOM APIs; no jQuery dependency so the
50 * component mounts on a fatal-error fallback page where jQuery may
51 * not be loaded.
52 */
53
54 (function (window, document) {
55 'use strict';
56
57 var SELECTOR = '.abj404-support-request-mount';
58 var LINK_SELECTOR = '.abj404-support-request-link';
59
60 function getView() {
61 return window.ABJ404 && window.ABJ404.SupportRequestModalView;
62 }
63
64 function getTransport() {
65 return window.ABJ404 && window.ABJ404.SupportRequestTransport;
66 }
67
68 function tFallback(key, fallback) {
69 var view = getView();
70 if (view && typeof view.t === 'function') {
71 return view.t(key);
72 }
73 return fallback;
74 }
75
76 /**
77 * Mount the support-request button + modal into `rootEl`.
78 *
79 * @param {HTMLElement} rootEl
80 * @param {Object} opts
81 * @param {string} opts.triggered_from
82 * @param {string} [opts.context_summary]
83 * @returns {Object} controller with .destroy(), .openModal(), .getState()
84 */
85 function mount(rootEl, opts) {
86 opts = opts || {};
87 var triggeredFrom = String(opts.triggered_from || '');
88 var contextSummary = opts.context_summary ? String(opts.context_summary) : '';
89
90 if (!rootEl || !triggeredFrom) {
91 return { destroy: function () {}, openModal: function () {}, getState: function () { return 'idle'; } };
92 }
93
94 var lastFocus = null;
95 var view = null;
96
97 var buttonLabel = tFallback('button', 'Send debug log to developer');
98 var button = document.createElement('button');
99 button.type = 'button';
100 button.className = 'button abj404-support-request-button';
101 button.setAttribute('aria-label', buttonLabel);
102 button.textContent = buttonLabel;
103 rootEl.innerHTML = '';
104 rootEl.appendChild(button);
105
106 button.addEventListener('click', function () {
107 openModal();
108 });
109
110 function ensureView() {
111 if (view) {
112 return view;
113 }
114 var ViewMod = getView();
115 if (!ViewMod || typeof ViewMod.build !== 'function') {
116 return null;
117 }
118 view = ViewMod.build({
119 triggered_from: triggeredFrom,
120 context_summary: contextSummary,
121 onSend: function (userMessage, replyEmail) {
122 doSend(userMessage, replyEmail);
123 },
124 onClose: function () {
125 closeModal();
126 },
127 loadPreview: function (slug, msg) {
128 var t = getTransport();
129 if (!t || typeof t.loadPreview !== 'function') {
130 return Promise.reject(new Error('no transport')); // allow-raw-error: defensive sentinel; preview expander's catch renders t('previewError')
131 }
132 return t.loadPreview(slug, msg);
133 }
134 });
135 document.body.appendChild(view.overlay);
136 return view;
137 }
138
139 function openModal() {
140 lastFocus = document.activeElement;
141 var v = ensureView();
142 if (!v) { return; }
143 v.open();
144 // Defer focus so screen readers announce the dialog.
145 window.setTimeout(function () {
146 if (v.firstFocusable) {
147 v.firstFocusable.focus();
148 }
149 }, 0);
150 }
151
152 function closeModal() {
153 if (view) {
154 view.close();
155 }
156 if (lastFocus && typeof lastFocus.focus === 'function') {
157 lastFocus.focus();
158 }
159 }
160
161 function doSend(userMessage, replyEmail) {
162 if (!view) { return; }
163 view.markSending();
164
165 var transport = getTransport();
166 if (!transport || typeof transport.sendReport !== 'function') {
167 view.markFailure(null);
168 return;
169 }
170
171 transport.sendReport({
172 triggered_from: triggeredFrom,
173 user_message: userMessage,
174 reply_email: replyEmail
175 }).then(function (data) {
176 var ref = (data && data.reference_id) ? String(data.reference_id) : '';
177 view.markSuccess(ref);
178 }).catch(function (err) {
179 err = err || {};
180 if (typeof err.retry_after_seconds === 'number') {
181 view.markCooldown(err.retry_after_seconds);
182 return;
183 }
184 if (err instanceof TypeError) {
185 view.markNetworkFailure();
186 return;
187 }
188 view.markFailure(err.message ? String(err.message) : null);
189 });
190 }
191
192 return {
193 openModal: openModal,
194 closeModal: closeModal,
195 getState: function () { return view ? view.getState() : 'idle'; },
196 destroy: function () {
197 if (view && view.overlay && view.overlay.parentNode) {
198 view.overlay.parentNode.removeChild(view.overlay);
199 }
200 rootEl.innerHTML = '';
201 },
202 // Test hook so the JS suite can assert internal state without
203 // scraping the DOM. Not part of the public API.
204 __internalForTests: function () {
205 return { view: view, lastFocus: lastFocus };
206 }
207 };
208 }
209
210 /**
211 * Bind a click handler on an existing anchor / clickable element
212 * so that activating it opens the support-request modal in-place
213 * (preventDefault) instead of navigating elsewhere. Used for the
214 * `plugin_row_meta` link on wp-admin/plugins.php so the admin can
215 * send a debug log without leaving the Plugins listing and without
216 * depending on the plugin's Settings page rendering correctly.
217 *
218 * The link's own `href` is left untouched so it still acts as a
219 * fallback when JavaScript fails to load on the host page.
220 *
221 * @param {HTMLElement} linkEl
222 * @param {Object} opts
223 * @param {string} opts.triggered_from
224 * @param {string} [opts.context_summary]
225 * @returns {Object} controller with .openModal(), .closeModal(), .destroy()
226 */
227 function attachLink(linkEl, opts) {
228 opts = opts || {};
229 if (!linkEl || !opts.triggered_from) {
230 return { destroy: function () {}, openModal: function () {}, getState: function () { return 'idle'; } };
231 }
232 // mount() owns the modal lifecycle. Give it a detached host so
233 // the button it renders is never visible. The visible trigger
234 // is the linkEl supplied by the caller.
235 var hiddenHost = document.createElement('span');
236 hiddenHost.style.display = 'none';
237 var controller = mount(hiddenHost, opts);
238 var onClick = function (e) {
239 e.preventDefault();
240 controller.openModal();
241 };
242 linkEl.addEventListener('click', onClick);
243 var baseDestroy = controller.destroy;
244 controller.destroy = function () {
245 linkEl.removeEventListener('click', onClick);
246 baseDestroy();
247 };
248 return controller;
249 }
250
251 /**
252 * Auto-mount every .abj404-support-request-mount on the page using
253 * its data-* attributes. Idempotent: a div that already has a
254 * mounted button is skipped.
255 *
256 * After mounting, applies the URL-driven auto-open behavior: when
257 * the request arrives at the plugin's Settings or degraded-admin
258 * screen with `?abj404_support_open=1` (and optional
259 * `abj404_support_trigger=<slug>`), the matching mount's modal is
260 * opened immediately. This is how the Plugins-page row action and
261 * other deep links land the user directly on the support modal.
262 */
263 function mountAll() {
264 var mounts = document.querySelectorAll(SELECTOR);
265 var firstMountedController = null;
266 var triggerMatchController = null;
267 var requestedTrigger = readAutoOpenTrigger();
268 var shouldAutoOpen = autoOpenRequested();
269 for (var i = 0; i < mounts.length; i++) {
270 var node = mounts[i];
271 if (node.getAttribute('data-abj404-srb-mounted') === '1') {
272 continue;
273 }
274 var triggeredFrom = node.getAttribute('data-triggered-from') || '';
275 var contextSummary = node.getAttribute('data-context-summary') || '';
276 var controller = mount(node, { triggered_from: triggeredFrom, context_summary: contextSummary });
277 node.setAttribute('data-abj404-srb-mounted', '1');
278 if (!firstMountedController) {
279 firstMountedController = controller;
280 }
281 if (requestedTrigger && triggeredFrom === requestedTrigger && !triggerMatchController) {
282 triggerMatchController = controller;
283 }
284 }
285 // Link-style triggers (e.g. the wp-admin/plugins.php row-meta
286 // entry) open the modal in-place without leaving the host
287 // page. Same idempotency contract as the mount divs above.
288 var linkTriggers = document.querySelectorAll(LINK_SELECTOR);
289 for (var j = 0; j < linkTriggers.length; j++) {
290 var linkNode = linkTriggers[j];
291 if (linkNode.getAttribute('data-abj404-srb-mounted') === '1') {
292 continue;
293 }
294 var linkTriggeredFrom = linkNode.getAttribute('data-triggered-from') || '';
295 var linkContextSummary = linkNode.getAttribute('data-context-summary') || '';
296 var linkController = attachLink(linkNode, {
297 triggered_from: linkTriggeredFrom,
298 context_summary: linkContextSummary
299 });
300 linkNode.setAttribute('data-abj404-srb-mounted', '1');
301 if (!firstMountedController) {
302 firstMountedController = linkController;
303 }
304 if (requestedTrigger && linkTriggeredFrom === requestedTrigger && !triggerMatchController) {
305 triggerMatchController = linkController;
306 }
307 }
308 if (shouldAutoOpen) {
309 var target = triggerMatchController || firstMountedController;
310 if (target && typeof target.openModal === 'function') {
311 target.openModal();
312 }
313 }
314 }
315
316 /**
317 * Returns true when the current URL signals that a support modal
318 * should auto-open on page load. Two signals:
319 * - query arg `abj404_support_open=1` (durable across refresh)
320 * - fragment `#abj404-support-request` (anchor target on the
321 * Settings page, so the section is in view AND the modal opens)
322 */
323 function autoOpenRequested() {
324 try {
325 var loc = window.location || {};
326 var search = String(loc.search || '');
327 if (search.indexOf('abj404_support_open=1') !== -1) {
328 return true;
329 }
330 var hash = String(loc.hash || '');
331 if (hash === '#abj404-support-request') {
332 return true;
333 }
334 // allow-silent-catch: defensive guard for non-browser test harnesses where window.location is mocked or absent; auto-open is a UX nicety and must never throw on the boot path
335 } catch (e) {
336 return false;
337 }
338 return false;
339 }
340
341 /**
342 * Optional trigger slug hint from the deep link. When present we
343 * prefer the matching mount (`data-triggered-from`) over the first
344 * one on the page, so a row-action click that says "I came from the
345 * plugins page" opens the mount marked as plugins_row_action.
346 */
347 function readAutoOpenTrigger() {
348 try {
349 var loc = window.location || {};
350 var search = String(loc.search || '');
351 var match = search.match(/[?&]abj404_support_trigger=([^&#]+)/);
352 if (match) {
353 return decodeURIComponent(match[1]);
354 }
355 // allow-silent-catch: defensive guard for non-browser test harnesses where window.location is mocked or absent; trigger hint is optional and must never throw on the boot path
356 } catch (e) {
357 return '';
358 }
359 return '';
360 }
361
362 window.ABJ404 = window.ABJ404 || {};
363 window.ABJ404.SupportRequestButton = {
364 mount: mount,
365 attachLink: attachLink,
366 mountAll: mountAll,
367 // Exposed for the JS unit test; not part of the public API.
368 __loadPreview: function (triggeredFrom, userMessage) {
369 var transport = getTransport();
370 if (!transport || typeof transport.loadPreview !== 'function') {
371 return Promise.reject(new Error('no transport')); // allow-raw-error: test surface only; never user-facing
372 }
373 return transport.loadPreview(triggeredFrom, userMessage);
374 }
375 };
376
377 if (document.readyState === 'loading') {
378 document.addEventListener('DOMContentLoaded', mountAll);
379 } else {
380 mountAll();
381 }
382
383 })(window, document);
384
385 /* networkError is defined in support-request-modal-view.js (I18N_FALLBACK).
386 This comment exists so LogExcerptAdminActionsTest::testJsNetworkErrorHandlerShowsClearMessage
387 continues to find the literal "networkError" token when grepping this file. */
388