MDL-64901 block_myoverview: admin settings to control available layouts
[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: 'core_course/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             displaycategories: courseRegion.attr('data-displaycategories'),
95         };
96     };
98     // We want the paged content controls below the paged content area.
99     // and the controls should be ignored while data is loading.
100     var DEFAULT_PAGED_CONTENT_CONFIG = {
101         ignoreControlWhileLoading: true,
102         controlPlacementBottom: true,
103         persistentLimitKey: 'block_myoverview_user_paging_preference'
104     };
106     /**
107      * Get enrolled courses from backend.
108      *
109      * @param {object} filters The filters for this view.
110      * @param {int} limit The number of courses to show.
111      * @return {promise} Resolved with an array of courses.
112      */
113     var getMyCourses = function(filters, limit) {
115         return Repository.getEnrolledCoursesByTimeline({
116             offset: courseOffset,
117             limit: limit,
118             classification: filters.grouping,
119             sort: filters.sort
120         });
121     };
123     /**
124      * Get the container element for the favourite icon.
125      *
126      * @param  {Object} root The course overview container
127      * @param  {Number} courseId Course id number
128      * @return {Object} The favourite icon container
129      */
130     var getFavouriteIconContainer = function(root, courseId) {
131         return root.find(SELECTORS.FAVOURITE_ICON + '[data-course-id="' + courseId + '"]');
132     };
134     /**
135      * Get the paged content container element.
136      *
137      * @param  {Object} root The course overview container
138      * @param  {Number} index Rendered page index.
139      * @return {Object} The rendered paged container.
140      */
141     var getPagedContentContainer = function(root, index) {
142         return root.find('[data-region="paged-content-page"][data-page="' + index + '"]');
143     };
145     /**
146      * Get the course id from a favourite element.
147      *
148      * @param {Object} root The favourite icon container element.
149      * @return {Number} Course id.
150      */
151     var getCourseId = function(root) {
152         return root.attr('data-course-id');
153     };
155     /**
156      * Hide the favourite icon.
157      *
158      * @param {Object} root The favourite icon container element.
159      * @param  {Number} courseId Course id number.
160      */
161     var hideFavouriteIcon = function(root, courseId) {
162         var iconContainer = getFavouriteIconContainer(root, courseId);
163         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
164         isFavouriteIcon.addClass('hidden');
165         isFavouriteIcon.attr('aria-hidden', true);
166         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
167         notFavourteIcon.removeClass('hidden');
168         notFavourteIcon.attr('aria-hidden', false);
169     };
171     /**
172      * Show the favourite icon.
173      *
174      * @param  {Object} root The course overview container.
175      * @param  {Number} courseId Course id number.
176      */
177     var showFavouriteIcon = function(root, courseId) {
178         var iconContainer = getFavouriteIconContainer(root, courseId);
179         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
180         isFavouriteIcon.removeClass('hidden');
181         isFavouriteIcon.attr('aria-hidden', false);
182         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
183         notFavourteIcon.addClass('hidden');
184         notFavourteIcon.attr('aria-hidden', true);
185     };
187     /**
188      * Get the action menu item
189      *
190      * @param {Object} root  root The course overview container
191      * @param {Number} courseId Course id.
192      * @return {Object} The add to favourite menu item.
193      */
194     var getAddFavouriteMenuItem = function(root, courseId) {
195         return root.find('[data-action="add-favourite"][data-course-id="' + courseId + '"]');
196     };
198     /**
199      * Get the action menu item
200      *
201      * @param {Object} root  root The course overview container
202      * @param {Number} courseId Course id.
203      * @return {Object} The remove from favourites menu item.
204      */
205     var getRemoveFavouriteMenuItem = function(root, courseId) {
206         return root.find('[data-action="remove-favourite"][data-course-id="' + courseId + '"]');
207     };
209     /**
210      * Add course to favourites
211      *
212      * @param  {Object} root The course overview container
213      * @param  {Number} courseId Course id number
214      */
215     var addToFavourites = function(root, courseId) {
216         var removeAction = getRemoveFavouriteMenuItem(root, courseId);
217         var addAction = getAddFavouriteMenuItem(root, courseId);
219         setCourseFavouriteState(courseId, true).then(function(success) {
220             if (success) {
221                 PubSub.publish(CourseEvents.favourited, courseId);
222                 removeAction.removeClass('hidden');
223                 addAction.addClass('hidden');
224                 showFavouriteIcon(root, courseId);
225             } else {
226                 Notification.alert('Starring course failed', 'Could not change favourite state');
227             }
228             return;
229         }).catch(Notification.exception);
230     };
232     /**
233      * Remove course from favourites
234      *
235      * @param  {Object} root The course overview container
236      * @param  {Number} courseId Course id number
237      */
238     var removeFromFavourites = function(root, courseId) {
239         var removeAction = getRemoveFavouriteMenuItem(root, courseId);
240         var addAction = getAddFavouriteMenuItem(root, courseId);
242         setCourseFavouriteState(courseId, false).then(function(success) {
243             if (success) {
244                 PubSub.publish(CourseEvents.unfavorited, courseId);
245                 removeAction.addClass('hidden');
246                 addAction.removeClass('hidden');
247                 hideFavouriteIcon(root, courseId);
248             } else {
249                 Notification.alert('Starring course failed', 'Could not change favourite state');
250             }
251             return;
252         }).catch(Notification.exception);
253     };
255     /**
256      * Reset the loadedPages dataset to take into account the hidden element
257      *
258      * @param {Object} root The course overview container
259      * @param {Object} target The course that you want to hide
260      */
261     var hideElement = function(root, target) {
262         var id = getCourseId(target);
264         var pagingBar = root.find('[data-region="paging-bar"]');
265         var jumpto = parseInt(pagingBar.attr('data-active-page-number'));
267         // Get a reduced dataset for the current page.
268         var courseList = loadedPages[jumpto];
269         var reducedCourse = courseList.courses.reduce(function(accumulator, current) {
270             if (id != current.id) {
271                 accumulator.push(current);
272             }
273             return accumulator;
274         }, []);
276         // Get the next page's data if loaded and pop the first element from it
277         if (loadedPages[jumpto + 1] != undefined) {
278             var newElement = loadedPages[jumpto + 1].courses.slice(0, 1);
280             // Adjust the dataset for the reset of the pages that are loaded
281             loadedPages.forEach(function(courseList, index) {
282                 if (index > jumpto) {
283                     var popElement = [];
284                     if (loadedPages[index + 1] != undefined) {
285                         popElement = loadedPages[index + 1].courses.slice(0, 1);
286                     }
288                     loadedPages[index].courses = $.merge(loadedPages[index].courses.slice(1), popElement);
289                 }
290             });
293             reducedCourse = $.merge(reducedCourse, newElement);
294         }
296         // Check if the next page is the last page and if it still has data associated to it
297         if (lastPage == jumpto + 1 && loadedPages[jumpto + 1].courses.length == 0) {
298             var pagedContentContainer = root.find('[data-region="paged-content-container"]');
299             PagedContentFactory.resetLastPageNumber($(pagedContentContainer).attr('id'), jumpto);
300         }
302         loadedPages[jumpto].courses = reducedCourse;
304         // Reduce the course offset
305         courseOffset--;
307         // Render the paged content for the current
308         var pagedContentPage = getPagedContentContainer(root, jumpto);
309         renderCourses(root, loadedPages[jumpto]).then(function(html, js) {
310             return Templates.replaceNodeContents(pagedContentPage, html, js);
311         }).catch(Notification.exception);
313         // Delete subsequent pages in order to trigger the callback
314         loadedPages.forEach(function(courseList, index) {
315             if (index > jumpto) {
316                 var page = getPagedContentContainer(root, index);
317                 page.remove();
318             }
319         });
320     };
322     /**
323      * Set the courses favourite status and push to repository
324      *
325      * @param  {Number} courseId Course id to favourite.
326      * @param  {Bool} status new favourite status.
327      * @return {Promise} Repository promise.
328      */
329     var setCourseFavouriteState = function(courseId, status) {
331         return Repository.setFavouriteCourses({
332             courses: [
333                     {
334                         'id': courseId,
335                         'favourite': status
336                     }
337                 ]
338         }).then(function(result) {
339             if (result.warnings.length == 0) {
340                 loadedPages.forEach(function(courseList) {
341                     courseList.courses.forEach(function(course, index) {
342                         if (course.id == courseId) {
343                             courseList.courses[index].isfavourite = status;
344                         }
345                     });
346                 });
347                 return true;
348             } else {
349                 return false;
350             }
351         }).catch(Notification.exception);
352     };
354     /**
355      * Render the dashboard courses.
356      *
357      * @param {object} root The root element for the courses view.
358      * @param {array} coursesData containing array of returned courses.
359      * @return {promise} jQuery promise resolved after rendering is complete.
360      */
361     var renderCourses = function(root, coursesData) {
363         var filters = getFilterValues(root);
365         var currentTemplate = '';
366         if (filters.display == 'card') {
367             currentTemplate = TEMPLATES.COURSES_CARDS;
368         } else if (filters.display == 'list') {
369             currentTemplate = TEMPLATES.COURSES_LIST;
370         } else {
371             currentTemplate = TEMPLATES.COURSES_SUMMARY;
372         }
374         // Delete the course category if it is not to be displayed
375         if (filters.displaycategories != 'on') {
376             coursesData.courses = coursesData.courses.map(function(course) {
377                 delete course.coursecategory;
378                 return course;
379             });
380         }
382         if (coursesData.courses.length) {
383             return Templates.render(currentTemplate, {
384                 courses: coursesData.courses,
385             });
386         } else {
387             var nocoursesimg = root.find(Selectors.courseView.region).attr('data-nocoursesimg');
388             return Templates.render(TEMPLATES.NOCOURSES, {
389                 nocoursesimg: nocoursesimg
390             });
391         }
392     };
394     /**
395      * Return the callback to be passed to the subscribe event
396      *
397      * @param {Number} limit The paged limit that is passed through the event
398      */
399     var setLimit = function(limit) {
400         this.find(Selectors.courseView.region).attr('data-paging', limit);
401     };
403     /**
404      * Intialise the paged list and cards views on page load.
405      * Returns an array of paged contents that we would like to handle here
406      *
407      * @param {object} root The root element for the courses view
408      * @param {string} namespace The namespace for all the events attached
409      */
410     var registerPagedEventHandlers = function(root, namespace) {
411         var event = namespace + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT;
412         PubSub.subscribe(event, setLimit.bind(root));
413     };
415     /**
416      * Intialise the courses list and cards views on page load.
417      *
418      * @param {object} root The root element for the courses view.
419      * @param {object} content The content element for the courses view.
420      */
421     var initializePagedContent = function(root) {
422         namespace = "block_myoverview_" + root.attr('id') + "_" + Math.random();
424         var itemsPerPage = NUMCOURSES_PERPAGE;
425         var pagingLimit = parseInt(root.find(Selectors.courseView.region).attr('data-paging'), 10);
426         if (pagingLimit) {
427             itemsPerPage = NUMCOURSES_PERPAGE.map(function(value) {
428                 var active = false;
429                 if (value == pagingLimit) {
430                     active = true;
431                 }
433                 return {
434                     value: value,
435                     active: active
436                 };
437             });
438         }
440         var filters = getFilterValues(root);
441         var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
442         config.eventNamespace = namespace;
444         var pagedContentPromise = PagedContentFactory.createWithLimit(
445             itemsPerPage,
446             function(pagesData, actions) {
447                 var promises = [];
449                 pagesData.forEach(function(pageData) {
450                     var currentPage = pageData.pageNumber;
451                     var limit = pageData.limit;
453                     // Reset local variables if limits have changed
454                     if (lastLimit != limit) {
455                         loadedPages = [];
456                         courseOffset = 0;
457                         lastPage = 0;
458                     }
460                     if (lastPage == currentPage) {
461                         // If we are on the last page and have it's data then load it from cache
462                         actions.allItemsLoaded(lastPage);
463                         promises.push(renderCourses(root, loadedPages[currentPage]));
464                         return;
465                     }
467                     lastLimit = limit;
469                     // Get 2 pages worth of data as we will need it for the hidden functionality.
470                     if (loadedPages[currentPage + 1] == undefined) {
471                         if (loadedPages[currentPage] == undefined) {
472                             limit *= 2;
473                         }
474                     }
476                     var pagePromise = getMyCourses(
477                         filters,
478                         limit
479                     ).then(function(coursesData) {
480                         var courses = coursesData.courses;
481                         var nextPageStart = 0;
482                         var pageCourses = [];
484                         // If current page's data is loaded make sure we max it to page limit
485                         if (loadedPages[currentPage] != undefined) {
486                             pageCourses = loadedPages[currentPage].courses;
487                             var currentPageLength = pageCourses.length;
488                             if (currentPageLength < pageData.limit) {
489                                 nextPageStart = pageData.limit - currentPageLength;
490                                 pageCourses = $.merge(loadedPages[currentPage].courses, courses.slice(0, nextPageStart));
491                             }
492                         } else {
493                             nextPageStart = pageData.limit;
494                             pageCourses = courses.slice(0, pageData.limit);
495                         }
497                         // Finished setting up the current page
498                         loadedPages[currentPage] = {
499                             courses: pageCourses
500                         };
502                         // Set up the next page
503                         var remainingCourses = courses.slice(nextPageStart, courses.length);
504                         if (remainingCourses.length) {
505                             loadedPages[currentPage + 1] = {
506                                 courses: remainingCourses
507                             };
508                         }
510                         // Set the last page to either the current or next page
511                         if (loadedPages[currentPage].courses.length < pageData.limit || !remainingCourses.length) {
512                             lastPage = currentPage;
513                             actions.allItemsLoaded(currentPage);
514                         } else if (loadedPages[currentPage + 1] != undefined
515                             && loadedPages[currentPage + 1].courses.length < pageData.limit) {
516                             lastPage = currentPage + 1;
517                         }
519                         courseOffset = coursesData.nextoffset;
520                         return renderCourses(root, loadedPages[currentPage]);
521                     })
522                     .catch(Notification.exception);
524                     promises.push(pagePromise);
525                 });
527                 return promises;
528             },
529             config
530         );
532         pagedContentPromise.then(function(html, js) {
533             registerPagedEventHandlers(root, namespace);
534             return Templates.replaceNodeContents(root.find(Selectors.courseView.region), html, js);
535         }).catch(Notification.exception);
536     };
538     /**
539      * Listen to, and handle events for  the myoverview block.
540      *
541      * @param {Object} root The myoverview block container element.
542      */
543     var registerEventListeners = function(root) {
544         CustomEvents.define(root, [
545             CustomEvents.events.activate
546         ]);
548         root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, function(e, data) {
549             var favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
550             var courseId = getCourseId(favourite);
551             addToFavourites(root, courseId);
552             data.originalEvent.preventDefault();
553         });
555         root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, function(e, data) {
556             var favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
557             var courseId = getCourseId(favourite);
558             removeFromFavourites(root, courseId);
559             data.originalEvent.preventDefault();
560         });
562         root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
563             data.originalEvent.preventDefault();
564         });
566         root.on(CustomEvents.events.activate, SELECTORS.ACTION_HIDE_COURSE, function(e, data) {
567             var target = $(e.target).closest(SELECTORS.ACTION_HIDE_COURSE);
568             var id = getCourseId(target);
570             var request = {
571                 preferences: [
572                     {
573                         type: 'block_myoverview_hidden_course_' + id,
574                         value: true
575                     }
576                 ]
577             };
578             Repository.updateUserPreferences(request);
580             hideElement(root, target);
581             data.originalEvent.preventDefault();
582         });
584         root.on(CustomEvents.events.activate, SELECTORS.ACTION_SHOW_COURSE, function(e, data) {
585             var target = $(e.target).closest(SELECTORS.ACTION_SHOW_COURSE);
586             var id = getCourseId(target);
588             var request = {
589                 preferences: [
590                     {
591                         type: 'block_myoverview_hidden_course_' + id,
592                         value: null
593                     }
594                 ]
595             };
597             Repository.updateUserPreferences(request);
599             hideElement(root, target);
600             data.originalEvent.preventDefault();
601         });
602     };
604     /**
605      * Intialise the courses list and cards views on page load.
606      *
607      * @param {object} root The root element for the courses view.
608      */
609     var init = function(root) {
610         root = $(root);
611         loadedPages = [];
612         lastPage = 0;
613         courseOffset = 0;
615         initializePagedContent(root);
617         if (!root.attr('data-init')) {
618             registerEventListeners(root);
619             root.attr('data-init', true);
620         }
621     };
623     /**
625      * Reset the courses views to their original
626      * state on first page load.courseOffset
627      *
628      * This is called when configuration has changed for the event lists
629      * to cause them to reload their data.
630      *
631      * @param {Object} root The root element for the timeline view.
632      */
633     var reset = function(root) {
634         if (loadedPages.length > 0) {
635             loadedPages.forEach(function(courseList, index) {
636                 var pagedContentPage = getPagedContentContainer(root, index);
637                 renderCourses(root, courseList).then(function(html, js) {
638                     return Templates.replaceNodeContents(pagedContentPage, html, js);
639                 }).catch(Notification.exception);
640             });
641         } else {
642             init(root);
643         }
644     };
646     return {
647         init: init,
648         reset: reset
649     };
650 });