PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | admin/assets/js/notice-response.js +0 -572 2.12.7 → 2.12.8 View file →
@@ -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, '&amp;' )
325 - .replace( /</g, '&lt;' )
326 - .replace( />/g, '&gt;' );
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 }() );