Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 * Manage the timeline courses view for the timeline block.
18
 *
19
 * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
20
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
21
 */
22
 
23
define(
24
[
25
    'jquery',
26
    'core/notification',
27
    'core/custom_interaction_events',
28
    'core/templates',
29
    'block_timeline/event_list',
30
    'core_course/repository',
31
    'block_timeline/calendar_events_repository',
32
    'core/pending'
33
],
34
function(
35
    $,
36
    Notification,
37
    CustomEvents,
38
    Templates,
39
    EventList,
40
    CourseRepository,
41
    EventsRepository,
42
    Pending
43
) {
44
 
45
    var SELECTORS = {
46
        MORE_COURSES_BUTTON: '[data-action="more-courses"]',
47
        MORE_COURSES_BUTTON_CONTAINER: '[data-region="more-courses-button-container"]',
48
        NO_COURSES_EMPTY_MESSAGE: '[data-region="no-courses-empty-message"]',
49
        NO_COURSES_WITH_EVENTS_MESSAGE: '[data-region="no-events-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',
55
        TIMELINE_BLOCK: '[data-region="timeline"]',
56
        TIMELINE_SEARCH: '[data-action="search"]'
57
    };
58
 
59
    var TEMPLATES = {
60
        COURSE_ITEMS: 'block_timeline/course-items',
61
        LOADING_ICON: 'core/loading'
62
    };
63
 
64
    var COURSE_CLASSIFICATION = 'all';
65
    var COURSE_SORT = 'fullname asc';
66
    var COURSE_EVENT_LIMIT = 5;
67
    var COURSE_LIMIT = 2;
68
    var SECONDS_IN_DAY = 60 * 60 * 24;
69
 
70
    const additionalConfig = {courseview: true};
71
 
72
    /**
73
     * Hide the loading placeholder elements.
74
     *
75
     * @param {object} root The rool element.
76
     */
77
    var hideLoadingPlaceholder = function(root) {
78
        root.find(SELECTORS.COURSE_ITEMS_LOADING_PLACEHOLDER).addClass('hidden');
79
    };
80
 
81
    /**
82
     * Show the loading placeholder elements.
83
     *
84
     * @param {object} root The rool element.
85
     */
86
    const showLoadingPlaceholder = function(root) {
87
        root.find(SELECTORS.COURSE_ITEMS_LOADING_PLACEHOLDER).removeClass('hidden');
88
    };
89
 
90
    /**
91
     * Hide the "more courses" button.
92
     *
93
     * @param {object} root The rool element.
94
     */
95
    var hideMoreCoursesButton = function(root) {
96
        root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).addClass('hidden');
97
    };
98
 
99
    /**
100
     * Show the "more courses" button.
101
     *
102
     * @param {object} root The rool element.
103
     */
104
    var showMoreCoursesButton = function(root) {
105
        root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).removeClass('hidden');
106
    };
107
 
108
    /**
109
     * Disable the "more courses" button and show the loading spinner.
110
     *
111
     * @param {object} root The rool element.
112
     */
113
    var enableMoreCoursesButtonLoading = function(root) {
114
        var button = root.find(SELECTORS.MORE_COURSES_BUTTON);
115
        button.prop('disabled', true);
116
        Templates.render(TEMPLATES.LOADING_ICON, {})
117
            .then(function(html) {
118
                button.append(html);
119
                return html;
120
            })
121
            .catch(function() {
122
                // It's not important if this false so just do so silently.
123
                return false;
124
            });
125
    };
126
 
127
    /**
128
     * Enable the "more courses" button and remove the loading spinner.
129
     *
130
     * @param {object} root The rool element.
131
     */
132
    var disableMoreCoursesButtonLoading = function(root) {
133
        var button = root.find(SELECTORS.MORE_COURSES_BUTTON);
134
        button.prop('disabled', false);
135
        button.find(SELECTORS.LOADING_ICON).remove();
136
    };
137
 
138
    /**
139
     * Display the message for when courses have no events available (within the current filtering).
140
     *
141
     * @param {object} root The rool element.
142
     */
143
    const showNoCoursesWithEventsMessage = function(root) {
144
        // Remove any course list contents, since we will display the no events message.
145
        const container = root.find(SELECTORS.COURSES_LIST);
146
        Templates.replaceNodeContents(container, '', '');
147
        root.find(SELECTORS.NO_COURSES_WITH_EVENTS_MESSAGE).removeClass('hidden');
148
    };
149
 
150
    /**
151
     * Hide the message for when courses have no events available (within the current filtering).
152
     *
153
     * @param {object} root The rool element.
154
     */
155
    const hideNoCoursesWithEventsMessage = function(root) {
156
        root.find(SELECTORS.NO_COURSES_WITH_EVENTS_MESSAGE).addClass('hidden');
157
    };
158
 
159
    /**
160
     * Render the course items HTML to the page.
161
     *
162
     * @param {object} root The rool element.
163
     * @param {string} html The course items HTML to render.
164
     * @param {boolean} append Whether the HTML should be appended (eg pressed "show more courses").
165
     *                         Defaults to false - replaces the existing content (eg when modifying filter values).
166
     */
167
    var renderCourseItemsHTML = function(root, html, append = false) {
168
        var container = root.find(SELECTORS.COURSES_LIST);
169
 
170
        if (append) {
171
            Templates.appendNodeContents(container, html, '');
172
        } else {
173
            Templates.replaceNodeContents(container, html, '');
174
        }
175
    };
176
 
177
    /**
178
     * Return the offset value for fetching courses.
179
     *
180
     * @param {object} root The rool element.
181
     * @return {Number}
182
     */
183
    var getOffset = function(root) {
184
        return parseInt(root.attr('data-offset'), 10);
185
    };
186
 
187
    /**
188
     * Set the offset value for fetching courses.
189
     *
190
     * @param {object} root The rool element.
191
     * @param {Number} offset Offset value.
192
     */
193
    var setOffset = function(root, offset) {
194
        root.attr('data-offset', offset);
195
    };
196
 
197
    /**
198
     * Return the limit value for fetching courses.
199
     *
200
     * @param {object} root The rool element.
201
     * @return {Number}
202
     */
203
    var getLimit = function(root) {
204
        return parseInt(root.attr('data-limit'), 10);
205
    };
206
 
207
    /**
208
     * Return the days offset value for fetching events.
209
     *
210
     * @param {object} root The rool element.
211
     * @return {Number}
212
     */
213
    var getDaysOffset = function(root) {
214
        return parseInt(root.attr('data-days-offset'), 10);
215
    };
216
 
217
    /**
218
     * Return the days limit value for fetching events. The days
219
     * limit is optional so undefined will be returned if it isn't
220
     * set.
221
     *
222
     * @param {object} root The rool element.
223
     * @return {int|undefined}
224
     */
225
    var getDaysLimit = function(root) {
226
        var daysLimit = root.attr('data-days-limit');
227
        return daysLimit != undefined ? parseInt(daysLimit, 10) : undefined;
228
    };
229
 
230
    /**
231
     * Return the timestamp for the user's midnight.
232
     *
233
     * @param {object} root The rool element.
234
     * @return {Number}
235
     */
236
    var getMidnight = function(root) {
237
        return parseInt(root.attr('data-midnight'), 10);
238
    };
239
 
240
    /**
241
     * Return the start time for fetching events. This is calculated
242
     * based on the user's midnight value so that timezones are
243
     * preserved.
244
     *
245
     * @param {object} root The rool element.
246
     * @return {Number}
247
     */
248
    var getStartTime = function(root) {
249
        var midnight = getMidnight(root);
250
        var daysOffset = getDaysOffset(root);
251
        return midnight + (daysOffset * SECONDS_IN_DAY);
252
    };
253
 
254
    /**
255
     * Return the end time for fetching events. This is calculated
256
     * based on the user's midnight value so that timezones are
257
     * preserved, unless filtering by overdue, where the current UNIX timestamp is used.
258
     *
259
     * @param {object} root The rool element.
260
     * @return {Number}
261
     */
262
    var getEndTime = function(root) {
263
        let endTime = null;
264
 
265
        if (root.attr('data-filter-overdue')) {
266
            // If filtering by overdue, end time will be the current timestamp in seconds.
267
            endTime = Math.floor(Date.now() / 1000);
268
        } else {
269
            const midnight = getMidnight(root);
270
            const daysLimit = getDaysLimit(root);
271
 
272
            if (daysLimit != undefined) {
273
                endTime = midnight + (daysLimit * SECONDS_IN_DAY);
274
            }
275
        }
276
 
277
        return endTime;
278
    };
279
 
280
    /**
281
     * Get a list of events for the given course ids. Returns a promise that will
282
     * be resolved with the events.
283
     *
284
     * @param {array} courseIds The list of course ids to fetch events for.
285
     * @param {Number} startTime Timestamp to fetch events from.
286
     * @param {Number} limit Limit to the number of events (this applies per course, not total)
287
     * @param {Number} endTime Timestamp to fetch events to.
288
     * @param {string|undefined} searchValue Search value
289
     * @return {object} jQuery promise.
290
     */
291
    var getEventsForCourseIds = function(courseIds, startTime, limit, endTime, searchValue) {
292
        var args = {
293
            courseids: courseIds,
294
            starttime: startTime,
295
            limit: limit
296
        };
297
 
298
        if (endTime) {
299
            args.endtime = endTime;
300
        }
301
 
302
        if (searchValue) {
303
            args.searchvalue = searchValue;
304
        }
305
 
306
        return EventsRepository.queryByCourses(args);
307
    };
308
 
309
    /**
310
     * Get the last time the events were reloaded.
311
     *
312
     * @param {object} root The rool element.
313
     * @return {Number}
314
     */
315
    var getEventReloadTime = function(root) {
316
        return root.data('last-event-load-time');
317
    };
318
 
319
    /**
320
     * Set the last time the events were reloaded.
321
     *
322
     * @param {object} root The rool element.
323
     * @param {Number} time Timestamp in milliseconds.
324
     */
325
    var setEventReloadTime = function(root, time) {
326
        root.data('last-event-load-time', time);
327
    };
328
 
329
    /**
330
     * Check if events have begun reloading since the given
331
     * time.
332
     *
333
     * @param {object} root The rool element.
334
     * @param {Number} time Timestamp in milliseconds.
335
     * @return {bool}
336
     */
337
    var hasReloadedEventsSince = function(root, time) {
338
        return getEventReloadTime(root) > time;
339
    };
340
 
341
    /**
342
     * Send a request to the server to load the events for the courses.
343
     *
344
     * @param {array} courses List of course objects.
345
     * @param {Number} startTime Timestamp to load events after.
346
     * @param {int|undefined} endTime Timestamp to load events up until.
347
     * @param {string|undefined} searchValue Search value
348
     * @return {object} jQuery promise resolved with the events.
349
     */
350
    var loadEventsForCourses = function(courses, startTime, endTime, searchValue) {
351
        var courseIds = courses.map(function(course) {
352
            return course.id;
353
        });
354
 
355
        return getEventsForCourseIds(courseIds, startTime, COURSE_EVENT_LIMIT + 1, endTime, searchValue);
356
    };
357
 
358
    /**
359
     * Render the courses in the DOM once the server has returned the courses.
360
     *
361
     * @param {array} courses List of course objects.
362
     * @param {object} root The root element
363
     * @param {Number} midnight The midnight timestamp in the user's timezone.
364
     * @param {Number} daysOffset Number of days from today to offset the events.
365
     * @param {Number} daysLimit Number of days from today to limit the events to.
366
     * @param {boolean} append Whether new content should be appended instead of replaced (eg "show more courses").
367
     * @return {object} jQuery promise resolved after rendering is complete.
368
     */
369
    var updateDisplayFromCourses = function(courses, root, midnight, daysOffset, daysLimit, append) {
370
        // Render the courses template.
371
        return Templates.render(TEMPLATES.COURSE_ITEMS, {
372
            courses: courses,
373
            midnight: midnight,
374
            hasdaysoffset: true,
375
            hasdayslimit: daysLimit != undefined,
376
            daysoffset: daysOffset,
377
            dayslimit: daysLimit,
378
            nodayslimit: daysLimit == undefined,
379
            courseview: true,
380
            hascourses: true
381
        }).then(function(html) {
382
            hideLoadingPlaceholder(root);
383
 
384
            if (html) {
385
                // Template rendering is complete and we have the HTML so we can
386
                // add it to the DOM.
387
                renderCourseItemsHTML(root, html, append);
388
            }
389
 
390
            return html;
391
        })
392
        .then(function(html) {
393
            if (courses.length < COURSE_LIMIT) {
394
                // We know there aren't any more courses because we got back less
395
                // than we asked for so hide the button to request more.
396
                hideMoreCoursesButton(root);
397
            } else {
398
                // Make sure the button is visible if there are more courses to load.
399
                showMoreCoursesButton(root);
400
            }
401
 
402
            return html;
403
        })
404
        .catch(function() {
405
            hideLoadingPlaceholder(root);
406
        });
407
    };
408
 
409
    /**
410
     * Find all of the visible course blocks and initialise the event
411
     * list module to being loading the events for the course block.
412
     *
413
     * @param {object} root The root element for the timeline courses view.
414
     * @param {boolean} append Whether content should be appended instead of replaced (eg "show more courses"). False by default.
415
     * @return {object} jQuery promise resolved with courses and events.
416
     */
417
    var loadMoreCourses = function(root, append = false) {
418
        const pendingPromise = new Pending('block/timeline:load-more-courses');
419
        var offset = getOffset(root);
420
        var limit = getLimit(root);
421
        const startTime = getStartTime(root);
422
        const endTime = getEndTime(root);
423
        const searchValue = root.closest(SELECTORS.TIMELINE_BLOCK).find(SELECTORS.TIMELINE_SEARCH).val();
424
 
425
        // Start loading the next set of courses.
426
        // Fetch up to limit number of courses with at least one action event in the time filtering specified.
427
        // Courses without events will also be fetched, but hidden in case they have events in other timespans.
428
        return CourseRepository.getEnrolledCoursesWithEventsByTimelineClassification(
429
            COURSE_CLASSIFICATION,
430
            limit,
431
            offset,
432
            COURSE_SORT,
433
            searchValue,
434
            startTime,
435
            endTime
436
        ).then(function(result) {
437
            var startEventLoadingTime = Date.now();
438
            var courses = result.courses;
439
            var nextOffset = result.nextoffset;
440
            var daysOffset = getDaysOffset(root);
441
            var daysLimit = getDaysLimit(root);
442
            var midnight = getMidnight(root);
443
            const moreCoursesAvailable = result.morecoursesavailable;
444
 
445
            // Record the next offset if we want to request more courses.
446
            setOffset(root, nextOffset);
447
            // Load the events for these courses.
448
            var eventsPromise = loadEventsForCourses(courses, startTime, endTime, searchValue);
449
            // Render the courses in the DOM.
450
            var renderPromise = updateDisplayFromCourses(courses, root, midnight, daysOffset, daysLimit, append);
451
 
452
            return $.when(eventsPromise, renderPromise)
453
                .then(function(eventsByCourse) {
454
                    if (hasReloadedEventsSince(root, startEventLoadingTime)) {
455
                        // All of the events are being reloaded so ignore our results.
456
                        return eventsByCourse;
457
                    }
458
 
459
                    if (courses.length > 0) {
460
                        // Render the events in the correct course event list.
461
                        courses.forEach(function(course) {
462
                            const courseId = course.id;
463
                            const containerSelector = '[data-region="course-events-container"][data-course-id="' + courseId + '"]';
464
                            const courseEventsContainer = root.find(containerSelector);
465
                            const eventListRoot = courseEventsContainer.find(EventList.rootSelector);
466
 
467
                            EventList.init(eventListRoot, additionalConfig);
468
                        });
469
 
470
                        if (!moreCoursesAvailable) {
471
                            // If no more courses with events matching the current filtering exist, hide the more courses button.
472
                            hideMoreCoursesButton(root);
473
                        } else {
474
                            // If more courses exist with events matching the current filtering, show the more courses button.
475
                            showMoreCoursesButton(root);
476
                        }
477
                    } else {
478
                        // No more courses to load, hide the more courses button.
479
                        hideMoreCoursesButton(root);
480
 
481
                        // A zero offset means this was not loading "more courses", so we need to display the no results message.
482
                        if (offset == 0) {
483
                            showNoCoursesWithEventsMessage(root);
484
                        }
485
                    }
486
 
487
                    return eventsByCourse;
488
                });
489
        }).then(() => {
490
            return pendingPromise.resolve();
491
        }).catch(Notification.exception);
492
    };
493
 
494
    /**
495
     * Add event listeners to load more courses for the courses view.
496
     *
497
     * @param {object} root The root element for the timeline courses view.
498
     */
499
    var registerEventListeners = function(root) {
500
        CustomEvents.define(root, [CustomEvents.events.activate]);
501
        // Show more courses and load their events when the user clicks the "more courses" button.
502
        root.on(CustomEvents.events.activate, SELECTORS.MORE_COURSES_BUTTON, function(e, data) {
503
            enableMoreCoursesButtonLoading(root);
504
            loadMoreCourses(root, true)
505
                .then(function() {
506
                    disableMoreCoursesButtonLoading(root);
507
                    return;
508
                })
509
                .catch(function() {
510
                    disableMoreCoursesButtonLoading(root);
511
                });
512
 
513
            if (data) {
514
                data.originalEvent.preventDefault();
515
                data.originalEvent.stopPropagation();
516
            }
517
            e.stopPropagation();
518
        });
519
    };
520
 
521
    /**
522
     * Initialise the timeline courses view. Begin loading the events
523
     * if this view is active. Add the relevant event listeners.
524
     *
525
     * This function should only be called once per page load because it
526
     * is adding event listeners to the page.
527
     *
528
     * @param {object} root The root element for the timeline courses view.
529
     */
530
    var init = function(root) {
531
        root = $(root);
532
 
533
        // Only need to handle course loading if the user is actively enrolled in a course.
534
        if (!root.find(SELECTORS.NO_COURSES_EMPTY_MESSAGE).length) {
535
            setEventReloadTime(root, Date.now());
536
 
537
            if (root.hasClass('active')) {
538
                // Only load if this is active otherwise it will be lazy loaded later.
539
                loadMoreCourses(root);
540
                root.attr('data-seen', true);
541
            }
542
 
543
            registerEventListeners(root);
544
        }
545
    };
546
 
547
    /**
548
     * Reset the element back to it's initial state. Begin loading the events again
549
     * if this view is active.
550
     *
551
     * @param {object} root The root element for the timeline courses view.
552
     */
553
    var reset = function(root) {
554
 
555
        setOffset(root, 0);
556
        showLoadingPlaceholder(root);
557
        hideNoCoursesWithEventsMessage(root);
558
        root.removeAttr('data-seen');
559
 
560
        if (root.hasClass('active')) {
561
            shown(root);
562
        }
563
    };
564
 
565
    /**
566
     * Begin loading the events unless we know there are no actively enrolled courses.
567
     *
568
     * @param {object} root The root element for the timeline courses view.
569
     */
570
    var shown = function(root) {
571
        if (!root.attr('data-seen') && !root.find(SELECTORS.NO_COURSES_EMPTY_MESSAGE).length) {
572
            loadMoreCourses(root);
573
            root.attr('data-seen', true);
574
        }
575
    };
576
 
577
    return {
578
        init: init,
579
        reset: reset,
580
        shown: shown
581
    };
582
});