MDL-63044 block_timeline: add timeline block
[moodle.git] / blocks / timeline / amd / src / view_courses.js
blob4749c920351ddec61339a77235403904de9cf891
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.
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  * Manage the timeline courses view for the timeline block.
18  *
19  * @package    block_timeline
20  * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
21  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22  */
24 define(
26     'jquery',
27     'core/notification',
28     'core/custom_interaction_events',
29     'core/str',
30     'core/templates',
31     'block_timeline/event_list',
32     'core_course/repository',
33     'block_timeline/calendar_events_repository'
35 function(
36     $,
37     Notification,
38     CustomEvents,
39     Str,
40     Templates,
41     EventList,
42     CourseRepository,
43     EventsRepository
44 ) {
46     var SELECTORS = {
47         MORE_COURSES_BUTTON: '[data-action="more-courses"]',
48         MORE_COURSES_BUTTON_CONTAINER: '[data-region="more-courses-button-container"]',
49         NO_COURSES_EMPTY_MESSAGE: '[data-region="no-courses-empty-message"]',
50         COURSES_LIST: '[data-region="courses-list"]',
51         COURSE_ITEMS_LOADING_PLACEHOLDER: '[data-region="course-items-loading-placeholder"]',
52         COURSE_EVENTS_CONTAINER: '[data-region="course-events-container"]',
53         COURSE_NAME: '[data-region="course-name"]',
54         LOADING_ICON: '.loading-icon'
55     };
57     var TEMPLATES = {
58         COURSE_ITEMS: 'block_timeline/course-items',
59         LOADING_ICON: 'core/loading'
60     };
62     var COURSE_CLASSIFICATION = 'inprogress';
63     var COURSE_SORT = 'fullname asc';
64     var COURSE_EVENT_LIMIT = 5;
65     var COURSE_LIMIT = 2;
66     var SECONDS_IN_DAY = 60 * 60 * 24;
68     /**
69      * Hide the loading placeholder elements.
70      *
71      * @param {object} root The rool element.
72      */
73     var hideLoadingPlaceholder = function(root) {
74         root.find(SELECTORS.COURSE_ITEMS_LOADING_PLACEHOLDER).addClass('hidden');
75     };
77     /**
78      * Hide the "more courses" button.
79      *
80      * @param {object} root The rool element.
81      */
82     var hideMoreCoursesButton = function(root) {
83         root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).addClass('hidden');
84     };
86     /**
87      * Show the "more courses" button.
88      *
89      * @param {object} root The rool element.
90      */
91     var showMoreCoursesButton = function(root) {
92         root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).removeClass('hidden');
93     };
95     /**
96      * Disable the "more courses" button and show the loading spinner.
97      *
98      * @param {object} root The rool element.
99      */
100     var enableMoreCoursesButtonLoading = function(root) {
101         var button = root.find(SELECTORS.MORE_COURSES_BUTTON);
102         button.prop('disabled', true);
103         Templates.render(TEMPLATES.LOADING_ICON, {})
104             .then(function(html) {
105                 button.append(html);
106                 return html;
107             })
108             .catch(function() {
109                 // It's not important if this false so just do so silently.
110                 return false;
111             });
112     };
114     /**
115      * Enable the "more courses" button and remove the loading spinner.
116      *
117      * @param {object} root The rool element.
118      */
119     var disableMoreCoursesButtonLoading = function(root) {
120         var button = root.find(SELECTORS.MORE_COURSES_BUTTON);
121         button.prop('disabled', false);
122         button.find(SELECTORS.LOADING_ICON).remove();
123     };
125     /**
126      * Display the message for when there are no courses available.
127      *
128      * @param {object} root The rool element.
129      */
130     var showNoCoursesEmptyMessage = function(root) {
131         root.find(SELECTORS.NO_COURSES_EMPTY_MESSAGE).removeClass('hidden');
132     };
134     /**
135      * Render the course items HTML to the page.
136      *
137      * @param {object} root The rool element.
138      * @param {string} html The course items HTML to render.
139      */
140     var renderCourseItemsHTML = function(root, html) {
141         var container = root.find(SELECTORS.COURSES_LIST);
142         Templates.appendNodeContents(container, html, '');
143     };
145     /**
146      * Check if any courses have been loaded.
147      *
148      * @param {object} root The rool element.
149      * @return {bool}
150      */
151     var hasLoadedCourses = function(root) {
152         return root.find(SELECTORS.COURSE_EVENTS_CONTAINER).length > 0;
153     };
155     /**
156      * Return the offset value for fetching courses.
157      *
158      * @param {object} root The rool element.
159      * @return {Number}
160      */
161     var getOffset = function(root) {
162         return parseInt(root.attr('data-offset'), 10);
163     };
165     /**
166      * Set the offset value for fetching courses.
167      *
168      * @param {object} root The rool element.
169      * @param {Number} offset Offset value.
170      */
171     var setOffset = function(root, offset) {
172         root.attr('data-offset', offset);
173     };
175     /**
176      * Return the limit value for fetching courses.
177      *
178      * @param {object} root The rool element.
179      * @return {Number}
180      */
181     var getLimit = function(root) {
182         return parseInt(root.attr('data-limit'), 10);
183     };
185     /**
186      * Return the days offset value for fetching events.
187      *
188      * @param {object} root The rool element.
189      * @return {Number}
190      */
191     var getDaysOffset = function(root) {
192         return parseInt(root.attr('data-days-offset'), 10);
193     };
195     /**
196      * Return the days limit value for fetching events. The days
197      * limit is optional so undefined will be returned if it isn't
198      * set.
199      *
200      * @param {object} root The rool element.
201      * @return {int|undefined}
202      */
203     var getDaysLimit = function(root) {
204         var daysLimit = root.attr('data-days-limit');
205         return daysLimit != undefined ? parseInt(daysLimit, 10) : undefined;
206     };
208     /**
209      * Return the timestamp for the user's midnight.
210      *
211      * @param {object} root The rool element.
212      * @return {Number}
213      */
214     var getMidnight = function(root) {
215         return parseInt(root.attr('data-midnight'), 10);
216     };
218     /**
219      * Return the start time for fetching events. This is calculated
220      * based on the user's midnight value so that timezones are
221      * preserved.
222      *
223      * @param {object} root The rool element.
224      * @return {Number}
225      */
226     var getStartTime = function(root) {
227         var midnight = getMidnight(root);
228         var daysOffset = getDaysOffset(root);
229         return midnight + (daysOffset * SECONDS_IN_DAY);
230     };
232     /**
233      * Return the end time for fetching events. This is calculated
234      * based on the user's midnight value so that timezones are
235      * preserved.
236      *
237      * @param {object} root The rool element.
238      * @return {Number}
239      */
240     var getEndTime = function(root) {
241         var midnight = getMidnight(root);
242         var daysLimit = getDaysLimit(root);
243         return daysLimit != undefined ? midnight + (daysLimit * SECONDS_IN_DAY) : false;
244     };
246     /**
247      * Get a list of events for the given course ids. Returns a promise that will
248      * be resolved with the events.
249      *
250      * @param {array} courseIds The list of course ids to fetch events for.
251      * @param {Number} startTime Timestamp to fetch events from.
252      * @param {Number} limit Limit to the number of events (this applies per course, not total)
253      * @param {Number} endTime Timestamp to fetch events to.
254      * @return {object} jQuery promise.
255      */
256     var getEventsForCourseIds = function(courseIds, startTime, limit, endTime) {
257         var args = {
258             courseids: courseIds,
259             starttime: startTime,
260             limit: limit
261         };
263         if (endTime) {
264             args.endtime = endTime;
265         }
267         return EventsRepository.queryByCourses(args);
268     };
270     /**
271      * Get the last time the events were reloaded.
272      *
273      * @param {object} root The rool element.
274      * @return {Number}
275      */
276     var getEventReloadTime = function(root) {
277         return root.data('last-event-load-time');
278     };
280     /**
281      * Set the last time the events were reloaded.
282      *
283      * @param {object} root The rool element.
284      * @param {Number} time Timestamp in milliseconds.
285      */
286     var setEventReloadTime = function(root, time) {
287         root.data('last-event-load-time', time);
288     };
290     /**
291      * Check if events have begun reloading since the given
292      * time.
293      *
294      * @param {object} root The rool element.
295      * @param {Number} time Timestamp in milliseconds.
296      * @return {bool}
297      */
298     var hasReloadedEventsSince = function(root, time) {
299         return getEventReloadTime(root) > time;
300     };
302     /**
303      * Send a request to the server to load the events for the courses.
304      *
305      * @param {array} courses List of course objects.
306      * @param {Number} startTime Timestamp to load events after.
307      * @param {int|undefined} endTime Timestamp to load events up until.
308      * @return {object} jQuery promise resolved with the events.
309      */
310     var loadEventsForCourses = function(courses, startTime, endTime) {
311         var courseIds = courses.map(function(course) {
312             return course.id;
313         });
315         return getEventsForCourseIds(courseIds, startTime, COURSE_EVENT_LIMIT + 1, endTime);
316     };
318     /**
319      * Render the courses in the DOM once the server has returned the courses.
320      *
321      * @param {array} courses List of course objects.
322      * @param {object} root The root element
323      * @param {Number} midnight The midnight timestamp in the user's timezone.
324      * @param {Number} daysOffset Number of days from today to offset the events.
325      * @param {Number} daysLimit Number of days from today to limit the events to.
326      * @param {string} noEventsURL URL for the image to display for no events.
327      * @return {object} jQuery promise resolved after rendering is complete.
328      */
329     var updateDisplayFromCourses = function(courses, root, midnight, daysOffset, daysLimit, noEventsURL) {
330         // Render the courses template.
331         return Templates.render(TEMPLATES.COURSE_ITEMS, {
332             courses: courses,
333             midnight: midnight,
334             hasdaysoffset: true,
335             hasdayslimit: daysLimit != undefined,
336             daysoffset: daysOffset,
337             dayslimit: daysLimit,
338             nodayslimit: daysLimit == undefined,
339             urls: {
340                 noevents: noEventsURL
341             }
342         }).then(function(html) {
343             hideLoadingPlaceholder(root);
345             if (html) {
346                 // Template rendering is complete and we have the HTML so we can
347                 // add it to the DOM.
348                 renderCourseItemsHTML(root, html);
349             } else {
350                 if (!hasLoadedCourses(root)) {
351                     // There were no courses to render so show the empty placeholder
352                     // message for the user to tell them.
353                     showNoCoursesEmptyMessage(root);
354                 }
355             }
357             return html;
358         })
359         .then(function(html) {
360             if (courses.length < COURSE_LIMIT) {
361                 // We know there aren't any more courses because we got back less
362                 // than we asked for so hide the button to request more.
363                 hideMoreCoursesButton(root);
364             } else {
365                 // Make sure the button is visible if there are more courses to load.
366                 showMoreCoursesButton(root);
367             }
369             return html;
370         })
371         .catch(function() {
372             hideLoadingPlaceholder(root);
373         });
374     };
376     /**
377      * Find all of the visible course blocks and initialise the event
378      * list module to being loading the events for the course block.
379      *
380      * @param {object} root The root element for the timeline courses view.
381      * @return {object} jQuery promise resolved with courses and events.
382      */
383     var loadMoreCourses = function(root) {
384         var offset = getOffset(root);
385         var limit = getLimit(root);
387         // Start loading the next set of courses.
388         return CourseRepository.getEnrolledCoursesByTimelineClassification(
389             COURSE_CLASSIFICATION,
390             limit,
391             offset,
392             COURSE_SORT
393         ).then(function(result) {
394             var startEventLoadingTime = Date.now();
395             var courses = result.courses;
396             var nextOffset = result.nextoffset;
397             var daysOffset = getDaysOffset(root);
398             var daysLimit = getDaysLimit(root);
399             var midnight = getMidnight(root);
400             var startTime = getStartTime(root);
401             var endTime = getEndTime(root);
402             var noEventsURL = root.attr('data-no-events-url');
403             // Record the next offset if we want to request more courses.
404             setOffset(root, nextOffset);
405             // Load the events for these courses.
406             var eventsPromise = loadEventsForCourses(courses, startTime, endTime);
407             // Render the courses in the DOM.
408             var renderPromise = updateDisplayFromCourses(courses, root, midnight, daysOffset, daysLimit, noEventsURL);
410             return $.when(eventsPromise, renderPromise)
411                 .then(function(eventsByCourse) {
412                     if (hasReloadedEventsSince(root, startEventLoadingTime)) {
413                         // All of the events are being reloaded so ignore our results.
414                         return eventsByCourse;
415                     }
417                     // When we've got all of the courses and events we can render the events in the
418                     // correct course event list.
419                     courses.forEach(function(course) {
420                         var courseId = course.id;
421                         var events = [];
422                         var containerSelector = '[data-region="course-events-container"][data-course-id="' + courseId + '"]';
423                         var courseEventsContainer = root.find(containerSelector);
424                         var eventListRoot = courseEventsContainer.find(EventList.rootSelector);
425                         var courseGroups = eventsByCourse.groupedbycourse.filter(function(group) {
426                             return group.courseid == courseId;
427                         });
429                         if (courseGroups.length) {
430                             // Get the events for this course.
431                             events = courseGroups[0].events;
432                         }
434                         // Create a preloaded page to pass to the event list because we've already
435                         // loaded the first page of events.
436                         var pageOnePreload = $.Deferred().resolve({events: events}).promise();
437                         // Initialise the event list pagination area for this course.
438                         Str.get_string('ariaeventlistpaginationnavcourses', 'block_timeline', course.fullnamedisplay)
439                             .then(function(string) {
440                                 EventList.init(eventListRoot, COURSE_EVENT_LIMIT, {'1': pageOnePreload}, string);
441                                 return string;
442                             })
443                             .catch(function() {
444                                 // An error is ok, just render with the default string.
445                                 EventList.init(eventListRoot, COURSE_EVENT_LIMIT, {'1': pageOnePreload});
446                             });
447                     });
449                     return eventsByCourse;
450                 });
451         }).catch(Notification.exception);
452     };
454     /**
455      * Reload the events for all of the visible courses. These events will be loaded
456      * in a single request to the server.
457      *
458      * @param {object} root The root element.
459      * @return {object} jQuery promise resolved with courses and events.
460      */
461     var reloadCourseEvents = function(root) {
462         var startReloadTime = Date.now();
463         var startTime = getStartTime(root);
464         var endTime = getEndTime(root);
465         var courseEventsContainers = root.find(SELECTORS.COURSE_EVENTS_CONTAINER);
466         var courseIds = courseEventsContainers.map(function() {
467             return $(this).attr('data-course-id');
468         }).get();
470         // Record when we started our request.
471         setEventReloadTime(root, startReloadTime);
473         // Load all of the events for the given courses.
474         return getEventsForCourseIds(courseIds, startTime, COURSE_EVENT_LIMIT + 1, endTime)
475             .then(function(eventsByCourse) {
476                 if (hasReloadedEventsSince(root, startReloadTime)) {
477                     // A new reload has begun so ignore our results.
478                     return eventsByCourse;
479                 }
481                 courseEventsContainers.each(function(index, container) {
482                     container = $(container);
483                     var courseId = container.attr('data-course-id');
484                     var courseName = container.find(SELECTORS.COURSE_NAME).text();
485                     var eventListContainer = container.find(EventList.rootSelector);
486                     var pageDeferred = $.Deferred();
487                     var events = [];
488                     var courseGroups = eventsByCourse.groupedbycourse.filter(function(group) {
489                         return group.courseid == courseId;
490                     });
492                     if (courseGroups.length) {
493                         // Get the events just for this course.
494                         events = courseGroups[0].events;
495                     }
497                     pageDeferred.resolve({events: events});
499                     // Re-initialise the events list with the preloaded events we just got from
500                     // the server.
501                     Str.get_string('ariaeventlistpaginationnavcourses', 'block_timeline', courseName)
502                         .then(function(string) {
503                             EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()}, string);
504                             return string;
505                         })
506                         .catch(function() {
507                             // Ignore a failure to load the string. Just render with the default string.
508                             EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()});
509                         });
510                 });
512                 return eventsByCourse;
513             }).catch(Notification.exception);
514     };
516     /**
517      * Add event listeners to load more courses for the courses view.
518      *
519      * @param {object} root The root element for the timeline courses view.
520      */
521     var registerEventListeners = function(root) {
522         CustomEvents.define(root, [CustomEvents.events.activate]);
523         // Show more courses and load their events when the user clicks the "more courses"
524         // button.
525         root.on(CustomEvents.events.activate, SELECTORS.MORE_COURSES_BUTTON, function(e, data) {
526             enableMoreCoursesButtonLoading(root);
527             loadMoreCourses(root)
528                 .then(function() {
529                     disableMoreCoursesButtonLoading(root);
530                     return;
531                 })
532                 .catch(function() {
533                     disableMoreCoursesButtonLoading(root);
534                 });
536             if (data) {
537                 data.originalEvent.preventDefault();
538                 data.originalEvent.stopPropagation();
539             }
540             e.stopPropagation();
541         });
542     };
544     /**
545      * Initialise the timeline courses view. Begin loading the events
546      * if this view is active. Add the relevant event listeners.
547      *
548      * This function should only be called once per page load because it
549      * is adding event listeners to the page.
550      *
551      * @param {object} root The root element for the timeline courses view.
552      */
553     var init = function(root) {
554         root = $(root);
556         setEventReloadTime(root, Date.now());
558         if (root.hasClass('active')) {
559             // Only load if this is active otherwise it will be lazy loaded later.
560             loadMoreCourses(root);
561             root.attr('data-seen', true);
562         }
564         registerEventListeners(root);
565     };
567     /**
568      * Reset the element back to it's initial state. Begin loading the events again
569      * if this view is active.
570      *
571      * @param {object} root The root element for the timeline courses view.
572      */
573     var reset = function(root) {
574         root.removeAttr('data-seen');
575         if (root.hasClass('active')) {
576             shown(root);
577         }
578     };
580     /**
581      * If this is the first time this view has been displayed then begin loading
582      * the events.
583      *
584      * @param {object} root The root element for the timeline courses view.
585      */
586     var shown = function(root) {
587         if (!root.attr('data-seen')) {
588             if (hasLoadedCourses(root)) {
589                 // This isn't the first time this view is shown so just reload the
590                 // events for the courses we've already loaded.
591                 reloadCourseEvents(root);
592             } else {
593                 // We haven't loaded any courses yet so do that now.
594                 loadMoreCourses(root);
595             }
597             root.attr('data-seen', true);
598         }
599     };
601     return {
602         init: init,
603         reset: reset,
604         shown: shown
605     };