f9d0e1db540283d91a5887323ed1f38a58001d0d
[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     'core/paged_content_events',
36 ],
37 function(
38     $,
39     Repository,
40     PagedContentFactory,
41     PubSub,
42     CustomEvents,
43     Notification,
44     Templates,
45     CourseEvents,
46     Selectors,
47     PagedContentEvents
48 ) {
50     var SELECTORS = {
51         COURSE_REGION: '[data-region="course-view-content"]',
52         ACTION_HIDE_COURSE: '[data-action="hide-course"]',
53         ACTION_SHOW_COURSE: '[data-action="show-course"]',
54         ACTION_ADD_FAVOURITE: '[data-action="add-favourite"]',
55         ACTION_REMOVE_FAVOURITE: '[data-action="remove-favourite"]',
56         FAVOURITE_ICON: '[data-region="favourite-icon"]',
57         ICON_IS_FAVOURITE: '[data-region="is-favourite"]',
58         ICON_NOT_FAVOURITE: '[data-region="not-favourite"]',
59         PAGED_CONTENT_CONTAINER: '[data-region="page-container"]'
61     };
63     var TEMPLATES = {
64         COURSES_CARDS: 'block_myoverview/view-cards',
65         COURSES_LIST: 'block_myoverview/view-list',
66         COURSES_SUMMARY: 'block_myoverview/view-summary',
67         NOCOURSES: 'block_myoverview/no-courses'
68     };
70     var NUMCOURSES_PERPAGE = [12, 24, 48];
72     var loadedPages = [];
74     var courseOffset = 0;
76     var lastPage = 0;
78     var lastLimit = 0;
80     var namespace = null;
82     /**
83      * Get filter values from DOM.
84      *
85      * @param {object} root The root element for the courses view.
86      * @return {filters} Set filters.
87      */
88     var getFilterValues = function(root) {
89         var courseRegion = root.find(Selectors.courseView.region);
90         return {
91             display: courseRegion.attr('data-display'),
92             grouping: courseRegion.attr('data-grouping'),
93             sort: courseRegion.attr('data-sort')
94         };
95     };
97     // We want the paged content controls below the paged content area.
98     // and the controls should be ignored while data is loading.
99     var DEFAULT_PAGED_CONTENT_CONFIG = {
100         ignoreControlWhileLoading: true,
101         controlPlacementBottom: true,
102         persistentLimitKey: 'block_myoverview_user_paging_preference'
103     };
105     /**
106      * Get enrolled courses from backend.
107      *
108      * @param {object} filters The filters for this view.
109      * @param {int} limit The number of courses to show.
110      * @return {promise} Resolved with an array of courses.
111      */
112     var getMyCourses = function(filters, limit) {
114         return Repository.getEnrolledCoursesByTimeline({
115             offset: courseOffset,
116             limit: limit,
117             classification: filters.grouping,
118             sort: filters.sort
119         });
120     };
122     /**
123      * Get the container element for the favourite icon.
124      *
125      * @param  {Object} root The course overview container
126      * @param  {Number} courseId Course id number
127      * @return {Object} The favourite icon container
128      */
129     var getFavouriteIconContainer = function(root, courseId) {
130         return root.find(SELECTORS.FAVOURITE_ICON + '[data-course-id="' + courseId + '"]');
131     };
133     /**
134      * Get the paged content container element.
135      *
136      * @param  {Object} root The course overview container
137      * @param  {Number} index Rendered page index.
138      * @return {Object} The rendered paged container.
139      */
140     var getPagedContentContainer = function(root, index) {
141         return root.find('[data-region="paged-content-page"][data-page="' + index + '"]');
142     };
144     /**
145      * Get the course id from a favourite element.
146      *
147      * @param {Object} root The favourite icon container element.
148      * @return {Number} Course id.
149      */
150     var getCourseId = function(root) {
151         return root.attr('data-course-id');
152     };
154     /**
155      * Hide the favourite icon.
156      *
157      * @param {Object} root The favourite icon container element.
158      * @param  {Number} courseId Course id number.
159      */
160     var hideFavouriteIcon = function(root, courseId) {
161         var iconContainer = getFavouriteIconContainer(root, courseId);
162         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
163         isFavouriteIcon.addClass('hidden');
164         isFavouriteIcon.attr('aria-hidden', true);
165         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
166         notFavourteIcon.removeClass('hidden');
167         notFavourteIcon.attr('aria-hidden', false);
168     };
170     /**
171      * Show the favourite icon.
172      *
173      * @param  {Object} root The course overview container.
174      * @param  {Number} courseId Course id number.
175      */
176     var showFavouriteIcon = function(root, courseId) {
177         var iconContainer = getFavouriteIconContainer(root, courseId);
178         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
179         isFavouriteIcon.removeClass('hidden');
180         isFavouriteIcon.attr('aria-hidden', false);
181         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
182         notFavourteIcon.addClass('hidden');
183         notFavourteIcon.attr('aria-hidden', true);
184     };
186     /**
187      * Get the action menu item
188      *
189      * @param {Object} root  root The course overview container
190      * @param {Number} courseId Course id.
191      * @return {Object} The add to favourite menu item.
192      */
193     var getAddFavouriteMenuItem = function(root, courseId) {
194         return root.find('[data-action="add-favourite"][data-course-id="' + courseId + '"]');
195     };
197     /**
198      * Get the action menu item
199      *
200      * @param {Object} root  root The course overview container
201      * @param {Number} courseId Course id.
202      * @return {Object} The remove from favourites menu item.
203      */
204     var getRemoveFavouriteMenuItem = function(root, courseId) {
205         return root.find('[data-action="remove-favourite"][data-course-id="' + courseId + '"]');
206     };
208     /**
209      * Add course to favourites
210      *
211      * @param  {Object} root The course overview container
212      * @param  {Number} courseId Course id number
213      */
214     var addToFavourites = function(root, courseId) {
215         var removeAction = getRemoveFavouriteMenuItem(root, courseId);
216         var addAction = getAddFavouriteMenuItem(root, courseId);
218         setCourseFavouriteState(courseId, true).then(function(success) {
219             if (success) {
220                 PubSub.publish(CourseEvents.favourited);
221                 removeAction.removeClass('hidden');
222                 addAction.addClass('hidden');
223                 showFavouriteIcon(root, courseId);
224             } else {
225                 Notification.alert('Starring course failed', 'Could not change favourite state');
226             }
227             return;
228         }).catch(Notification.exception);
229     };
231     /**
232      * Remove course from favourites
233      *
234      * @param  {Object} root The course overview container
235      * @param  {Number} courseId Course id number
236      */
237     var removeFromFavourites = function(root, courseId) {
238         var removeAction = getRemoveFavouriteMenuItem(root, courseId);
239         var addAction = getAddFavouriteMenuItem(root, courseId);
241         setCourseFavouriteState(courseId, false).then(function(success) {
242             if (success) {
243                 PubSub.publish(CourseEvents.unfavorited);
244                 removeAction.addClass('hidden');
245                 addAction.removeClass('hidden');
246                 hideFavouriteIcon(root, courseId);
247             } else {
248                 Notification.alert('Starring course failed', 'Could not change favourite state');
249             }
250             return;
251         }).catch(Notification.exception);
252     };
254     /**
255      * Reset the loadedPages dataset to take into account the hidden element
256      *
257      * @param {Object} root The course overview container
258      * @param {Object} target The course that you want to hide
259      */
260     var hideElement = function(root, target) {
261         var id = getCourseId(target);
263         var pagingBar = root.find('[data-region="paging-bar"]');
264         var jumpto = parseInt(pagingBar.attr('data-active-page-number'));
266         // Get a reduced dataset for the current page.
267         var courseList = loadedPages[jumpto];
268         var reducedCourse = courseList.courses.reduce(function(accumulator, current) {
269             if (id != current.id) {
270                 accumulator.push(current);
271             }
272             return accumulator;
273         }, []);
275         // Get the next page's data if loaded and pop the first element from it
276         if (loadedPages[jumpto + 1] != undefined) {
277             var newElement = loadedPages[jumpto + 1].courses.slice(0, 1);
279             // Adjust the dataset for the reset of the pages that are loaded
280             loadedPages.forEach(function(courseList, index) {
281                 if (index > jumpto) {
282                     var popElement = [];
283                     if (loadedPages[index + 1] != undefined) {
284                         popElement = loadedPages[index + 1].courses.slice(0, 1);
285                     }
287                     loadedPages[index].courses = $.merge(loadedPages[index].courses.slice(1), popElement);
288                 }
289             });
292             reducedCourse = $.merge(reducedCourse, newElement);
293         }
295         // Check if the next page is the last page and if it still has data associated to it
296         if (lastPage == jumpto + 1 && loadedPages[jumpto + 1].courses.length == 0) {
297             var pagedContentContainer = root.find('[data-region="paged-content-container"]');
298             PagedContentFactory.resetLastPageNumber($(pagedContentContainer).attr('id'), jumpto);
299         }
301         loadedPages[jumpto].courses = reducedCourse;
303         // Reduce the course offset
304         courseOffset--;
306         // Render the paged content for the current
307         var pagedContentPage = getPagedContentContainer(root, jumpto);
308         renderCourses(root, loadedPages[jumpto]).then(function(html, js) {
309             return Templates.replaceNodeContents(pagedContentPage, html, js);
310         }).catch(Notification.exception);
312         // Delete subsequent pages in order to trigger the callback
313         loadedPages.forEach(function(courseList, index) {
314             if (index > jumpto) {
315                 var page = getPagedContentContainer(root, index);
316                 page.remove();
317             }
318         });
319     };
321     /**
322      * Set the courses favourite status and push to repository
323      *
324      * @param  {Number} courseId Course id to favourite.
325      * @param  {Bool} status new favourite status.
326      * @return {Promise} Repository promise.
327      */
328     var setCourseFavouriteState = function(courseId, status) {
330         return Repository.setFavouriteCourses({
331             courses: [
332                     {
333                         'id': courseId,
334                         'favourite': status
335                     }
336                 ]
337         }).then(function(result) {
338             if (result.warnings.length == 0) {
339                 loadedPages.forEach(function(courseList) {
340                     courseList.courses.forEach(function(course, index) {
341                         if (course.id == courseId) {
342                             courseList.courses[index].isfavourite = status;
343                         }
344                     });
345                 });
346                 return true;
347             } else {
348                 return false;
349             }
350         }).catch(Notification.exception);
351     };
353     /**
354      * Render the dashboard courses.
355      *
356      * @param {object} root The root element for the courses view.
357      * @param {array} coursesData containing array of returned courses.
358      * @return {promise} jQuery promise resolved after rendering is complete.
359      */
360     var renderCourses = function(root, coursesData) {
362         var filters = getFilterValues(root);
364         var currentTemplate = '';
365         if (filters.display == 'cards') {
366             currentTemplate = TEMPLATES.COURSES_CARDS;
367         } else if (filters.display == 'list') {
368             currentTemplate = TEMPLATES.COURSES_LIST;
369         } else {
370             currentTemplate = TEMPLATES.COURSES_SUMMARY;
371         }
373         if (coursesData.courses.length) {
374             return Templates.render(currentTemplate, {
375                 courses: coursesData.courses
376             });
377         } else {
378             var nocoursesimg = root.find(Selectors.courseView.region).attr('data-nocoursesimg');
379             return Templates.render(TEMPLATES.NOCOURSES, {
380                 nocoursesimg: nocoursesimg
381             });
382         }
383     };
385     /**
386      * Return the callback to be passed to the subscribe event
387      *
388      * @param {Number} limit The paged limit that is passed through the event
389      */
390     var setLimit = function(limit) {
391         this.find(Selectors.courseView.region).attr('data-paging', limit);
392     };
394     /**
395      * Intialise the paged list and cards views on page load.
396      * Returns an array of paged contents that we would like to handle here
397      *
398      * @param {object} root The root element for the courses view
399      * @param {string} namespace The namespace for all the events attached
400      */
401     var registerPagedEventHandlers = function(root, namespace) {
402         var event = namespace + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT;
403         PubSub.subscribe(event, setLimit.bind(root));
404     };
406     /**
407      * Intialise the courses list and cards views on page load.
408      *
409      * @param {object} root The root element for the courses view.
410      * @param {object} content The content element for the courses view.
411      */
412     var initializePagedContent = function(root) {
413         namespace = "block_myoverview_" + root.attr('id') + "_" + Math.random();
415         var itemsPerPage = NUMCOURSES_PERPAGE;
416         var pagingLimit = parseInt(root.find(Selectors.courseView.region).attr('data-paging'), 10);
417         if (pagingLimit) {
418             itemsPerPage = NUMCOURSES_PERPAGE.map(function(value) {
419                 var active = false;
420                 if (value == pagingLimit) {
421                     active = true;
422                 }
424                 return {
425                     value: value,
426                     active: active
427                 };
428             });
429         }
431         var filters = getFilterValues(root);
432         var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
433         config.eventNamespace = namespace;
435         var pagedContentPromise = PagedContentFactory.createWithLimit(
436             itemsPerPage,
437             function(pagesData, actions) {
438                 var promises = [];
440                 pagesData.forEach(function(pageData) {
441                     var currentPage = pageData.pageNumber;
442                     var limit = pageData.limit;
444                     // Reset local variables if limits have changed
445                     if (lastLimit != limit) {
446                         loadedPages = [];
447                         courseOffset = 0;
448                         lastPage = 0;
449                     }
451                     if (lastPage == currentPage) {
452                         // If we are on the last page and have it's data then load it from cache
453                         actions.allItemsLoaded(lastPage);
454                         promises.push(renderCourses(root, loadedPages[currentPage]));
455                         return;
456                     }
458                     lastLimit = limit;
460                     // Get 2 pages worth of data as we will need it for the hidden functionality.
461                     if (loadedPages[currentPage + 1] == undefined) {
462                         if (loadedPages[currentPage] == undefined) {
463                             limit *= 2;
464                         }
465                     }
467                     var pagePromise = getMyCourses(
468                         filters,
469                         limit
470                     ).then(function(coursesData) {
471                         var courses = coursesData.courses;
472                         var nextPageStart = 0;
473                         var pageCourses = [];
475                         // If current page's data is loaded make sure we max it to page limit
476                         if (loadedPages[currentPage] != undefined) {
477                             pageCourses = loadedPages[currentPage].courses;
478                             var currentPageLength = pageCourses.length;
479                             if (currentPageLength < pageData.limit) {
480                                 nextPageStart = pageData.limit - currentPageLength;
481                                 pageCourses = $.merge(loadedPages[currentPage].courses, courses.slice(0, nextPageStart));
482                             }
483                         } else {
484                             nextPageStart = pageData.limit;
485                             pageCourses = courses.slice(0, pageData.limit);
486                         }
488                         // Finished setting up the current page
489                         loadedPages[currentPage] = {
490                             courses: pageCourses
491                         };
493                         // Set up the next page
494                         var remainingCourses = courses.slice(nextPageStart, courses.length);
495                         if (remainingCourses.length) {
496                             loadedPages[currentPage + 1] = {
497                                 courses: remainingCourses
498                             };
499                         }
501                         // Set the last page to either the current or next page
502                         if (loadedPages[currentPage].courses.length < pageData.limit) {
503                             lastPage = currentPage;
504                             actions.allItemsLoaded(currentPage);
505                         } else if (loadedPages[currentPage + 1] != undefined
506                             && loadedPages[currentPage + 1].courses.length < pageData.limit) {
507                             lastPage = currentPage + 1;
508                         }
510                         courseOffset = coursesData.nextoffset;
511                         return renderCourses(root, loadedPages[currentPage]);
512                     })
513                     .catch(Notification.exception);
515                     promises.push(pagePromise);
516                 });
518                 return promises;
519             },
520             config
521         );
523         pagedContentPromise.then(function(html, js) {
524             registerPagedEventHandlers(root, namespace);
525             return Templates.replaceNodeContents(root.find(Selectors.courseView.region), html, js);
526         }).catch(Notification.exception);
527     };
529     /**
530      * Listen to, and handle events for  the myoverview block.
531      *
532      * @param {Object} root The myoverview block container element.
533      */
534     var registerEventListeners = function(root) {
535         CustomEvents.define(root, [
536             CustomEvents.events.activate
537         ]);
539         root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, function(e, data) {
540             var favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
541             var courseId = getCourseId(favourite);
542             addToFavourites(root, courseId);
543             data.originalEvent.preventDefault();
544         });
546         root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, function(e, data) {
547             var favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
548             var courseId = getCourseId(favourite);
549             removeFromFavourites(root, courseId);
550             data.originalEvent.preventDefault();
551         });
553         root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
554             data.originalEvent.preventDefault();
555         });
557         root.on(CustomEvents.events.activate, SELECTORS.ACTION_HIDE_COURSE, function(e, data) {
558             var target = $(e.target).closest(SELECTORS.ACTION_HIDE_COURSE);
559             var id = getCourseId(target);
561             var request = {
562                 preferences: [
563                     {
564                         type: 'block_myoverview_hidden_course_' + id,
565                         value: true
566                     }
567                 ]
568             };
569             Repository.updateUserPreferences(request);
571             hideElement(root, target);
572             data.originalEvent.preventDefault();
573         });
575         root.on(CustomEvents.events.activate, SELECTORS.ACTION_SHOW_COURSE, function(e, data) {
576             var target = $(e.target).closest(SELECTORS.ACTION_SHOW_COURSE);
577             var id = getCourseId(target);
579             var request = {
580                 preferences: [
581                     {
582                         type: 'block_myoverview_hidden_course_' + id,
583                         value: null
584                     }
585                 ]
586             };
588             Repository.updateUserPreferences(request);
590             hideElement(root, target);
591             data.originalEvent.preventDefault();
592         });
593     };
595     /**
596      * Intialise the courses list and cards views on page load.
597      *
598      * @param {object} root The root element for the courses view.
599      */
600     var init = function(root) {
601         root = $(root);
602         loadedPages = [];
603         lastPage = 0;
604         courseOffset = 0;
606         initializePagedContent(root);
608         if (!root.attr('data-init')) {
609             registerEventListeners(root);
610             root.attr('data-init', true);
611         }
612     };
614     /**
616      * Reset the courses views to their original
617      * state on first page load.courseOffset
618      *
619      * This is called when configuration has changed for the event lists
620      * to cause them to reload their data.
621      *
622      * @param {Object} root The root element for the timeline view.
623      */
624     var reset = function(root) {
625         if (loadedPages.length > 0) {
626             loadedPages.forEach(function(courseList, index) {
627                 var pagedContentPage = getPagedContentContainer(root, index);
628                 renderCourses(root, courseList).then(function(html, js) {
629                     return Templates.replaceNodeContents(pagedContentPage, html, js);
630                 }).catch(Notification.exception);
631             });
632         } else {
633             init(root);
634         }
635     };
637     return {
638         init: init,
639         reset: reset
640     };
641 });