Merge pull request #8010 from mlutfy/master-crm17862
[civicrm-core.git] / js / jquery / jquery.dashboard.js
1 /**
2 +--------------------------------------------------------------------+
3 | CiviCRM version 4.7 |
4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC (c) 2004-2016 |
6 +--------------------------------------------------------------------+
7 | This file is a part of CiviCRM. |
8 | |
9 | CiviCRM is free software; you can copy, modify, and distribute it |
10 | under the terms of the GNU Affero General Public License |
11 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
12 | |
13 | CiviCRM is distributed in the hope that it will be useful, but |
14 | WITHOUT ANY WARRANTY; without even the implied warranty of |
15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
16 | See the GNU Affero General Public License for more details. |
17 | |
18 | You should have received a copy of the GNU Affero General Public |
19 | License and the CiviCRM Licensing Exception along |
20 | with this program; if not, contact CiviCRM LLC |
21 | at info[AT]civicrm[DOT]org. If you have questions about the |
22 | GNU Affero General Public License or the licensing of CiviCRM, |
23 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
24 +--------------------------------------------------------------------+
25 *
26 * Copyright (C) 2009 Bevan Rudge
27 * Licensed to CiviCRM under the Academic Free License version 3.0.
28 *
29 * @file Defines the jQuery.dashboard() plugin.
30 *
31 * Uses jQuery 1.3, jQuery UI 1.6 and several jQuery UI extensions, most of all Sortable
32 * http://visualjquery.com/
33 * http://docs.jquery.com/UI/Sortable
34 * http://ui.jquery.com/download
35 * Sortable
36 * Draggable
37 * UI Core
38 *
39 * NOTE: This file is viewed as "legacy" and shouldn't be used to
40 * develop new functionality. Its lint problems are grandfathered
41 * (although if someone wants to cleanup+test, please feel welcome).
42 */
43 /* jshint ignore:start */
44 (function($) { // Create closure.
45 // Constructor for dashboard object.
46 $.fn.dashboard = function(options) {
47 // Public properties of dashboard.
48 var dashboard = {};
49 dashboard.element = this.empty();
50 dashboard.ready = false;
51 dashboard.columns = Array();
52 dashboard.widgets = Array();
53 // End of public properties of dashboard.
54
55 /**
56 * Public methods of dashboard.
57 */
58
59 // Saves the order of widgets for all columns including the widget.minimized status to options.ajaxCallbacks.saveColumns.
60 dashboard.saveColumns = function() {
61 // Update the display status of the empty placeholders.
62 for (var c in dashboard.columns) {
63 var col = dashboard.columns[c];
64 if ( typeof col == 'object' ) {
65 // Are there any visible children of the column (excluding the empty placeholder)?
66 if (col.element.children(':visible').not(col.emptyPlaceholder).length > 0) {
67 col.emptyPlaceholder.hide();
68 }
69 else {
70 col.emptyPlaceholder.show();
71 }
72 }
73 }
74
75 // Don't save any changes to the server unless the dashboard has finished initiating.
76 if (!dashboard.ready) {
77 return;
78 }
79
80 // Build a list of params to post to the server.
81 var params = {};
82
83 // For each column...
84 for (var c2 in dashboard.columns) {
85
86 // IDs of the sortable elements in this column.
87 var ids = (typeof dashboard.columns[c2] == 'object') ? dashboard.columns[c2].element.sortable('toArray') : undefined;
88
89 // For each id...
90 for (var w in ids) {
91 // Chop 'widget-' off of the front so that we have the real widget id.
92 var id = (typeof ids[w] == 'string') ? ids[w].substring('widget-'.length) : undefined;
93
94 // Add one flat property to the params object that will look like an array element to the PHP server.
95 // Unfortunately jQuery doesn't do this for us.
96 if ( typeof dashboard.widgets[id] == 'object' ) params['columns[' + c2 + '][' + id + ']'] = (dashboard.widgets[id].minimized ? '1' : '0');
97 }
98 }
99
100 // The ajaxCallback settings overwrite any duplicate properties.
101 $.extend(params, opts.ajaxCallbacks.saveColumns.data);
102 $.post(opts.ajaxCallbacks.saveColumns.url, params, function(response, status) {
103 invokeCallback(opts.callbacks.saveColumns, dashboard);
104 });
105 };
106
107 // Puts the dashboard into full screen mode, saving element for when the user exits full-screen mode.
108 // Does not add element to the DOM – this is the caller's responsibility.
109 // Does show and hide element though.
110 dashboard.enterFullscreen = function(element) {
111 // Hide the columns.
112 for (var c in dashboard.columns) {
113 if ( typeof dashboard.columns[c] == 'object' ) dashboard.columns[c].element.hide();
114 }
115
116 if (!dashboard.fullscreen) {
117 // Initialize.
118 var markup = '<a id="full-screen-header" class="full-screen-close-icon">' + opts.fullscreenHeaderInner + '</a>';
119 dashboard.fullscreen = {
120 headerElement: $(markup).prependTo(dashboard.element).click(dashboard.exitFullscreen).hide()
121 };
122 }
123
124 dashboard.fullscreen.headerElement.slideDown();
125 dashboard.fullscreen.currentElement = element.show();
126 dashboard.fullscreen.displayed = true;
127 invokeCallback(opts.callbacks.enterFullscreen, dashboard, dashboard.fullscreen.currentElement);
128 };
129
130 // Takes the dashboard out of full screen mode, hiding the active fullscreen element.
131 dashboard.exitFullscreen = function() {
132 if (!dashboard.fullscreen.displayed) {
133 return;
134 }
135
136 dashboard.fullscreen.headerElement.slideUp();
137 dashboard.fullscreen.currentElement.hide();
138 dashboard.fullscreen.displayed = false;
139
140 // Show the columns.
141 for (var c in dashboard.columns) {
142 if ( typeof dashboard.columns[c] == 'object' ) dashboard.columns[c].element.show();
143 }
144
145 invokeCallback(opts.callbacks.exitFullscreen, dashboard, dashboard.fullscreen.currentElement);
146 };
147 // End of public methods of dashboard.
148
149 /**
150 * Private properties of dashboard.
151 */
152
153 // Used to determine whether there are any incomplete ajax requests pending initialization of the dashboard.
154 var asynchronousRequestCounter = 0;
155
156 // Used to determine whether two resort events are resulting from the same UI event.
157 var currentReSortEvent = null;
158
159 // Merge in the caller's options with the defaults.
160 var opts = $.extend({}, $.fn.dashboard.defaults, options);
161
162 // Execution 'forks' here and restarts in init(). Tell the user we're busy with a throbber.
163 var throbber = $(opts.throbberMarkup).appendTo(dashboard.element);
164 $.getJSON(opts.ajaxCallbacks.getWidgetsByColumn.url, opts.ajaxCallbacks.getWidgetsByColumn.data, init);
165 asynchronousRequestCounter++;
166 return dashboard;
167 // End of constructor and private properties for dashboard object.
168
169 /**
170 * Private methods of dashboard.
171 */
172
173 // Ajax callback for getWidgetsByColumn.
174 function init(widgets, status) {
175 asynchronousRequestCounter--;
176 throbber.remove();
177 var markup = '<li class="empty-placeholder">' + opts.emptyPlaceholderInner + '</li>';
178
179 // Build the dashboard in the DOM. For each column...
180 // (Don't iterate on widgets since this will break badly if the dataset has empty columns.)
181 var emptyDashboard = true;
182 for (var c = 0; c < opts.columns; c++) {
183 // Save the column to both the public scope for external accessibility and the local scope for readability.
184 var col = dashboard.columns[c] = {
185 initialWidgets: Array(),
186 element: $('<ul id="column-' + c + '" class="column column-' + c + '"></ul>').appendTo(dashboard.element)
187 };
188
189 // Add the empty placeholder now, hide it and save it.
190 col.emptyPlaceholder = $(markup).appendTo(col.element).hide();
191
192 // For each widget in this column.
193 for (var id in widgets[c]) {
194 var widgetID = id.split('-');
195 // Build a new widget object and save it to various publicly accessible places.
196 col.initialWidgets[id] = dashboard.widgets[widgetID[1]] = widget({
197 id: widgetID[1],
198 element: $('<li class="widget"></li>').appendTo(col.element),
199 initialColumn: col,
200 minimized: ( widgets[c][widgetID[1]] > 0 ? true : false )
201 });
202
203 //set empty Dashboard to false
204 emptyDashboard = false;
205 }
206 }
207
208 if ( emptyDashboard ) {
209 emptyDashboardCondition( );
210 }
211
212 invokeCallback(opts.callbacks.init, dashboard);
213 }
214
215 // function that is called when dashboard is empty
216 function emptyDashboardCondition( ) {
217 cj(".show-refresh").hide( );
218 cj("#empty-message").show( );
219 }
220
221 // Contructors for each widget call this when initialization has finished so that dashboard can complete it's intitialization.
222 function completeInit() {
223 // Don't do anything if any widgets are waiting for ajax requests to complete in order to finish initialization.
224 if (asynchronousRequestCounter > 0) {
225 return;
226 }
227
228 // Make widgets sortable across columns.
229 dashboard.sortableElement = $('.column').sortable({
230 connectWith: ['.column'],
231
232 // The class of the element by which widgets are draggable.
233 handle: '.widget-header',
234
235 // The class of placeholder elements (the 'ghost' widget showing where the dragged item would land if released now.)
236 placeholder: 'placeholder',
237 activate: function(event, ui) {
238 var h= cj(ui.item).height();
239 $('.placeholder').css('height', h +'px'); },
240
241 opacity: 0.2,
242
243 // Maks sure that only widgets are sortable, and not empty placeholders.
244 items: '> .widget',
245
246 forcePlaceholderSize: true,
247
248 // Callback functions.
249 update: resorted,
250 start: hideEmptyPlaceholders
251 });
252
253 // Update empty placeholders.
254 dashboard.saveColumns();
255 dashboard.ready = true;
256 invokeCallback(opts.callbacks.ready, dashboard);
257 }
258
259 // Callback for when any list has changed (and the user has finished resorting).
260 function resorted(e, ui) {
261 // Only do anything if we haven't already handled resorts based on changes from this UI DOM event.
262 // (resorted() gets invoked once for each list when an item is moved from one to another.)
263 if (!currentReSortEvent || e.originalEvent != currentReSortEvent) {
264 currentReSortEvent = e.originalEvent;
265 dashboard.saveColumns();
266 }
267 }
268
269 // Callback for when a user starts resorting a list. Hides all the empty placeholders.
270 function hideEmptyPlaceholders(e, ui) {
271 for (var c in dashboard.columns) {
272 if( (typeof dashboard.columns[c]) == 'object' ) dashboard.columns[c].emptyPlaceholder.hide();
273 }
274 }
275
276 // @todo use an event library to register, bind to and invoke events.
277 // @param callback is a function.
278 // @param theThis is the context given to that function when it executes. It becomes 'this' inside of that function.
279 function invokeCallback(callback, theThis, parameterOne) {
280 if (callback) {
281 callback.call(theThis, parameterOne);
282 }
283 }
284
285 /**
286 * widget object
287 * Private sub-class of dashboard
288 * Constructor starts
289 */
290 function widget(widget) {
291 // Merge default options with the options defined for this widget.
292 widget = $.extend({}, $.fn.dashboard.widget.defaults, widget);
293
294 /**
295 * Public methods of widget.
296 */
297
298 // Toggles the minimize() & maximize() methods.
299 widget.toggleMinimize = function() {
300 if (widget.minimized) {
301 widget.maximize();
302 }
303 else {
304 widget.minimize();
305 }
306
307 widget.hideSettings();
308 dashboard.saveColumns();
309 };
310 widget.minimize = function() {
311 $('.widget-content', widget.element).slideUp(opts.animationSpeed);
312 $(widget.controls.minimize.element).addClass( 'fa-caret-right' );
313 $(widget.controls.minimize.element).removeClass( 'fa-caret-down' );
314 widget.minimized = true;
315 };
316 widget.maximize = function() {
317 $('.widget-content', widget.element).slideDown(opts.animationSpeed);
318 $(widget.controls.minimize.element).removeClass( 'fa-caret-right' );
319 $(widget.controls.minimize.element).addClass( 'fa-caret-down' );
320 widget.minimized = false;
321 };
322
323 // Toggles whether the widget is in settings-display mode or not.
324 widget.toggleSettings = function() {
325 if (widget.settings.displayed) {
326 // Widgets always exit settings into maximized state.
327 widget.maximize();
328 widget.hideSettings();
329 invokeCallback(opts.widgetCallbacks.hideSettings, widget);
330 }
331 else {
332 widget.minimize();
333 widget.showSettings();
334 invokeCallback(opts.widgetCallbacks.showSettings, widget);
335 }
336 };
337 widget.showSettings = function() {
338 if (widget.settings.element) {
339 widget.settings.element.show();
340
341 // Settings are loaded via AJAX. Only execute the script if the settings have been loaded.
342 if (widget.settings.ready) {
343 getJavascript(widget.settings.script);
344 }
345 }
346 else {
347 // Settings have not been initialized. Do so now.
348 initSettings();
349 }
350 widget.settings.displayed = true;
351 };
352 widget.hideSettings = function() {
353 if (widget.settings.element) {
354 widget.settings.element.hide();
355 }
356 widget.settings.displayed = false;
357 };
358 widget.saveSettings = function() {
359 // Build list of parameters to POST to server.
360 var params = {};
361 // serializeArray() returns an array of objects. Process it.
362 var fields = widget.settings.element.serializeArray();
363 for (var i in fields) {
364 var field = fields[i];
365 // Put the values into flat object properties that PHP will parse into an array server-side.
366 // (Unfortunately jQuery doesn't do this)
367 params['settings[' + field.name + ']'] = field.value;
368 }
369
370 // Things get messy here.
371 // @todo Refactor to use currentState and targetedState properties to determine what needs
372 // to be done to get to any desired state on any UI or AJAX event – since these don't always
373 // match.
374 // E.g. When a user starts a new UI event before the Ajax event handler from a previous
375 // UI event gets invoked.
376
377 // Hide the settings first of all.
378 widget.toggleSettings();
379 // Save the real settings element so that we can restore the reference later.
380 var settingsElement = widget.settings.element;
381 // Empty the settings form.
382 widget.settings.innerElement.empty();
383 initThrobber();
384 // So that showSettings() and hideSettings() can do SOMETHING, without showing the empty settings form.
385 widget.settings.element = widget.throbber.hide();
386 widget.settings.ready = false;
387
388 // Save the settings to the server.
389 $.extend(params, opts.ajaxCallbacks.widgetSettings.data, { id: widget.id });
390 $.post(opts.ajaxCallbacks.widgetSettings.url, params, function(response, status) {
391 // Merge the response into widget.settings.
392 $.extend(widget.settings, response);
393 // Restore the reference to the real settings element.
394 widget.settings.element = settingsElement;
395 // Make sure the settings form is empty and add the updated settings form.
396 widget.settings.innerElement.empty().append(widget.settings.markup);
397 widget.settings.ready = true;
398
399 // Did the user already jump back into settings-display mode before we could finish reloading the settings form?
400 if (widget.settings.displayed) {
401 // Ooops! We had better take care of hiding the throbber and showing the settings form then.
402 widget.throbber.hide();
403 widget.showSettings();
404 invokeCallback(opts.widgetCallbacks.saveSettings, dashboard);
405 }
406 }, 'json');
407
408 // Don't let form submittal bubble up.
409 return false;
410 };
411
412 widget.enterFullscreen = function() {
413 // Make sure the widget actually supports full screen mode.
414 if (!widget.fullscreenUrl) {
415 return;
416 }
417 CRM.loadPage(widget.fullscreenUrl);
418 };
419
420 // Exit fullscreen mode.
421 widget.exitFullscreen = function() {
422 // This is just a wrapper for dashboard.exitFullscreen() which does the heavy lifting.
423 dashboard.exitFullscreen();
424 };
425
426 // Adds controls to a widget. id is for internal use and image file name in images/dashboard/ (a .gif).
427 widget.addControl = function(id, control) {
428 var markup = '<a class="crm-i ' + control.icon + '" alt="' + control.description + '" title="' + control.description + '"></a>';
429 control.element = $(markup).prependTo($('.widget-controls', widget.element)).click(control.callback);
430 };
431
432 // An external method used only by and from external scripts to reload content. Not invoked or used internally.
433 // The widget must provide the script that executes this, as well as the script that invokes it.
434 widget.reloadContent = function() {
435 getJavascript(widget.reloadContentScript);
436 invokeCallback(opts.widgetCallbacks.reloadContent, widget);
437 };
438
439 // Removes the widget from the dashboard, and saves columns.
440 widget.remove = function() {
441 if ( confirm( 'Are you sure you want to remove "' + widget.title + '"?') ) {
442 invokeCallback(opts.widgetCallbacks.remove, widget);
443 widget.element.fadeOut(opts.animationSpeed, function() {
444 $(this).remove();
445 dashboard.saveColumns();
446 });
447 }
448 };
449 // End public methods of widget.
450
451 /**
452 * Public properties of widget.
453 */
454
455 // Default controls. External script can add more with widget.addControls()
456 widget.controls = {
457 settings: {
458 description: ts('Configure this dashlet'),
459 callback: widget.toggleSettings,
460 icon: 'fa-wrench'
461 },
462 minimize: {
463 description: ts('Collapse or expand'),
464 callback: widget.toggleMinimize,
465 icon: 'fa-caret-down',
466 },
467 fullscreen: {
468 description: ts('View fullscreen'),
469 callback: widget.enterFullscreen,
470 icon: 'fa-expand',
471 },
472 close: {
473 description: ts('Remove from dashboard'),
474 callback: widget.remove,
475 icon: 'fa-times'
476 }
477 };
478 // End public properties of widget.
479
480 /**
481 * Private properties of widget.
482 */
483
484 // We're gonna 'fork' execution again, so let's tell the user to hold with us till the AJAX callback gets invoked.
485 var throbber = $(opts.throbberMarkup).appendTo(widget.element);
486 var params = $.extend({}, opts.ajaxCallbacks.getWidget.data, {id: widget.id});
487 $.getJSON(opts.ajaxCallbacks.getWidget.url, params, init);
488
489 // Help dashboard track whether we've got any outstanding requests on which initialization is pending.
490 asynchronousRequestCounter++;
491 return widget;
492 // End of private properties of widget.
493
494 /**
495 * Private methods of widget.
496 */
497
498 // Ajax callback for widget initialization.
499 function init(data, status) {
500 asynchronousRequestCounter--;
501 $.extend(widget, data);
502
503 // Delete controls that don't apply to this widget.
504 if (!widget.settings) {
505 delete widget.controls.settings;
506 }
507 if (!widget.fullscreenUrl) {
508 delete widget.controls.fullscreen;
509 }
510
511 widget.element.attr('id', 'widget-' + widget.id).addClass(widget.classes);
512 throbber.remove();
513 // Build and add the widget's DOM element.
514 $(widget.element).append(widgetHTML()).trigger('crmLoad');
515 // Save the content element so that external scripts can reload it easily.
516 widget.contentElement = $('.widget-content', widget.element);
517 $.each(widget.controls, widget.addControl);
518
519 // Switch the initial state so that it initializes to the correct state.
520 widget.minimized = !widget.minimized;
521 widget.toggleMinimize();
522 getJavascript(widget.initScript);
523 invokeCallback(opts.widgetCallbacks.get, widget);
524
525 // completeInit() is a private method of the dashboard. Let it complete initialization of the dashboard.
526 completeInit();
527 }
528
529 // Builds inner HTML for widgets.
530 function widgetHTML() {
531 var html = '';
532 html += '<div class="widget-wrapper">';
533 html += ' <div class="widget-controls"><h3 class="widget-header">' + widget.title + '</h3></div>';
534 html += ' <div class="widget-content crm-ajax-container">' + widget.content + '</div>';
535 html += '</div>';
536 return html;
537 }
538
539 // Initializes a widgets settings pane.
540 function initSettings() {
541 // Overwrite widget.settings (boolean).
542 initThrobber();
543 widget.settings = {
544 element: widget.throbber.show(),
545 ready: false
546 };
547
548 // Get the settings markup and script executables for this widget.
549 var params = $.extend({}, opts.ajaxCallbacks.widgetSettings.data, { id: widget.id });
550 $.getJSON(opts.ajaxCallbacks.widgetSettings.url, params, function(response, status) {
551 $.extend(widget.settings, response);
552 // Build and add the settings form to the DOM. Bind the form's submit event handler/callback.
553 widget.settings.element = $(widgetSettingsHTML()).appendTo($('.widget-wrapper', widget.element)).submit(widget.saveSettings);
554 // Bind the cancel button's event handler too.
555 widget.settings.cancelButton = $('.widget-settings-cancel', widget.settings.element).click(cancelEditSettings);
556 // Build and add the inner form elements from the HTML markup provided in the AJAX data.
557 widget.settings.innerElement = $('.widget-settings-inner', widget.settings.element).append(widget.settings.markup);
558 widget.settings.ready = true;
559
560 if (widget.settings.displayed) {
561 // If the user hasn't clicked away from the settings pane, then display the form.
562 widget.throbber.hide();
563 widget.showSettings();
564 }
565
566 getJavascript(widget.settings.initScript);
567 });
568 }
569
570 // Builds HTML for widget settings forms.
571 function widgetSettingsHTML() {
572 var html = '';
573 html += '<form class="widget-settings">';
574 html += ' <div class="widget-settings-inner"></div>';
575 html += ' <div class="widget-settings-buttons">';
576 html += ' <input id="' + widget.id + '-settings-save" class="widget-settings-save" value="Save" type="submit" />';
577 html += ' <input id="' + widget.id + '-settings-cancel" class="widget-settings-cancel" value="Cancel" type="submit" />';
578 html += ' </div>';
579 html += '</form>';
580 return html;
581 }
582
583 // Initializes a generic widget content throbber, for use by settings form and external scripts.
584 function initThrobber() {
585 if (!widget.throbber) {
586 widget.throbber = $(opts.throbberMarkup).appendTo($('.widget-wrapper', widget.element));
587 }
588 }
589
590 // Event handler/callback for cancel button clicks.
591 // @todo test this gets caught by all browsers when the cancel button is 'clicked' via the keyboard.
592 function cancelEditSettings() {
593 widget.toggleSettings();
594 return false;
595 }
596
597 // Helper function to execute external script on the server.
598 // @todo It would be nice to provide some context to the script. How?
599 function getJavascript(url) {
600 if (url) {
601 $.getScript(url);
602 }
603 }
604 }
605 };
606
607 // Public static properties of dashboard. Default settings.
608 $.fn.dashboard.defaults = {
609 columns: 2,
610 emptyPlaceholderInner: ts('There are no dashlets in this column of your dashboard.'),
611 fullscreenHeaderInner: ts('Back to dashboard mode'),
612 throbberMarkup: '<div class="crm-loading-element">' + ts('Loading') + '...</div>',
613 animationSpeed: 200,
614 callbacks: {},
615 widgetCallbacks: {}
616 };
617
618 // Default widget settings.
619 $.fn.dashboard.widget = {
620 defaults: {
621 minimized: false,
622 settings: false,
623 fullscreen: false
624 }
625 };
626 })(jQuery);