1 // This file is part of Moodle - http://moodle.org/
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.
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/>.
17 * Manage the timeline courses view for the timeline block.
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
28 'core/custom_interaction_events',
31 'block_timeline/event_list',
32 'core_course/repository',
33 'block_timeline/calendar_events_repository'
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'
58 COURSE_ITEMS: 'block_timeline/course-items',
59 LOADING_ICON: 'core/loading'
62 var COURSE_CLASSIFICATION = 'inprogress';
63 var COURSE_SORT = 'fullname asc';
64 var COURSE_EVENT_LIMIT = 5;
66 var SECONDS_IN_DAY = 60 * 60 * 24;
69 * Hide the loading placeholder elements.
71 * @param {object} root The rool element.
73 var hideLoadingPlaceholder = function(root) {
74 root.find(SELECTORS.COURSE_ITEMS_LOADING_PLACEHOLDER).addClass('hidden');
78 * Hide the "more courses" button.
80 * @param {object} root The rool element.
82 var hideMoreCoursesButton = function(root) {
83 root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).addClass('hidden');
87 * Show the "more courses" button.
89 * @param {object} root The rool element.
91 var showMoreCoursesButton = function(root) {
92 root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).removeClass('hidden');
96 * Disable the "more courses" button and show the loading spinner.
98 * @param {object} root The rool element.
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) {
109 // It's not important if this false so just do so silently.
115 * Enable the "more courses" button and remove the loading spinner.
117 * @param {object} root The rool element.
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();
126 * Display the message for when there are no courses available.
128 * @param {object} root The rool element.
130 var showNoCoursesEmptyMessage = function(root) {
131 root.find(SELECTORS.NO_COURSES_EMPTY_MESSAGE).removeClass('hidden');
135 * Render the course items HTML to the page.
137 * @param {object} root The rool element.
138 * @param {string} html The course items HTML to render.
140 var renderCourseItemsHTML = function(root, html) {
141 var container = root.find(SELECTORS.COURSES_LIST);
142 Templates.appendNodeContents(container, html, '');
146 * Check if any courses have been loaded.
148 * @param {object} root The rool element.
151 var hasLoadedCourses = function(root) {
152 return root.find(SELECTORS.COURSE_EVENTS_CONTAINER).length > 0;
156 * Return the offset value for fetching courses.
158 * @param {object} root The rool element.
161 var getOffset = function(root) {
162 return parseInt(root.attr('data-offset'), 10);
166 * Set the offset value for fetching courses.
168 * @param {object} root The rool element.
169 * @param {Number} offset Offset value.
171 var setOffset = function(root, offset) {
172 root.attr('data-offset', offset);
176 * Return the limit value for fetching courses.
178 * @param {object} root The rool element.
181 var getLimit = function(root) {
182 return parseInt(root.attr('data-limit'), 10);
186 * Return the days offset value for fetching events.
188 * @param {object} root The rool element.
191 var getDaysOffset = function(root) {
192 return parseInt(root.attr('data-days-offset'), 10);
196 * Return the days limit value for fetching events. The days
197 * limit is optional so undefined will be returned if it isn't
200 * @param {object} root The rool element.
201 * @return {int|undefined}
203 var getDaysLimit = function(root) {
204 var daysLimit = root.attr('data-days-limit');
205 return daysLimit != undefined ? parseInt(daysLimit, 10) : undefined;
209 * Return the timestamp for the user's midnight.
211 * @param {object} root The rool element.
214 var getMidnight = function(root) {
215 return parseInt(root.attr('data-midnight'), 10);
219 * Return the start time for fetching events. This is calculated
220 * based on the user's midnight value so that timezones are
223 * @param {object} root The rool element.
226 var getStartTime = function(root) {
227 var midnight = getMidnight(root);
228 var daysOffset = getDaysOffset(root);
229 return midnight + (daysOffset * SECONDS_IN_DAY);
233 * Return the end time for fetching events. This is calculated
234 * based on the user's midnight value so that timezones are
237 * @param {object} root The rool element.
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;
247 * Get a list of events for the given course ids. Returns a promise that will
248 * be resolved with the events.
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.
256 var getEventsForCourseIds = function(courseIds, startTime, limit, endTime) {
258 courseids: courseIds,
259 starttime: startTime,
264 args.endtime = endTime;
267 return EventsRepository.queryByCourses(args);
271 * Get the last time the events were reloaded.
273 * @param {object} root The rool element.
276 var getEventReloadTime = function(root) {
277 return root.data('last-event-load-time');
281 * Set the last time the events were reloaded.
283 * @param {object} root The rool element.
284 * @param {Number} time Timestamp in milliseconds.
286 var setEventReloadTime = function(root, time) {
287 root.data('last-event-load-time', time);
291 * Check if events have begun reloading since the given
294 * @param {object} root The rool element.
295 * @param {Number} time Timestamp in milliseconds.
298 var hasReloadedEventsSince = function(root, time) {
299 return getEventReloadTime(root) > time;
303 * Send a request to the server to load the events for the courses.
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.
310 var loadEventsForCourses = function(courses, startTime, endTime) {
311 var courseIds = courses.map(function(course) {
315 return getEventsForCourseIds(courseIds, startTime, COURSE_EVENT_LIMIT + 1, endTime);
319 * Render the courses in the DOM once the server has returned the courses.
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.
329 var updateDisplayFromCourses = function(courses, root, midnight, daysOffset, daysLimit, noEventsURL) {
330 // Render the courses template.
331 return Templates.render(TEMPLATES.COURSE_ITEMS, {
335 hasdayslimit: daysLimit != undefined,
336 daysoffset: daysOffset,
337 dayslimit: daysLimit,
338 nodayslimit: daysLimit == undefined,
340 noevents: noEventsURL
342 }).then(function(html) {
343 hideLoadingPlaceholder(root);
346 // Template rendering is complete and we have the HTML so we can
347 // add it to the DOM.
348 renderCourseItemsHTML(root, html);
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);
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);
365 // Make sure the button is visible if there are more courses to load.
366 showMoreCoursesButton(root);
372 hideLoadingPlaceholder(root);
377 * Find all of the visible course blocks and initialise the event
378 * list module to being loading the events for the course block.
380 * @param {object} root The root element for the timeline courses view.
381 * @return {object} jQuery promise resolved with courses and events.
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,
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;
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;
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;
429 if (courseGroups.length) {
430 // Get the events for this course.
431 events = courseGroups[0].events;
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);
444 // An error is ok, just render with the default string.
445 EventList.init(eventListRoot, COURSE_EVENT_LIMIT, {'1': pageOnePreload});
449 return eventsByCourse;
451 }).catch(Notification.exception);
455 * Reload the events for all of the visible courses. These events will be loaded
456 * in a single request to the server.
458 * @param {object} root The root element.
459 * @return {object} jQuery promise resolved with courses and events.
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');
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;
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();
488 var courseGroups = eventsByCourse.groupedbycourse.filter(function(group) {
489 return group.courseid == courseId;
492 if (courseGroups.length) {
493 // Get the events just for this course.
494 events = courseGroups[0].events;
497 pageDeferred.resolve({events: events});
499 // Re-initialise the events list with the preloaded events we just got from
501 Str.get_string('ariaeventlistpaginationnavcourses', 'block_timeline', courseName)
502 .then(function(string) {
503 EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()}, string);
507 // Ignore a failure to load the string. Just render with the default string.
508 EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()});
512 return eventsByCourse;
513 }).catch(Notification.exception);
517 * Add event listeners to load more courses for the courses view.
519 * @param {object} root The root element for the timeline courses view.
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"
525 root.on(CustomEvents.events.activate, SELECTORS.MORE_COURSES_BUTTON, function(e, data) {
526 enableMoreCoursesButtonLoading(root);
527 loadMoreCourses(root)
529 disableMoreCoursesButtonLoading(root);
533 disableMoreCoursesButtonLoading(root);
537 data.originalEvent.preventDefault();
538 data.originalEvent.stopPropagation();
545 * Initialise the timeline courses view. Begin loading the events
546 * if this view is active. Add the relevant event listeners.
548 * This function should only be called once per page load because it
549 * is adding event listeners to the page.
551 * @param {object} root The root element for the timeline courses view.
553 var init = function(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);
564 registerEventListeners(root);
568 * Reset the element back to it's initial state. Begin loading the events again
569 * if this view is active.
571 * @param {object} root The root element for the timeline courses view.
573 var reset = function(root) {
574 root.removeAttr('data-seen');
575 if (root.hasClass('active')) {
581 * If this is the first time this view has been displayed then begin loading
584 * @param {object} root The root element for the timeline courses view.
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);
593 // We haven't loaded any courses yet so do that now.
594 loadMoreCourses(root);
597 root.attr('data-seen', true);