MDL-63817 block_timeline: Persist page limits when sorting by dates
[moodle.git] / blocks / timeline / 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_timeline/event_list
21  * @package    block_timeline
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(
26 [
27     'jquery',
28     'core/notification',
29     'core/templates',
30     'core/paged_content_factory',
31     'core/str',
32     'core/user_date',
33     'block_timeline/calendar_events_repository'
34 ],
35 function(
36     $,
37     Notification,
38     Templates,
39     PagedContentFactory,
40     Str,
41     UserDate,
42     CalendarEventsRepository
43 ) {
45     var SECONDS_IN_DAY = 60 * 60 * 24;
47     var SELECTORS = {
48         EMPTY_MESSAGE: '[data-region="empty-message"]',
49         ROOT: '[data-region="event-list-container"]',
50         EVENT_LIST_CONTENT: '[data-region="event-list-content"]',
51         EVENT_LIST_LOADING_PLACEHOLDER: '[data-region="event-list-loading-placeholder"]',
52     };
54     var TEMPLATES = {
55         EVENT_LIST_CONTENT: 'block_timeline/event-list-content'
56     };
58     // We want the paged content controls below the paged content area
59     // and the controls should be ignored while data is loading.
60     var DEFAULT_PAGED_CONTENT_CONFIG = {
61         ignoreControlWhileLoading: true,
62         controlPlacementBottom: true,
63         ariaLabels: {
64             itemsperpagecomponents: 'ariaeventlistpagelimit, block_timeline',
65         }
66     };
68     /**
69      * Hide the content area and display the empty content message.
70      *
71      * @param {object} root The container element
72      */
73     var hideContent = function(root) {
74         root.find(SELECTORS.EVENT_LIST_CONTENT).addClass('hidden');
75         root.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
76     };
78     /**
79      * Show the content area and hide the empty content message.
80      *
81      * @param {object} root The container element
82      */
83     var showContent = function(root) {
84         root.find(SELECTORS.EVENT_LIST_CONTENT).removeClass('hidden');
85         root.find(SELECTORS.EMPTY_MESSAGE).addClass('hidden');
86     };
88     /**
89      * Empty the content area.
90      *
91      * @param {object} root The container element
92      */
93     var emptyContent = function(root) {
94         root.find(SELECTORS.EVENT_LIST_CONTENT).empty();
95     };
97     /**
98      * Construct the template context from a list of calendar events. The events
99      * are grouped by which day they are on. The day is calculated from the user's
100      * midnight timestamp to ensure that the calculation is timezone agnostic.
101      *
102      * The return data structure will look like:
103      * {
104      *      eventsbyday: [
105      *          {
106      *              dayTimestamp: 1533744000,
107      *              events: [
108      *                  { ...event 1 data... },
109      *                  { ...event 2 data... }
110      *              ]
111      *          },
112      *          {
113      *              dayTimestamp: 1533830400,
114      *              events: [
115      *                  { ...event 3 data... },
116      *                  { ...event 4 data... }
117      *              ]
118      *          }
119      *      ]
120      * }
121      *
122      * Each day timestamp is the day's midnight in the user's timezone.
123      *
124      * @param {array} calendarEvents List of calendar events
125      * @param {Number} midnight A timestamp representing midnight in the user's timezone
126      * @return {object}
127      */
128     var buildTemplateContext = function(calendarEvents, midnight) {
129         var eventsByDay = {};
130         var templateContext = {
131             eventsbyday: []
132         };
134         calendarEvents.forEach(function(calendarEvent) {
135             var dayTimestamp = UserDate.getUserMidnightForTimestamp(calendarEvent.timesort, midnight);
136             if (eventsByDay[dayTimestamp]) {
137                 eventsByDay[dayTimestamp].push(calendarEvent);
138             } else {
139                 eventsByDay[dayTimestamp] = [calendarEvent];
140             }
141         });
143         Object.keys(eventsByDay).forEach(function(dayTimestamp) {
144             var events = eventsByDay[dayTimestamp];
145             templateContext.eventsbyday.push({
146                 past: dayTimestamp < midnight,
147                 dayTimestamp: dayTimestamp,
148                 events: events
149             });
150         });
152         return templateContext;
153     };
155     /**
156      * Render the HTML for the given calendar events.
157      *
158      * @param {array} calendarEvents  A list of calendar events
159      * @param {Number} midnight A timestamp representing midnight for the user
160      * @return {promise} Resolved with HTML and JS strings.
161      */
162     var render = function(calendarEvents, midnight) {
163         var templateContext = buildTemplateContext(calendarEvents, midnight);
164         var templateName = TEMPLATES.EVENT_LIST_CONTENT;
166         return Templates.render(templateName, templateContext);
167     };
169     /**
170      * Retrieve a list of calendar events from the server for the given
171      * constraints.
172      *
173      * @param {Number} midnight The user's midnight time in unix timestamp.
174      * @param {Number} limit Limit the result set to this number of items
175      * @param {Number} daysOffset How many days (from midnight) to offset the results from
176      * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
177      * @param {int|falsey} lastId The ID of the last seen event (if any)
178      * @param {int|undefined} courseId Course ID to restrict events to
179      * @return {promise} A jquery promise
180      */
181     var load = function(midnight, limit, daysOffset, daysLimit, lastId, courseId) {
182         var startTime = midnight + (daysOffset * SECONDS_IN_DAY);
183         var endTime = daysLimit != undefined ? midnight + (daysLimit * SECONDS_IN_DAY) : false;
185         var args = {
186             starttime: startTime,
187             limit: limit,
188         };
190         if (lastId) {
191             args.aftereventid = lastId;
192         }
194         if (endTime) {
195             args.endtime = endTime;
196         }
198         if (courseId) {
199             // If we have a course id then we only want events from that course.
200             args.courseid = courseId;
201             return CalendarEventsRepository.queryByCourse(args);
202         } else {
203             // Otherwise we want events from any course.
204             return CalendarEventsRepository.queryByTime(args);
205         }
206     };
208     /**
209      * Handle a single page request from the paged content. Uses the given page data to request
210      * the events from the server.
211      *
212      * Checks the given preloadedPages before sending a request to the server to make sure we
213      * don't load data unnecessarily.
214      *
215      * @param {object} pageData A single page data (see core/paged_content_pages for more info).
216      * @param {object} actions Paged content actions (see core/paged_content_pages for more info).
217      * @param {Number} midnight The user's midnight time in unix timestamp.
218      * @param {object} lastIds The last event ID for each loaded page. Page number is key, id is value.
219      * @param {object} preloadedPages An object of preloaded page data. Page number as key, data promise as value.
220      * @param {int|undefined} courseId Course ID to restrict events to
221      * @param {Number} daysOffset How many days (from midnight) to offset the results from
222      * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
223      * @return {object} jQuery promise resolved with calendar events.
224      */
225     var loadEventsFromPageData = function(
226         pageData,
227         actions,
228         midnight,
229         lastIds,
230         preloadedPages,
231         courseId,
232         daysOffset,
233         daysLimit
234     ) {
235         var pageNumber = pageData.pageNumber;
236         var limit = pageData.limit;
237         var lastPageNumber = pageNumber;
239         // This is here to protect us if, for some reason, the pages
240         // are loaded out of order somehow and we don't have a reference
241         // to the previous page. In that case, scan back to find the most
242         // recent page we've seen.
243         while (!lastIds.hasOwnProperty(lastPageNumber)) {
244             lastPageNumber--;
245         }
246         // Use the last id of the most recent page.
247         var lastId = lastIds[lastPageNumber];
248         var eventsPromise = null;
250         if (preloadedPages && preloadedPages.hasOwnProperty(pageNumber)) {
251             // This page has been preloaded so use that rather than load the values
252             // again.
253             eventsPromise = preloadedPages[pageNumber];
254         } else {
255             // Load one more than the given limit so that we can tell if there
256             // is more content to load after this.
257             eventsPromise = load(midnight, limit + 1, daysOffset, daysLimit, lastId, courseId);
258         }
260         return eventsPromise.then(function(result) {
261             if (!result.events.length) {
262                 // If we didn't get any events back then tell the paged content
263                 // that we're done loading.
264                 actions.allItemsLoaded(pageNumber);
265                 return [];
266             }
268             var calendarEvents = result.events;
269             // We expect to receive limit + 1 events back from the server.
270             // Any less means there are no more events to load.
271             var loadedAll = calendarEvents.length <= limit;
273             if (loadedAll) {
274                 // Tell the pagination that everything is loaded.
275                 actions.allItemsLoaded(pageNumber);
276             } else {
277                 // Remove the last element from the array because it isn't
278                 // needed in this result set.
279                 calendarEvents.pop();
280             }
282             return calendarEvents;
283         });
284     };
286     /**
287      * Use the paged content factory to create a paged content element for showing
288      * the event list. We only provide a page limit to the factory because we don't
289      * know exactly how many pages we'll need. This creates a paging bar with just
290      * next/previous buttons.
291      *
292      * This function specifies the callback for loading the event data that the user
293      * is requesting.
294      *
295      * @param {int|array} pageLimit A single limit or list of limits as options for the paged content
296      * @param {object} preloadedPages An object of preloaded page data. Page number as key, data promise as value.
297      * @param {Number} midnight The user's midnight time in unix timestamp.
298      * @param {object} firstLoad A jQuery promise to be resolved after the first set of data is loaded.
299      * @param {int|undefined} courseId Course ID to restrict events to
300      * @param {Number} daysOffset How many days (from midnight) to offset the results from
301      * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
302      * @param {string} paginationAriaLabel String to set as the aria label for the pagination bar.
303      * @param {object} additionalConfig Additional config options to pass to pagedContentFactory
304      * @return {object} jQuery promise.
305      */
306     var createPagedContent = function(
307         pageLimit,
308         preloadedPages,
309         midnight,
310         firstLoad,
311         courseId,
312         daysOffset,
313         daysLimit,
314         paginationAriaLabel,
315         additionalConfig
316     ) {
317         // Remember the last event id we loaded on each page because we can't
318         // use the offset value since the backend can skip events if the user doesn't
319         // have the capability to see them. Instead we load the next page of events
320         // based on the last seen event id.
321         var lastIds = {'1': 0};
322         var hasContent = false;
323         var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG, additionalConfig);
325         return Str.get_string(
326                 'ariaeventlistpagelimit',
327                 'block_timeline',
328                 $.isArray(pageLimit) ? pageLimit[0] : pageLimit
329             )
330             .then(function(string) {
331                 config.ariaLabels.itemsperpage = string;
332                 config.ariaLabels.paginationnav = paginationAriaLabel;
333                 return string;
334             })
335             .then(function() {
336                 return PagedContentFactory.createWithLimit(
337                     pageLimit,
338                     function(pagesData, actions) {
339                         var promises = [];
341                         pagesData.forEach(function(pageData) {
342                             var pageNumber = pageData.pageNumber;
343                             // Load the page data.
344                             var pagePromise = loadEventsFromPageData(
345                                 pageData,
346                                 actions,
347                                 midnight,
348                                 lastIds,
349                                 preloadedPages,
350                                 courseId,
351                                 daysOffset,
352                                 daysLimit
353                             ).then(function(calendarEvents) {
354                                 if (calendarEvents.length) {
355                                     // Remember that we've loaded content.
356                                     hasContent = true;
357                                     // Remember the last id we've seen.
358                                     var lastEventId = calendarEvents[calendarEvents.length - 1].id;
359                                     // Record the id that the next page will need to start from.
360                                     lastIds[pageNumber + 1] = lastEventId;
361                                     // Get the HTML and JS for these calendar events.
362                                     return render(calendarEvents, midnight);
363                                 } else {
364                                     return calendarEvents;
365                                 }
366                             })
367                             .catch(Notification.exception);
369                             promises.push(pagePromise);
370                         });
372                         $.when.apply($, promises).then(function() {
373                             // Tell the calling code that the first page has been loaded
374                             // and whether it contains any content.
375                             firstLoad.resolve(hasContent);
376                             return;
377                         })
378                         .catch(function() {
379                             firstLoad.resolve(hasContent);
380                         });
382                         return promises;
383                     },
384                     config
385                 );
386             });
387     };
389     /**
390      * Create a paged content region for the calendar events in the given root element.
391      * The content of the root element are replaced with a new paged content section
392      * each time this function is called.
393      *
394      * This function will be called each time the offset or limit values are changed to
395      * reload the event list region.
396      *
397      * @param {object} root The event list container element
398      * @param {int|array} pageLimit A single limit or list of limits as options for the paged content
399      * @param {object} preloadedPages An object of preloaded page data. Page number as key, data promise as value.
400      * @param {string} paginationAriaLabel String to set as the aria label for the pagination bar.
401      * @param {object} additionalConfig Additional config options to pass to pagedContentFactory
402      */
403     var init = function(root, pageLimit, preloadedPages, paginationAriaLabel, additionalConfig) {
404         root = $(root);
406         // Create a promise that will be resolved once the first set of page
407         // data has been loaded. This ensures that the loading placeholder isn't
408         // hidden until we have all of the data back to prevent the page elements
409         // jumping around.
410         var firstLoad = $.Deferred();
411         var eventListContent = root.find(SELECTORS.EVENT_LIST_CONTENT);
412         var loadingPlaceholder = root.find(SELECTORS.EVENT_LIST_LOADING_PLACEHOLDER);
413         var courseId = root.attr('data-course-id');
414         var daysOffset = parseInt(root.attr('data-days-offset'), 10);
415         var daysLimit = root.attr('data-days-limit');
416         var midnight = parseInt(root.attr('data-midnight'), 10);
418         // Make sure the content area and loading placeholder is visible.
419         // This is because the init function can be called to re-initialise
420         // an existing event list area.
421         emptyContent(root);
422         showContent(root);
423         loadingPlaceholder.removeClass('hidden');
425         // Days limit isn't mandatory.
426         if (daysLimit != undefined) {
427             daysLimit = parseInt(daysLimit, 10);
428         }
430         // Created the paged content element.
431         return createPagedContent(pageLimit, preloadedPages, midnight, firstLoad, courseId, daysOffset, daysLimit,
432                 paginationAriaLabel, additionalConfig)
433             .then(function(html, js) {
434                 html = $(html);
435                 // Hide the content for now.
436                 html.addClass('hidden');
437                 // Replace existing elements with the newly created paged content.
438                 // If we're reinitialising an existing event list this will replace
439                 // the old event list (including removing any event handlers).
440                 Templates.replaceNodeContents(eventListContent, html, js);
442                 firstLoad.then(function(hasContent) {
443                     // Prevent changing page elements too much by only showing the content
444                     // once we've loaded some data for the first time. This allows our
445                     // fancy loading placeholder to shine.
446                     html.removeClass('hidden');
447                     loadingPlaceholder.addClass('hidden');
449                     if (!hasContent) {
450                         // If we didn't get any data then show the empty data message.
451                         hideContent(root);
452                     }
454                     return hasContent;
455                 })
456                 .catch(function() {
457                     return false;
458                 });
460                 return html;
461             })
462             .catch(Notification.exception);
463     };
465     return {
466         init: init,
467         rootSelector: SELECTORS.ROOT,
468     };
469 });