Commit | Line | Data |
---|---|---|
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 | */ | |
25 | define(['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 | }); |