| 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 |
|