MDL-57139 myoverview: fix paging button edge case
[moodle.git] / blocks / myoverview / amd / src / event_list.js
CommitLineData
38c795b2
CB
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/**
0fa82f84
RW
17 * Javascript to load and render the list of calendar events for a
18 * given day range.
38c795b2 19 *
0fa82f84 20 * @module block_myoverview/event_list
38c795b2
CB
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 */
25define(['jquery', 'core/notification', 'core/templates',
1d0cbd77 26 'core/custom_interaction_events',
38c795b2 27 'block_myoverview/calendar_events_repository'],
1d0cbd77
RW
28 function($, Notification, Templates, CustomEvents, CalendarEventsRepository) {
29
30 var SECONDS_IN_DAY = 60 * 60 * 24;
38c795b2 31
6103ce8b 32 var SELECTORS = {
d4718e5a 33 EMPTY_MESSAGE: '[data-region="empty-message"]',
42016853 34 ROOT: '[data-region="event-list-container"]',
6103ce8b 35 EVENT_LIST: '[data-region="event-list"]',
d4718e5a 36 EVENT_LIST_CONTENT: '[data-region="event-list-content"]',
1d0cbd77
RW
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"]'
6103ce8b
RW
40 };
41
0eb85562
RW
42 var TEMPLATES = {
43 EVENT_LIST_ITEMS: 'block_myoverview/event-list-items',
44 COURSE_EVENT_LIST_ITEMS: 'block_myoverview/course-event-list-items'
45 };
46
1d0cbd77
RW
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 };
58
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 };
70
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);
81
82 root.addClass('loading');
83 loadingIcon.removeClass('hidden');
84 viewMoreButton.prop('disabled', true);
85 };
86
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);
97
98 root.removeClass('loading');
99 loadingIcon.addClass('hidden');
100
101 if (!hasLoadedAll(root)) {
102 // Only enable the button if we've got more events to load.
103 viewMoreButton.prop('disabled', false);
104 }
105 };
106
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
775c6bac 113 * @returns {Boolean}
1d0cbd77
RW
114 */
115 var isLoading = function(root) {
116 return root.hasClass('loading');
117 };
118
d4718e5a
RW
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 };
129
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 };
141
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 };
164
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 };
176
1d0cbd77
RW
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
775c6bac 185 * @param {string} templateName The template name
1d0cbd77
RW
186 * @return {promise} Resolved when the elements are attached to the DOM
187 */
0eb85562
RW
188 var renderGroup = function(group, calendarEvents, templateName) {
189
1d0cbd77
RW
190 group.removeClass('hidden');
191
192 return Templates.render(
0eb85562 193 templateName,
1d0cbd77
RW
194 {events: calendarEvents}
195 ).done(function(html, js) {
196 Templates.appendNodeContents(group.find(SELECTORS.EVENT_LIST), html, js);
197 });
198 };
199
200 /**
10a8ea17
CB
201 * Determine the time (in seconds) from the given timestamp until the calendar
202 * event will need actioning.
1d0cbd77 203 *
10a8ea17 204 * @method timeUntilEvent
1d0cbd77 205 * @private
10a8ea17
CB
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) {
6fbecb92 211 var orderTime = event.timesort || 0;
10a8ea17
CB
212 return orderTime - timestamp;
213 };
214
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
5f6ff895 225 * @param {object} root The root element
10a8ea17
CB
226 * @param {object} event The calendar event
227 * @param {object} container The group event list container
1d0cbd77
RW
228 * @return {bool}
229 */
5f6ff895
RW
230 var eventBelongsInContainer = function(root, event, container) {
231 var todayTime = root.attr('data-midnight'),
10a8ea17
CB
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);
1d0cbd77 235
5e52a8a9 236 if (container.attr('data-end-day') === '') {
10a8ea17 237 return timeUntilContainerStart <= timeUntilEventNeedsAction;
1d0cbd77 238 } else {
10a8ea17
CB
239 return timeUntilContainerStart <= timeUntilEventNeedsAction &&
240 timeUntilEventNeedsAction < timeUntilContainerEnd;
1d0cbd77
RW
241 }
242 };
243
244 /**
10a8ea17
CB
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
5f6ff895 250 * @param {object} root The root element
10a8ea17
CB
251 * @param {object} container Event list group container
252 * @return {function}
253 */
5f6ff895 254 var getFilterCallbackForContainer = function(root, container) {
10a8ea17 255 return function(event) {
5f6ff895 256 return eventBelongsInContainer(root, event, $(container));
10a8ea17
CB
257 };
258 };
259
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.
1d0cbd77
RW
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) {
10a8ea17 272 var renderCount = 0;
0eb85562
RW
273 var templateName = TEMPLATES.EVENT_LIST_ITEMS;
274
275 if (root.attr('data-course-id')) {
276 templateName = TEMPLATES.COURSE_EVENT_LIST_ITEMS;
277 }
1d0cbd77 278
10a8ea17
CB
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) {
5f6ff895 283 var events = calendarEvents.filter(getFilterCallbackForContainer(root, container));
1d0cbd77 284
10a8ea17
CB
285 if (events.length) {
286 renderCount += events.length;
0eb85562 287 return renderGroup($(container), events, templateName);
10a8ea17
CB
288 } else {
289 return null;
38c795b2 290 }
10a8ea17 291 })).then(function() {
1d0cbd77
RW
292 return renderCount;
293 });
294 };
295
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 *
42016853
RW
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 *
1d0cbd77 309 * @method load
42016853
RW
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
1d0cbd77 313 */
42016853 314 var load = function(root, promise) {
1d0cbd77
RW
315 root = $(root);
316 var limit = +root.attr('data-limit'),
cfc57eec 317 courseId = +root.attr('data-course-id'),
42016853 318 lastId = root.attr('data-last-id'),
5f6ff895
RW
319 midnight = root.attr('data-midnight'),
320 startTime = midnight - (14 * SECONDS_IN_DAY);
1d0cbd77
RW
321
322 // Don't load twice.
323 if (isLoading(root)) {
324 return $.Deferred().resolve();
325 }
38c795b2 326
1d0cbd77 327 startLoading(root);
38c795b2 328
42016853
RW
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 = {
5e52a8a9 333 starttime: startTime,
42016853
RW
334 limit: limit,
335 };
336
337 if (lastId) {
338 args.aftereventid = lastId;
339 }
340
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 }
cfc57eec
SL
349 }
350
1d0cbd77 351 // Request data from the server.
6fbecb92 352 return promise.then(function(result) {
72ed079f
DP
353 if (!result.events.length) {
354 // No events, nothing to do.
1d0cbd77 355 setLoadedAll(root);
72ed079f 356 return 0;
1d0cbd77
RW
357 }
358
72ed079f
DP
359 var calendarEvents = result.events;
360
361 // Remember the last id we've seen.
362 root.attr('data-last-id', calendarEvents[calendarEvents.length - 1].id);
363
afaa33d9 364 if (calendarEvents.length < limit) {
72ed079f
DP
365 // No more events to load, disable loading button.
366 setLoadedAll(root);
1d0cbd77 367 }
72ed079f
DP
368
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);
1d0cbd77
RW
384 }).fail(
385 Notification.exception
386 ).always(function() {
387 stopLoading(root);
388 });
389 };
390
391 /**
392 * Register the event listeners for the container element.
393 *
394 * @method registerEventListeners
775c6bac 395 * @param {object} root The root element of the event list
1d0cbd77
RW
396 */
397 var registerEventListeners = function(root) {
398 CustomEvents.define(root, [CustomEvents.events.activate]);
42016853 399 root.on(CustomEvents.events.activate, SELECTORS.VIEW_MORE_BUTTON, function() {
1d0cbd77
RW
400 load(root);
401 });
402 };
403
404 return {
405 init: function(root) {
406 root = $(root);
407 load(root);
408 registerEventListeners(root);
cfc57eec
SL
409 },
410 registerEventListeners: registerEventListeners,
42016853
RW
411 load: load,
412 rootSelector: SELECTORS.ROOT,
38c795b2
CB
413 };
414});