| @@ -299,577 +299,5 @@ | ||
| 299 | 299 | document.addEventListener( 'DOMContentLoaded', buildNoticeCarousel ); |
| 300 | 300 | } else { |
| 301 | 301 | buildNoticeCarousel(); |
| 302 | 302 | } |
| 303 | - | |
| 304 | - // ------------------------------------------------------------------ | |
| 305 | - // Details modal | |
| 306 | - // ------------------------------------------------------------------ | |
| 307 | - | |
| 308 | - /** | |
| 309 | - * The details as HTML, for the clipboard's text/html flavour. | |
| 310 | - * | |
| 311 | - * Gmail's composer is a rich-text field: it drops the newlines out of plain | |
| 312 | - * text, which is what turned the diagnostics into one paragraph. Pasting HTML | |
| 313 | - * instead keeps every break, and <pre> keeps the log's columns lined up. | |
| 314 | - * | |
| 315 | - * Escaped here, not on the server, so the escaping happens once and in the same | |
| 316 | - * place the markup is built. The source is a log holding whatever a server put | |
| 317 | - * in an error message, so it is never trusted as markup. | |
| 318 | - * | |
| 319 | - * @param {string} text Plain-text details. | |
| 320 | - * @return {string} Escaped HTML. | |
| 321 | - */ | |
| 322 | - function detailsAsHtml( text ) { | |
| 323 | - const escaped = text | |
| 324 | - .replace( /&/g, '&' ) | |
| 325 | - .replace( /</g, '<' ) | |
| 326 | - .replace( />/g, '>' ); | |
| 327 | - | |
| 328 | - return ( | |
| 329 | - '<pre style="font-family:monospace;white-space:pre-wrap;' + | |
| 330 | - 'word-break:break-word;margin:0">' + | |
| 331 | - escaped + | |
| 332 | - '</pre>' | |
| 333 | - ); | |
| 334 | - } | |
| 335 | - | |
| 336 | - /** | |
| 337 | - * Put the details on the clipboard in both flavours. | |
| 338 | - * | |
| 339 | - * A rich composer takes the HTML and keeps the line breaks; a plain-text field | |
| 340 | - * takes the text. Falls back to writeText where ClipboardItem is unavailable -- | |
| 341 | - * that loses the formatting, but losing it is better than copying nothing. | |
| 342 | - * | |
| 343 | - * @param {string} text Plain-text details. | |
| 344 | - * @param {Function} done Called once the clipboard actually holds it. | |
| 345 | - * @param {Function} fail Called when it does not, including where the API is | |
| 346 | - * absent entirely -- on a plain-HTTP admin | |
| 347 | - * navigator.clipboard does not exist, and a silent | |
| 348 | - * no-op there is what left the dialog with a button | |
| 349 | - * waiting on something that could never happen. | |
| 350 | - */ | |
| 351 | - function copyDetails( text, done, fail ) { | |
| 352 | - const nope = | |
| 353 | - typeof fail === 'function' | |
| 354 | - ? fail | |
| 355 | - : function () {}; | |
| 356 | - | |
| 357 | - const supportsRich = | |
| 358 | - window.ClipboardItem && | |
| 359 | - navigator.clipboard && | |
| 360 | - navigator.clipboard.write; | |
| 361 | - | |
| 362 | - if ( supportsRich ) { | |
| 363 | - // Both of these throw synchronously, not through the promise: | |
| 364 | - // ClipboardItem's constructor on an unsupported MIME type, and | |
| 365 | - // clipboard.write() on a bad argument in Chromium or a stale user | |
| 366 | - // gesture in WebKit. Outside a try, the exception leaves copyDetails | |
| 367 | - // and the click handler with neither callback run -- so the caller | |
| 368 | - // never unlocks and the notice keeps no working action. | |
| 369 | - try { | |
| 370 | - const item = new window.ClipboardItem( { | |
| 371 | - 'text/plain': new Blob( [ text ], { type: 'text/plain' } ), | |
| 372 | - 'text/html': new Blob( [ detailsAsHtml( text ) ], { | |
| 373 | - type: 'text/html', | |
| 374 | - } ), | |
| 375 | - } ); | |
| 376 | - | |
| 377 | - navigator.clipboard.write( [ item ] ).then( done, nope ); | |
| 378 | - } catch ( e ) { | |
| 379 | - nope(); | |
| 380 | - } | |
| 381 | - | |
| 382 | - return; | |
| 383 | - } | |
| 384 | - | |
| 385 | - if ( navigator.clipboard && navigator.clipboard.writeText ) { | |
| 386 | - try { | |
| 387 | - navigator.clipboard.writeText( text ).then( done, nope ); | |
| 388 | - } catch ( e ) { | |
| 389 | - nope(); | |
| 390 | - } | |
| 391 | - | |
| 392 | - return; | |
| 393 | - } | |
| 394 | - | |
| 395 | - // No clipboard API at all. The text is on screen and selectable, so there | |
| 396 | - // is nothing to recover -- but the caller has to be told, or it waits for a | |
| 397 | - // callback that never arrives. | |
| 398 | - nope(); | |
| 399 | - } | |
| 400 | - | |
| 401 | - /** | |
| 402 | - * Show what would be sent to support, before anything is sent. | |
| 403 | - * | |
| 404 | - * The dialog for the classic wp-admin notices. The SureForms dashboard has its | |
| 405 | - * own, built on force-ui's Dialog, because there is no React on these screens | |
| 406 | - * and no force-ui bundle either -- so the two surfaces are separate | |
| 407 | - * implementations by necessity. They read their strings from one PHP array | |
| 408 | - * (Admin::get_details_dialog_labels(), reaching here as | |
| 409 | - * srfmNoticeResponse.details and the dashboard as srfm_admin.details_dialog), | |
| 410 | - * so the copy cannot drift even though the markup does. | |
| 411 | - * | |
| 412 | - * This one appends to document.body, so it is not subject to a containing | |
| 413 | - * block established by an ancestor transform or filter. | |
| 414 | - * | |
| 415 | - * The fallbacks below are English, because a plain admin script has no | |
| 416 | - * gettext runtime to fall back to; they are reached only if the localize data | |
| 417 | - * is missing entirely. | |
| 418 | - * | |
| 419 | - * Read with textContent and written with textContent, never innerHTML: the log | |
| 420 | - * contains whatever a server or a browser put in an error message, and that is | |
| 421 | - * not markup to be trusted. | |
| 422 | - * | |
| 423 | - * @param {Object} config Dialog contents. | |
| 424 | - * @param {string} config.noticeId Item id, used as the analytics key. | |
| 425 | - * @param {string} config.text Plain-text details to show and copy. | |
| 426 | - * @param {string} config.supportUrl Where Contact Support goes. Empty means | |
| 427 | - * there is nowhere to send them, so the | |
| 428 | - * button is not reachable at all. | |
| 429 | - * @param {boolean} config.pending Whether `text` is a placeholder that a | |
| 430 | - * later setText() will replace. | |
| 431 | - * @return {Object|false} Handle with setText()/setSupportUrl(), or false if | |
| 432 | - * there was nothing to open. | |
| 433 | - */ | |
| 434 | - function showDetails( config ) { | |
| 435 | - const noticeId = ( config && config.noticeId ) || ''; | |
| 436 | - const pending = !! ( config && config.pending ); | |
| 437 | - | |
| 438 | - let text = ( config && config.text ) || ''; | |
| 439 | - let supportUrl = ( config && config.supportUrl ) || ''; | |
| 440 | - | |
| 441 | - if ( ! text ) { | |
| 442 | - return false; | |
| 443 | - } | |
| 444 | - | |
| 445 | - const labels = | |
| 446 | - ( typeof srfmNoticeResponse !== 'undefined' && | |
| 447 | - srfmNoticeResponse.details ) || | |
| 448 | - {}; | |
| 449 | - | |
| 450 | - const overlay = document.createElement( 'div' ); | |
| 451 | - overlay.className = 'srfm-details-overlay'; | |
| 452 | - | |
| 453 | - // Restored on close. Without it the trigger is gone from the tab order and | |
| 454 | - // a keyboard user starts again from the top of the page (WCAG 2.4.3). | |
| 455 | - const opener = overlay.ownerDocument.activeElement; | |
| 456 | - const previousOverflow = document.body.style.overflow; | |
| 457 | - | |
| 458 | - // Whether copying is even possible here. On a plain-HTTP admin | |
| 459 | - // navigator.clipboard does not exist, so the copy step cannot be a | |
| 460 | - // precondition for anything -- see the unlock reasoning below. | |
| 461 | - const canCopy = !! ( | |
| 462 | - navigator.clipboard && | |
| 463 | - ( navigator.clipboard.write || navigator.clipboard.writeText ) | |
| 464 | - ); | |
| 465 | - | |
| 466 | - const ids = 'srfm-details-' + Math.random().toString( 36 ).slice( 2, 10 ); | |
| 467 | - | |
| 468 | - const panel = document.createElement( 'div' ); | |
| 469 | - panel.setAttribute( 'role', 'dialog' ); | |
| 470 | - panel.setAttribute( 'aria-modal', 'true' ); | |
| 471 | - // Pointed at the real heading and description rather than repeating the | |
| 472 | - // title in an aria-label, which announces it twice. | |
| 473 | - panel.setAttribute( 'aria-labelledby', ids + '-title' ); | |
| 474 | - panel.setAttribute( 'aria-describedby', ids + '-desc' ); | |
| 475 | - panel.className = 'srfm-details-panel'; | |
| 476 | - | |
| 477 | - const heading = document.createElement( 'h2' ); | |
| 478 | - heading.id = ids + '-title'; | |
| 479 | - heading.textContent = labels.title || 'Details'; | |
| 480 | - | |
| 481 | - const description = document.createElement( 'p' ); | |
| 482 | - description.id = ids + '-desc'; | |
| 483 | - description.textContent = labels.description || ''; | |
| 484 | - description.className = 'srfm-details-description'; | |
| 485 | - | |
| 486 | - // Selectable and scrollable, because clipboard access can be refused and | |
| 487 | - // then selecting by hand is the only way through. tabindex because Chromium | |
| 488 | - // and WebKit do not make a scroll container focusable on their own, so | |
| 489 | - // without it a keyboard user cannot reach the very thing the dialog exists | |
| 490 | - // to show. Firefox does, which is why this looks fine there. | |
| 491 | - const pre = document.createElement( 'pre' ); | |
| 492 | - pre.textContent = text; | |
| 493 | - pre.tabIndex = 0; | |
| 494 | - pre.setAttribute( 'role', 'region' ); | |
| 495 | - pre.setAttribute( 'aria-label', labels.logRegion || 'Diagnostics' ); | |
| 496 | - | |
| 497 | - if ( pending ) { | |
| 498 | - // Announced as busy rather than silently showing a placeholder, so a | |
| 499 | - // screen reader says the region is still filling instead of reading | |
| 500 | - // "Loading" as if it were the report. | |
| 501 | - pre.setAttribute( 'aria-busy', 'true' ); | |
| 502 | - } | |
| 503 | - | |
| 504 | - const actions = document.createElement( 'p' ); | |
| 505 | - actions.className = 'srfm-details-actions'; | |
| 506 | - | |
| 507 | - // Visible, not a title attribute. pointer-events:none suppresses the native | |
| 508 | - // tooltip, a title never fires on keyboard focus, and screen readers | |
| 509 | - // commonly drop it on an unavailable control -- so the sentence explaining | |
| 510 | - // why the button is inert could not be read by anyone. | |
| 511 | - const hint = document.createElement( 'span' ); | |
| 512 | - hint.className = 'srfm-details-hint'; | |
| 513 | - hint.textContent = canCopy ? labels.copyFirst || '' : ''; | |
| 514 | - | |
| 515 | - // Doubles as the live region for the unlock. Copying changes three things at | |
| 516 | - // once -- the label, the icon and whether Contact Support works -- and none | |
| 517 | - // of them was announced. | |
| 518 | - hint.setAttribute( 'role', 'status' ); | |
| 519 | - | |
| 520 | - const copy = document.createElement( 'button' ); | |
| 521 | - copy.type = 'button'; | |
| 522 | - copy.className = 'button srfm-details-copy'; | |
| 523 | - copy.textContent = labels.copy || 'Copy details'; | |
| 524 | - | |
| 525 | - // Locked until the details are on the clipboard. The support form asks for | |
| 526 | - // them, and arriving with nothing to paste means describing the failure | |
| 527 | - // from memory. | |
| 528 | - // | |
| 529 | - // No href while it is locked, and a click guard behind that: `disabled` on | |
| 530 | - // an <a> does nothing at all -- it still navigates -- so removing the | |
| 531 | - // destination is what actually locks it. | |
| 532 | - // | |
| 533 | - // Hidden, not merely href-less, until there is a destination. An empty href | |
| 534 | - // resolves to the current document, so the click used to open a duplicate | |
| 535 | - // of the admin page and still acknowledge the failure -- standing the | |
| 536 | - // notice down without anything having been reported. The destination now | |
| 537 | - // arrives with the fetched payload, so the element is built up front and | |
| 538 | - // revealed by setSupportUrl(). | |
| 539 | - const contact = document.createElement( 'a' ); | |
| 540 | - contact.className = 'button button-primary srfm-details-contact'; | |
| 541 | - contact.textContent = labels.contact || 'Contact Support'; | |
| 542 | - contact.hidden = ! supportUrl; | |
| 543 | - | |
| 544 | - function lockContact() { | |
| 545 | - // The dimming and the pointer-events block hang off aria-disabled in the | |
| 546 | - // stylesheet, so the state is declared once rather than in two places. | |
| 547 | - contact.removeAttribute( 'href' ); | |
| 548 | - contact.setAttribute( 'aria-disabled', 'true' ); | |
| 549 | - } | |
| 550 | - | |
| 551 | - function unlockContact() { | |
| 552 | - if ( ! supportUrl ) { | |
| 553 | - return; | |
| 554 | - } | |
| 555 | - | |
| 556 | - contact.href = supportUrl; | |
| 557 | - contact.removeAttribute( 'aria-disabled' ); | |
| 558 | - } | |
| 559 | - | |
| 560 | - // Locked only where copying can actually happen. Where it cannot, the copy | |
| 561 | - // step is not a step the user can take, and Contact Support is the only | |
| 562 | - // route that acknowledges the failure -- these items are not dismissible, | |
| 563 | - // and only a submission failure ever clears itself. Keeping it locked | |
| 564 | - // behind an unavailable clipboard would leave an undismissable notice with | |
| 565 | - // no working action on it. | |
| 566 | - if ( canCopy ) { | |
| 567 | - lockContact(); | |
| 568 | - } | |
| 569 | - | |
| 570 | - // Nothing to copy yet. Left focusable-but-disabled rather than hidden, so | |
| 571 | - // the control does not appear from nowhere once the payload lands. | |
| 572 | - copy.disabled = pending; | |
| 573 | - | |
| 574 | - const close = document.createElement( 'button' ); | |
| 575 | - close.type = 'button'; | |
| 576 | - close.className = 'button-link srfm-details-close'; | |
| 577 | - close.textContent = labels.close || 'Close'; | |
| 578 | - | |
| 579 | - let revert = 0; | |
| 580 | - | |
| 581 | - function dismiss() { | |
| 582 | - document.removeEventListener( 'keydown', onKey ); | |
| 583 | - window.clearTimeout( revert ); | |
| 584 | - document.body.style.overflow = previousOverflow; | |
| 585 | - overlay.remove(); | |
| 586 | - | |
| 587 | - // Back to whatever opened it, on every close path. | |
| 588 | - if ( opener && typeof opener.focus === 'function' ) { | |
| 589 | - opener.focus(); | |
| 590 | - } | |
| 591 | - } | |
| 592 | - | |
| 593 | - // aria-modal asserts that the background is unavailable; it does nothing to | |
| 594 | - // the tab sequence in any browser. Without this, Tab from the last control | |
| 595 | - // walks into the admin bar, the admin menu and the links behind the overlay. | |
| 596 | - function focusables() { | |
| 597 | - return [ pre, close, copy, contact ].filter( function ( el ) { | |
| 598 | - return ( | |
| 599 | - el && | |
| 600 | - ! el.hidden && | |
| 601 | - ! el.disabled && | |
| 602 | - ! el.hasAttribute( 'aria-disabled' ) | |
| 603 | - ); | |
| 604 | - } ); | |
| 605 | - } | |
| 606 | - | |
| 607 | - function onKey( e ) { | |
| 608 | - if ( e.key === 'Escape' ) { | |
| 609 | - dismiss(); | |
| 610 | - return; | |
| 611 | - } | |
| 612 | - | |
| 613 | - if ( e.key !== 'Tab' ) { | |
| 614 | - return; | |
| 615 | - } | |
| 616 | - | |
| 617 | - const stops = focusables(); | |
| 618 | - | |
| 619 | - if ( ! stops.length ) { | |
| 620 | - return; | |
| 621 | - } | |
| 622 | - | |
| 623 | - const at = stops.indexOf( overlay.ownerDocument.activeElement ); | |
| 624 | - const next = e.shiftKey ? at - 1 : at + 1; | |
| 625 | - | |
| 626 | - if ( at === -1 || next < 0 || next >= stops.length ) { | |
| 627 | - e.preventDefault(); | |
| 628 | - stops[ | |
| 629 | - e.shiftKey ? stops.length - 1 : 0 | |
| 630 | - ].focus(); | |
| 631 | - } | |
| 632 | - } | |
| 633 | - | |
| 634 | - copy.addEventListener( 'click', function () { | |
| 635 | - copyDetails( | |
| 636 | - text, | |
| 637 | - function () { | |
| 638 | - copy.textContent = labels.copied || 'Copied'; | |
| 639 | - // Reverts on its own: a button stuck on "Copied" says nothing | |
| 640 | - // about the next click. The unlock does not revert with it -- | |
| 641 | - // having the clipboard stays true after the label has gone back. | |
| 642 | - window.clearTimeout( revert ); | |
| 643 | - revert = window.setTimeout( function () { | |
| 644 | - copy.textContent = labels.copy || 'Copy details'; | |
| 645 | - }, 2000 ); | |
| 646 | - | |
| 647 | - unlockContact(); | |
| 648 | - hint.textContent = labels.unlocked || ''; | |
| 649 | - | |
| 650 | - // On success only, so the event means a copy happened rather | |
| 651 | - // than a copy was attempted. | |
| 652 | - sendResponse( noticeId, 'copy_details' ); | |
| 653 | - }, | |
| 654 | - function () { | |
| 655 | - // Refused: an insecure origin, a permission policy, a user | |
| 656 | - // decision. Do not claim success -- but release Contact Support | |
| 657 | - // for the reason above, and say why the text was not copied. | |
| 658 | - hint.textContent = labels.copyFailed || ''; | |
| 659 | - unlockContact(); | |
| 660 | - } | |
| 661 | - ); | |
| 662 | - } ); | |
| 663 | - | |
| 664 | - contact.addEventListener( 'click', function () { | |
| 665 | - sendResponse( noticeId, 'contact_support' ); | |
| 666 | - // The form opens in its own tab, so the dialog has nothing left to | |
| 667 | - // show. | |
| 668 | - dismiss(); | |
| 669 | - } ); | |
| 670 | - | |
| 671 | - close.addEventListener( 'click', dismiss ); | |
| 672 | - overlay.addEventListener( 'click', function ( e ) { | |
| 673 | - // Backdrop only: a click inside the panel must not close it while | |
| 674 | - // someone is selecting the text. | |
| 675 | - if ( e.target === overlay ) { | |
| 676 | - dismiss(); | |
| 677 | - } | |
| 678 | - } ); | |
| 679 | - document.addEventListener( 'keydown', onKey ); | |
| 680 | - | |
| 681 | - actions.appendChild( hint ); | |
| 682 | - actions.appendChild( close ); | |
| 683 | - actions.appendChild( copy ); | |
| 684 | - | |
| 685 | - actions.appendChild( contact ); | |
| 686 | - | |
| 687 | - panel.appendChild( heading ); | |
| 688 | - panel.appendChild( description ); | |
| 689 | - panel.appendChild( pre ); | |
| 690 | - panel.appendChild( actions ); | |
| 691 | - overlay.appendChild( panel ); | |
| 692 | - document.body.appendChild( overlay ); | |
| 693 | - | |
| 694 | - // The page behind must not scroll under the backdrop. | |
| 695 | - document.body.style.overflow = 'hidden'; | |
| 696 | - | |
| 697 | - // The diagnostics region while the payload is still coming: it carries the | |
| 698 | - // placeholder, so focusing it says what the dialog is doing. Once there is | |
| 699 | - // something to copy, the copy button is the first thing anyone wants. | |
| 700 | - ( copy.disabled ? pre : copy ).focus(); | |
| 701 | - | |
| 702 | - return { | |
| 703 | - /** | |
| 704 | - * Replace the placeholder once the payload arrives. | |
| 705 | - * | |
| 706 | - * @param {string} next The text to show and copy. | |
| 707 | - * @param {boolean} copyable Whether it is worth copying. False for an | |
| 708 | - * error message, which would otherwise unlock | |
| 709 | - * Contact Support on the strength of having | |
| 710 | - * copied the error. | |
| 711 | - * @return {void} | |
| 712 | - */ | |
| 713 | - setText( next, copyable ) { | |
| 714 | - // Dropped once this dialog is gone. Open, close, open again inside | |
| 715 | - // one round trip and the first response would otherwise write into | |
| 716 | - // the second dialog -- or into a detached node, silently. | |
| 717 | - if ( ! overlay.isConnected ) { | |
| 718 | - return; | |
| 719 | - } | |
| 720 | - | |
| 721 | - text = next || ''; | |
| 722 | - pre.textContent = text; | |
| 723 | - pre.removeAttribute( 'aria-busy' ); | |
| 724 | - | |
| 725 | - if ( false === copyable ) { | |
| 726 | - // No report to paste, so the copy-first gate has nothing to gate | |
| 727 | - // on. Contact Support is the only route that retires the notice, | |
| 728 | - // and these items are not dismissible -- leaving it locked would | |
| 729 | - // be an undismissable notice with no working action on it. | |
| 730 | - copy.hidden = true; | |
| 731 | - hint.textContent = ''; | |
| 732 | - unlockContact(); | |
| 733 | - return; | |
| 734 | - } | |
| 735 | - | |
| 736 | - copy.disabled = ! text; | |
| 737 | - }, | |
| 738 | - | |
| 739 | - /** | |
| 740 | - * Point Contact Support somewhere, once the payload names it. | |
| 741 | - * | |
| 742 | - * @param {string} next Destination. Empty keeps the button out of the | |
| 743 | - * dialog entirely. | |
| 744 | - * @return {void} | |
| 745 | - */ | |
| 746 | - setSupportUrl( next ) { | |
| 747 | - if ( ! overlay.isConnected ) { | |
| 748 | - return; | |
| 749 | - } | |
| 750 | - | |
| 751 | - supportUrl = next || ''; | |
| 752 | - contact.hidden = ! supportUrl; | |
| 753 | - | |
| 754 | - // A mailto: has no document to open, so _blank would leave a blank | |
| 755 | - // tab behind. srfm_support_contact_url can return one, and the | |
| 756 | - // notice's own links already match on this. | |
| 757 | - if ( /^mailto:/i.test( supportUrl ) ) { | |
| 758 | - contact.removeAttribute( 'target' ); | |
| 759 | - contact.removeAttribute( 'rel' ); | |
| 760 | - } else { | |
| 761 | - contact.target = '_blank'; | |
| 762 | - contact.rel = 'noopener noreferrer'; | |
| 763 | - } | |
| 764 | - | |
| 765 | - if ( supportUrl && ! contact.hasAttribute( 'aria-disabled' ) ) { | |
| 766 | - contact.href = supportUrl; | |
| 767 | - } | |
| 768 | - }, | |
| 769 | - | |
| 770 | - dismiss, | |
| 771 | - }; | |
| 772 | - } | |
| 773 | - | |
| 774 | - /** | |
| 775 | - * Open the dialog for one classic notice and fetch its payload. | |
| 776 | - * | |
| 777 | - * Fetched on open rather than printed beside every notice. The diagnostics are | |
| 778 | - * written by a public REST route, so shipping them with the page put | |
| 779 | - * attacker-authored text on every admin screen whether or not anyone opened | |
| 780 | - * the dialog. | |
| 781 | - * | |
| 782 | - * The dialog opens first, with a placeholder, and fills in when the response | |
| 783 | - * lands -- a surface whose whole job is reporting a failure must not be a | |
| 784 | - * button that does nothing until the network answers. A failed fetch says so | |
| 785 | - * in place and still offers Contact Support, which is the only action that | |
| 786 | - * retires the notice. | |
| 787 | - * | |
| 788 | - * @param {string} noticeId Item id, used as the analytics key. | |
| 789 | - * @param {string} category Failure category, sent to the handler. | |
| 790 | - * @return {boolean} Whether the payload was found and the dialog opened. | |
| 791 | - */ | |
| 792 | - function openDetails( noticeId, category ) { | |
| 793 | - if ( typeof srfmNoticeResponse === 'undefined' || ! category ) { | |
| 794 | - return false; | |
| 795 | - } | |
| 796 | - | |
| 797 | - const labels = srfmNoticeResponse.details || {}; | |
| 798 | - | |
| 799 | - const dialog = showDetails( { | |
| 800 | - noticeId, | |
| 801 | - text: labels.loading || 'Loading…', | |
| 802 | - supportUrl: '', | |
| 803 | - pending: true, | |
| 804 | - } ); | |
| 805 | - | |
| 806 | - if ( ! dialog ) { | |
| 807 | - return false; | |
| 808 | - } | |
| 809 | - | |
| 810 | - const body = new FormData(); | |
| 811 | - body.append( 'action', 'srfm_action_item_details' ); | |
| 812 | - body.append( 'nonce', srfmNoticeResponse.detailsNonce ); | |
| 813 | - body.append( 'category', category ); | |
| 814 | - | |
| 815 | - fetch( srfmNoticeResponse.ajaxurl, { method: 'POST', body } ) | |
| 816 | - .then( function ( response ) { | |
| 817 | - return response.json(); | |
| 818 | - } ) | |
| 819 | - .then( function ( json ) { | |
| 820 | - if ( ! json || ! json.success || ! json.data ) { | |
| 821 | - throw new Error( 'unavailable' ); | |
| 822 | - } | |
| 823 | - | |
| 824 | - // Same fallback as the failure path below. A successful fetch must | |
| 825 | - // not end up with less to act on than a failed one: these notices | |
| 826 | - // are not dismissible and Contact Support is the only thing that | |
| 827 | - // retires them, so an empty destination here is a dead end. | |
| 828 | - dialog.setSupportUrl( | |
| 829 | - json.data.support_url || | |
| 830 | - srfmNoticeResponse.supportUrl || | |
| 831 | - '' | |
| 832 | - ); | |
| 833 | - dialog.setText( json.data.details || '' ); | |
| 834 | - } ) | |
| 835 | - .catch( function () { | |
| 836 | - // The report is built server-side, so there is nothing to show in | |
| 837 | - // its place. Say so, and fall back to the untagged support form: | |
| 838 | - // Contact Support is the only action that retires these notices, so | |
| 839 | - // a failed fetch must not also take the way out with it. | |
| 840 | - // | |
| 841 | - // Destination set before the message, because the message releases | |
| 842 | - // the copy-first lock and that release needs somewhere to point. | |
| 843 | - dialog.setSupportUrl( srfmNoticeResponse.supportUrl || '' ); | |
| 844 | - dialog.setText( labels.unavailable || '', false ); | |
| 845 | - } ); | |
| 846 | - | |
| 847 | - return true; | |
| 848 | - } | |
| 849 | - | |
| 850 | - document.addEventListener( 'click', function ( e ) { | |
| 851 | - // A click whose target is not an Element -- text nodes, some synthetic | |
| 852 | - // events -- has no closest() and would throw out of the handler. | |
| 853 | - if ( ! e.target || typeof e.target.closest !== 'function' ) { | |
| 854 | - return; | |
| 855 | - } | |
| 856 | - | |
| 857 | - const trigger = e.target.closest( '[data-srfm-details-for]' ); | |
| 858 | - | |
| 859 | - if ( ! trigger ) { | |
| 860 | - return; | |
| 861 | - } | |
| 862 | - | |
| 863 | - // Only swallow the navigation if the dialog actually opened. If it cannot, | |
| 864 | - // the href still goes to the dashboard, which is where the same details are | |
| 865 | - // readable. | |
| 866 | - if ( | |
| 867 | - openDetails( | |
| 868 | - trigger.getAttribute( 'data-srfm-details-for' ), | |
| 869 | - trigger.getAttribute( 'data-srfm-category' ) | |
| 870 | - ) | |
| 871 | - ) { | |
| 872 | - e.preventDefault(); | |
| 873 | - } | |
| 874 | - } ); | |
| 875 | 303 | }() ); |