1 // https://civicrm.org/licensing
3 /*jshint loopfunc: true */
6 // Constructor for dashboard object.
7 $.fn
.dashboard = function(options
) {
8 // Public properties of dashboard.
10 dashboard
.element
= this.empty();
11 dashboard
.ready
= false;
12 dashboard
.columns
= [];
13 dashboard
.widgets
= {};
16 * Public methods of dashboard.
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 $.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();
29 col
.emptyPlaceholder
.show();
34 // Don't save any changes to the server unless the dashboard has finished initiating.
35 if (!dashboard
.ready
) {
39 // Build a list of params to post to the server.
43 $.each(dashboard
.columns
, function(c
, col
) {
45 // IDs of the sortable elements in this column.
46 var ids
= (typeof col
== 'object') ? col
.element
.sortable('toArray') : [];
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');
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() {
63 invokeCallback(opts
.callbacks
.saveColumns
, dashboard
);
65 if (showStatus
!== false) {
71 * Private properties of dashboard.
74 // Used to determine whether two resort events are resulting from the same UI event.
75 var currentReSortEvent
= null;
77 // Merge in the caller's options with the defaults.
78 var opts
= $.extend({}, $.fn
.dashboard
.defaults
, options
);
80 var localCache
= window
.localStorage
&& localStorage
.dashboard
? JSON
.parse(localStorage
.dashboard
) : {};
82 init(opts
.widgetsByColumn
);
87 * Private methods of dashboard.
90 // Initialize widget columns.
91 function init(widgets
) {
92 var markup
= '<li class="empty-placeholder">' + opts
.emptyPlaceholderInner
+ '</li>';
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
] = {
101 element
: $('<ul id="column-' + c
+ '" class="column column-' + c
+ '"></ul>').appendTo(dashboard
.element
)
104 // Add the empty placeholder now, hide it and save it.
105 col
.emptyPlaceholder
= $(markup
).appendTo(col
.element
).hide();
107 // For each widget in this column.
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
),
114 emptyDashboard
= false;
118 if (emptyDashboard
) {
119 emptyDashboardCondition();
124 invokeCallback(opts
.callbacks
.init
, dashboard
);
127 // function that is called when dashboard is empty
128 function emptyDashboardCondition( ) {
129 $(".show-refresh").hide( );
130 $("#empty-message").show( );
133 // Cache dashlet info in localStorage
134 function saveLocalCache() {
136 $.each(dashboard
.widgets
, function(id
, widget
) {
138 content
: widget
.content
,
139 lastLoaded
: widget
.lastLoaded
,
140 minimized
: widget
.minimized
143 if (window
.localStorage
) {
144 localStorage
.dashboard
= JSON
.stringify(localCache
);
148 // Contructors for each widget call this when initialization has finished so that dashboard can complete it's intitialization.
149 function completeInit() {
150 // Only do this once.
151 if (dashboard
.ready
) {
155 // Make widgets sortable across columns.
156 dashboard
.sortableElement
= $('.column').sortable({
157 connectWith
: ['.column'],
159 // The class of the element by which widgets are draggable.
160 handle
: '.widget-header',
162 // The class of placeholder elements (the 'ghost' widget showing where the dragged item would land if released now.)
163 placeholder
: 'placeholder',
164 activate: function(event
, ui
) {
165 var h
= $(ui
.item
).height();
166 $('.placeholder').css('height', h
+'px');
171 // Maks sure that only widgets are sortable, and not empty placeholders.
174 forcePlaceholderSize
: true,
176 // Callback functions.
178 start
: hideEmptyPlaceholders
181 // Update empty placeholders.
182 dashboard
.saveColumns();
183 dashboard
.ready
= true;
184 invokeCallback(opts
.callbacks
.ready
, dashboard
);
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();
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();
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
) {
211 if( (typeof dashboard
.columns
[c
]) == 'object' ) dashboard
.columns
[c
].emptyPlaceholder
.hide();
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
) {
220 callback
.call(theThis
, parameterOne
);
226 * Private sub-class of dashboard
229 function widget(widget
) {
230 // Merge default options with the options defined for this widget.
231 widget
= $.extend({}, $.fn
.dashboard
.widget
.defaults
, localCache
[widget
.id
] || {}, widget
);
234 * Public methods of widget.
237 // Toggles the minimize() & maximize() methods.
238 widget
.toggleMinimize = function() {
239 if (widget
.minimized
) {
246 widget
.hideSettings();
248 widget
.minimize = function() {
249 $('.widget-content', widget
.element
).slideUp(opts
.animationSpeed
);
250 $(widget
.controls
.minimize
.element
)
251 .addClass('fa-caret-right')
252 .removeClass('fa-caret-down')
253 .attr('title', ts('Expand'));
254 widget
.minimized
= true;
257 widget
.maximize = function() {
258 $(widget
.controls
.minimize
.element
)
259 .removeClass( 'fa-caret-right' )
260 .addClass( 'fa-caret-down' )
261 .attr('title', ts('Collapse'));
262 widget
.minimized
= false;
264 if (!widget
.contentLoaded
) {
267 $('.widget-content', widget
.element
).slideDown(opts
.animationSpeed
);
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.
275 widget
.hideSettings();
276 invokeCallback(opts
.widgetCallbacks
.hideSettings
, widget
);
280 widget
.showSettings();
281 invokeCallback(opts
.widgetCallbacks
.showSettings
, widget
);
284 widget
.showSettings = function() {
285 if (widget
.settings
.element
) {
286 widget
.settings
.element
.show();
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
);
294 // Settings have not been initialized. Do so now.
297 widget
.settings
.displayed
= true;
299 widget
.hideSettings = function() {
300 if (widget
.settings
.element
) {
301 widget
.settings
.element
.hide();
303 widget
.settings
.displayed
= false;
305 widget
.saveSettings = function() {
306 // Build list of parameters to POST to server.
308 // serializeArray() returns an array of objects. Process it.
309 var fields
= widget
.settings
.element
.serializeArray();
310 $.each(fields
, function(i
, field
) {
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
;
316 // Things get messy here.
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
320 // E.g. When a user starts a new UI event before the Ajax event handler from a previous
321 // UI event gets invoked.
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();
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;
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;
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
);
354 // Don't let form submittal bubble up.
358 widget
.enterFullscreen = function() {
359 // Make sure the widget actually supports full screen mode.
360 if (widget
.fullscreenUrl
) {
361 CRM
.loadPage(widget
.fullscreenUrl
);
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
) {
367 var markup
= '<a class="crm-i ' + control
.icon
+ '" alt="' + control
.description
+ '" title="' + control
.description
+ '"></a>';
368 control
.element
= $(markup
).prependTo($('.widget-controls', widget
.element
)).click(control
.callback
);
371 // Fetch remote content.
372 widget
.reloadContent = function() {
373 // If minimized, we'll reload later
374 if (widget
.minimized
) {
375 widget
.contentLoaded
= false;
376 widget
.lastLoaded
= 0;
378 CRM
.loadPage(widget
.url
, {target
: widget
.contentElement
});
382 // Removes the widget from the dashboard, and saves columns.
383 widget
.remove = function() {
384 invokeCallback(opts
.widgetCallbacks
.remove
, widget
);
385 widget
.element
.fadeOut(opts
.animationSpeed
, function() {
387 delete(dashboard
.widgets
[widget
.id
]);
388 dashboard
.saveColumns(false);
391 ts('You can re-add it by clicking the "Configure Your Dashboard" button.'),
392 ts('"%1" Removed', {1: widget
.title
}),
397 widget
.cacheIsFresh = function() {
398 return (((widget
.cacheMinutes
* 60000 + widget
.lastLoaded
) > $.now()) && widget
.content
);
402 * Public properties of widget.
405 // Default controls. External script can add more with widget.addControls()
408 description
: ts('Configure this dashlet'),
409 callback
: widget
.toggleSettings
,
413 description
: widget
.minimized
? ts('Expand') : ts('Collapse'),
414 callback
: widget
.toggleMinimize
,
415 icon
: widget
.minimized
? 'fa-caret-right' : 'fa-caret-down'
418 description
: ts('View fullscreen'),
419 callback
: widget
.enterFullscreen
,
423 description
: ts('Remove from dashboard'),
424 callback
: widget
.remove
,
428 widget
.contentLoaded
= false;
434 * Private methods of widget.
437 function loadContent() {
438 var loadFromCache
= widget
.cacheIsFresh();
440 widget
.contentElement
.html(widget
.content
).trigger('crmLoad', widget
);
442 widget
.contentElement
.off('crmLoad').on('crmLoad', function(event
, data
) {
443 if ($(event
.target
).is(widget
.contentElement
)) {
444 widget
.content
= data
.content
;
446 widget
.lastLoaded
= $.now();
448 invokeCallback(opts
.widgetCallbacks
.get, widget
);
451 if (!loadFromCache
) {
452 widget
.reloadContent();
454 widget
.contentLoaded
= true;
457 // Build widget & load content.
459 // Delete controls that don't apply to this widget.
460 if (!widget
.settings
) {
461 delete widget
.controls
.settings
;
462 widget
.settings
= {};
464 if (!widget
.fullscreenUrl
) {
465 delete widget
.controls
.fullscreen
;
467 var cssClass
= 'widget-' + widget
.name
.replace('/', '-');
468 widget
.element
.attr('id', 'widget-' + widget
.id
).addClass(cssClass
);
469 // Build and add the widget's DOM element.
470 $(widget
.element
).append(widgetHTML());
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
);
475 if (widget
.minimized
) {
476 widget
.contentElement
.hide();
482 // Builds inner HTML for widgets.
483 function widgetHTML() {
485 html
+= '<div class="widget-wrapper">';
486 html
+= ' <div class="widget-controls"><h3 class="widget-header">' + widget
.title
+ '</h3></div>';
487 html
+= ' <div class="widget-content"></div>';
492 // Initializes a widgets settings pane.
493 function initSettings() {
494 // Overwrite widget.settings (boolean).
497 element
: widget
.throbber
.show(),
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;
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();
519 getJavascript(widget
.settings
.initScript
);
523 // Builds HTML for widget settings forms.
524 function widgetSettingsHTML() {
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" />';
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
));
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();
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
) {
560 // Public static properties of dashboard. Default settings.
561 $.fn
.dashboard
.defaults
= {
563 emptyPlaceholderInner
: '',
570 // Default widget settings.
571 $.fn
.dashboard
.widget
= {
577 // id, url, fullscreenUrl, title, name, cacheMinutes