Source: src/js/views/AppView.js

/*global define */
define([
  "jquery",
  "underscore",
  "backbone",
  "views/AltHeaderView",
  "views/NavbarView",
  "views/FooterView",
  "views/SignInView",
  "text!templates/alert.html",
  "text!templates/appHead.html",
  "text!templates/jsonld.txt",
  "text!templates/app.html",
  "text!templates/loading.html",
], function (
  $,
  _,
  Backbone,
  AltHeaderView,
  NavbarView,
  FooterView,
  SignInView,
  AlertTemplate,
  AppHeadTemplate,
  JsonLDTemplate,
  AppTemplate,
  LoadingTemplate
) {
  "use strict";

  var app = app || {};

  /**
   * @class AppView
   * @classdesc The top-level view of the UI that contains and coordinates all other views of the UI
   * @classcategory Views
   */
  var AppView = Backbone.View.extend(
    /** @lends AppView.prototype */ {
      // Instead of generating a new element, bind to the existing skeleton of
      // the App already present in the HTML.
      el: "#metacatui-app",

      //Templates
      template: _.template(AppTemplate),
      alertTemplate: _.template(AlertTemplate),
      appHeadTemplate: _.template(AppHeadTemplate),
      jsonLDTemplate: _.template(JsonLDTemplate),
      loadingTemplate: _.template(LoadingTemplate),

      events: {
        click: "closePopovers",
        "click .btn.direct-search": "routeToMetadata",
        "keypress input.direct-search": "routeToMetadataOnEnter",
        "click .toggle-slide": "toggleSlide",
        "click input.copy": "higlightInput",
        "focus input.copy": "higlightInput",
        "click textarea.copy": "higlightInput",
        "focus textarea.copy": "higlightInput",
        "click .open-chat": "openChatWithMessage",
        "click .login.redirect": "sendToLogin",
        "focus .jump-width-input": "widenInput",
        "focusout .jump-width-input": "narrowInput",
        "click .temporary-message .close": "hideTemporaryMessage",
      },

      initialize: function () {
        //Check for the LDAP sign in error message
        if (
          window.location.search.indexOf(
            "error=Unable%20to%20authenticate%20LDAP%20user"
          ) > -1
        ) {
          window.location =
            window.location.origin +
            window.location.pathname +
            "#signinldaperror";
        }

        //Is there a logged-in user?
        MetacatUI.appUserModel.checkStatus();

        //Change the document title when the app changes the MetacatUI.appModel title at any time
        this.listenTo(MetacatUI.appModel, "change:title", this.changeTitle);
        this.listenTo(MetacatUI.appModel, "change:description", this.changeDescription);

        this.checkIncompatibility();
      },

      /**
      * The JS query selector for the element inside the AppView that contains the main view contents. When a new view is routed to
      * and displayed via {@link AppView#showView}, the view will be inserted into this element.
      * @type {string}
      * @default "#Content"
      * @since 2.22.0
      */
      contentSelector: "#Content",

      /**
       * Gets the Element in this AppView via the {@link AppView#contentSelector}
       * @returns {Element}
       */
      getContentEl: function () {
        return this.el.querySelector(this.contentSelector);
      },

      /**
       * Change the web document's title
       */
      changeTitle: function () {
        document.title = MetacatUI.appModel.get("title");
      },

      /**
       * Change the web document's description
       * @since 2.25.0
       */
      changeDescription: function () {
        $("meta[name=description]").attr(
          "content",
          MetacatUI.appModel.get("description")
        );
      },

      /** Render the main view and/or re-render subviews. Delegate rendering
        and event handling to sub views.
    ---
    If there is no AppView element on the page, don't render the application.
     ---
    If there is no AppView element on the page, this function will exit without rendering anything.
    For instance, this can occur when the AppView is loaded during unit tests.
    See {@link AppView#el} to check which element is required for rendering. By default,
    it is set to the element with the `metacatui-app` id (check docs for the most up-to-date info).
    ----
    This step is usually unnecessary for Backbone Views since they should only render elements inside of
    their own `el` anyway. But the APpView is different since it renders the overall structure of MetacatUI.
    */
      render: function () {
        //If there is no AppView element on the page, don't render the application.
        if (!this.el) {
          console.error(
            "Not rendering the UI of the app since the AppView HTML element (AppView.el) does not exist on the page. Make sure you have the AppView element included in index.html"
          );
          return;
        }

        // set up the head - make sure to prepend, otherwise the CSS may be out of order!
        $("head")
          .append(
            this.appHeadTemplate({
              theme: MetacatUI.theme
            })
          )
          //Add the JSON-LD to the head element
          .append(
            $(document.createElement("script"))
              .attr("type", "application/ld+json")
              .attr("id", "jsonld")
              .html(this.jsonLDTemplate())
          );

        // set up the body
        this.$el.append(this.template());

        /**
         * @name MetacatUI.navbarView
         * @type NavbarView
         * @description The view that displays a navigation menu on every MetacatUI page and controls the navigation between pages in MetacatUI.
         */
        MetacatUI.navbarView = new NavbarView();
        MetacatUI.navbarView.setElement($("#Navbar")).render();

        /**
         * @name MetacatUI.altHeaderView
         * @type AltHeaderView
         * @description The view that displays a header on every MetacatUI view that uses the "AltHeader" header type.
         * This header is usually for decorative / aesthetic purposes only.
         */
        MetacatUI.altHeaderView = new AltHeaderView();
        MetacatUI.altHeaderView.setElement($("#HeaderContainer")).render();

        /**
         * @name MetacatUI.footerView
         * @type FooterView
         * @description The view that displays the main footer of the MetacatUI page.
         * It has informational and navigational links in it and is displayed on every page, except for views that hide it for full-screen display.
         */
        MetacatUI.footerView = new FooterView();
        MetacatUI.footerView.setElement($("#Footer")).render();

        this.showTemporaryMessage();

        //Load the Slaask chat widget if it is enabled in this theme
        if (MetacatUI.appModel.get("slaaskKey") && window._slaask)
          _slaask.init(MetacatUI.appModel.get("slaaskKey"));

        this.listenForActivity();
        this.listenForTimeout();

        this.initializeWidgets();

        return this;
      },

      // the currently rendered view
      currentView: null,

      // Our view switcher for the whole app
      showView: function (view, viewOptions) {
        if (!this.el) {
          console.error(
            "Not rendering the UI of the app since the AppView HTML element (AppView.el) does not exist on the page. Make sure you have the AppView element included in index.html"
          );
          return;
        }

        //reference to appView
        var thisAppViewRef = this;

        // Change the background image if there is one
        MetacatUI.navbarView.changeBackground();

        // close the current view
        if (this.currentView) {
          //If the current view has a function to confirm closing of the view, call it
          if (typeof this.currentView.canClose == "function") {
            //If the user or view confirmed that the view shouldn't be closed, then don't navigate to the next route
            if (!this.currentView.canClose()) {
              //Get a confirmation message from the view, or use a default one
              if (
                typeof this.currentView.getConfirmCloseMessage == "function"
              ) {
                var confirmMessage = this.currentView.getConfirmCloseMessage();
              } else {
                var confirmMessage = "Leave this page?";
              }

              //Show a confirm alert to the user and wait for their response
              var leave = confirm(confirmMessage);
              //If they clicked Cancel, then don't navigate to the next route
              if (!leave) {
                MetacatUI.uiRouter.undoLastRoute();
                return;
              }
            }
          }

          // need reference to the old/current view for the callback method
          var oldView = this.currentView;

          this.currentView.$el.fadeOut("slow", function () {
            // clean up old view
            if (oldView.onClose) oldView.onClose();

            //If the view to show is not the same as the main content element, then put it inside the content element.
            if (view.el !== thisAppViewRef.getContentEl()) {
              thisAppViewRef.getContentEl().replaceChildren(view.el);
              $(thisAppViewRef.getContentEl()).show();
            }

            view.$el.fadeIn("slow", function () {
              // render the new view
              view.render(viewOptions);

              // after fade in, do postRender()
              if (view.postRender) view.postRender();
              // force scroll to top if no custom scrolling is implemented
              else thisAppViewRef.scrollToTop();
            });
          });
        } else {
          //If the view to show is not the same as the main content element, then put it inside the content element.
          if (view.el !== this.getContentEl()) {
            this.getContentEl().replaceChildren(view.el);
          }

          // just show the view without transition
          view.render(viewOptions);

          if (view.postRender) view.postRender();
          // force scroll to top if no custom scrolling is implemented
          else thisAppViewRef.scrollToTop();
        }

        // track the current view
        this.currentView = view;
        MetacatUI.analytics?.trackPageView();

        this.trigger("appRenderComplete");
      },

      routeToMetadata: function (e) {
        e.preventDefault();

        //Get the value from the input element
        var form = $(e.target).attr("form") || null,
          val = this.$("#" + form)
            .find("input[type=text]")
            .val();

        //Remove the text from the input
        this.$("#" + form)
          .find("input[type=text]")
          .val("");

        if (!val) return false;

        MetacatUI.uiRouter.navigate("view/" + val, { trigger: true });
      },

      routeToMetadataOnEnter: function (e) {
        //If the user pressed a key inside a text input, we only want to proceed if it was the Enter key
        if (e.type == "keypress" && e.keycode != 13) return;
        else this.routeToMetadata(e);
      },

      sendToLogin: function (e) {
        if (e) e.preventDefault();

        var url = $(e.target).attr("href");
        url = url.substring(0, url.indexOf("target=") + 7);
        url += window.location.href;

        window.location.href = url;
      },

      resetSearch: function () {
        // Clear the search and map model to start a fresh search
        MetacatUI.appSearchModel.clear();
        MetacatUI.appSearchModel.set(MetacatUI.appSearchModel.defaults());
        MetacatUI.mapModel.clear();
        MetacatUI.mapModel.set(MetacatUI.mapModel.defaults());

        //Clear the search history
        MetacatUI.appModel.set("searchHistory", new Array());

        MetacatUI.uiRouter.navigate("data", { trigger: true });
      },

      closePopovers: function (e) {
        if (this.currentView && this.currentView.closePopovers)
          this.currentView.closePopovers(e);
      },

      toggleSlide: function (e) {
        if (e) e.preventDefault();
        else return false;

        var clickedOn = $(e.target),
          toggleElId =
            clickedOn.attr("data-slide-el") ||
            clickedOn.parents("[data-slide-el]").attr("data-slide-el"),
          toggleEl = $("#" + toggleElId);

        toggleEl.slideToggle("fast", function () {
          //Toggle the display of the link if it has the right class
          if (clickedOn.is(".toggle-display-on-slide")) {
            clickedOn.siblings(".toggle-display-on-slide").toggle();
            clickedOn.toggle();
          }
        });
      },

      /**
       * Displays the given message to the user in a Bootstrap "alert" style.
       * @param {object} options A literal object of options for the alert message.
       * @property {string|Element} options.message A message string or HTML Element to display
       * @property {string} [options.classes] A string of HTML classes to set on the alert
       * @property {string|Element} [options.container] The container to show the alert in
       * @property {boolean} [options.replaceContents] If true, the alert will replace the contents of the container element.
       *                                               If false, the alert will be prepended to the container element.
       * @property {boolean|number} [options.delay] Set to true or specify a number of milliseconds to display the alert temporarily
       * @property {boolean} [options.remove] If true, the user will be able to remove the alert with a "close" icon.
       * @property {boolean} [options.includeEmail] If true, the alert will include a link to the {@link AppConfig#emailContact}
       * @property {string} [options.emailBody] Specify an email body to use in the email link.
       * @returns {Element} The alert element
       */
      showAlert: function () {
        if (arguments.length > 1) {
          var options = {
            message: arguments[0],
            classes: arguments[1],
            container: arguments[2],
            delay: arguments[3],
          };
          if (typeof arguments[4] == "object") {
            options = _.extend(options, arguments[4]);
          }
        } else {
          var options = arguments[0];
        }

        if (typeof options != "object" || !options) {
          return;
        }

        if (!options.classes) options.classes = "alert-success";

        if (!options.container || !$(options.container).length)
          options.container = this.$el;

        //Remove any alerts that are already in this container
        if ($(options.container).children(".alert-container").length > 0)
          $(options.container).children(".alert-container").remove();

        //Allow messages to be HTML or strings
        if (typeof options.message != "string")
          options.message = $(document.createElement("div"))
            .append($(options.message))
            .html();

        var emailOptions = "";

        //Check for more options
        if (options.emailBody) emailOptions += "?body=" + options.emailBody;

        var alert = $.parseHTML(
          this.alertTemplate({
            msg: options.message,
            classes: options.classes,
            emailOptions: emailOptions,
            remove: options.remove || false,
            includeEmail: options.includeEmail,
          }).trim()
        );

        if (options.delay) {
          $(alert).hide();

          if (options.replaceContents) {
            $(options.container).html(alert);
          } else {
            $(options.container).prepend(alert);
          }

          $(alert)
            .show()
            .delay(typeof options.delay == "number" ? options.delay : 3000)
            .fadeOut();
        } else {
          if (options.replaceContents) {
            $(options.container).html(alert);
          } else {
            $(options.container).prepend(alert);
          }
        }
        return alert;
      },

      /**
       * Previous to MetacatUI 2.14.0, the {@link AppView#showAlert} function allowed up to five parameters
       * to customize the alert message. As of 2.14.0, the function has condensed these options into
       * a single literal object. See the docs for {@link AppView#showAlert}. The old signature of five
       * parameters may soon be deprecated completely, but is still supported.
       * @deprecated
       * @param {string|Element} msg
       * @param {string} [classes]
       * @param {string|Element} [container]
       * @param {boolean} [delay]
       * @param {object} [options]
       * @param {boolean} [options.includeEmail] If true, the alert will include a link to the {@link AppConfig#emailContact}
       * @param {string} [options.emailBody]
       * @param {boolean} [options.remove]
       * @param {boolean} [options.replaceContents]
       */
      showAlert_deprecated: function (
        msg,
        classes,
        container,
        delay,
        options
      ) {},

      /**
       * Listens to the focus event on the window to detect when a user switches back to this browser tab from somewhere else
       * When a user checks back, we want to check for log-in status
       */
      listenForActivity: function () {
        MetacatUI.appUserModel.on("change:loggedIn", function () {
          if (!MetacatUI.appUserModel.get("loggedIn")) return;

          //When the user re-focuses back on the window
          $(window).focus(function () {
            //If the user has logged out in the meantime, then exit
            if (!MetacatUI.appUserModel.get("loggedIn")) return;

            //If the expiration date of the token has passed, then allow the user to sign back in
            if (MetacatUI.appUserModel.get("expires") <= new Date()) {
              MetacatUI.appView.showTimeoutSignIn();
            }
          });
        });
      },

      /**
       * Will determine the length of time until the user's current token expires,
       * and will set a window timeout for that length of time. When the timeout
       * is triggered, the sign in modal window will be displayed so that the user
       * can sign in again (which happens in AppView.showTimeoutSignIn())
       */
      listenForTimeout: function () {
        //Only proceed if the user is logged in
        if (!MetacatUI.appUserModel.get("checked")) {
          //When the user logged back in, listen again for the next timeout
          this.listenToOnce(
            MetacatUI.appUserModel,
            "change:checked",
            function () {
              //If the user is logged in, then listen call this function again
              if (
                MetacatUI.appUserModel.get("checked") &&
                MetacatUI.appUserModel.get("loggedIn")
              )
                this.listenForTimeout();
            }
          );

          return;
        } else if (!MetacatUI.appUserModel.get("loggedIn")) {
          //When the user logged back in, listen again for the next timeout
          this.listenToOnce(
            MetacatUI.appUserModel,
            "change:loggedIn",
            function () {
              //If the user is logged in, then listen call this function again
              if (
                MetacatUI.appUserModel.get("checked") &&
                MetacatUI.appUserModel.get("loggedIn")
              )
                this.listenForTimeout();
            }
          );

          return;
        }

        var view = this,
          expires = MetacatUI.appUserModel.get("expires"),
          timeLeft = expires - new Date();

        //If there is no time left until expiration, then show the sign in view now
        if (timeLeft < 0) {
          this.showTimeoutSignIn();
        }
        //Otherwise, set a timeout for a expiration time, then show the Sign In View
        else {
          var timeoutId = setTimeout(function () {
            view.showTimeoutSignIn.call(view);
          }, timeLeft);

          //Save the timeout id in case we want to destroy the timeout later
          MetacatUI.appUserModel.set("timeoutId", timeoutId);
        }
      },

      /**
       * If the user's auth token has expired, a new SignInView model window is
       * displayed so the user can sign back in. A listener is set on the appUserModel
       * so that when they do successfully sign back in, we set another timeout listener
       * via AppView.listenForTimeout()
       */
      showTimeoutSignIn: function () {
        if (MetacatUI.appUserModel.get("expires") <= new Date()) {
          MetacatUI.appUserModel.set("loggedIn", false);

          var signInView = new SignInView({
            inPlace: true,
            closeButtons: false,
            topMessage:
              "Your session has timed out. Click Sign In to open a " +
              "new window to sign in again. Make sure your browser settings allow pop-ups.",
          });
          var signInForm = signInView.render().el;

          if (this.subviews && Array.isArray(this.subviews))
            this.subviews.push(signInView);
          else this.subviews = [signInView];

          $("body").append(signInForm);
          $(signInForm).modal();

          //When the user logged back in, listen again for the next timeout
          this.listenToOnce(
            MetacatUI.appUserModel,
            "change:checked",
            function () {
              if (
                MetacatUI.appUserModel.get("checked") &&
                MetacatUI.appUserModel.get("loggedIn")
              )
                this.listenForTimeout();
            }
          );
        }
      },

      openChatWithMessage: function () {
        if (!_slaask) return;

        $("#slaask-input").val(MetacatUI.appModel.get("defaultSupportMessage"));
        $("#slaask-button").trigger("click");
      },

      initializeWidgets: function () {
        // Autocomplete widget extension to provide description tooltips.
        $.widget("app.hoverAutocomplete", $.ui.autocomplete, {
          // Set the content attribute as the "item.desc" value.
          // This becomes the tooltip content.
          _renderItem: function (ul, item) {
            // if we have a label, use it for the title
            var title = item.value;
            if (item.label) {
              title = item.label;
            }
            // if we have a description, use it for the content
            var content = item.value;
            if (item.desc) {
              content = item.desc;
              if (item.desc != item.value) {
                content += " (" + item.value + ")";
              }
            }
            var element = this._super(ul, item)
              .attr("data-title", title)
              .attr("data-content", content);
            element.popover({
              placement: "right",
              trigger: "hover",
              container: "body",
            });
            return element;
          },
        });
      },

      /**
       * Checks if the user's browser is an outdated version that won't work with
       * MetacatUI well, and displays a warning message to the user..
       * The user agent is checked against the `unsupportedBrowsers` list in the AppModel.
       */
      checkIncompatibility: function () {
        //Check if this browser is incompatible with this app. i.e. It is an old browser version
        var isUnsupportedBrowser = _.some(
          MetacatUI.appModel.get("unsupportedBrowsers"),
          function (browserRegEx) {
            var matches = navigator.userAgent.match(browserRegEx);
            return matches && matches.length > 0;
          }
        );

        if (!isUnsupportedBrowser) {
          return;
        } else {
          //Show a warning message to the user about their browser.
          this.showAlert(
            "Your web browser is out of date. Update your browser for more security, " +
              "speed and the best experience on this site.",
            "alert-warning",
            this.$el,
            false,
            { remove: true }
          );
          this.$el
            .children(".alert-container")
            .addClass("important-app-message");
        }
      },

      /**
       * Shows a temporary message at the top of the view
       */
      showTemporaryMessage: function () {
        try {
          //Is there a temporary message to display throughout the app?
          if (MetacatUI.appModel.get("temporaryMessage")) {
            var startTime = MetacatUI.appModel.get("temporaryMessageStartTime"),
              endTime = MetacatUI.appModel.get("temporaryMessageEndTime"),
              today = new Date(),
              isDisplayed = false;

            //Find cases where we should display the message
            //If there is a date range and today is in the range
            if (startTime && endTime && today > startTime && today < endTime) {
              isDisplayed = true;
            }
            //If there's just a start time and today is after it
            else if (startTime && !endTime && today > startTime) {
              isDisplayed = true;
            }
            //If there's just an end time and today is before it
            else if (!startTime && endTime && today < endTime) {
              isDisplayed = true;
            }
            //If there's no start or end time
            else if (!startTime && !endTime) {
              isDisplayed = true;
            }

            if (isDisplayed) {
              require(["text!templates/alert.html"], function (alertTemplate) {
                //Get classes for the message
                var classes =
                  MetacatUI.appModel.get("temporaryMessageClasses") || "";
                classes += " temporary-message";

                var container =
                  MetacatUI.appModel.get("temporaryMessageContainer") ||
                  "#Navbar";

                //If the message exists already, return
                if ($(container + " .temporary-message").length) {
                  return;
                }

                //Insert the message using the Alert template
                $(container).prepend(
                  _.template(alertTemplate)({
                    classes: classes,
                    msg: MetacatUI.appModel.get("temporaryMessage"),
                    includeEmail: MetacatUI.appModel.get(
                      "temporaryMessageIncludeEmail"
                    ),
                    remove: true,
                  })
                );

                //Add a class to the body in case we need to adjust other elements on the page
                $("body").addClass("has-temporary-message");
              });
            }
          }
        } catch (e) {
          console.error("Couldn't display the temporary message: ", e);
        }
      },

      /**
       * Hides the temporary message
       */
      hideTemporaryMessage: function () {
        try {
          this.$(".temporary-message").remove();
          $("body").removeClass("has-temporary-message");
        } catch (e) {
          console.error("Couldn't hide the temporary message: ", e);
        }
      },

      /********************** Utilities ********************************/
      // Various utility functions to use across the app //
      /************ Function to add commas to large numbers ************/
      commaSeparateNumber: function (val) {
        if (!val) return 0;

        if (val < 1) return Math.round(val * 100) / 100;

        while (/(\d+)(\d{3})/.test(val.toString())) {
          val = val.toString().replace(/(\d+)(\d{3})/, "$1" + "," + "$2");
        }
        return val;
      },
      numberAbbreviator: function (number, decimalPlaces) {
        if (number === 0) {
          return 0;
        }
        decimalPlaces = Math.pow(10, decimalPlaces);
        var abbreviations = ["K", "M", "B", "T"];

        // Go through the array backwards, so we do the largest first
        for (var i = abbreviations.length - 1; i >= 0; i--) {
          // Convert array index to "1000", "1000000", etc
          var size = Math.pow(10, (i + 1) * 3);

          // If the number is bigger or equal do the abbreviation
          if (size <= number) {
            // Here, we multiply by decimalPlaces, round, and then divide by decimalPlaces.
            // This gives us nice rounding to a particular decimal place.
            number =
              Math.round((number * decimalPlaces) / size) / decimalPlaces;

            // Handle special case where we round up to the next abbreviation
            if (number == 1000 && i < abbreviations.length - 1) {
              number = 1;
              i++;
            }

            // Add the letter for the abbreviation
            number += abbreviations[i];
            break;
          }
        }
        return number;
      },
      higlightInput: function (e) {
        if (!e) return;

        e.preventDefault();
        e.target.setSelectionRange(0, 9999);
      },

      widenInput: function (e) {
        $(e.target).css("width", "200px");
      },

      narrowInput: function (e) {
        $(e.target).delay(500).animate({ width: "60px" });
      },

      // scroll to top of page
      scrollToTop: function () {
        $("body,html")
          .stop(true, true) //stop first for it to work in FF
          .animate({ scrollTop: 0 }, "slow");
        return false;
      },

      scrollTo: function (pageElement, offsetTop) {
        //Find the header height if it is a fixed element
        var headerOffset =
          this.$("#Header").css("position") == "fixed"
            ? this.$("#Header").outerHeight()
            : 0;
        var navOffset =
          this.$("#Navbar").css("position") == "fixed"
            ? this.$("#Navbar").outerHeight()
            : 0;
        var totalOffset = headerOffset + navOffset;

        $("body,html")
          .stop(true, true) //stop first for it to work in FF
          .animate(
            { scrollTop: $(pageElement).offset().top - 40 - totalOffset },
            1000
          );
        return false;
      },
    }
  );
  return AppView;
});