Merge pull request #17836 from seamuslee001/dev_core_1874
[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 */
e4075792 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
a8f56d71 133 // Cache dashlet info in localStorage
4e1046df
CW
134 function saveLocalCache() {
135 localCache = {};
136 $.each(dashboard.widgets, function(id, widget) {
137 localCache[id] = {
138 content: widget.content,
a8f56d71 139 lastLoaded: widget.lastLoaded,
4e1046df
CW
140 minimized: widget.minimized
141 };
142 });
143 if (window.localStorage) {
144 localStorage.dashboard = JSON.stringify(localCache);
145 }
146 }
147
3d80d622
CW
148 // Contructors for each widget call this when initialization has finished so that dashboard can complete it's intitialization.
149 function completeInit() {
4e1046df
CW
150 // Only do this once.
151 if (dashboard.ready) {
3d80d622
CW
152 return;
153 }
154
155 // Make widgets sortable across columns.
156 dashboard.sortableElement = $('.column').sortable({
157 connectWith: ['.column'],
158
159 // The class of the element by which widgets are draggable.
160 handle: '.widget-header',
161
162 // The class of placeholder elements (the 'ghost' widget showing where the dragged item would land if released now.)
31037a42
EM
163 placeholder: 'placeholder',
164 activate: function(event, ui) {
dd3770bc
CW
165 var h= $(ui.item).height();
166 $('.placeholder').css('height', h +'px');
167 },
31037a42 168
3d80d622
CW
169 opacity: 0.2,
170
171 // Maks sure that only widgets are sortable, and not empty placeholders.
172 items: '> .widget',
31037a42 173
3d80d622 174 forcePlaceholderSize: true,
31037a42 175
3d80d622
CW
176 // Callback functions.
177 update: resorted,
178 start: hideEmptyPlaceholders
179 });
180
181 // Update empty placeholders.
182 dashboard.saveColumns();
183 dashboard.ready = true;
184 invokeCallback(opts.callbacks.ready, dashboard);
a8f56d71
CW
185
186 // Auto-refresh widgets when content is stale
187 window.setInterval(function() {
188 if (!document.hasFocus || document.hasFocus()) {
189 $.each(dashboard.widgets, function (i, widget) {
190 if (!widget.cacheIsFresh()) {
191 widget.reloadContent();
192 }
193 });
194 }
195 }, 5000);
3d80d622
CW
196 }
197
198 // Callback for when any list has changed (and the user has finished resorting).
199 function resorted(e, ui) {
200 // Only do anything if we haven't already handled resorts based on changes from this UI DOM event.
201 // (resorted() gets invoked once for each list when an item is moved from one to another.)
202 if (!currentReSortEvent || e.originalEvent != currentReSortEvent) {
203 currentReSortEvent = e.originalEvent;
204 dashboard.saveColumns();
205 }
206 }
207
208 // Callback for when a user starts resorting a list. Hides all the empty placeholders.
209 function hideEmptyPlaceholders(e, ui) {
210 for (var c in dashboard.columns) {
9e483b7f 211 if( (typeof dashboard.columns[c]) == 'object' ) dashboard.columns[c].emptyPlaceholder.hide();
3d80d622
CW
212 }
213 }
214
215 // @todo use an event library to register, bind to and invoke events.
216 // @param callback is a function.
217 // @param theThis is the context given to that function when it executes. It becomes 'this' inside of that function.
218 function invokeCallback(callback, theThis, parameterOne) {
219 if (callback) {
220 callback.call(theThis, parameterOne);
221 }
222 }
223
224 /**
225 * widget object
226 * Private sub-class of dashboard
227 * Constructor starts
228 */
229 function widget(widget) {
230 // Merge default options with the options defined for this widget.
4e1046df 231 widget = $.extend({}, $.fn.dashboard.widget.defaults, localCache[widget.id] || {}, widget);
3d80d622
CW
232
233 /**
234 * Public methods of widget.
235 */
236
237 // Toggles the minimize() & maximize() methods.
238 widget.toggleMinimize = function() {
239 if (widget.minimized) {
240 widget.maximize();
241 }
242 else {
243 widget.minimize();
244 }
245
246 widget.hideSettings();
3d80d622
CW
247 };
248 widget.minimize = function() {
249 $('.widget-content', widget.element).slideUp(opts.animationSpeed);
dd3770bc
CW
250 $(widget.controls.minimize.element)
251 .addClass('fa-caret-right')
252 .removeClass('fa-caret-down')
253 .attr('title', ts('Expand'));
3d80d622 254 widget.minimized = true;
4e1046df 255 saveLocalCache();
3d80d622
CW
256 };
257 widget.maximize = function() {
dd3770bc
CW
258 $(widget.controls.minimize.element)
259 .removeClass( 'fa-caret-right' )
260 .addClass( 'fa-caret-down' )
261 .attr('title', ts('Collapse'));
3d80d622 262 widget.minimized = false;
4e1046df 263 saveLocalCache();
dd3770bc
CW
264 if (!widget.contentLoaded) {
265 loadContent();
266 }
4e1046df 267 $('.widget-content', widget.element).slideDown(opts.animationSpeed);
3d80d622
CW
268 };
269
270 // Toggles whether the widget is in settings-display mode or not.
271 widget.toggleSettings = function() {
272 if (widget.settings.displayed) {
273 // Widgets always exit settings into maximized state.
274 widget.maximize();
275 widget.hideSettings();
276 invokeCallback(opts.widgetCallbacks.hideSettings, widget);
277 }
278 else {
279 widget.minimize();
280 widget.showSettings();
281 invokeCallback(opts.widgetCallbacks.showSettings, widget);
282 }
283 };
284 widget.showSettings = function() {
285 if (widget.settings.element) {
286 widget.settings.element.show();
287
288 // Settings are loaded via AJAX. Only execute the script if the settings have been loaded.
289 if (widget.settings.ready) {
290 getJavascript(widget.settings.script);
291 }
292 }
293 else {
294 // Settings have not been initialized. Do so now.
295 initSettings();
296 }
297 widget.settings.displayed = true;
298 };
299 widget.hideSettings = function() {
300 if (widget.settings.element) {
301 widget.settings.element.hide();
302 }
303 widget.settings.displayed = false;
304 };
305 widget.saveSettings = function() {
306 // Build list of parameters to POST to server.
307 var params = {};
308 // serializeArray() returns an array of objects. Process it.
309 var fields = widget.settings.element.serializeArray();
4e1046df 310 $.each(fields, function(i, field) {
3d80d622
CW
311 // Put the values into flat object properties that PHP will parse into an array server-side.
312 // (Unfortunately jQuery doesn't do this)
313 params['settings[' + field.name + ']'] = field.value;
4e1046df 314 });
3d80d622
CW
315
316 // Things get messy here.
31037a42
EM
317 // @todo Refactor to use currentState and targetedState properties to determine what needs
318 // to be done to get to any desired state on any UI or AJAX event – since these don't always
319 // match.
320 // E.g. When a user starts a new UI event before the Ajax event handler from a previous
3d80d622
CW
321 // UI event gets invoked.
322
323 // Hide the settings first of all.
324 widget.toggleSettings();
325 // Save the real settings element so that we can restore the reference later.
326 var settingsElement = widget.settings.element;
327 // Empty the settings form.
328 widget.settings.innerElement.empty();
329 initThrobber();
330 // So that showSettings() and hideSettings() can do SOMETHING, without showing the empty settings form.
331 widget.settings.element = widget.throbber.hide();
332 widget.settings.ready = false;
333
334 // Save the settings to the server.
335 $.extend(params, opts.ajaxCallbacks.widgetSettings.data, { id: widget.id });
336 $.post(opts.ajaxCallbacks.widgetSettings.url, params, function(response, status) {
337 // Merge the response into widget.settings.
338 $.extend(widget.settings, response);
339 // Restore the reference to the real settings element.
340 widget.settings.element = settingsElement;
341 // Make sure the settings form is empty and add the updated settings form.
342 widget.settings.innerElement.empty().append(widget.settings.markup);
343 widget.settings.ready = true;
344
345 // Did the user already jump back into settings-display mode before we could finish reloading the settings form?
346 if (widget.settings.displayed) {
347 // Ooops! We had better take care of hiding the throbber and showing the settings form then.
348 widget.throbber.hide();
349 widget.showSettings();
350 invokeCallback(opts.widgetCallbacks.saveSettings, dashboard);
351 }
352 }, 'json');
353
354 // Don't let form submittal bubble up.
355 return false;
356 };
357
358 widget.enterFullscreen = function() {
359 // Make sure the widget actually supports full screen mode.
dd3770bc
CW
360 if (widget.fullscreenUrl) {
361 CRM.loadPage(widget.fullscreenUrl);
3d80d622 362 }
3d80d622
CW
363 };
364
365 // Adds controls to a widget. id is for internal use and image file name in images/dashboard/ (a .gif).
366 widget.addControl = function(id, control) {
13a3d214 367 var markup = '<a class="crm-i ' + control.icon + '" alt="' + control.description + '" title="' + control.description + '" aria-hidden="true"></a>';
3d80d622
CW
368 control.element = $(markup).prependTo($('.widget-controls', widget.element)).click(control.callback);
369 };
370
4e1046df 371 // Fetch remote content.
3d80d622 372 widget.reloadContent = function() {
4e1046df
CW
373 // If minimized, we'll reload later
374 if (widget.minimized) {
375 widget.contentLoaded = false;
a8f56d71 376 widget.lastLoaded = 0;
4e1046df
CW
377 } else {
378 CRM.loadPage(widget.url, {target: widget.contentElement});
379 }
3d80d622
CW
380 };
381
382 // Removes the widget from the dashboard, and saves columns.
383 widget.remove = function() {
ce2cc43e
CW
384 invokeCallback(opts.widgetCallbacks.remove, widget);
385 widget.element.fadeOut(opts.animationSpeed, function() {
386 $(this).remove();
4e1046df
CW
387 delete(dashboard.widgets[widget.id]);
388 dashboard.saveColumns(false);
ce2cc43e 389 });
ce2cc43e
CW
390 CRM.alert(
391 ts('You can re-add it by clicking the "Configure Your Dashboard" button.'),
e4075792 392 ts('"%1" Removed', {1: _.escape(widget.title)}),
ce2cc43e
CW
393 'success'
394 );
3d80d622 395 };
3d80d622 396
a8f56d71
CW
397 widget.cacheIsFresh = function() {
398 return (((widget.cacheMinutes * 60000 + widget.lastLoaded) > $.now()) && widget.content);
399 };
400
3d80d622
CW
401 /**
402 * Public properties of widget.
403 */
404
405 // Default controls. External script can add more with widget.addControls()
406 widget.controls = {
407 settings: {
77a8d7f9 408 description: ts('Configure this dashlet'),
41ce1b88
AH
409 callback: widget.toggleSettings,
410 icon: 'fa-wrench'
3d80d622
CW
411 },
412 minimize: {
dd3770bc 413 description: widget.minimized ? ts('Expand') : ts('Collapse'),
41ce1b88 414 callback: widget.toggleMinimize,
dd3770bc 415 icon: widget.minimized ? 'fa-caret-right' : 'fa-caret-down'
3d80d622
CW
416 },
417 fullscreen: {
77a8d7f9 418 description: ts('View fullscreen'),
41ce1b88 419 callback: widget.enterFullscreen,
55be4d47 420 icon: 'fa-expand'
3d80d622
CW
421 },
422 close: {
77a8d7f9 423 description: ts('Remove from dashboard'),
41ce1b88
AH
424 callback: widget.remove,
425 icon: 'fa-times'
3d80d622
CW
426 }
427 };
dd3770bc 428 widget.contentLoaded = false;
3d80d622 429
dd3770bc 430 init();
3d80d622 431 return widget;
3d80d622
CW
432
433 /**
434 * Private methods of widget.
435 */
436
dd3770bc 437 function loadContent() {
a8f56d71 438 var loadFromCache = widget.cacheIsFresh();
4e1046df
CW
439 if (loadFromCache) {
440 widget.contentElement.html(widget.content).trigger('crmLoad', widget);
441 }
442 widget.contentElement.off('crmLoad').on('crmLoad', function(event, data) {
443 if ($(event.target).is(widget.contentElement)) {
444 widget.content = data.content;
445 // Cache for one day
a8f56d71 446 widget.lastLoaded = $.now();
4e1046df 447 saveLocalCache();
dd3770bc 448 invokeCallback(opts.widgetCallbacks.get, widget);
4e1046df
CW
449 }
450 });
451 if (!loadFromCache) {
452 widget.reloadContent();
453 }
454 widget.contentLoaded = true;
dd3770bc 455 }
3d80d622 456
dd3770bc
CW
457 // Build widget & load content.
458 function init() {
3d80d622
CW
459 // Delete controls that don't apply to this widget.
460 if (!widget.settings) {
461 delete widget.controls.settings;
dd3770bc 462 widget.settings = {};
3d80d622
CW
463 }
464 if (!widget.fullscreenUrl) {
465 delete widget.controls.fullscreen;
466 }
dd3770bc
CW
467 var cssClass = 'widget-' + widget.name.replace('/', '-');
468 widget.element.attr('id', 'widget-' + widget.id).addClass(cssClass);
3d80d622 469 // Build and add the widget's DOM element.
dd3770bc 470 $(widget.element).append(widgetHTML());
3d80d622
CW
471 // Save the content element so that external scripts can reload it easily.
472 widget.contentElement = $('.widget-content', widget.element);
473 $.each(widget.controls, widget.addControl);
474
dd3770bc
CW
475 if (widget.minimized) {
476 widget.contentElement.hide();
477 } else {
478 loadContent();
479 }
3d80d622
CW
480 }
481
482 // Builds inner HTML for widgets.
483 function widgetHTML() {
484 var html = '';
485 html += '<div class="widget-wrapper">';
e4075792 486 html += ' <div class="widget-controls"><h3 class="widget-header">' + _.escape(widget.title) + '</h3></div>';
dd3770bc 487 html += ' <div class="widget-content"></div>';
3d80d622
CW
488 html += '</div>';
489 return html;
490 }
491
492 // Initializes a widgets settings pane.
493 function initSettings() {
494 // Overwrite widget.settings (boolean).
495 initThrobber();
496 widget.settings = {
497 element: widget.throbber.show(),
498 ready: false
499 };
500
501 // Get the settings markup and script executables for this widget.
502 var params = $.extend({}, opts.ajaxCallbacks.widgetSettings.data, { id: widget.id });
503 $.getJSON(opts.ajaxCallbacks.widgetSettings.url, params, function(response, status) {
504 $.extend(widget.settings, response);
505 // Build and add the settings form to the DOM. Bind the form's submit event handler/callback.
506 widget.settings.element = $(widgetSettingsHTML()).appendTo($('.widget-wrapper', widget.element)).submit(widget.saveSettings);
507 // Bind the cancel button's event handler too.
508 widget.settings.cancelButton = $('.widget-settings-cancel', widget.settings.element).click(cancelEditSettings);
509 // Build and add the inner form elements from the HTML markup provided in the AJAX data.
510 widget.settings.innerElement = $('.widget-settings-inner', widget.settings.element).append(widget.settings.markup);
511 widget.settings.ready = true;
512
513 if (widget.settings.displayed) {
514 // If the user hasn't clicked away from the settings pane, then display the form.
515 widget.throbber.hide();
516 widget.showSettings();
517 }
518
519 getJavascript(widget.settings.initScript);
520 });
521 }
522
523 // Builds HTML for widget settings forms.
524 function widgetSettingsHTML() {
525 var html = '';
526 html += '<form class="widget-settings">';
527 html += ' <div class="widget-settings-inner"></div>';
528 html += ' <div class="widget-settings-buttons">';
529 html += ' <input id="' + widget.id + '-settings-save" class="widget-settings-save" value="Save" type="submit" />';
530 html += ' <input id="' + widget.id + '-settings-cancel" class="widget-settings-cancel" value="Cancel" type="submit" />';
531 html += ' </div>';
532 html += '</form>';
533 return html;
534 }
535
536 // Initializes a generic widget content throbber, for use by settings form and external scripts.
537 function initThrobber() {
538 if (!widget.throbber) {
539 widget.throbber = $(opts.throbberMarkup).appendTo($('.widget-wrapper', widget.element));
540 }
1a40fe56 541 }
3d80d622
CW
542
543 // Event handler/callback for cancel button clicks.
544 // @todo test this gets caught by all browsers when the cancel button is 'clicked' via the keyboard.
545 function cancelEditSettings() {
546 widget.toggleSettings();
547 return false;
1a40fe56 548 }
3d80d622
CW
549
550 // Helper function to execute external script on the server.
551 // @todo It would be nice to provide some context to the script. How?
552 function getJavascript(url) {
553 if (url) {
554 $.getScript(url);
555 }
556 }
1a40fe56 557 }
3d80d622
CW
558 };
559
560 // Public static properties of dashboard. Default settings.
561 $.fn.dashboard.defaults = {
562 columns: 2,
a8f56d71 563 emptyPlaceholderInner: '',
dd3770bc 564 throbberMarkup: '',
3d80d622
CW
565 animationSpeed: 200,
566 callbacks: {},
567 widgetCallbacks: {}
568 };
569
570 // Default widget settings.
571 $.fn.dashboard.widget = {
572 defaults: {
573 minimized: false,
4e1046df 574 content: null,
a8f56d71 575 lastLoaded: 0,
dd3770bc 576 settings: false
a8f56d71 577 // id, url, fullscreenUrl, title, name, cacheMinutes
3d80d622
CW
578 }
579 };
e4075792 580})(jQuery, CRM._);