/*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.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); }, //Changes the web document's title changeTitle: function () { document.title = MetacatUI.appModel.get("title"); }, /** 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, googleAnalyticsKey: MetacatUI.appModel.get("googleAnalyticsKey"), }) ) //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; this.sendAnalytics(); this.trigger("appRenderComplete"); }, sendAnalytics: function () { if ( !MetacatUI.appModel.get("googleAnalyticsKey") || typeof ga === "undefined" ) return; var page = window.location.pathname || "/"; page = page.replace("#", ""); //remove the leading pound sign ga("send", "pageview", { page: page }); }, 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. */ 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); } } }, /** * 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; });