MDL-63817 block_timeline: Persist page limits when sorting by dates
[moodle.git] / blocks / timeline / amd / src / event_list.js
CommitLineData
1e44de35
RW
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/>.
15
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 */
25define(
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],
35function(
36 $,
37 Notification,
38 Templates,
39 PagedContentFactory,
40 Str,
41 UserDate,
42 CalendarEventsRepository
43) {
44
45 var SECONDS_IN_DAY = 60 * 60 * 24;
46
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 };
53
54 var TEMPLATES = {
55 EVENT_LIST_CONTENT: 'block_timeline/event-list-content'
56 };
57
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 };
67
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 };
77
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 };
87
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 };
96
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 };
133
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 });
142
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 });
151
152 return templateContext;
153 };
154
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;
165
166 return Templates.render(templateName, templateContext);
167 };
168
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;
184
185 var args = {
186 starttime: startTime,
187 limit: limit,
188 };
189
190 if (lastId) {
191 args.aftereventid = lastId;
192 }
193
194 if (endTime) {
195 args.endtime = endTime;
196 }
197
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 };
207
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;
238
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;
249
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 }
259
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 }
267
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;
272
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 }
281
282 return calendarEvents;
283 });
284 };
285
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.
2044a539 303 * @param {object} additionalConfig Additional config options to pass to pagedContentFactory
1e44de35
RW
304 * @return {object} jQuery promise.
305 */
306 var createPagedContent = function(
307 pageLimit,
308 preloadedPages,
309 midnight,
310 firstLoad,
311 courseId,
312 daysOffset,
313 daysLimit,
2044a539
P
314 paginationAriaLabel,
315 additionalConfig
1e44de35
RW
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;
2044a539 323 var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG, additionalConfig);
1e44de35
RW
324
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 = [];
340
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);
368
369 promises.push(pagePromise);
370 });
371
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 });
381
382 return promises;
383 },
384 config
385 );
386 });
387 };
388
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.
2044a539 401 * @param {object} additionalConfig Additional config options to pass to pagedContentFactory
1e44de35 402 */
2044a539 403 var init = function(root, pageLimit, preloadedPages, paginationAriaLabel, additionalConfig) {
1e44de35
RW
404 root = $(root);
405
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);
417
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');
424
425 // Days limit isn't mandatory.
426 if (daysLimit != undefined) {
427 daysLimit = parseInt(daysLimit, 10);
428 }
429
430 // Created the paged content element.
2044a539
P
431 return createPagedContent(pageLimit, preloadedPages, midnight, firstLoad, courseId, daysOffset, daysLimit,
432 paginationAriaLabel, additionalConfig)
1e44de35
RW
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);
441
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');
448
449 if (!hasContent) {
450 // If we didn't get any data then show the empty data message.
451 hideContent(root);
452 }
453
454 return hasContent;
455 })
456 .catch(function() {
457 return false;
458 });
459
460 return html;
461 })
462 .catch(Notification.exception);
463 };
464
465 return {
466 init: init,
467 rootSelector: SELECTORS.ROOT,
468 };
469});