Merge pull request #13893 from colemanw/dev/core#821
[civicrm-core.git] / js / jquery / jquery.dashboard.js
index d1f68bb4e16badc3d23e99def5a6048643ba2cf8..394635d3618260770817cc7a4f01479f4d24a63f 100644 (file)
@@ -1,76 +1,35 @@
-/**
- +--------------------------------------------------------------------+
- | CiviCRM version 4.7                                                |
- +--------------------------------------------------------------------+
- | Copyright CiviCRM LLC (c) 2004-2016                                |
- +--------------------------------------------------------------------+
- | This file is a part of CiviCRM.                                    |
- |                                                                    |
- | CiviCRM is free software; you can copy, modify, and distribute it  |
- | under the terms of the GNU Affero General Public License           |
- | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
- |                                                                    |
- | CiviCRM is distributed in the hope that it will be useful, but     |
- | WITHOUT ANY WARRANTY; without even the implied warranty of         |
- | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
- | See the GNU Affero General Public License for more details.        |
- |                                                                    |
- | You should have received a copy of the GNU Affero General Public   |
- | License and the CiviCRM Licensing Exception along                  |
- | with this program; if not, contact CiviCRM LLC                     |
- | at info[AT]civicrm[DOT]org. If you have questions about the        |
- | GNU Affero General Public License or the licensing of CiviCRM,     |
- | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
- +--------------------------------------------------------------------+
- *
- * Copyright (C) 2009 Bevan Rudge
- * Licensed to CiviCRM under the Academic Free License version 3.0.
- *
- * @file Defines the jQuery.dashboard() plugin.
- *
- * Uses jQuery 1.3, jQuery UI 1.6 and several jQuery UI extensions, most of all Sortable
- *    http://visualjquery.com/
- *    http://docs.jquery.com/UI/Sortable
- *    http://ui.jquery.com/download
- *      Sortable
- *      Draggable
- *      UI Core
- *
- * NOTE: This file is viewed as "legacy" and shouldn't be used to
- * develop new functionality. Its lint problems are grandfathered
- * (although if someone wants to cleanup+test, please feel welcome).
- */
-/* jshint ignore:start */
-(function($) { // Create closure.
+// https://civicrm.org/licensing
+/* global CRM, ts */
+/*jshint loopfunc: true */
+(function($) {
+  'use strict';
   // Constructor for dashboard object.
   $.fn.dashboard = function(options) {
     // Public properties of dashboard.
     var dashboard = {};
     dashboard.element = this.empty();
     dashboard.ready = false;
-    dashboard.columns = Array();
-    dashboard.widgets = Array();
-    // End of public properties of dashboard.
+    dashboard.columns = [];
+    dashboard.widgets = {};
 
     /**
      * Public methods of dashboard.
      */
 
     // Saves the order of widgets for all columns including the widget.minimized status to options.ajaxCallbacks.saveColumns.
-    dashboard.saveColumns = function() {
+    dashboard.saveColumns = function(showStatus) {
       // Update the display status of the empty placeholders.
-      for (var c in dashboard.columns) {
-        var col = dashboard.columns[c];
-       if ( typeof col == 'object' ) {
-                       // Are there any visible children of the column (excluding the empty placeholder)?
-                       if (col.element.children(':visible').not(col.emptyPlaceholder).length > 0) {
-                         col.emptyPlaceholder.hide();
-                       }
-                       else {
-                         col.emptyPlaceholder.show();
-                       }
-               }
-         }
+      $.each(dashboard.columns, function(c, col) {
+        if ( typeof col == 'object' ) {
+          // Are there any visible children of the column (excluding the empty placeholder)?
+          if (col.element.children(':visible').not(col.emptyPlaceholder).length > 0) {
+            col.emptyPlaceholder.hide();
+          }
+          else {
+            col.emptyPlaceholder.show();
+          }
+        }
+      });
 
       // Don't save any changes to the server unless the dashboard has finished initiating.
       if (!dashboard.ready) {
       var params = {};
 
       // For each column...
-      for (var c2 in dashboard.columns) {
+      $.each(dashboard.columns, function(c, col) {
 
         // IDs of the sortable elements in this column.
-        var ids = (typeof dashboard.columns[c2] == 'object') ? dashboard.columns[c2].element.sortable('toArray') : undefined;
+        var ids = (typeof col == 'object') ? col.element.sortable('toArray') : [];
 
         // For each id...
-        for (var w in ids) {
-          // Chop 'widget-' off of the front so that we have the real widget id.
-          var id = (typeof ids[w] == 'string') ? ids[w].substring('widget-'.length) : undefined;
-
-          // Add one flat property to the params object that will look like an array element to the PHP server.
-          // Unfortunately jQuery doesn't do this for us.
-          if ( typeof dashboard.widgets[id] == 'object' ) params['columns[' + c2 + '][' + id + ']'] = (dashboard.widgets[id].minimized ? '1' : '0');
-        }
-      }
+        $.each(ids, function(w, id) {
+          if (typeof id == 'string') {
+            // Chop 'widget-' off of the front so that we have the real widget id.
+            id = id.substring('widget-'.length);
+            // Add one flat property to the params object that will look like an array element to the PHP server.
+            // Unfortunately jQuery doesn't do this for us.
+            if (typeof dashboard.widgets[id] == 'object') params['columns[' + c + '][' + id + ']'] = (dashboard.widgets[id].minimized ? '1' : '0');
+          }
+        });
+      });
 
       // The ajaxCallback settings overwrite any duplicate properties.
       $.extend(params, opts.ajaxCallbacks.saveColumns.data);
-      $.post(opts.ajaxCallbacks.saveColumns.url, params, function(response, status) {
+      var post = $.post(opts.ajaxCallbacks.saveColumns.url, params, function() {
         invokeCallback(opts.callbacks.saveColumns, dashboard);
       });
-    };
-
-    // Puts the dashboard into full screen mode, saving element for when the user exits full-screen mode.
-    // Does not add element to the DOM – this is the caller's responsibility.
-    // Does show and hide element though.
-    dashboard.enterFullscreen = function(element) {
-      // Hide the columns.
-      for (var c in dashboard.columns) {
-       if ( typeof dashboard.columns[c] == 'object' ) dashboard.columns[c].element.hide();
-      }
-
-      if (!dashboard.fullscreen) {
-        // Initialize.
-        var markup = '<a id="full-screen-header" class="full-screen-close-icon">' + opts.fullscreenHeaderInner + '</a>';
-        dashboard.fullscreen = {
-          headerElement: $(markup).prependTo(dashboard.element).click(dashboard.exitFullscreen).hide()
-        };
+      if (showStatus !== false) {
+        CRM.status({}, post);
       }
-
-      dashboard.fullscreen.headerElement.slideDown();
-      dashboard.fullscreen.currentElement = element.show();
-      dashboard.fullscreen.displayed = true;
-      invokeCallback(opts.callbacks.enterFullscreen, dashboard, dashboard.fullscreen.currentElement);
     };
 
-    // Takes the dashboard out of full screen mode, hiding the active fullscreen element.
-    dashboard.exitFullscreen = function() {
-      if (!dashboard.fullscreen.displayed) {
-        return;
-      }
-
-      dashboard.fullscreen.headerElement.slideUp();
-      dashboard.fullscreen.currentElement.hide();
-      dashboard.fullscreen.displayed = false;
-
-      // Show the columns.
-      for (var c in dashboard.columns) {
-          if ( typeof dashboard.columns[c] == 'object' ) dashboard.columns[c].element.show();
-      }
-
-      invokeCallback(opts.callbacks.exitFullscreen, dashboard, dashboard.fullscreen.currentElement);
-    };
-    // End of public methods of dashboard.
-
     /**
      * Private properties of dashboard.
      */
 
-    // Used to determine whether there are any incomplete ajax requests pending initialization of the dashboard.
-    var asynchronousRequestCounter = 0;
-
     // Used to determine whether two resort events are resulting from the same UI event.
     var currentReSortEvent = null;
 
     // Merge in the caller's options with the defaults.
     var opts = $.extend({}, $.fn.dashboard.defaults, options);
 
-    // Execution 'forks' here and restarts in init().  Tell the user we're busy with a throbber.
-    var throbber = $(opts.throbberMarkup).appendTo(dashboard.element);
-    $.getJSON(opts.ajaxCallbacks.getWidgetsByColumn.url, opts.ajaxCallbacks.getWidgetsByColumn.data, init);
-    asynchronousRequestCounter++;
+    var localCache = window.localStorage && localStorage.dashboard ? JSON.parse(localStorage.dashboard) : {};
+
+    init(opts.widgetsByColumn);
+
     return dashboard;
-    // End of constructor and private properties for dashboard object.
 
     /**
      * Private methods of dashboard.
      */
 
-    // Ajax callback for getWidgetsByColumn.
-    function init(widgets, status) {
-      asynchronousRequestCounter--;
-      throbber.remove();
+    // Initialize widget columns.
+    function init(widgets) {
       var markup = '<li class="empty-placeholder">' + opts.emptyPlaceholderInner + '</li>';
 
       // Build the dashboard in the DOM.  For each column...
       for (var c = 0; c < opts.columns; c++) {
           // Save the column to both the public scope for external accessibility and the local scope for readability.
           var col = dashboard.columns[c] = {
-              initialWidgets: Array(),
+              initialWidgets: [],
               element: $('<ul id="column-' + c + '" class="column column-' + c + '"></ul>').appendTo(dashboard.element)
           };
 
           col.emptyPlaceholder = $(markup).appendTo(col.element).hide();
 
           // For each widget in this column.
-          for (var id in widgets[c]) {
-              var widgetID = id.split('-');
-              // Build a new widget object and save it to various publicly accessible places.
-              col.initialWidgets[id] = dashboard.widgets[widgetID[1]] = widget({
-                  id: widgetID[1],
-                  element: $('<li class="widget"></li>').appendTo(col.element),
-                  initialColumn: col,
-                  minimized: ( widgets[c][widgetID[1]] > 0  ? true : false )
-              });
-
-              //set empty Dashboard to false
-              emptyDashboard = false;
-          }
+          $.each(widgets[c], function(num, item) {
+            var id = (num+1) + '-' + item.id;
+            col.initialWidgets[id] = dashboard.widgets[item.id] = widget($.extend({
+              element: $('<li class="widget"></li>').appendTo(col.element),
+              initialColumn: col
+            }, item));
+            emptyDashboard = false;
+          });
       }
 
-      if ( emptyDashboard ) {
-          emptyDashboardCondition( );
+      if (emptyDashboard) {
+        emptyDashboardCondition();
+      } else {
+        completeInit();
       }
 
       invokeCallback(opts.callbacks.init, dashboard);
 
     // function that is called when dashboard is empty
     function emptyDashboardCondition( ) {
-        cj(".show-refresh").hide( );
-        cj("#empty-message").show( );
+        $(".show-refresh").hide( );
+        $("#empty-message").show( );
+    }
+
+    // Cache dashlet info in localStorage
+    function saveLocalCache() {
+      localCache = {};
+      $.each(dashboard.widgets, function(id, widget) {
+        localCache[id] = {
+          content: widget.content,
+          lastLoaded: widget.lastLoaded,
+          minimized: widget.minimized
+        };
+      });
+      if (window.localStorage) {
+        localStorage.dashboard = JSON.stringify(localCache);
+      }
     }
 
     // Contructors for each widget call this when initialization has finished so that dashboard can complete it's intitialization.
     function completeInit() {
-      // Don't do anything if any widgets are waiting for ajax requests to complete in order to finish initialization.
-      if (asynchronousRequestCounter > 0) {
+      // Only do this once.
+      if (dashboard.ready) {
           return;
       }
 
         // The class of placeholder elements (the 'ghost' widget showing where the dragged item would land if released now.)
         placeholder: 'placeholder',
         activate: function(event, ui) {
-                 var h= cj(ui.item).height();
-                 $('.placeholder').css('height', h +'px'); },
+          var h= $(ui.item).height();
+          $('.placeholder').css('height', h +'px');
+        },
 
         opacity: 0.2,
 
       dashboard.saveColumns();
       dashboard.ready = true;
       invokeCallback(opts.callbacks.ready, dashboard);
+
+      // Auto-refresh widgets when content is stale
+      window.setInterval(function() {
+        if (!document.hasFocus || document.hasFocus()) {
+          $.each(dashboard.widgets, function (i, widget) {
+            if (!widget.cacheIsFresh()) {
+              widget.reloadContent();
+            }
+          });
+        }
+      }, 5000);
     }
 
     // Callback for when any list has changed (and the user has finished resorting).
      */
     function widget(widget) {
       // Merge default options with the options defined for this widget.
-      widget = $.extend({}, $.fn.dashboard.widget.defaults, widget);
+      widget = $.extend({}, $.fn.dashboard.widget.defaults, localCache[widget.id] || {}, widget);
 
       /**
        * Public methods of widget.
         }
 
         widget.hideSettings();
-        dashboard.saveColumns();
       };
       widget.minimize = function() {
         $('.widget-content', widget.element).slideUp(opts.animationSpeed);
-        $(widget.controls.minimize.element).addClass( 'fa-caret-right' );
-        $(widget.controls.minimize.element).removeClass( 'fa-caret-down' );
+        $(widget.controls.minimize.element)
+          .addClass('fa-caret-right')
+          .removeClass('fa-caret-down')
+          .attr('title', ts('Expand'));
         widget.minimized = true;
+        saveLocalCache();
       };
       widget.maximize = function() {
-        $('.widget-content', widget.element).slideDown(opts.animationSpeed);
-        $(widget.controls.minimize.element).removeClass( 'fa-caret-right' );
-        $(widget.controls.minimize.element).addClass( 'fa-caret-down' );
+        $(widget.controls.minimize.element)
+          .removeClass( 'fa-caret-right' )
+          .addClass( 'fa-caret-down' )
+          .attr('title', ts('Collapse'));
         widget.minimized = false;
+        saveLocalCache();
+        if (!widget.contentLoaded) {
+          loadContent();
+        }
+        $('.widget-content', widget.element).slideDown(opts.animationSpeed);
       };
 
       // Toggles whether the widget is in settings-display mode or not.
         var params = {};
         // serializeArray() returns an array of objects.  Process it.
         var fields = widget.settings.element.serializeArray();
-        for (var i in fields) {
-            var field = fields[i];
+        $.each(fields, function(i, field) {
             // Put the values into flat object properties that PHP will parse into an array server-side.
             // (Unfortunately jQuery doesn't do this)
             params['settings[' + field.name + ']'] = field.value;
-        }
+        });
 
         // Things get messy here.
         // @todo Refactor to use currentState and targetedState properties to determine what needs
 
       widget.enterFullscreen = function() {
         // Make sure the widget actually supports full screen mode.
-        if (!widget.fullscreenUrl) {
-          return;
+        if (widget.fullscreenUrl) {
+          CRM.loadPage(widget.fullscreenUrl);
         }
-        CRM.loadPage(widget.fullscreenUrl);
-      };
-
-      // Exit fullscreen mode.
-      widget.exitFullscreen = function() {
-        // This is just a wrapper for dashboard.exitFullscreen() which does the heavy lifting.
-        dashboard.exitFullscreen();
       };
 
       // Adds controls to a widget.  id is for internal use and image file name in images/dashboard/ (a .gif).
           control.element = $(markup).prependTo($('.widget-controls', widget.element)).click(control.callback);
       };
 
-      // An external method used only by and from external scripts to reload content.  Not invoked or used internally.
-      // The widget must provide the script that executes this, as well as the script that invokes it.
+      // Fetch remote content.
       widget.reloadContent = function() {
-          getJavascript(widget.reloadContentScript);
-          invokeCallback(opts.widgetCallbacks.reloadContent, widget);
+        // If minimized, we'll reload later
+        if (widget.minimized) {
+          widget.contentLoaded = false;
+          widget.lastLoaded = 0;
+        } else {
+          CRM.loadPage(widget.url, {target: widget.contentElement});
+        }
       };
 
       // Removes the widget from the dashboard, and saves columns.
       widget.remove = function() {
-          if ( confirm( 'Are you sure you want to remove "' + widget.title + '"?') ) {
-              invokeCallback(opts.widgetCallbacks.remove, widget);
-              widget.element.fadeOut(opts.animationSpeed, function() {
-                  $(this).remove();
-                  dashboard.saveColumns();
-              });
-          }
+        invokeCallback(opts.widgetCallbacks.remove, widget);
+        widget.element.fadeOut(opts.animationSpeed, function() {
+          $(this).remove();
+          delete(dashboard.widgets[widget.id]);
+          dashboard.saveColumns(false);
+        });
+        CRM.alert(
+          ts('You can re-add it by clicking the "Configure Your Dashboard" button.'),
+          ts('"%1" Removed', {1: widget.title}),
+          'success'
+        );
+      };
+
+      widget.cacheIsFresh = function() {
+        return (((widget.cacheMinutes * 60000 + widget.lastLoaded) > $.now()) && widget.content);
       };
-      // End public methods of widget.
 
       /**
        * Public properties of widget.
           icon: 'fa-wrench'
         },
         minimize: {
-          description: ts('Collapse or expand'),
+          description: widget.minimized ? ts('Expand') : ts('Collapse'),
           callback: widget.toggleMinimize,
-          icon: 'fa-caret-down',
+          icon: widget.minimized ? 'fa-caret-right' : 'fa-caret-down'
         },
         fullscreen: {
           description: ts('View fullscreen'),
           callback: widget.enterFullscreen,
-          icon: 'fa-expand',
+          icon: 'fa-expand'
         },
         close: {
           description: ts('Remove from dashboard'),
           icon: 'fa-times'
         }
       };
-      // End public properties of widget.
-
-      /**
-       * Private properties of widget.
-       */
-
-      // We're gonna 'fork' execution again, so let's tell the user to hold with us till the AJAX callback gets invoked.
-      var throbber = $(opts.throbberMarkup).appendTo(widget.element);
-      var params = $.extend({}, opts.ajaxCallbacks.getWidget.data, {id: widget.id});
-      $.getJSON(opts.ajaxCallbacks.getWidget.url, params, init);
+      widget.contentLoaded = false;
 
-      // Help dashboard track whether we've got any outstanding requests on which initialization is pending.
-      asynchronousRequestCounter++;
+      init();
       return widget;
-      // End of private properties of widget.
 
       /**
        * Private methods of widget.
        */
 
-      // Ajax callback for widget initialization.
-      function init(data, status) {
-        asynchronousRequestCounter--;
-        $.extend(widget, data);
+      function loadContent() {
+        var loadFromCache = widget.cacheIsFresh();
+        if (loadFromCache) {
+          widget.contentElement.html(widget.content).trigger('crmLoad', widget);
+        }
+        widget.contentElement.off('crmLoad').on('crmLoad', function(event, data) {
+          if ($(event.target).is(widget.contentElement)) {
+            widget.content = data.content;
+            // Cache for one day
+            widget.lastLoaded = $.now();
+            saveLocalCache();
+            invokeCallback(opts.widgetCallbacks.get, widget);
+          }
+        });
+        if (!loadFromCache) {
+          widget.reloadContent();
+        }
+        widget.contentLoaded = true;
+      }
 
+      // Build widget & load content.
+      function init() {
         // Delete controls that don't apply to this widget.
         if (!widget.settings) {
           delete widget.controls.settings;
+          widget.settings = {};
         }
         if (!widget.fullscreenUrl) {
           delete widget.controls.fullscreen;
         }
-
-        widget.element.attr('id', 'widget-' + widget.id).addClass(widget.classes);
-        throbber.remove();
+        var cssClass = 'widget-' + widget.name.replace('/', '-');
+        widget.element.attr('id', 'widget-' + widget.id).addClass(cssClass);
         // Build and add the widget's DOM element.
-        $(widget.element).append(widgetHTML()).trigger('crmLoad');
+        $(widget.element).append(widgetHTML());
         // Save the content element so that external scripts can reload it easily.
         widget.contentElement = $('.widget-content', widget.element);
         $.each(widget.controls, widget.addControl);
 
-        // Switch the initial state so that it initializes to the correct state.
-        widget.minimized = !widget.minimized;
-        widget.toggleMinimize();
-        getJavascript(widget.initScript);
-        invokeCallback(opts.widgetCallbacks.get, widget);
-
-        // completeInit() is a private method of the dashboard.  Let it complete initialization of the dashboard.
-        completeInit();
+        if (widget.minimized) {
+          widget.contentElement.hide();
+        } else {
+          loadContent();
+        }
       }
 
       // Builds inner HTML for widgets.
         var html = '';
         html += '<div class="widget-wrapper">';
         html += '  <div class="widget-controls"><h3 class="widget-header">' + widget.title + '</h3></div>';
-        html += '  <div class="widget-content crm-ajax-container">' + widget.content + '</div>';
+        html += '  <div class="widget-content"></div>';
         html += '</div>';
         return html;
       }
   // Public static properties of dashboard.  Default settings.
   $.fn.dashboard.defaults = {
     columns: 2,
-    emptyPlaceholderInner: ts('There are no dashlets in this column of your dashboard.'),
-    fullscreenHeaderInner: ts('Back to dashboard mode'),
-    throbberMarkup: '<div class="crm-loading-element">' + ts('Loading') + '...</div>',
+    emptyPlaceholderInner: '',
+    throbberMarkup: '',
     animationSpeed: 200,
     callbacks: {},
     widgetCallbacks: {}
   $.fn.dashboard.widget = {
     defaults: {
       minimized: false,
-      settings: false,
-      fullscreen: false
+      content: null,
+      lastLoaded: 0,
+      settings: false
+      // id, url, fullscreenUrl, title, name, cacheMinutes
     }
   };
 })(jQuery);