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