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