MDL-68889 block_recentlyaccessedcourses: small viewport issues
[moodle.git] / blocks / recentlyaccessedcourses / amd / src / main.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  * Javascript to initialise the Recently accessed courses block.
18  *
19  * @module     block_recentlyaccessedcourses/main.js
20  * @package    block_recentlyaccessedcourses
21  * @copyright  2018 Victor Deniz <victor@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 define(
26     [
27         'jquery',
28         'core/custom_interaction_events',
29         'core/notification',
30         'core/pubsub',
31         'core/paged_content_paging_bar',
32         'core/templates',
33         'core_course/events',
34         'core_course/repository',
35         'core/aria',
36     ],
37     function(
38         $,
39         CustomEvents,
40         Notification,
41         PubSub,
42         PagedContentPagingBar,
43         Templates,
44         CourseEvents,
45         CoursesRepository,
46         Aria
47     ) {
49         // Constants.
50         var NUM_COURSES_TOTAL = 10;
51         var SELECTORS = {
52             BLOCK_CONTAINER: '[data-region="recentlyaccessedcourses"]',
53             CARD_CONTAINER: '[data-region="card-deck"]',
54             COURSE_IS_FAVOURITE: '[data-region="is-favourite"]',
55             CONTENT: '[data-region="view-content"]',
56             EMPTY_MESSAGE: '[data-region="empty-message"]',
57             LOADING_PLACEHOLDER: '[data-region="loading-placeholder"]',
58             PAGING_BAR: '[data-region="paging-bar"]',
59             PAGING_BAR_NEXT: '[data-control="next"]',
60             PAGING_BAR_PREVIOUS: '[data-control="previous"]'
61         };
62         // Module variables.
63         var contentLoaded = false;
64         var allCourses = [];
65         var visibleCoursesId = null;
66         var cardWidth = null;
67         var viewIndex = 0;
68         var availableVisibleCards = 1;
70         /**
71          * Show the empty message when no course are found.
72          *
73          * @param {object} root The root element for the courses view.
74          */
75         var showEmptyMessage = function(root) {
76             root.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
77             root.find(SELECTORS.LOADING_PLACEHOLDER).addClass('hidden');
78             root.find(SELECTORS.CONTENT).addClass('hidden');
79         };
81         /**
82          * Show the empty message when no course are found.
83          *
84          * @param {object} root The root element for the courses view.
85          */
86         var showContent = function(root) {
87             root.find(SELECTORS.CONTENT).removeClass('hidden');
88             root.find(SELECTORS.EMPTY_MESSAGE).addClass('hidden');
89             root.find(SELECTORS.LOADING_PLACEHOLDER).addClass('hidden');
90         };
92         /**
93          * Show the paging bar.
94          *
95          * @param {object} root The root element for the courses view.
96          */
97         var showPagingBar = function(root) {
98             var pagingBar = root.find(SELECTORS.PAGING_BAR);
99             pagingBar.css('opacity', 1);
100             pagingBar.css('visibility', 'visible');
101             Aria.unhide(pagingBar);
102         };
104         /**
105          * Hide the paging bar.
106          *
107          * @param {object} root The root element for the courses view.
108          */
109         var hidePagingBar = function(root) {
110             var pagingBar = root.find(SELECTORS.PAGING_BAR);
111             pagingBar.css('opacity', 0);
112             pagingBar.css('visibility', 'hidden');
113             Aria.hide(pagingBar);
114         };
116         /**
117          * Show the favourite indicator for the given course (if it's in the list).
118          *
119          * @param {object} root The root element for the courses view.
120          * @param {number} courseId The id of the course to be favourited.
121          */
122         var favouriteCourse = function(root, courseId) {
123             allCourses.forEach(function(course) {
124                 if (course.attr('data-course-id') == courseId) {
125                     course.find(SELECTORS.COURSE_IS_FAVOURITE).removeClass('hidden');
126                 }
127             });
128         };
130         /**
131          * Hide the favourite indicator for the given course (if it's in the list).
132          *
133          * @param {object} root The root element for the courses view.
134          * @param {number} courseId The id of the course to be unfavourited.
135          */
136         var unfavouriteCourse = function(root, courseId) {
137             allCourses.forEach(function(course) {
138                 if (course.attr('data-course-id') == courseId) {
139                     course.find(SELECTORS.COURSE_IS_FAVOURITE).addClass('hidden');
140                 }
141             });
142         };
144         /**
145          * Render the a list of courses.
146          *
147          * @param {array} courses containing array of courses.
148          * @return {promise} Resolved with list of rendered courses as jQuery objects.
149          */
150         var renderAllCourses = function(courses) {
151             var showcoursecategory = $(SELECTORS.BLOCK_CONTAINER).data('displaycoursecategory');
152             var promises = courses.map(function(course) {
153                 course.showcoursecategory = showcoursecategory;
154                 return Templates.render('block_recentlyaccessedcourses/course-card', course);
155             });
157             return $.when.apply(null, promises).then(function() {
158                 var renderedCourses = [];
160                 promises.forEach(function(promise) {
161                     promise.then(function(html) {
162                         renderedCourses.push($(html));
163                         return;
164                     })
165                     .catch(Notification.exception);
166                 });
168                 return renderedCourses;
169             });
170         };
172         /**
173          * Fetch user's recently accessed courses and reload the content of the block.
174          *
175          * @param {int} userid User whose courses will be shown
176          * @returns {promise} The updated content for the block.
177          */
178         var loadContent = function(userid) {
179             return CoursesRepository.getLastAccessedCourses(userid, NUM_COURSES_TOTAL)
180                 .then(function(courses) {
181                     return renderAllCourses(courses);
182                 });
183         };
185         /**
186          * Recalculate the number of courses that should be visible.
187          *
188          * @param {object} root The root element for the courses view.
189          */
190         var recalculateVisibleCourses = function(root) {
191             var container = root.find(SELECTORS.CONTENT).find(SELECTORS.CARD_CONTAINER);
192             var availableWidth = parseFloat(root.css('width'));
193             var numberOfCourses = allCourses.length;
194             var start = 0;
196             if (!cardWidth) {
197                 container.html(allCourses[0]);
198                 // Render one card initially to calculate the width of the cards
199                 // including the margins.
200                 cardWidth = allCourses[0].outerWidth(true);
201             }
203             availableVisibleCards = Math.floor(availableWidth / cardWidth);
205             if (viewIndex + availableVisibleCards < numberOfCourses) {
206                 start = viewIndex;
207             } else {
208                 var overflow = (viewIndex + availableVisibleCards) - numberOfCourses;
209                 start = viewIndex - overflow;
210                 start = start >= 0 ? start : 0;
211             }
213             // At least show one card.
214             if (availableVisibleCards === 0) {
215                 availableVisibleCards = 1;
216             }
218             var coursesToShow = allCourses.slice(start, start + availableVisibleCards);
219             // Create an id for the list of courses we expect to be displayed.
220             var newVisibleCoursesId = coursesToShow.reduce(function(carry, course) {
221                 return carry + course.attr('data-course-id');
222             }, '');
224             // Centre the courses if we have an overflow of courses.
225             if (allCourses.length > coursesToShow.length) {
226                 container.addClass('justify-content-center');
227                 container.removeClass('justify-content-start');
228             } else {
229                 container.removeClass('justify-content-center');
230                 container.addClass('justify-content-start');
231             }
233             // Don't bother updating the DOM unless the visible courses have changed.
234             if (visibleCoursesId != newVisibleCoursesId) {
235                 var pagingBar = root.find(PagedContentPagingBar.rootSelector);
236                 container.html(coursesToShow);
237                 visibleCoursesId = newVisibleCoursesId;
239                 if (availableVisibleCards >= allCourses.length) {
240                     hidePagingBar(root);
241                 } else {
242                     showPagingBar(root);
244                     if (viewIndex === 0) {
245                         PagedContentPagingBar.disablePreviousControlButtons(pagingBar);
246                     } else {
247                         PagedContentPagingBar.enablePreviousControlButtons(pagingBar);
248                     }
250                     if (viewIndex + availableVisibleCards >= allCourses.length) {
251                         PagedContentPagingBar.disableNextControlButtons(pagingBar);
252                     } else {
253                         PagedContentPagingBar.enableNextControlButtons(pagingBar);
254                     }
255                 }
256             }
257         };
259         /**
260          * Register event listeners for the block.
261          *
262          * @param {object} root The root element for the recentlyaccessedcourses block.
263          */
264         var registerEventListeners = function(root) {
265             var resizeTimeout = null;
266             var drawerToggling = false;
268             PubSub.subscribe(CourseEvents.favourited, function(courseId) {
269                 favouriteCourse(root, courseId);
270             });
272             PubSub.subscribe(CourseEvents.unfavorited, function(courseId) {
273                 unfavouriteCourse(root, courseId);
274             });
276             PubSub.subscribe('nav-drawer-toggle-start', function() {
277                 if (!contentLoaded || !allCourses.length || drawerToggling) {
278                     // Nothing to recalculate.
279                     return;
280                 }
282                 drawerToggling = true;
283                 var recalculationCount = 0;
284                 // This function is going to recalculate the number of courses while
285                 // the nav drawer is opening or closes (up to a maximum of 5 recalcs).
286                 var doRecalculation = function() {
287                     setTimeout(function() {
288                         recalculateVisibleCourses(root);
289                         recalculationCount++;
291                         if (recalculationCount < 5 && drawerToggling) {
292                             // If we haven't done too many recalculations and the drawer
293                             // is still toggling then recurse.
294                             doRecalculation();
295                         }
296                     }, 100);
297                 };
299                 // Start the recalculations.
300                 doRecalculation(root);
301             });
303             PubSub.subscribe('nav-drawer-toggle-end', function() {
304                 drawerToggling = false;
305             });
307             $(window).on('resize', function() {
308                 if (!contentLoaded || !allCourses.length) {
309                     // Nothing to reclculate.
310                     return;
311                 }
313                 // Resize events fire rapidly so recalculating the visible courses each
314                 // time can be expensive. Let's debounce them,
315                 if (!resizeTimeout) {
316                     resizeTimeout = setTimeout(function() {
317                         resizeTimeout = null;
318                         recalculateVisibleCourses(root);
319                     // The recalculateVisibleCourses function will execute at a rate of 15fps.
320                     }, 66);
321                 }
322             });
324             CustomEvents.define(root, [CustomEvents.events.activate]);
325             root.on(CustomEvents.events.activate, SELECTORS.PAGING_BAR_NEXT, function(e, data) {
326                 var button = $(e.target).closest(SELECTORS.PAGING_BAR_NEXT);
327                 if (!button.hasClass('disabled')) {
328                     viewIndex = viewIndex + availableVisibleCards;
329                     recalculateVisibleCourses(root);
330                 }
332                 data.originalEvent.preventDefault();
333             });
335             root.on(CustomEvents.events.activate, SELECTORS.PAGING_BAR_PREVIOUS, function(e, data) {
336                 var button = $(e.target).closest(SELECTORS.PAGING_BAR_PREVIOUS);
337                 if (!button.hasClass('disabled')) {
338                     viewIndex = viewIndex - availableVisibleCards;
339                     viewIndex = viewIndex < 0 ? 0 : viewIndex;
340                     recalculateVisibleCourses(root);
341                 }
343                 data.originalEvent.preventDefault();
344             });
345         };
347         /**
348          * Get and show the recent courses into the block.
349          *
350          * @param {int} userid User from which the courses will be obtained
351          * @param {object} root The root element for the recentlyaccessedcourses block.
352          */
353         var init = function(userid, root) {
354             root = $(root);
356             registerEventListeners(root);
357             loadContent(userid)
358                 .then(function(renderedCourses) {
359                     allCourses = renderedCourses;
360                     contentLoaded = true;
362                     if (allCourses.length) {
363                         showContent(root);
364                         recalculateVisibleCourses(root);
365                     } else {
366                         showEmptyMessage(root);
367                     }
369                     return;
370                 })
371                 .catch(Notification.exception);
372         };
374         return {
375             init: init
376         };
377     });