CRM-17663 - Implement localStorage cache
[civicrm-core.git] / js / jquery / jquery.dashboard.js
CommitLineData
dd3770bc
CW
1// https://civicrm.org/licensing
2/* global CRM, ts */
4e1046df 3/*jshint loopfunc: true */
ce2cc43e 4(function($) {
dd3770bc 5 'use strict';
3d80d622
CW
6 // Constructor for dashboard object.
7 $.fn.dashboard = function(options) {
8 // Public properties of dashboard.
9 var dashboard = {};
10 dashboard.element = this.empty();
11 dashboard.ready = false;
dd3770bc 12 dashboard.columns = [];
4e1046df 13 dashboard.widgets = {};
3d80d622
CW
14
15 /**
16 * Public methods of dashboard.
17 */
18
19 // Saves the order of widgets for all columns including the widget.minimized status to options.ajaxCallbacks.saveColumns.
ce2cc43e 20 dashboard.saveColumns = function(showStatus) {
3d80d622 21 // Update the display status of the empty placeholders.
4e1046df
CW
22 $.each(dashboard.columns, function(c, col) {
23 if ( typeof col == 'object' ) {
24 // Are there any visible children of the column (excluding the empty placeholder)?
25 if (col.element.children(':visible').not(col.emptyPlaceholder).length > 0) {
26 col.emptyPlaceholder.hide();
27 }
28 else {
29 col.emptyPlaceholder.show();
30 }
31 }
32 });
3d80d622
CW
33
34 // Don't save any changes to the server unless the dashboard has finished initiating.
35 if (!dashboard.ready) {
36 return;
37 }
38
39 // Build a list of params to post to the server.
40 var params = {};
41
42 // For each column...
4e1046df 43 $.each(dashboard.columns, function(c, col) {
3d80d622
CW
44
45 // IDs of the sortable elements in this column.
4e1046df 46 var ids = (typeof col == 'object') ? col.element.sortable('toArray') : [];
3d80d622
CW
47
48 // For each id...
4e1046df
CW
49 $.each(ids, function(w, id) {
50 if (typeof id == 'string') {
51 // Chop 'widget-' off of the front so that we have the real widget id.
52 id = id.substring('widget-'.length);
53 // Add one flat property to the params object that will look like an array element to the PHP server.
54 // Unfortunately jQuery doesn't do this for us.
55 if (typeof dashboard.widgets[id] == 'object') params['columns[' + c + '][' + id + ']'] = (dashboard.widgets[id].minimized ? '1' : '0');
56 }
57 });
58 });
3d80d622
CW
59
60 // The ajaxCallback settings overwrite any duplicate properties.
61 $.extend(params, opts.ajaxCallbacks.saveColumns.data);
4e1046df 62 var post = $.post(opts.ajaxCallbacks.saveColumns.url, params, function() {
3d80d622
CW
63 invokeCallback(opts.callbacks.saveColumns, dashboard);
64 });
ce2cc43e
CW
65 if (showStatus !== false) {
66 CRM.status({}, post);
67 }
3d80d622 68 };
3d80d622
CW
69
70 /**
71 * Private properties of dashboard.
72 */
31037a42 73
3d80d622
CW
74 // Used to determine whether two resort events are resulting from the same UI event.
75 var currentReSortEvent = null;
76
77 // Merge in the caller's options with the defaults.
78 var opts = $.extend({}, $.fn.dashboard.defaults, options);
79
4e1046df
CW
80 var localCache = window.localStorage && localStorage.dashboard ? JSON.parse(localStorage.dashboard) : {};
81
55be4d47
CW
82 init(opts.widgetsByColumn);
83
3d80d622 84 return dashboard;
3d80d622
CW
85
86 /**
87 * Private methods of dashboard.
88 */
89
55be4d47
CW
90 // Initialize widget columns.
91 function init(widgets) {
3d80d622
CW
92 var markup = '<li class="empty-placeholder">' + opts.emptyPlaceholderInner + '</li>';
93
94 // Build the dashboard in the DOM. For each column...
95 // (Don't iterate on widgets since this will break badly if the dataset has empty columns.)
96 var emptyDashboard = true;
97 for (var c = 0; c < opts.columns; c++) {
98 // Save the column to both the public scope for external accessibility and the local scope for readability.
99 var col = dashboard.columns[c] = {
dd3770bc 100 initialWidgets: [],
3d80d622
CW
101 element: $('<ul id="column-' + c + '" class="column column-' + c + '"></ul>').appendTo(dashboard.element)
102 };
31037a42 103
3d80d622
CW
104 // Add the empty placeholder now, hide it and save it.
105 col.emptyPlaceholder = $(markup).appendTo(col.element).hide();
106
107 // For each widget in this column.
dd3770bc
CW
108 $.each(widgets[c], function(num, item) {
109 var id = (num+1) + '-' + item.id;
110 col.initialWidgets[id] = dashboard.widgets[item.id] = widget($.extend({
111 element: $('<li class="widget"></li>').appendTo(col.element),
112 initialColumn: col
113 }, item));
114 emptyDashboard = false;
115 });
3d80d622
CW
116 }
117
dd3770bc
CW
118 if (emptyDashboard) {
119 emptyDashboardCondition();
120 } else {
121 completeInit();
3d80d622 122 }
31037a42 123
3d80d622
CW
124 invokeCallback(opts.callbacks.init, dashboard);
125 }
126
127 // function that is called when dashboard is empty
128 function emptyDashboardCondition( ) {
dd3770bc
CW
129 $(".show-refresh").hide( );
130 $("#empty-message").show( );
3d80d622 131 }
31037a42 132
4e1046df
CW
133 function saveLocalCache() {
134 localCache = {};
135 $.each(dashboard.widgets, function(id, widget) {
136 localCache[id] = {
137 content: widget.content,
138 expires: widget.expires,
139 minimized: widget.minimized
140 };
141 });
142 if (window.localStorage) {
143 localStorage.dashboard = JSON.stringify(localCache);
144 }
145 }
146
3d80d622
CW
147 // Contructors for each widget call this when initialization has finished so that dashboard can complete it's intitialization.
148 function completeInit() {
4e1046df
CW
149 // Only do this once.
150 if (dashboard.ready) {
3d80d622
CW
151 return;
152 }
153
154 // Make widgets sortable across columns.
155 dashboard.sortableElement = $('.column').sortable({
156 connectWith: ['.column'],
157
158 // The class of the element by which widgets are draggable.
159 handle: '.widget-header',
160
161 // The class of placeholder elements (the 'ghost' widget showing where the dragged item would land if released now.)
31037a42
EM
162 placeholder: 'placeholder',
163 activate: function(event, ui) {
dd3770bc
CW
164 var h= $(ui.item).height();
165 $('.placeholder').css('height', h +'px');
166 },
31037a42 167
3d80d622
CW
168 opacity: 0.2,
169
170 // Maks sure that only widgets are sortable, and not empty placeholders.
171 items: '> .widget',
31037a42 172
3d80d622 173 forcePlaceholderSize: true,
31037a42 174
3d80d622
CW
175 // Callback functions.
176 update: resorted,
177 start: hideEmptyPlaceholders
178 });
179
180 // Update empty placeholders.
181 dashboard.saveColumns();
182 dashboard.ready = true;
183 invokeCallback(opts.callbacks.ready, dashboard);
184 }
185
186 // Callback for when any list has changed (and the user has finished resorting).
187 function resorted(e, ui) {
188 // Only do anything if we haven't already handled resorts based on changes from this UI DOM event.
189 // (resorted() gets invoked once for each list when an item is moved from one to another.)
190 if (!currentReSortEvent || e.originalEvent != currentReSortEvent) {
191 currentReSortEvent = e.originalEvent;
192 dashboard.saveColumns();
193 }
194 }
195
196 // Callback for when a user starts resorting a list. Hides all the empty placeholders.
197 function hideEmptyPlaceholders(e, ui) {
198 for (var c in dashboard.columns) {
9e483b7f 199 if( (typeof dashboard.columns[c]) == 'object' ) dashboard.columns[c].emptyPlaceholder.hide();
3d80d622
CW
200 }
201 }
202
203 // @todo use an event library to register, bind to and invoke events.
204 // @param callback is a function.
205 // @param theThis is the context given to that function when it executes. It becomes 'this' inside of that function.
206 function invokeCallback(callback, theThis, parameterOne) {
207 if (callback) {
208 callback.call(theThis, parameterOne);
209 }
210 }
211
212 /**
213 * widget object
214 * Private sub-class of dashboard
215 * Constructor starts
216 */
217 function widget(widget) {
218 // Merge default options with the options defined for this widget.
4e1046df 219 widget = $.extend({}, $.fn.dashboard.widget.defaults, localCache[widget.id] || {}, widget);
3d80d622
CW
220
221 /**
222 * Public methods of widget.
223 */
224
225 // Toggles the minimize() & maximize() methods.
226 widget.toggleMinimize = function() {
227 if (widget.minimized) {
228 widget.maximize();
229 }
230 else {
231 widget.minimize();
232 }
233
234 widget.hideSettings();
3d80d622
CW
235 };
236 widget.minimize = function() {
237 $('.widget-content', widget.element).slideUp(opts.animationSpeed);
dd3770bc
CW
238 $(widget.controls.minimize.element)
239 .addClass('fa-caret-right')
240 .removeClass('fa-caret-down')
241 .attr('title', ts('Expand'));
3d80d622 242 widget.minimized = true;
4e1046df 243 saveLocalCache();
3d80d622
CW
244 };
245 widget.maximize = function() {
dd3770bc
CW
246 $(widget.controls.minimize.element)
247 .removeClass( 'fa-caret-right' )
248 .addClass( 'fa-caret-down' )
249 .attr('title', ts('Collapse'));
3d80d622 250 widget.minimized = false;
4e1046df 251 saveLocalCache();
dd3770bc
CW
252 if (!widget.contentLoaded) {
253 loadContent();
254 }
4e1046df 255 $('.widget-content', widget.element).slideDown(opts.animationSpeed);
3d80d622
CW
256 };
257
258 // Toggles whether the widget is in settings-display mode or not.
259 widget.toggleSettings = function() {
260 if (widget.settings.displayed) {
261 // Widgets always exit settings into maximized state.
262 widget.maximize();
263 widget.hideSettings();
264 invokeCallback(opts.widgetCallbacks.hideSettings, widget);
265 }
266 else {
267 widget.minimize();
268 widget.showSettings();
269 invokeCallback(opts.widgetCallbacks.showSettings, widget);
270 }
271 };
272 widget.showSettings = function() {
273 if (widget.settings.element) {
274 widget.settings.element.show();
275
276 // Settings are loaded via AJAX. Only execute the script if the settings have been loaded.
277 if (widget.settings.ready) {
278 getJavascript(widget.settings.script);
279 }
280 }
281 else {
282 // Settings have not been initialized. Do so now.
283 initSettings();
284 }
285 widget.settings.displayed = true;
286 };
287 widget.hideSettings = function() {
288 if (widget.settings.element) {
289 widget.settings.element.hide();
290 }
291 widget.settings.displayed = false;
292 };
293 widget.saveSettings = function() {
294 // Build list of parameters to POST to server.
295 var params = {};
296 // serializeArray() returns an array of objects. Process it.
297 var fields = widget.settings.element.serializeArray();
4e1046df 298 $.each(fields, function(i, field) {
3d80d622
CW
299 // Put the values into flat object properties that PHP will parse into an array server-side.
300 // (Unfortunately jQuery doesn't do this)
301 params['settings[' + field.name + ']'] = field.value;
4e1046df 302 });
3d80d622
CW
303
304 // Things get messy here.
31037a42
EM
305 // @todo Refactor to use currentState and targetedState properties to determine what needs
306 // to be done to get to any desired state on any UI or AJAX event – since these don't always
307 // match.
308 // E.g. When a user starts a new UI event before the Ajax event handler from a previous
3d80d622
CW
309 // UI event gets invoked.
310
311 // Hide the settings first of all.
312 widget.toggleSettings();
313 // Save the real settings element so that we can restore the reference later.
314 var settingsElement = widget.settings.element;
315 // Empty the settings form.
316 widget.settings.innerElement.empty();
317 initThrobber();
318 // So that showSettings() and hideSettings() can do SOMETHING, without showing the empty settings form.
319 widget.settings.element = widget.throbber.hide();
320 widget.settings.ready = false;
321
322 // Save the settings to the server.
323 $.extend(params, opts.ajaxCallbacks.widgetSettings.data, { id: widget.id });
324 $.post(opts.ajaxCallbacks.widgetSettings.url, params, function(response, status) {
325 // Merge the response into widget.settings.
326 $.extend(widget.settings, response);
327 // Restore the reference to the real settings element.
328 widget.settings.element = settingsElement;
329 // Make sure the settings form is empty and add the updated settings form.
330 widget.settings.innerElement.empty().append(widget.settings.markup);
331 widget.settings.ready = true;
332
333 // Did the user already jump back into settings-display mode before we could finish reloading the settings form?
334 if (widget.settings.displayed) {
335 // Ooops! We had better take care of hiding the throbber and showing the settings form then.
336 widget.throbber.hide();
337 widget.showSettings();
338 invokeCallback(opts.widgetCallbacks.saveSettings, dashboard);
339 }
340 }, 'json');
341
342 // Don't let form submittal bubble up.
343 return false;
344 };
345
346 widget.enterFullscreen = function() {
347 // Make sure the widget actually supports full screen mode.
dd3770bc
CW
348 if (widget.fullscreenUrl) {
349 CRM.loadPage(widget.fullscreenUrl);
3d80d622 350 }
3d80d622
CW
351 };
352
353 // Adds controls to a widget. id is for internal use and image file name in images/dashboard/ (a .gif).
354 widget.addControl = function(id, control) {
41ce1b88 355 var markup = '<a class="crm-i ' + control.icon + '" alt="' + control.description + '" title="' + control.description + '"></a>';
3d80d622
CW
356 control.element = $(markup).prependTo($('.widget-controls', widget.element)).click(control.callback);
357 };
358
4e1046df 359 // Fetch remote content.
3d80d622 360 widget.reloadContent = function() {
4e1046df
CW
361 // If minimized, we'll reload later
362 if (widget.minimized) {
363 widget.contentLoaded = false;
364 widget.expires = 0;
365 } else {
366 CRM.loadPage(widget.url, {target: widget.contentElement});
367 }
3d80d622
CW
368 };
369
370 // Removes the widget from the dashboard, and saves columns.
371 widget.remove = function() {
ce2cc43e
CW
372 invokeCallback(opts.widgetCallbacks.remove, widget);
373 widget.element.fadeOut(opts.animationSpeed, function() {
374 $(this).remove();
4e1046df
CW
375 delete(dashboard.widgets[widget.id]);
376 dashboard.saveColumns(false);
ce2cc43e 377 });
ce2cc43e
CW
378 CRM.alert(
379 ts('You can re-add it by clicking the "Configure Your Dashboard" button.'),
380 ts('"%1" Removed', {1: widget.title}),
381 'success'
382 );
3d80d622 383 };
3d80d622
CW
384
385 /**
386 * Public properties of widget.
387 */
388
389 // Default controls. External script can add more with widget.addControls()
390 widget.controls = {
391 settings: {
77a8d7f9 392 description: ts('Configure this dashlet'),
41ce1b88
AH
393 callback: widget.toggleSettings,
394 icon: 'fa-wrench'
3d80d622
CW
395 },
396 minimize: {
dd3770bc 397 description: widget.minimized ? ts('Expand') : ts('Collapse'),
41ce1b88 398 callback: widget.toggleMinimize,
dd3770bc 399 icon: widget.minimized ? 'fa-caret-right' : 'fa-caret-down'
3d80d622
CW
400 },
401 fullscreen: {
77a8d7f9 402 description: ts('View fullscreen'),
41ce1b88 403 callback: widget.enterFullscreen,
55be4d47 404 icon: 'fa-expand'
3d80d622
CW
405 },
406 close: {
77a8d7f9 407 description: ts('Remove from dashboard'),
41ce1b88
AH
408 callback: widget.remove,
409 icon: 'fa-times'
3d80d622
CW
410 }
411 };
dd3770bc 412 widget.contentLoaded = false;
3d80d622 413
dd3770bc 414 init();
3d80d622 415 return widget;
3d80d622
CW
416
417 /**
418 * Private methods of widget.
419 */
420
dd3770bc 421 function loadContent() {
4e1046df
CW
422 var loadFromCache = (widget.expires > $.now() && widget.content);
423 if (loadFromCache) {
424 widget.contentElement.html(widget.content).trigger('crmLoad', widget);
425 }
426 widget.contentElement.off('crmLoad').on('crmLoad', function(event, data) {
427 if ($(event.target).is(widget.contentElement)) {
428 widget.content = data.content;
429 // Cache for one day
430 widget.expires = $.now() + 86400000;
431 saveLocalCache();
dd3770bc 432 invokeCallback(opts.widgetCallbacks.get, widget);
4e1046df
CW
433 }
434 });
435 if (!loadFromCache) {
436 widget.reloadContent();
437 }
438 widget.contentLoaded = true;
dd3770bc 439 }
3d80d622 440
dd3770bc
CW
441 // Build widget & load content.
442 function init() {
3d80d622
CW
443 // Delete controls that don't apply to this widget.
444 if (!widget.settings) {
445 delete widget.controls.settings;
dd3770bc 446 widget.settings = {};
3d80d622
CW
447 }
448 if (!widget.fullscreenUrl) {
449 delete widget.controls.fullscreen;
450 }
dd3770bc
CW
451 var cssClass = 'widget-' + widget.name.replace('/', '-');
452 widget.element.attr('id', 'widget-' + widget.id).addClass(cssClass);
3d80d622 453 // Build and add the widget's DOM element.
dd3770bc 454 $(widget.element).append(widgetHTML());
3d80d622
CW
455 // Save the content element so that external scripts can reload it easily.
456 widget.contentElement = $('.widget-content', widget.element);
457 $.each(widget.controls, widget.addControl);
458
dd3770bc
CW
459 if (widget.minimized) {
460 widget.contentElement.hide();
461 } else {
462 loadContent();
463 }
3d80d622
CW
464 }
465
466 // Builds inner HTML for widgets.
467 function widgetHTML() {
468 var html = '';
469 html += '<div class="widget-wrapper">';
470 html += ' <div class="widget-controls"><h3 class="widget-header">' + widget.title + '</h3></div>';
dd3770bc 471 html += ' <div class="widget-content"></div>';
3d80d622
CW
472 html += '</div>';
473 return html;
474 }
475
476 // Initializes a widgets settings pane.
477 function initSettings() {
478 // Overwrite widget.settings (boolean).
479 initThrobber();
480 widget.settings = {
481 element: widget.throbber.show(),
482 ready: false
483 };
484
485 // Get the settings markup and script executables for this widget.
486 var params = $.extend({}, opts.ajaxCallbacks.widgetSettings.data, { id: widget.id });
487 $.getJSON(opts.ajaxCallbacks.widgetSettings.url, params, function(response, status) {
488 $.extend(widget.settings, response);
489 // Build and add the settings form to the DOM. Bind the form's submit event handler/callback.
490 widget.settings.element = $(widgetSettingsHTML()).appendTo($('.widget-wrapper', widget.element)).submit(widget.saveSettings);
491 // Bind the cancel button's event handler too.
492 widget.settings.cancelButton = $('.widget-settings-cancel', widget.settings.element).click(cancelEditSettings);
493 // Build and add the inner form elements from the HTML markup provided in the AJAX data.
494 widget.settings.innerElement = $('.widget-settings-inner', widget.settings.element).append(widget.settings.markup);
495 widget.settings.ready = true;
496
497 if (widget.settings.displayed) {
498 // If the user hasn't clicked away from the settings pane, then display the form.
499 widget.throbber.hide();
500 widget.showSettings();
501 }
502
503 getJavascript(widget.settings.initScript);
504 });
505 }
506
507 // Builds HTML for widget settings forms.
508 function widgetSettingsHTML() {
509 var html = '';
510 html += '<form class="widget-settings">';
511 html += ' <div class="widget-settings-inner"></div>';
512 html += ' <div class="widget-settings-buttons">';
513 html += ' <input id="' + widget.id + '-settings-save" class="widget-settings-save" value="Save" type="submit" />';
514 html += ' <input id="' + widget.id + '-settings-cancel" class="widget-settings-cancel" value="Cancel" type="submit" />';
515 html += ' </div>';
516 html += '</form>';
517 return html;
518 }
519
520 // Initializes a generic widget content throbber, for use by settings form and external scripts.
521 function initThrobber() {
522 if (!widget.throbber) {
523 widget.throbber = $(opts.throbberMarkup).appendTo($('.widget-wrapper', widget.element));
524 }
1a40fe56 525 }
3d80d622
CW
526
527 // Event handler/callback for cancel button clicks.
528 // @todo test this gets caught by all browsers when the cancel button is 'clicked' via the keyboard.
529 function cancelEditSettings() {
530 widget.toggleSettings();
531 return false;
1a40fe56 532 }
3d80d622
CW
533
534 // Helper function to execute external script on the server.
535 // @todo It would be nice to provide some context to the script. How?
536 function getJavascript(url) {
537 if (url) {
538 $.getScript(url);
539 }
540 }
1a40fe56 541 }
3d80d622
CW
542 };
543
544 // Public static properties of dashboard. Default settings.
545 $.fn.dashboard.defaults = {
546 columns: 2,
77a8d7f9 547 emptyPlaceholderInner: ts('There are no dashlets in this column of your dashboard.'),
dd3770bc 548 throbberMarkup: '',
3d80d622
CW
549 animationSpeed: 200,
550 callbacks: {},
551 widgetCallbacks: {}
552 };
553
554 // Default widget settings.
555 $.fn.dashboard.widget = {
556 defaults: {
557 minimized: false,
4e1046df
CW
558 content: null,
559 expires: 0,
dd3770bc 560 settings: false
4e1046df 561 // url, fullscreenUrl, title, name
3d80d622
CW
562 }
563 };
564})(jQuery);