MDL-57139 myoverview: Use promise best practices
[moodle.git] / blocks / myoverview / amd / src / event_list.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * Javascript to load and render the list of calendar events for a
18  * given day range.
19  *
20  * @module     block_myoverview/event_list
21  * @package    block_myoverview
22  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
25 define(['jquery', 'core/notification', 'core/templates',
26         'core/custom_interaction_events',
27         'block_myoverview/calendar_events_repository'],
28         function($, Notification, Templates, CustomEvents, CalendarEventsRepository) {
30     var SECONDS_IN_DAY = 60 * 60 * 24;
32     var SELECTORS = {
33         EMPTY_MESSAGE: '[data-region="empty-message"]',
34         ROOT: '[data-region="event-list-container"]',
35         EVENT_LIST: '[data-region="event-list"]',
36         EVENT_LIST_CONTENT: '[data-region="event-list-content"]',
37         EVENT_LIST_GROUP_CONTAINER: '[data-region="event-list-group-container"]',
38         LOADING_ICON_CONTAINER: '[data-region="loading-icon-container"]',
39         VIEW_MORE_BUTTON: '[data-action="view-more"]'
40     };
42     var TEMPLATES = {
43         EVENT_LIST_ITEMS: 'block_myoverview/event-list-items',
44         COURSE_EVENT_LIST_ITEMS: 'block_myoverview/course-event-list-items'
45     };
47     /**
48      * Set a flag on the element to indicate that it has completed
49      * loading all event data.
50      *
51      * @method setLoadedAll
52      * @private
53      * @param {object} root The container element
54      */
55     var setLoadedAll = function(root) {
56         root.attr('data-loaded-all', true);
57     };
59     /**
60      * Check if all event data has finished loading.
61      *
62      * @method hasLoadedAll
63      * @private
64      * @param {object} root The container element
65      * @return {bool} if the element has completed all loading
66      */
67     var hasLoadedAll = function(root) {
68         return !!root.attr('data-loaded-all');
69     };
71     /**
72      * Set the element state to loading.
73      *
74      * @method startLoading
75      * @private
76      * @param {object} root The container element
77      */
78     var startLoading = function(root) {
79         var loadingIcon = root.find(SELECTORS.LOADING_ICON_CONTAINER),
80             viewMoreButton = root.find(SELECTORS.VIEW_MORE_BUTTON);
82         root.addClass('loading');
83         loadingIcon.removeClass('hidden');
84         viewMoreButton.prop('disabled', true);
85     };
87     /**
88      * Remove the loading state from the element.
89      *
90      * @method stopLoading
91      * @private
92      * @param {object} root The container element
93      */
94     var stopLoading = function(root) {
95         var loadingIcon = root.find(SELECTORS.LOADING_ICON_CONTAINER),
96             viewMoreButton = root.find(SELECTORS.VIEW_MORE_BUTTON);
98         root.removeClass('loading');
99         loadingIcon.addClass('hidden');
101         if (!hasLoadedAll(root)) {
102             // Only enable the button if we've got more events to load.
103             viewMoreButton.prop('disabled', false);
104         }
105     };
107     /**
108      * Check if the element is currently loading some event data.
109      *
110      * @method isLoading
111      * @private
112      * @param {object} root The container element
113      * @returns {Boolean}
114      */
115     var isLoading = function(root) {
116         return root.hasClass('loading');
117     };
119     /**
120      * Flag the root element to remember that it contains events.
121      *
122      * @method setHasContent
123      * @private
124      * @param {object} root The container element
125      */
126     var setHasContent = function(root) {
127         root.attr('data-has-events', true);
128     };
130     /**
131      * Check if the root element has had events loaded.
132      *
133      * @method hasContent
134      * @private
135      * @param {object} root The container element
136      * @return {bool}
137      */
138     var hasContent = function(root) {
139         return root.attr('data-has-events') ? true : false;
140     };
142     /**
143      * Update the visibility of the content area. The content area
144      * is hidden if we have no events.
145      *
146      * @method updateContentVisibility
147      * @private
148      * @param {object} root The container element
149      * @param {int} eventCount A count of the events we just received.
150      */
151     var updateContentVisibility = function(root, eventCount) {
152         if (eventCount) {
153             // We've rendered some events, let's remember that.
154             setHasContent(root);
155         } else {
156             // If this is the first time trying to load events and
157             // we don't have any then there isn't any so let's show
158             // the empty message.
159             if (!hasContent(root)) {
160                 hideContent(root);
161             }
162         }
163     };
165     /**
166      * Hide the content area and display the empty content message.
167      *
168      * @method hideContent
169      * @private
170      * @param {object} root The container element
171      */
172     var hideContent = function(root) {
173         root.find(SELECTORS.EVENT_LIST_CONTENT).addClass('hidden');
174         root.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
175     };
177     /**
178      * Render a group of calendar events and add them to the event
179      * list.
180      *
181      * @method renderGroup
182      * @private
183      * @param {object}  group           The group container element
184      * @param {array}   calendarEvents  The list of calendar events
185      * @param {string}  templateName    The template name
186      * @return {promise} Resolved when the elements are attached to the DOM
187      */
188     var renderGroup = function(group, calendarEvents, templateName) {
190         group.removeClass('hidden');
192         return Templates.render(
193             templateName,
194             {events: calendarEvents}
195         ).done(function(html, js) {
196             Templates.appendNodeContents(group.find(SELECTORS.EVENT_LIST), html, js);
197         });
198     };
200     /**
201      * Determine the time (in seconds) from the given timestamp until the calendar
202      * event will need actioning.
203      *
204      * @method timeUntilEvent
205      * @private
206      * @param {int}     timestamp   The time to compare with
207      * @param {object}  event       The calendar event
208      * @return {int}
209      */
210     var timeUntilEvent = function(timestamp, event) {
211         var orderTime = event.timesort || 0;
212         return orderTime - timestamp;
213     };
215     /**
216      * Check if the given calendar event should be added to the given event
217      * list group container. The event list group container will specify a
218      * day range for the time boundary it is interested in.
219      *
220      * If only a start day is specified for the container then it will be treated
221      * as an open catchment for all events that begin after that time.
222      *
223      * @method eventBelongsInContainer
224      * @private
225      * @param {object} root         The root element
226      * @param {object} event        The calendar event
227      * @param {object} container    The group event list container
228      * @return {bool}
229      */
230     var eventBelongsInContainer = function(root, event, container) {
231         var todayTime = root.attr('data-midnight'),
232             timeUntilContainerStart = +container.attr('data-start-day') * SECONDS_IN_DAY,
233             timeUntilContainerEnd = +container.attr('data-end-day') * SECONDS_IN_DAY,
234             timeUntilEventNeedsAction = timeUntilEvent(todayTime, event);
236         if (container.attr('data-end-day') === '') {
237             return timeUntilContainerStart <= timeUntilEventNeedsAction;
238         } else {
239             return timeUntilContainerStart <= timeUntilEventNeedsAction &&
240                    timeUntilEventNeedsAction < timeUntilContainerEnd;
241         }
242     };
244     /**
245      * Return a function that can be used to filter a list of events based on the day
246      * range specified on the given event list group container.
247      *
248      * @method getFilterCallbackForContainer
249      * @private
250      * @param {object} root      The root element
251      * @param {object} container Event list group container
252      * @return {function}
253      */
254     var getFilterCallbackForContainer = function(root, container) {
255         return function(event) {
256             return eventBelongsInContainer(root, event, $(container));
257         };
258     };
260     /**
261      * Render the given calendar events in the container element. The container
262      * elements must have a day range defined using data attributes that will be
263      * used to group the calendar events according to their order time.
264      *
265      * @method render
266      * @private
267      * @param {object}  root            The container element
268      * @param {array}   calendarEvents  A list of calendar events
269      * @return {promise} Resolved with a count of the number of rendered events
270      */
271     var render = function(root, calendarEvents) {
272         var renderCount = 0;
273         var templateName = TEMPLATES.EVENT_LIST_ITEMS;
275         if (root.attr('data-course-id')) {
276             templateName = TEMPLATES.COURSE_EVENT_LIST_ITEMS;
277         }
279         // Loop over each of the element list groups and find the set of calendar events
280         // that belong to that group (as defined by the group's day range). The matching
281         // list of calendar events are rendered and added to the DOM within that group.
282         return $.when.apply($, $.map(root.find(SELECTORS.EVENT_LIST_GROUP_CONTAINER), function(container) {
283             var events = calendarEvents.filter(getFilterCallbackForContainer(root, container));
285             if (events.length) {
286                 renderCount += events.length;
287                 return renderGroup($(container), events, templateName);
288             } else {
289                 return null;
290             }
291         })).then(function() {
292             return renderCount;
293         });
294     };
296     /**
297      * Retrieve a list of calendar events, render and append them to the end of the
298      * existing list. The events will be loaded based on the set of data attributes
299      * on the root element.
300      *
301      * This function can be provided with a jQuery promise. If it is then it won't
302      * attempt to load data by itself, instead it will use the given promise.
303      *
304      * The provided promise must resolve with an an object that has an events key
305      * and value is an array of calendar events.
306      * E.g.
307      * { events: ['event 1', 'event 2'] }
308      *
309      * @method load
310      * @param {object} root The root element of the event list
311      * @param {object} promise A jQuery promise resolved with events
312      * @return {promise} A jquery promise
313      */
314     var load = function(root, promise) {
315         root = $(root);
316         var limit = +root.attr('data-limit'),
317             courseId = +root.attr('data-course-id'),
318             lastId = root.attr('data-last-id'),
319             midnight = root.attr('data-midnight'),
320             startTime = midnight - (14 * SECONDS_IN_DAY);
322         // Don't load twice.
323         if (isLoading(root)) {
324             return $.Deferred().resolve();
325         }
327         startLoading(root);
329         // If we haven't been provided a promise to resolve the
330         // data then we will load our own.
331         if (typeof promise == 'undefined') {
332             var args = {
333                 starttime: startTime,
334                 limit: limit,
335             };
337             if (lastId) {
338                 args.aftereventid = lastId;
339             }
341             // If we have a course id then we only want events from that course.
342             if (courseId) {
343                 args.courseid = courseId;
344                 promise = CalendarEventsRepository.queryByCourse(args);
345             } else {
346                 // Otherwise we want events from any course.
347                 promise = CalendarEventsRepository.queryByTime(args);
348             }
349         }
351         // Request data from the server.
352         return promise.then(function(result) {
353             if (!result.events.length) {
354                 // No events, nothing to do.
355                 setLoadedAll(root);
356                 return 0;
357             }
359             var calendarEvents = result.events;
361             // Remember the last id we've seen.
362             root.attr('data-last-id', calendarEvents[calendarEvents.length - 1].id);
364             if (calendarEvents.length <= limit) {
365                 // No more events to load, disable loading button.
366                 setLoadedAll(root);
367             }
369             // Render the events.
370             return render(root, calendarEvents).then(function(renderCount) {
371                 if (renderCount < calendarEvents.length) {
372                     // if the number of events that was rendered is less than
373                     // the number we sent for rendering we can assume that there
374                     // are no groups to add them in. Since the ordering of the
375                     // events is guaranteed it means that any future requests will
376                     // also yield events that can't be rendered, so let's not bother
377                     // sending any more requests.
378                     setLoadedAll(root);
379                 }
380                 return calendarEvents.length;
381             });
382         }).then(function(eventCount) {
383             return updateContentVisibility(root, eventCount);
384         }).fail(
385             Notification.exception
386         ).always(function() {
387             stopLoading(root);
388         });
389     };
391     /**
392      * Register the event listeners for the container element.
393      *
394      * @method registerEventListeners
395      * @param {object} root The root element of the event list
396      */
397     var registerEventListeners = function(root) {
398         CustomEvents.define(root, [CustomEvents.events.activate]);
399         root.on(CustomEvents.events.activate, SELECTORS.VIEW_MORE_BUTTON, function() {
400             load(root);
401         });
402     };
404     return {
405         init: function(root) {
406             root = $(root);
407             load(root);
408             registerEventListeners(root);
409         },
410         registerEventListeners: registerEventListeners,
411         load: load,
412         rootSelector: SELECTORS.ROOT,
413     };
414 });