62f44a830ef72b7b57acf6f6fb5f45778835547f
[moodle.git] / blocks / myoverview / amd / src / view.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  * Manage the courses view for the overview block.
18  *
19  * @package    block_myoverview
20  * @copyright  2018 Bas Brands <bas@moodle.com>
21  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22  */
24 define(
25 [
26     'jquery',
27     'block_myoverview/repository',
28     'core/paged_content_factory',
29     'core/pubsub',
30     'core/custom_interaction_events',
31     'core/notification',
32     'core/templates',
33     'core_course/events',
34     'block_myoverview/selectors'
35 ],
36 function(
37     $,
38     Repository,
39     PagedContentFactory,
40     PubSub,
41     CustomEvents,
42     Notification,
43     Templates,
44     CourseEvents,
45     Selectors
46 ) {
48     var SELECTORS = {
49         COURSE_REGION: '[data-region="course-view-content"]',
50         ACTION_HIDE_COURSE: '[data-action="hide-course"]',
51         ACTION_SHOW_COURSE: '[data-action="show-course"]',
52         ACTION_ADD_FAVOURITE: '[data-action="add-favourite"]',
53         ACTION_REMOVE_FAVOURITE: '[data-action="remove-favourite"]',
54         FAVOURITE_ICON: '[data-region="favourite-icon"]',
55         ICON_IS_FAVOURITE: '[data-region="is-favourite"]',
56         ICON_NOT_FAVOURITE: '[data-region="not-favourite"]',
57         PAGED_CONTENT_CONTAINER: '[data-region="page-container"]'
59     };
61     var TEMPLATES = {
62         COURSES_CARDS: 'block_myoverview/view-cards',
63         COURSES_LIST: 'block_myoverview/view-list',
64         COURSES_SUMMARY: 'block_myoverview/view-summary',
65         NOCOURSES: 'block_myoverview/no-courses'
66     };
68     var NUMCOURSES_PERPAGE = [12, 24, 48];
70     var loadedPages = [];
72     var courseOffset = 0;
74     var lastPage = 0;
76     var lastLimit = 0;
78     /**
79      * Get filter values from DOM.
80      *
81      * @param {object} root The root element for the courses view.
82      * @return {filters} Set filters.
83      */
84     var getFilterValues = function(root) {
85         var courseRegion = root.find(Selectors.courseView.region);
86         return {
87             display: courseRegion.attr('data-display'),
88             grouping: courseRegion.attr('data-grouping'),
89             sort: courseRegion.attr('data-sort')
90         };
91     };
93     // We want the paged content controls below the paged content area.
94     // and the controls should be ignored while data is loading.
95     var DEFAULT_PAGED_CONTENT_CONFIG = {
96         ignoreControlWhileLoading: true,
97         controlPlacementBottom: true,
98     };
100     /**
101      * Get enrolled courses from backend.
102      *
103      * @param {object} filters The filters for this view.
104      * @param {int} limit The number of courses to show.
105      * @return {promise} Resolved with an array of courses.
106      */
107     var getMyCourses = function(filters, limit) {
109         return Repository.getEnrolledCoursesByTimeline({
110             offset: courseOffset,
111             limit: limit,
112             classification: filters.grouping,
113             sort: filters.sort
114         });
115     };
117     /**
118      * Get the container element for the favourite icon.
119      *
120      * @param  {Object} root The course overview container
121      * @param  {Number} courseId Course id number
122      * @return {Object} The favourite icon container
123      */
124     var getFavouriteIconContainer = function(root, courseId) {
125         return root.find(SELECTORS.FAVOURITE_ICON + '[data-course-id="' + courseId + '"]');
126     };
128     /**
129      * Get the paged content container element.
130      *
131      * @param  {Object} root The course overview container
132      * @param  {Number} index Rendered page index.
133      * @return {Object} The rendered paged container.
134      */
135     var getPagedContentContainer = function(root, index) {
136         return root.find('[data-region="paged-content-page"][data-page="' + index + '"]');
137     };
139     /**
140      * Get the course id from a favourite element.
141      *
142      * @param {Object} root The favourite icon container element.
143      * @return {Number} Course id.
144      */
145     var getCourseId = function(root) {
146         return root.attr('data-course-id');
147     };
149     /**
150      * Hide the favourite icon.
151      *
152      * @param {Object} root The favourite icon container element.
153      * @param  {Number} courseId Course id number.
154      */
155     var hideFavouriteIcon = function(root, courseId) {
156         var iconContainer = getFavouriteIconContainer(root, courseId);
157         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
158         isFavouriteIcon.addClass('hidden');
159         isFavouriteIcon.attr('aria-hidden', true);
160         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
161         notFavourteIcon.removeClass('hidden');
162         notFavourteIcon.attr('aria-hidden', false);
163     };
165     /**
166      * Show the favourite icon.
167      *
168      * @param  {Object} root The course overview container.
169      * @param  {Number} courseId Course id number.
170      */
171     var showFavouriteIcon = function(root, courseId) {
172         var iconContainer = getFavouriteIconContainer(root, courseId);
173         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
174         isFavouriteIcon.removeClass('hidden');
175         isFavouriteIcon.attr('aria-hidden', false);
176         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
177         notFavourteIcon.addClass('hidden');
178         notFavourteIcon.attr('aria-hidden', true);
179     };
181     /**
182      * Get the action menu item
183      *
184      * @param {Object} root  root The course overview container
185      * @param {Number} courseId Course id.
186      * @return {Object} The add to favourite menu item.
187      */
188     var getAddFavouriteMenuItem = function(root, courseId) {
189         return root.find('[data-action="add-favourite"][data-course-id="' + courseId + '"]');
190     };
192     /**
193      * Get the action menu item
194      *
195      * @param {Object} root  root The course overview container
196      * @param {Number} courseId Course id.
197      * @return {Object} The remove from favourites menu item.
198      */
199     var getRemoveFavouriteMenuItem = function(root, courseId) {
200         return root.find('[data-action="remove-favourite"][data-course-id="' + courseId + '"]');
201     };
203     /**
204      * Add course to favourites
205      *
206      * @param  {Object} root The course overview container
207      * @param  {Number} courseId Course id number
208      */
209     var addToFavourites = function(root, courseId) {
210         var removeAction = getRemoveFavouriteMenuItem(root, courseId);
211         var addAction = getAddFavouriteMenuItem(root, courseId);
213         setCourseFavouriteState(courseId, true).then(function(success) {
214             if (success) {
215                 PubSub.publish(CourseEvents.favourited);
216                 removeAction.removeClass('hidden');
217                 addAction.addClass('hidden');
218                 showFavouriteIcon(root, courseId);
219             } else {
220                 Notification.alert('Starring course failed', 'Could not change favourite state');
221             }
222             return;
223         }).catch(Notification.exception);
224     };
226     /**
227      * Remove course from favourites
228      *
229      * @param  {Object} root The course overview container
230      * @param  {Number} courseId Course id number
231      */
232     var removeFromFavourites = function(root, courseId) {
233         var removeAction = getRemoveFavouriteMenuItem(root, courseId);
234         var addAction = getAddFavouriteMenuItem(root, courseId);
236         setCourseFavouriteState(courseId, false).then(function(success) {
237             if (success) {
238                 PubSub.publish(CourseEvents.unfavorited);
239                 removeAction.addClass('hidden');
240                 addAction.removeClass('hidden');
241                 hideFavouriteIcon(root, courseId);
242             } else {
243                 Notification.alert('Starring course failed', 'Could not change favourite state');
244             }
245             return;
246         }).catch(Notification.exception);
247     };
249     /**
250      * Reset the loadedPages dataset to take into account the hidden element
251      *
252      * @param {Object} root The course overview container
253      * @param {Object} target The course that you want to hide
254      */
255     var hideElement = function(root, target) {
256         var id = getCourseId(target);
258         var pagingBar = root.find('[data-region="paging-bar"]');
259         var jumpto = parseInt(pagingBar.attr('data-active-page-number'));
261         // Get a reduced dataset for the current page.
262         var courseList = loadedPages[jumpto];
263         var reducedCourse = courseList.courses.reduce(function(accumulator, current) {
264             if (id != current.id) {
265                 accumulator.push(current);
266             }
267             return accumulator;
268         }, []);
270         // Get the next page's data if loaded and pop the first element from it
271         if (loadedPages[jumpto + 1] != undefined) {
272             var newElement = loadedPages[jumpto + 1].courses.slice(0, 1);
274             // Adjust the dataset for the reset of the pages that are loaded
275             loadedPages.forEach(function(courseList, index) {
276                 if (index > jumpto) {
277                     var popElement = [];
278                     if (loadedPages[index + 1] != undefined) {
279                         popElement = loadedPages[index + 1].courses.slice(0, 1);
280                     }
282                     loadedPages[index].courses = $.merge(loadedPages[index].courses.slice(1), popElement);
283                 }
284             });
287             reducedCourse = $.merge(reducedCourse, newElement);
288         }
290         // Check if the next page is the last page and if it still has data associated to it
291         if (lastPage == jumpto + 1 && loadedPages[jumpto + 1].courses.length == 0) {
292             var pagedContentContainer = root.find('[data-region="paged-content-container"]');
293             PagedContentFactory.resetLastPageNumber($(pagedContentContainer).attr('id'), jumpto);
294         }
296         loadedPages[jumpto].courses = reducedCourse;
298         // Reduce the course offset
299         courseOffset--;
301         // Render the paged content for the current
302         var pagedContentPage = getPagedContentContainer(root, jumpto);
303         renderCourses(root, loadedPages[jumpto]).then(function(html, js) {
304             return Templates.replaceNodeContents(pagedContentPage, html, js);
305         }).catch(Notification.exception);
307         // Delete subsequent pages in order to trigger the callback
308         loadedPages.forEach(function(courseList, index) {
309             if (index > jumpto) {
310                 var page = getPagedContentContainer(root, index);
311                 page.remove();
312             }
313         });
314     };
316     /**
317      * Set the courses favourite status and push to repository
318      *
319      * @param  {Number} courseId Course id to favourite.
320      * @param  {Bool} status new favourite status.
321      * @return {Promise} Repository promise.
322      */
323     var setCourseFavouriteState = function(courseId, status) {
325         return Repository.setFavouriteCourses({
326             courses: [
327                     {
328                         'id': courseId,
329                         'favourite': status
330                     }
331                 ]
332         }).then(function(result) {
333             if (result.warnings.length == 0) {
334                 loadedPages.forEach(function(courseList) {
335                     courseList.courses.forEach(function(course, index) {
336                         if (course.id == courseId) {
337                             courseList.courses[index].isfavourite = status;
338                         }
339                     });
340                 });
341                 return true;
342             } else {
343                 return false;
344             }
345         }).catch(Notification.exception);
346     };
348     /**
349      * Render the dashboard courses.
350      *
351      * @param {object} root The root element for the courses view.
352      * @param {array} coursesData containing array of returned courses.
353      * @return {promise} jQuery promise resolved after rendering is complete.
354      */
355     var renderCourses = function(root, coursesData) {
357         var filters = getFilterValues(root);
359         var currentTemplate = '';
360         if (filters.display == 'cards') {
361             currentTemplate = TEMPLATES.COURSES_CARDS;
362         } else if (filters.display == 'list') {
363             currentTemplate = TEMPLATES.COURSES_LIST;
364         } else {
365             currentTemplate = TEMPLATES.COURSES_SUMMARY;
366         }
368         if (coursesData.courses.length) {
369             return Templates.render(currentTemplate, {
370                 courses: coursesData.courses
371             });
372         } else {
373             var nocoursesimg = root.find(Selectors.courseView.region).attr('data-nocoursesimg');
374             return Templates.render(TEMPLATES.NOCOURSES, {
375                 nocoursesimg: nocoursesimg
376             });
377         }
378     };
380     /**
381      * Intialise the paged list and cards views on page load.
382      *
383      * @param {object} root The root element for the courses view.
384      * @param {object} content The content element for the courses view.
385      */
386     var initializePagedContent = function(root) {
387         var filters = getFilterValues(root);
389         var pagedContentPromise = PagedContentFactory.createWithLimit(
390             NUMCOURSES_PERPAGE,
391             function(pagesData, actions) {
392                 var promises = [];
394                 pagesData.forEach(function(pageData) {
395                     var currentPage = pageData.pageNumber;
396                     var limit = pageData.limit;
398                     // Reset local variables if limits have changed
399                     if (lastLimit != limit) {
400                         loadedPages = [];
401                         courseOffset = 0;
402                         lastPage = 0;
403                     }
405                     if (lastPage == currentPage) {
406                         // If we are on the last page and have it's data then load it from cache
407                         actions.allItemsLoaded(lastPage);
408                         promises.push(renderCourses(root, loadedPages[currentPage]));
409                         return;
410                     }
412                     lastLimit = limit;
414                     // Get 2 pages worth of data as we will need it for the hidden functionality.
415                     if (loadedPages[currentPage + 1] == undefined) {
416                         if (loadedPages[currentPage] == undefined) {
417                             limit *= 2;
418                         }
419                     }
421                     var pagePromise = getMyCourses(
422                         filters,
423                         limit
424                     ).then(function(coursesData) {
425                         var courses = coursesData.courses;
426                         var nextPageStart = 0;
427                         var pageCourses = [];
429                         // If current page's data is loaded make sure we max it to page limit
430                         if (loadedPages[currentPage] != undefined) {
431                             pageCourses = loadedPages[currentPage].courses;
432                             var currentPageLength = pageCourses.length;
433                             if (currentPageLength < pageData.limit) {
434                                 nextPageStart = pageData.limit - currentPageLength;
435                                 pageCourses = $.merge(loadedPages[currentPage].courses, courses.slice(0, nextPageStart));
436                             }
437                         } else {
438                             nextPageStart = pageData.limit;
439                             pageCourses = courses.slice(0, pageData.limit);
440                         }
442                         // Finished setting up the current page
443                         loadedPages[currentPage] = {
444                             courses: pageCourses
445                         };
447                         // Set up the next page
448                         var remainingCourses = courses.slice(nextPageStart, courses.length);
449                         if (remainingCourses.length) {
450                             loadedPages[currentPage + 1] = {
451                                 courses: remainingCourses
452                             };
453                         }
455                         // Set the last page to either the current or next page
456                         if (loadedPages[currentPage].courses.length < pageData.limit) {
457                             lastPage = currentPage;
458                             actions.allItemsLoaded(currentPage);
459                         } else if (loadedPages[currentPage + 1] != undefined
460                             && loadedPages[currentPage + 1].courses.length < pageData.limit) {
461                             lastPage = currentPage + 1;
462                         }
464                         courseOffset = coursesData.nextoffset;
465                         return renderCourses(root, loadedPages[currentPage]);
466                     })
467                     .catch(Notification.exception);
469                     promises.push(pagePromise);
470                 });
472                 return promises;
473             },
474             DEFAULT_PAGED_CONTENT_CONFIG
475         );
477         pagedContentPromise.then(function(html, js) {
478             return Templates.replaceNodeContents(root.find(Selectors.courseView.region), html, js);
479         }).catch(Notification.exception);
480     };
482     /**
483      * Listen to, and handle events for  the myoverview block.
484      *
485      * @param {Object} root The myoverview block container element.
486      */
487     var registerEventListeners = function(root) {
488         CustomEvents.define(root, [
489             CustomEvents.events.activate
490         ]);
492         root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, function(e, data) {
493             var favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
494             var courseId = getCourseId(favourite);
495             addToFavourites(root, courseId);
496             data.originalEvent.preventDefault();
497         });
499         root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, function(e, data) {
500             var favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
501             var courseId = getCourseId(favourite);
502             removeFromFavourites(root, courseId);
503             data.originalEvent.preventDefault();
504         });
506         root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
507             data.originalEvent.preventDefault();
508         });
510         root.on(CustomEvents.events.activate, SELECTORS.ACTION_HIDE_COURSE, function(e, data) {
511             var target = $(e.target).closest(SELECTORS.ACTION_HIDE_COURSE);
512             var id = getCourseId(target);
514             var request = {
515                 preferences: [
516                     {
517                         type: 'block_myoverview_hidden_course_' + id,
518                         value: true
519                     }
520                 ]
521             };
522             Repository.updateUserPreferences(request);
524             hideElement(root, target);
525             data.originalEvent.preventDefault();
526         });
528         root.on(CustomEvents.events.activate, SELECTORS.ACTION_SHOW_COURSE, function(e, data) {
529             var target = $(e.target).closest(SELECTORS.ACTION_SHOW_COURSE);
530             var id = getCourseId(target);
532             var request = {
533                 preferences: [
534                     {
535                         type: 'block_myoverview_hidden_course_' + id,
536                         value: null
537                     }
538                 ]
539             };
541             Repository.updateUserPreferences(request);
543             hideElement(root, target);
544             data.originalEvent.preventDefault();
545         });
546     };
548     /**
549      * Intialise the courses list and cards views on page load.
550      *
551      * @param {object} root The root element for the courses view.
552      */
553     var init = function(root) {
554         root = $(root);
555         loadedPages = [];
556         lastPage = 0;
557         courseOffset = 0;
559         if (!root.attr('data-init')) {
560             registerEventListeners(root);
561             root.attr('data-init', true);
562         }
564         initializePagedContent(root);
565     };
567     /**
569      * Reset the courses views to their original
570      * state on first page load.courseOffset
571      *
572      * This is called when configuration has changed for the event lists
573      * to cause them to reload their data.
574      *
575      * @param {Object} root The root element for the timeline view.
576      */
577     var reset = function(root) {
578         if (loadedPages.length > 0) {
579             loadedPages.forEach(function(courseList, index) {
580                 var pagedContentPage = getPagedContentContainer(root, index);
581                 renderCourses(root, courseList).then(function(html, js) {
582                     return Templates.replaceNodeContents(pagedContentPage, html, js);
583                 }).catch(Notification.exception);
584             });
585         } else {
586             init(root);
587         }
588     };
590     return {
591         init: init,
592         reset: reset
593     };
594 });