/*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(); // 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()); // render the nav MetacatUI.navbarView = new NavbarView(); MetacatUI.navbarView.setElement($('#Navbar')).render(); MetacatUI.altHeaderView = new AltHeaderView(); MetacatUI.altHeaderView.setElement($('#HeaderContainer')).render(); 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")); //Change the document title when the app changes the MetacatUI.appModel title at any time this.listenTo(MetacatUI.appModel, "change:title", this.changeTitle); this.listenForActivity(); this.listenForTimeout(); this.initializeWidgets(); this.checkIncompatibility(); }, //Changes the web document's title changeTitle: function(){ document.title = MetacatUI.appModel.get("title"); }, // Render the main view and/or re-render subviews. Don't call .html() here // so we don't lose state, rather use .setElement(). Delegate rendering // and event handling to sub views render: function () { return this; }, // the currently rendered view currentView: null, // Our view switcher for the whole app showView: function(view, viewOptions) { //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(); 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 { // 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 }).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; });