1 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
17 * Manage the courses view for the overview block.
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
27 'block_myoverview/repository',
28 'core/paged_content_factory',
30 'core/custom_interaction_events',
34 'block_myoverview/selectors',
35 'core/paged_content_events',
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"]'
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'
70 var NUMCOURSES_PERPAGE = [12, 24, 48];
83 * Get filter values from DOM.
85 * @param {object} root The root element for the courses view.
86 * @return {filters} Set filters.
88 var getFilterValues = function(root) {
89 var courseRegion = root.find(Selectors.courseView.region);
91 display: courseRegion.attr('data-display'),
92 grouping: courseRegion.attr('data-grouping'),
93 sort: courseRegion.attr('data-sort'),
94 displaycategories: courseRegion.attr('data-displaycategories'),
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'
107 * Get enrolled courses from backend.
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.
113 var getMyCourses = function(filters, limit) {
115 return Repository.getEnrolledCoursesByTimeline({
116 offset: courseOffset,
118 classification: filters.grouping,
124 * Get the container element for the favourite icon.
126 * @param {Object} root The course overview container
127 * @param {Number} courseId Course id number
128 * @return {Object} The favourite icon container
130 var getFavouriteIconContainer = function(root, courseId) {
131 return root.find(SELECTORS.FAVOURITE_ICON + '[data-course-id="' + courseId + '"]');
135 * Get the paged content container element.
137 * @param {Object} root The course overview container
138 * @param {Number} index Rendered page index.
139 * @return {Object} The rendered paged container.
141 var getPagedContentContainer = function(root, index) {
142 return root.find('[data-region="paged-content-page"][data-page="' + index + '"]');
146 * Get the course id from a favourite element.
148 * @param {Object} root The favourite icon container element.
149 * @return {Number} Course id.
151 var getCourseId = function(root) {
152 return root.attr('data-course-id');
156 * Hide the favourite icon.
158 * @param {Object} root The favourite icon container element.
159 * @param {Number} courseId Course id number.
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);
172 * Show the favourite icon.
174 * @param {Object} root The course overview container.
175 * @param {Number} courseId Course id number.
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);
188 * Get the action menu item
190 * @param {Object} root root The course overview container
191 * @param {Number} courseId Course id.
192 * @return {Object} The add to favourite menu item.
194 var getAddFavouriteMenuItem = function(root, courseId) {
195 return root.find('[data-action="add-favourite"][data-course-id="' + courseId + '"]');
199 * Get the action menu item
201 * @param {Object} root root The course overview container
202 * @param {Number} courseId Course id.
203 * @return {Object} The remove from favourites menu item.
205 var getRemoveFavouriteMenuItem = function(root, courseId) {
206 return root.find('[data-action="remove-favourite"][data-course-id="' + courseId + '"]');
210 * Add course to favourites
212 * @param {Object} root The course overview container
213 * @param {Number} courseId Course id number
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) {
221 PubSub.publish(CourseEvents.favourited, courseId);
222 removeAction.removeClass('hidden');
223 addAction.addClass('hidden');
224 showFavouriteIcon(root, courseId);
226 Notification.alert('Starring course failed', 'Could not change favourite state');
229 }).catch(Notification.exception);
233 * Remove course from favourites
235 * @param {Object} root The course overview container
236 * @param {Number} courseId Course id number
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) {
244 PubSub.publish(CourseEvents.unfavorited, courseId);
245 removeAction.addClass('hidden');
246 addAction.removeClass('hidden');
247 hideFavouriteIcon(root, courseId);
249 Notification.alert('Starring course failed', 'Could not change favourite state');
252 }).catch(Notification.exception);
256 * Reset the loadedPages dataset to take into account the hidden element
258 * @param {Object} root The course overview container
259 * @param {Object} target The course that you want to hide
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);
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) {
284 if (loadedPages[index + 1] != undefined) {
285 popElement = loadedPages[index + 1].courses.slice(0, 1);
288 loadedPages[index].courses = $.merge(loadedPages[index].courses.slice(1), popElement);
293 reducedCourse = $.merge(reducedCourse, newElement);
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);
302 loadedPages[jumpto].courses = reducedCourse;
304 // Reduce the course offset
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);
323 * Set the courses favourite status and push to repository
325 * @param {Number} courseId Course id to favourite.
326 * @param {Bool} status new favourite status.
327 * @return {Promise} Repository promise.
329 var setCourseFavouriteState = function(courseId, status) {
331 return Repository.setFavouriteCourses({
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;
351 }).catch(Notification.exception);
355 * Render the dashboard courses.
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.
361 var renderCourses = function(root, coursesData) {
363 var filters = getFilterValues(root);
365 var currentTemplate = '';
366 if (filters.display == 'cards') {
367 currentTemplate = TEMPLATES.COURSES_CARDS;
368 } else if (filters.display == 'list') {
369 currentTemplate = TEMPLATES.COURSES_LIST;
371 currentTemplate = TEMPLATES.COURSES_SUMMARY;
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;
382 if (coursesData.courses.length) {
383 return Templates.render(currentTemplate, {
384 courses: coursesData.courses,
387 var nocoursesimg = root.find(Selectors.courseView.region).attr('data-nocoursesimg');
388 return Templates.render(TEMPLATES.NOCOURSES, {
389 nocoursesimg: nocoursesimg
395 * Return the callback to be passed to the subscribe event
397 * @param {Number} limit The paged limit that is passed through the event
399 var setLimit = function(limit) {
400 this.find(Selectors.courseView.region).attr('data-paging', limit);
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
407 * @param {object} root The root element for the courses view
408 * @param {string} namespace The namespace for all the events attached
410 var registerPagedEventHandlers = function(root, namespace) {
411 var event = namespace + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT;
412 PubSub.subscribe(event, setLimit.bind(root));
416 * Intialise the courses list and cards views on page load.
418 * @param {object} root The root element for the courses view.
419 * @param {object} content The content element for the courses view.
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);
427 itemsPerPage = NUMCOURSES_PERPAGE.map(function(value) {
429 if (value == pagingLimit) {
440 var filters = getFilterValues(root);
441 var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
442 config.eventNamespace = namespace;
444 var pagedContentPromise = PagedContentFactory.createWithLimit(
446 function(pagesData, actions) {
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) {
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]));
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) {
476 var pagePromise = getMyCourses(
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));
493 nextPageStart = pageData.limit;
494 pageCourses = courses.slice(0, pageData.limit);
497 // Finished setting up the current page
498 loadedPages[currentPage] = {
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
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;
519 courseOffset = coursesData.nextoffset;
520 return renderCourses(root, loadedPages[currentPage]);
522 .catch(Notification.exception);
524 promises.push(pagePromise);
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);
539 * Listen to, and handle events for the myoverview block.
541 * @param {Object} root The myoverview block container element.
543 var registerEventListeners = function(root) {
544 CustomEvents.define(root, [
545 CustomEvents.events.activate
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();
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();
562 root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
563 data.originalEvent.preventDefault();
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);
573 type: 'block_myoverview_hidden_course_' + id,
578 Repository.updateUserPreferences(request);
580 hideElement(root, target);
581 data.originalEvent.preventDefault();
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);
591 type: 'block_myoverview_hidden_course_' + id,
597 Repository.updateUserPreferences(request);
599 hideElement(root, target);
600 data.originalEvent.preventDefault();
605 * Intialise the courses list and cards views on page load.
607 * @param {object} root The root element for the courses view.
609 var init = function(root) {
615 initializePagedContent(root);
617 if (!root.attr('data-init')) {
618 registerEventListeners(root);
619 root.attr('data-init', true);
625 * Reset the courses views to their original
626 * state on first page load.courseOffset
628 * This is called when configuration has changed for the event lists
629 * to cause them to reload their data.
631 * @param {Object} root The root element for the timeline view.
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);