MDL-63793 block_myoverview: Persist the user's paging limit preference
[moodle.git] / blocks / myoverview / amd / src / view.js
CommitLineData
e4b4b9e7
BB
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 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 */
23
24define(
25[
26 'jquery',
e4b4b9e7
BB
27 'block_myoverview/repository',
28 'core/paged_content_factory',
07fdb5a0 29 'core/pubsub',
3cfff885
BB
30 'core/custom_interaction_events',
31 'core/notification',
e4b4b9e7 32 'core/templates',
f3d077d0 33 'core_course/events',
11988d74
P
34 'block_myoverview/selectors',
35 'core/paged_content_events',
e4b4b9e7
BB
36],
37function(
38 $,
e4b4b9e7
BB
39 Repository,
40 PagedContentFactory,
07fdb5a0 41 PubSub,
3cfff885
BB
42 CustomEvents,
43 Notification,
07fdb5a0 44 Templates,
f3d077d0 45 CourseEvents,
11988d74
P
46 Selectors,
47 PagedContentEvents
e4b4b9e7
BB
48) {
49
3cfff885 50 var SELECTORS = {
e6f03948
P
51 COURSE_REGION: '[data-region="course-view-content"]',
52 ACTION_HIDE_COURSE: '[data-action="hide-course"]',
53 ACTION_SHOW_COURSE: '[data-action="show-course"]',
3cfff885
BB
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"]'
60
61 };
62
e4b4b9e7
BB
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 };
69
3cfff885 70 var NUMCOURSES_PERPAGE = [12, 24, 48];
e4b4b9e7 71
3cfff885 72 var loadedPages = [];
e4b4b9e7 73
fd955097
P
74 var courseOffset = 0;
75
76 var lastPage = 0;
77
645cb7dd
P
78 var lastLimit = 0;
79
11988d74
P
80 var namespace = null;
81
e4b4b9e7
BB
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) {
fd955097
P
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 };
e4b4b9e7
BB
95 };
96
3cfff885 97 // We want the paged content controls below the paged content area.
e4b4b9e7
BB
98 // and the controls should be ignored while data is loading.
99 var DEFAULT_PAGED_CONTENT_CONFIG = {
100 ignoreControlWhileLoading: true,
101 controlPlacementBottom: true,
11988d74 102 persistentLimitKey: 'block_myoverview_user_paging_preference'
e4b4b9e7
BB
103 };
104
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.
7583dd69 110 * @return {promise} Resolved with an array of courses.
e4b4b9e7 111 */
fd955097 112 var getMyCourses = function(filters, limit) {
3cfff885 113
e4b4b9e7 114 return Repository.getEnrolledCoursesByTimeline({
fd955097 115 offset: courseOffset,
e4b4b9e7
BB
116 limit: limit,
117 classification: filters.grouping,
118 sort: filters.sort
119 });
120 };
121
3cfff885
BB
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 };
132
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 };
143
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 */
e6f03948 150 var getCourseId = function(root) {
3cfff885
BB
151 return root.attr('data-course-id');
152 };
153
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 };
169
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 };
185
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 };
196
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 };
207
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);
217
218 setCourseFavouriteState(courseId, true).then(function(success) {
219 if (success) {
07fdb5a0 220 PubSub.publish(CourseEvents.favourited);
3cfff885
BB
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 };
230
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);
240
241 setCourseFavouriteState(courseId, false).then(function(success) {
242 if (success) {
07fdb5a0 243 PubSub.publish(CourseEvents.unfavorited);
3cfff885
BB
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 };
253
fd68f5a9 254 /**
fd955097 255 * Reset the loadedPages dataset to take into account the hidden element
fd68f5a9
BB
256 *
257 * @param {Object} root The course overview container
fd955097 258 * @param {Object} target The course that you want to hide
fd68f5a9 259 */
fd955097
P
260 var hideElement = function(root, target) {
261 var id = getCourseId(target);
fd68f5a9
BB
262
263 var pagingBar = root.find('[data-region="paging-bar"]');
fd955097
P
264 var jumpto = parseInt(pagingBar.attr('data-active-page-number'));
265
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 }, []);
274
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);
645cb7dd
P
278
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 }
286
287 loadedPages[index].courses = $.merge(loadedPages[index].courses.slice(1), popElement);
288 }
289 });
290
fd955097
P
291
292 reducedCourse = $.merge(reducedCourse, newElement);
293 }
294
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 }
300
301 loadedPages[jumpto].courses = reducedCourse;
302
303 // Reduce the course offset
304 courseOffset--;
305
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);
fd68f5a9 311
fd955097
P
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 });
c896546c
P
319 };
320
3cfff885
BB
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) {
329
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 };
352
e4b4b9e7
BB
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.
e4b4b9e7
BB
358 * @return {promise} jQuery promise resolved after rendering is complete.
359 */
3cfff885
BB
360 var renderCourses = function(root, coursesData) {
361
362 var filters = getFilterValues(root);
e4b4b9e7
BB
363
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 }
372
373 if (coursesData.courses.length) {
374 return Templates.render(currentTemplate, {
375 courses: coursesData.courses
376 });
377 } else {
fd955097 378 var nocoursesimg = root.find(Selectors.courseView.region).attr('data-nocoursesimg');
e4b4b9e7
BB
379 return Templates.render(TEMPLATES.NOCOURSES, {
380 nocoursesimg: nocoursesimg
381 });
382 }
383 };
384
11988d74
P
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 };
393
e4b4b9e7 394 /**
e6f03948 395 * Intialise the paged list and cards views on page load.
11988d74
P
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 };
405
406 /**
407 * Intialise the courses list and cards views on page load.
c99b1036 408 *
e4b4b9e7
BB
409 * @param {object} root The root element for the courses view.
410 * @param {object} content The content element for the courses view.
411 */
fd955097 412 var initializePagedContent = function(root) {
11988d74
P
413 namespace = "block_myoverview_" + root.attr('id') + "_" + Math.random();
414
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 }
423
424 return {
425 value: value,
426 active: active
427 };
428 });
429 }
430
e4b4b9e7 431 var filters = getFilterValues(root);
11988d74
P
432 var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
433 config.eventNamespace = namespace;
e4b4b9e7
BB
434
435 var pagedContentPromise = PagedContentFactory.createWithLimit(
11988d74 436 itemsPerPage,
e4b4b9e7
BB
437 function(pagesData, actions) {
438 var promises = [];
439
440 pagesData.forEach(function(pageData) {
3cfff885 441 var currentPage = pageData.pageNumber;
fd955097
P
442 var limit = pageData.limit;
443
645cb7dd
P
444 // Reset local variables if limits have changed
445 if (lastLimit != limit) {
446 loadedPages = [];
447 courseOffset = 0;
448 lastPage = 0;
449 }
450
fd955097
P
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]));
f3d077d0 455 return;
fd955097
P
456 }
457
645cb7dd
P
458 lastLimit = limit;
459
fd955097
P
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 }
e4b4b9e7
BB
466
467 var pagePromise = getMyCourses(
468 filters,
fd955097 469 limit
e4b4b9e7 470 ).then(function(coursesData) {
fd955097
P
471 var courses = coursesData.courses;
472 var nextPageStart = 0;
473 var pageCourses = [];
474
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);
e4b4b9e7 486 }
fd955097
P
487
488 // Finished setting up the current page
489 loadedPages[currentPage] = {
490 courses: pageCourses
491 };
492
493 // Set up the next page
494 var remainingCourses = courses.slice(nextPageStart, courses.length);
645cb7dd
P
495 if (remainingCourses.length) {
496 loadedPages[currentPage + 1] = {
497 courses: remainingCourses
498 };
499 }
fd955097
P
500
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 }
509
510 courseOffset = coursesData.nextoffset;
511 return renderCourses(root, loadedPages[currentPage]);
e4b4b9e7
BB
512 })
513 .catch(Notification.exception);
514
515 promises.push(pagePromise);
516 });
517
518 return promises;
519 },
11988d74 520 config
e4b4b9e7
BB
521 );
522
523 pagedContentPromise.then(function(html, js) {
11988d74 524 registerPagedEventHandlers(root, namespace);
fd955097 525 return Templates.replaceNodeContents(root.find(Selectors.courseView.region), html, js);
e4b4b9e7
BB
526 }).catch(Notification.exception);
527 };
528
3cfff885
BB
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 ]);
538
539 root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, function(e, data) {
540 var favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
e6f03948 541 var courseId = getCourseId(favourite);
3cfff885
BB
542 addToFavourites(root, courseId);
543 data.originalEvent.preventDefault();
544 });
545
546 root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, function(e, data) {
547 var favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
e6f03948 548 var courseId = getCourseId(favourite);
3cfff885
BB
549 removeFromFavourites(root, courseId);
550 data.originalEvent.preventDefault();
551 });
552
553 root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
554 data.originalEvent.preventDefault();
555 });
e6f03948
P
556
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);
560
561 var request = {
562 preferences: [
563 {
564 type: 'block_myoverview_hidden_course_' + id,
565 value: true
566 }
567 ]
568 };
569 Repository.updateUserPreferences(request);
570
fd955097 571 hideElement(root, target);
e6f03948
P
572 data.originalEvent.preventDefault();
573 });
574
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);
578
579 var request = {
580 preferences: [
581 {
582 type: 'block_myoverview_hidden_course_' + id,
583 value: null
584 }
585 ]
586 };
587
588 Repository.updateUserPreferences(request);
589
fd955097 590 hideElement(root, target);
e6f03948
P
591 data.originalEvent.preventDefault();
592 });
3cfff885
BB
593 };
594
e4b4b9e7 595 /**
e6f03948 596 * Intialise the courses list and cards views on page load.
033adb02 597 *
e6f03948 598 * @param {object} root The root element for the courses view.
e6f03948 599 */
fd955097 600 var init = function(root) {
e6f03948 601 root = $(root);
fd955097
P
602 loadedPages = [];
603 lastPage = 0;
604 courseOffset = 0;
e6f03948 605
11988d74
P
606 initializePagedContent(root);
607
e6f03948 608 if (!root.attr('data-init')) {
fd955097 609 registerEventListeners(root);
e6f03948
P
610 root.attr('data-init', true);
611 }
e6f03948
P
612 };
613
614 /**
615
e4b4b9e7 616 * Reset the courses views to their original
fd955097 617 * state on first page load.courseOffset
c99b1036 618 *
e4b4b9e7
BB
619 * This is called when configuration has changed for the event lists
620 * to cause them to reload their data.
c99b1036 621 *
3cfff885 622 * @param {Object} root The root element for the timeline view.
e4b4b9e7 623 */
fd955097 624 var reset = function(root) {
3cfff885
BB
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 {
fd955097 633 init(root);
3cfff885 634 }
e4b4b9e7
BB
635 };
636
637 return {
638 init: init,
639 reset: reset
640 };
641});