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/>.
  15. /**
  16. * Manage the courses view for the overview block.
  17. *
  18. * @copyright 2018 Bas Brands <bas@moodle.com>
  19. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  20. */
  21. import $ from 'jquery';
  22. import * as Repository from 'block_myoverview/repository';
  23. import * as PagedContentFactory from 'core/paged_content_factory';
  24. import * as PubSub from 'core/pubsub';
  25. import * as CustomEvents from 'core/custom_interaction_events';
  26. import * as Notification from 'core/notification';
  27. import * as Templates from 'core/templates';
  28. import * as CourseEvents from 'core_course/events';
  29. import SELECTORS from 'block_myoverview/selectors';
  30. import * as PagedContentEvents from 'core/paged_content_events';
  31. import * as Aria from 'core/aria';
  32. import {debounce} from 'core/utils';
  33. import {setUserPreference} from 'core_user/repository';
  34. const TEMPLATES = {
  35. COURSES_CARDS: 'block_myoverview/view-cards',
  36. COURSES_LIST: 'block_myoverview/view-list',
  37. COURSES_SUMMARY: 'block_myoverview/view-summary',
  38. NOCOURSES: 'core_course/no-courses'
  39. };
  40. const GROUPINGS = {
  41. GROUPING_ALLINCLUDINGHIDDEN: 'allincludinghidden',
  42. GROUPING_ALL: 'all',
  43. GROUPING_INPROGRESS: 'inprogress',
  44. GROUPING_FUTURE: 'future',
  45. GROUPING_PAST: 'past',
  46. GROUPING_FAVOURITES: 'favourites',
  47. GROUPING_HIDDEN: 'hidden'
  48. };
  49. const NUMCOURSES_PERPAGE = [12, 24, 48, 96, 0];
  50. let loadedPages = [];
  51. let courseOffset = 0;
  52. let lastPage = 0;
  53. let lastLimit = 0;
  54. let namespace = null;
  55. /**
  56. * Get filter values from DOM.
  57. *
  58. * @param {object} root The root element for the courses view.
  59. * @return {filters} Set filters.
  60. */
  61. const getFilterValues = root => {
  62. const courseRegion = root.find(SELECTORS.courseView.region);
  63. return {
  64. display: courseRegion.attr('data-display'),
  65. grouping: courseRegion.attr('data-grouping'),
  66. sort: courseRegion.attr('data-sort'),
  67. displaycategories: courseRegion.attr('data-displaycategories'),
  68. customfieldname: courseRegion.attr('data-customfieldname'),
  69. customfieldvalue: courseRegion.attr('data-customfieldvalue'),
  70. };
  71. };
  72. // We want the paged content controls below the paged content area.
  73. // and the controls should be ignored while data is loading.
  74. const DEFAULT_PAGED_CONTENT_CONFIG = {
  75. ignoreControlWhileLoading: true,
  76. controlPlacementBottom: true,
  77. persistentLimitKey: 'block_myoverview_user_paging_preference'
  78. };
  79. /**
  80. * Get enrolled courses from backend.
  81. *
  82. * @param {object} filters The filters for this view.
  83. * @param {int} limit The number of courses to show.
  84. * @return {promise} Resolved with an array of courses.
  85. */
  86. const getMyCourses = (filters, limit) => {
  87. return Repository.getEnrolledCoursesByTimeline({
  88. offset: courseOffset,
  89. limit: limit,
  90. classification: filters.grouping,
  91. sort: filters.sort,
  92. customfieldname: filters.customfieldname,
  93. customfieldvalue: filters.customfieldvalue
  94. });
  95. };
  96. /**
  97. * Search for enrolled courses from backend.
  98. *
  99. * @param {object} filters The filters for this view.
  100. * @param {int} limit The number of courses to show.
  101. * @param {string} searchValue What does the user want to search within their courses.
  102. * @return {promise} Resolved with an array of courses.
  103. */
  104. const getSearchMyCourses = (filters, limit, searchValue) => {
  105. return Repository.getEnrolledCoursesByTimeline({
  106. offset: courseOffset,
  107. limit: limit,
  108. classification: 'search',
  109. sort: filters.sort,
  110. customfieldname: filters.customfieldname,
  111. customfieldvalue: filters.customfieldvalue,
  112. searchvalue: searchValue
  113. });
  114. };
  115. /**
  116. * Get the container element for the favourite icon.
  117. *
  118. * @param {Object} root The course overview container
  119. * @param {Number} courseId Course id number
  120. * @return {Object} The favourite icon container
  121. */
  122. const getFavouriteIconContainer = (root, courseId) => {
  123. return root.find(SELECTORS.FAVOURITE_ICON + '[data-course-id="' + courseId + '"]');
  124. };
  125. /**
  126. * Get the paged content container element.
  127. *
  128. * @param {Object} root The course overview container
  129. * @param {Number} index Rendered page index.
  130. * @return {Object} The rendered paged container.
  131. */
  132. const getPagedContentContainer = (root, index) => {
  133. return root.find('[data-region="paged-content-page"][data-page="' + index + '"]');
  134. };
  135. /**
  136. * Get the course id from a favourite element.
  137. *
  138. * @param {Object} root The favourite icon container element.
  139. * @return {Number} Course id.
  140. */
  141. const getCourseId = root => {
  142. return root.attr('data-course-id');
  143. };
  144. /**
  145. * Hide the favourite icon.
  146. *
  147. * @param {Object} root The favourite icon container element.
  148. * @param {Number} courseId Course id number.
  149. */
  150. const hideFavouriteIcon = (root, courseId) => {
  151. const iconContainer = getFavouriteIconContainer(root, courseId);
  152. const isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
  153. isFavouriteIcon.addClass('hidden');
  154. Aria.hide(isFavouriteIcon);
  155. const notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
  156. notFavourteIcon.removeClass('hidden');
  157. Aria.unhide(notFavourteIcon);
  158. };
  159. /**
  160. * Show the favourite icon.
  161. *
  162. * @param {Object} root The course overview container.
  163. * @param {Number} courseId Course id number.
  164. */
  165. const showFavouriteIcon = (root, courseId) => {
  166. const iconContainer = getFavouriteIconContainer(root, courseId);
  167. const isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
  168. isFavouriteIcon.removeClass('hidden');
  169. Aria.unhide(isFavouriteIcon);
  170. const notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
  171. notFavourteIcon.addClass('hidden');
  172. Aria.hide(notFavourteIcon);
  173. };
  174. /**
  175. * Get the action menu item
  176. *
  177. * @param {Object} root The course overview container
  178. * @param {Number} courseId Course id.
  179. * @return {Object} The add to favourite menu item.
  180. */
  181. const getAddFavouriteMenuItem = (root, courseId) => {
  182. return root.find('[data-action="add-favourite"][data-course-id="' + courseId + '"]');
  183. };
  184. /**
  185. * Get the action menu item
  186. *
  187. * @param {Object} root The course overview container
  188. * @param {Number} courseId Course id.
  189. * @return {Object} The remove from favourites menu item.
  190. */
  191. const getRemoveFavouriteMenuItem = (root, courseId) => {
  192. return root.find('[data-action="remove-favourite"][data-course-id="' + courseId + '"]');
  193. };
  194. /**
  195. * Add course to favourites
  196. *
  197. * @param {Object} root The course overview container
  198. * @param {Number} courseId Course id number
  199. */
  200. const addToFavourites = (root, courseId) => {
  201. const removeAction = getRemoveFavouriteMenuItem(root, courseId);
  202. const addAction = getAddFavouriteMenuItem(root, courseId);
  203. setCourseFavouriteState(courseId, true).then(success => {
  204. if (success) {
  205. PubSub.publish(CourseEvents.favourited, courseId);
  206. removeAction.removeClass('hidden');
  207. addAction.addClass('hidden');
  208. showFavouriteIcon(root, courseId);
  209. } else {
  210. Notification.alert('Starring course failed', 'Could not change favourite state');
  211. }
  212. return;
  213. }).catch(Notification.exception);
  214. };
  215. /**
  216. * Remove course from favourites
  217. *
  218. * @param {Object} root The course overview container
  219. * @param {Number} courseId Course id number
  220. */
  221. const removeFromFavourites = (root, courseId) => {
  222. const removeAction = getRemoveFavouriteMenuItem(root, courseId);
  223. const addAction = getAddFavouriteMenuItem(root, courseId);
  224. setCourseFavouriteState(courseId, false).then(success => {
  225. if (success) {
  226. PubSub.publish(CourseEvents.unfavorited, courseId);
  227. removeAction.addClass('hidden');
  228. addAction.removeClass('hidden');
  229. hideFavouriteIcon(root, courseId);
  230. } else {
  231. Notification.alert('Starring course failed', 'Could not change favourite state');
  232. }
  233. return;
  234. }).catch(Notification.exception);
  235. };
  236. /**
  237. * Get the action menu item
  238. *
  239. * @param {Object} root The course overview container
  240. * @param {Number} courseId Course id.
  241. * @return {Object} The hide course menu item.
  242. */
  243. const getHideCourseMenuItem = (root, courseId) => {
  244. return root.find('[data-action="hide-course"][data-course-id="' + courseId + '"]');
  245. };
  246. /**
  247. * Get the action menu item
  248. *
  249. * @param {Object} root The course overview container
  250. * @param {Number} courseId Course id.
  251. * @return {Object} The show course menu item.
  252. */
  253. const getShowCourseMenuItem = (root, courseId) => {
  254. return root.find('[data-action="show-course"][data-course-id="' + courseId + '"]');
  255. };
  256. /**
  257. * Hide course
  258. *
  259. * @param {Object} root The course overview container
  260. * @param {Number} courseId Course id number
  261. */
  262. const hideCourse = (root, courseId) => {
  263. const hideAction = getHideCourseMenuItem(root, courseId);
  264. const showAction = getShowCourseMenuItem(root, courseId);
  265. const filters = getFilterValues(root);
  266. setCourseHiddenState(courseId, true);
  267. // Remove the course from this view as it is now hidden and thus not covered by this view anymore.
  268. // Do only if we are not in "All (including archived)" view mode where really all courses are shown.
  269. if (filters.grouping !== GROUPINGS.GROUPING_ALLINCLUDINGHIDDEN) {
  270. hideElement(root, courseId);
  271. }
  272. hideAction.addClass('hidden');
  273. showAction.removeClass('hidden');
  274. };
  275. /**
  276. * Show course
  277. *
  278. * @param {Object} root The course overview container
  279. * @param {Number} courseId Course id number
  280. */
  281. const showCourse = (root, courseId) => {
  282. const hideAction = getHideCourseMenuItem(root, courseId);
  283. const showAction = getShowCourseMenuItem(root, courseId);
  284. const filters = getFilterValues(root);
  285. setCourseHiddenState(courseId, null);
  286. // Remove the course from this view as it is now shown again and thus not covered by this view anymore.
  287. // Do only if we are not in "All (including archived)" view mode where really all courses are shown.
  288. if (filters.grouping !== GROUPINGS.GROUPING_ALLINCLUDINGHIDDEN) {
  289. hideElement(root, courseId);
  290. }
  291. hideAction.removeClass('hidden');
  292. showAction.addClass('hidden');
  293. };
  294. /**
  295. * Set the courses hidden status and push to repository
  296. *
  297. * @param {Number} courseId Course id to favourite.
  298. * @param {Boolean} status new hidden status.
  299. * @return {Promise} Repository promise.
  300. */
  301. const setCourseHiddenState = (courseId, status) => {
  302. // If the given status is not hidden, the preference has to be deleted with a null value.
  303. if (status === false) {
  304. status = null;
  305. }
  306. return setUserPreference(`block_myoverview_hidden_course_${courseId}`, status)
  307. .catch(Notification.exception);
  308. };
  309. /**
  310. * Reset the loadedPages dataset to take into account the hidden element
  311. *
  312. * @param {Object} root The course overview container
  313. * @param {Number} id The course id number
  314. */
  315. const hideElement = (root, id) => {
  316. const pagingBar = root.find('[data-region="paging-bar"]');
  317. const jumpto = parseInt(pagingBar.attr('data-active-page-number'));
  318. // Get a reduced dataset for the current page.
  319. const courseList = loadedPages[jumpto];
  320. let reducedCourse = courseList.courses.reduce((accumulator, current) => {
  321. if (+id !== +current.id) {
  322. accumulator.push(current);
  323. }
  324. return accumulator;
  325. }, []);
  326. // Get the next page's data if loaded and pop the first element from it.
  327. if (typeof (loadedPages[jumpto + 1]) !== 'undefined') {
  328. const newElement = loadedPages[jumpto + 1].courses.slice(0, 1);
  329. // Adjust the dataset for the reset of the pages that are loaded.
  330. loadedPages.forEach((courseList, index) => {
  331. if (index > jumpto) {
  332. let popElement = [];
  333. if (typeof (loadedPages[index + 1]) !== 'undefined') {
  334. popElement = loadedPages[index + 1].courses.slice(0, 1);
  335. }
  336. loadedPages[index].courses = [...loadedPages[index].courses.slice(1), ...popElement];
  337. }
  338. });
  339. reducedCourse = [...reducedCourse, ...newElement];
  340. }
  341. // Check if the next page is the last page and if it still has data associated to it.
  342. if (lastPage === jumpto + 1 && loadedPages[jumpto + 1].courses.length === 0) {
  343. const pagedContentContainer = root.find('[data-region="paged-content-container"]');
  344. PagedContentFactory.resetLastPageNumber($(pagedContentContainer).attr('id'), jumpto);
  345. }
  346. loadedPages[jumpto].courses = reducedCourse;
  347. // Reduce the course offset.
  348. courseOffset--;
  349. // Render the paged content for the current.
  350. const pagedContentPage = getPagedContentContainer(root, jumpto);
  351. renderCourses(root, loadedPages[jumpto]).then((html, js) => {
  352. return Templates.replaceNodeContents(pagedContentPage, html, js);
  353. }).catch(Notification.exception);
  354. // Delete subsequent pages in order to trigger the callback.
  355. loadedPages.forEach((courseList, index) => {
  356. if (index > jumpto) {
  357. const page = getPagedContentContainer(root, index);
  358. page.remove();
  359. }
  360. });
  361. };
  362. /**
  363. * Set the courses favourite status and push to repository
  364. *
  365. * @param {Number} courseId Course id to favourite.
  366. * @param {boolean} status new favourite status.
  367. * @return {Promise} Repository promise.
  368. */
  369. const setCourseFavouriteState = (courseId, status) => {
  370. return Repository.setFavouriteCourses({
  371. courses: [
  372. {
  373. 'id': courseId,
  374. 'favourite': status
  375. }
  376. ]
  377. }).then(result => {
  378. if (result.warnings.length === 0) {
  379. loadedPages.forEach(courseList => {
  380. courseList.courses.forEach((course, index) => {
  381. if (course.id == courseId) {
  382. courseList.courses[index].isfavourite = status;
  383. }
  384. });
  385. });
  386. return true;
  387. } else {
  388. return false;
  389. }
  390. }).catch(Notification.exception);
  391. };
  392. /**
  393. * Given there are no courses to render provide the rendered template.
  394. *
  395. * @param {object} root The root element for the courses view.
  396. * @return {promise} jQuery promise resolved after rendering is complete.
  397. */
  398. const noCoursesRender = root => {
  399. const nocoursesimg = root.find(SELECTORS.courseView.region).attr('data-nocoursesimg');
  400. const newcourseurl = root.find(SELECTORS.courseView.region).attr('data-newcourseurl');
  401. return Templates.render(TEMPLATES.NOCOURSES, {
  402. nocoursesimg: nocoursesimg,
  403. newcourseurl: newcourseurl
  404. });
  405. };
  406. /**
  407. * Render the dashboard courses.
  408. *
  409. * @param {object} root The root element for the courses view.
  410. * @param {array} coursesData containing array of returned courses.
  411. * @return {promise} jQuery promise resolved after rendering is complete.
  412. */
  413. const renderCourses = (root, coursesData) => {
  414. const filters = getFilterValues(root);
  415. let currentTemplate = '';
  416. if (filters.display === 'card') {
  417. currentTemplate = TEMPLATES.COURSES_CARDS;
  418. } else if (filters.display === 'list') {
  419. currentTemplate = TEMPLATES.COURSES_LIST;
  420. } else {
  421. currentTemplate = TEMPLATES.COURSES_SUMMARY;
  422. }
  423. if (!coursesData) {
  424. return noCoursesRender(root);
  425. } else {
  426. // Sometimes we get weird objects coming after a failed search, cast to ensure typing functions.
  427. if (Array.isArray(coursesData.courses) === false) {
  428. coursesData.courses = Object.values(coursesData.courses);
  429. }
  430. // Whether the course category should be displayed in the course item.
  431. coursesData.courses = coursesData.courses.map(course => {
  432. course.showcoursecategory = filters.displaycategories === 'on';
  433. return course;
  434. });
  435. if (coursesData.courses.length) {
  436. return Templates.render(currentTemplate, {
  437. courses: coursesData.courses,
  438. });
  439. } else {
  440. return noCoursesRender(root);
  441. }
  442. }
  443. };
  444. /**
  445. * Return the callback to be passed to the subscribe event
  446. *
  447. * @param {object} root The root element for the courses view
  448. * @return {function} Partially applied function that'll execute when passed a limit
  449. */
  450. const setLimit = root => {
  451. // @param {Number} limit The paged limit that is passed through the event.
  452. return limit => root.find(SELECTORS.courseView.region).attr('data-paging', limit);
  453. };
  454. /**
  455. * Intialise the paged list and cards views on page load.
  456. * Returns an array of paged contents that we would like to handle here
  457. *
  458. * @param {object} root The root element for the courses view
  459. * @param {string} namespace The namespace for all the events attached
  460. */
  461. const registerPagedEventHandlers = (root, namespace) => {
  462. const event = namespace + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT;
  463. PubSub.subscribe(event, setLimit(root));
  464. };
  465. /**
  466. * Figure out how many items are going to be allowed to be rendered in the block.
  467. *
  468. * @param {Number} pagingLimit How many courses to display
  469. * @param {Object} root The course overview container
  470. * @return {Number[]} How many courses will be rendered
  471. */
  472. const itemsPerPageFunc = (pagingLimit, root) => {
  473. let itemsPerPage = NUMCOURSES_PERPAGE.map(value => {
  474. let active = false;
  475. if (value === pagingLimit) {
  476. active = true;
  477. }
  478. return {
  479. value: value,
  480. active: active
  481. };
  482. });
  483. // Filter out all pagination options which are too large for the amount of courses user is enrolled in.
  484. const totalCourseCount = parseInt(root.find(SELECTORS.courseView.region).attr('data-totalcoursecount'), 10);
  485. return itemsPerPage.filter(pagingOption => {
  486. if (pagingOption.value === 0 && totalCourseCount > 100) {
  487. // To minimise performance issues, do not show the "All" option if the user is enrolled in more than 100 courses.
  488. return false;
  489. }
  490. return pagingOption.value < totalCourseCount;
  491. });
  492. };
  493. /**
  494. * Mutates and controls the loadedPages array and handles the bootstrapping.
  495. *
  496. * @param {Array|Object} coursesData Array of all of the courses to start building the page from
  497. * @param {Number} currentPage What page are we currently on?
  498. * @param {Object} pageData Any current page information
  499. * @param {Object} actions Paged content helper
  500. * @param {null|boolean} activeSearch Are we currently actively searching and building up search results?
  501. */
  502. const pageBuilder = (coursesData, currentPage, pageData, actions, activeSearch = null) => {
  503. // If the courseData comes in an object then get the value otherwise it is a pure array.
  504. let courses = coursesData.courses ? coursesData.courses : coursesData;
  505. let nextPageStart = 0;
  506. let pageCourses = [];
  507. // If current page's data is loaded make sure we max it to page limit.
  508. if (typeof (loadedPages[currentPage]) !== 'undefined') {
  509. pageCourses = loadedPages[currentPage].courses;
  510. const currentPageLength = pageCourses.length;
  511. if (currentPageLength < pageData.limit) {
  512. nextPageStart = pageData.limit - currentPageLength;
  513. pageCourses = {...loadedPages[currentPage].courses, ...courses.slice(0, nextPageStart)};
  514. }
  515. } else {
  516. // When the page limit is zero, there is only one page of courses, no start for next page.
  517. nextPageStart = pageData.limit || false;
  518. pageCourses = (pageData.limit > 0) ? courses.slice(0, pageData.limit) : courses;
  519. }
  520. // Finished setting up the current page.
  521. loadedPages[currentPage] = {
  522. courses: pageCourses
  523. };
  524. // Set up the next page (if there is more than one page).
  525. const remainingCourses = nextPageStart !== false ? courses.slice(nextPageStart, courses.length) : [];
  526. if (remainingCourses.length) {
  527. loadedPages[currentPage + 1] = {
  528. courses: remainingCourses
  529. };
  530. }
  531. // Set the last page to either the current or next page.
  532. if (loadedPages[currentPage].courses.length < pageData.limit || !remainingCourses.length) {
  533. lastPage = currentPage;
  534. if (activeSearch === null) {
  535. actions.allItemsLoaded(currentPage);
  536. }
  537. } else if (typeof (loadedPages[currentPage + 1]) !== 'undefined'
  538. && loadedPages[currentPage + 1].courses.length < pageData.limit) {
  539. lastPage = currentPage + 1;
  540. }
  541. courseOffset = coursesData.nextoffset;
  542. };
  543. /**
  544. * In cases when switching between regular rendering and search rendering we need to reset some variables.
  545. */
  546. const resetGlobals = () => {
  547. courseOffset = 0;
  548. loadedPages = [];
  549. lastPage = 0;
  550. lastLimit = 0;
  551. };
  552. /**
  553. * The default functionality of fetching paginated courses without special handling.
  554. *
  555. * @return {function(Object, Object, Object, Object, Object, Promise, Number): void}
  556. */
  557. const standardFunctionalityCurry = () => {
  558. resetGlobals();
  559. return (filters, currentPage, pageData, actions, root, promises, limit) => {
  560. const pagePromise = getMyCourses(
  561. filters,
  562. limit
  563. ).then(coursesData => {
  564. pageBuilder(coursesData, currentPage, pageData, actions);
  565. return renderCourses(root, loadedPages[currentPage]);
  566. }).catch(Notification.exception);
  567. promises.push(pagePromise);
  568. };
  569. };
  570. /**
  571. * Initialize the searching functionality so we can call it when required.
  572. *
  573. * @return {function(Object, Number, Object, Object, Object, Promise, Number, String): void}
  574. */
  575. const searchFunctionalityCurry = () => {
  576. resetGlobals();
  577. return (filters, currentPage, pageData, actions, root, promises, limit, inputValue) => {
  578. const searchingPromise = getSearchMyCourses(
  579. filters,
  580. limit,
  581. inputValue
  582. ).then(coursesData => {
  583. pageBuilder(coursesData, currentPage, pageData, actions);
  584. return renderCourses(root, loadedPages[currentPage]);
  585. }).catch(Notification.exception);
  586. promises.push(searchingPromise);
  587. };
  588. };
  589. /**
  590. * Initialise the courses list and cards views on page load.
  591. *
  592. * @param {object} root The root element for the courses view.
  593. * @param {function} promiseFunction How do we fetch the courses and what do we do with them?
  594. * @param {null | string} inputValue What to search for
  595. */
  596. const initializePagedContent = (root, promiseFunction, inputValue = null) => {
  597. const pagingLimit = parseInt(root.find(SELECTORS.courseView.region).attr('data-paging'), 10);
  598. let itemsPerPage = itemsPerPageFunc(pagingLimit, root);
  599. const filters = getFilterValues(root);
  600. const config = {...{}, ...DEFAULT_PAGED_CONTENT_CONFIG};
  601. config.eventNamespace = namespace;
  602. const pagedContentPromise = PagedContentFactory.createWithLimit(
  603. itemsPerPage,
  604. (pagesData, actions) => {
  605. let promises = [];
  606. pagesData.forEach(pageData => {
  607. const currentPage = pageData.pageNumber;
  608. let limit = (pageData.limit > 0) ? pageData.limit : 0;
  609. // Reset local variables if limits have changed.
  610. if (+lastLimit !== +limit) {
  611. loadedPages = [];
  612. courseOffset = 0;
  613. lastPage = 0;
  614. }
  615. if (lastPage === currentPage) {
  616. // If we are on the last page and have it's data then load it from cache.
  617. actions.allItemsLoaded(lastPage);
  618. promises.push(renderCourses(root, loadedPages[currentPage]));
  619. return;
  620. }
  621. lastLimit = limit;
  622. // Get 2 pages worth of data as we will need it for the hidden functionality.
  623. if (typeof (loadedPages[currentPage + 1]) === 'undefined') {
  624. if (typeof (loadedPages[currentPage]) === 'undefined') {
  625. limit *= 2;
  626. }
  627. }
  628. // Call the curried function that'll handle the course promise and any manipulation of it.
  629. promiseFunction(filters, currentPage, pageData, actions, root, promises, limit, inputValue);
  630. });
  631. return promises;
  632. },
  633. config
  634. );
  635. pagedContentPromise.then((html, js) => {
  636. registerPagedEventHandlers(root, namespace);
  637. return Templates.replaceNodeContents(root.find(SELECTORS.courseView.region), html, js);
  638. }).catch(Notification.exception);
  639. };
  640. /**
  641. * Listen to, and handle events for the myoverview block.
  642. *
  643. * @param {Object} root The myoverview block container element.
  644. * @param {HTMLElement} page The whole HTMLElement for our block.
  645. */
  646. const registerEventListeners = (root, page) => {
  647. CustomEvents.define(root, [
  648. CustomEvents.events.activate
  649. ]);
  650. root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, (e, data) => {
  651. const favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
  652. const courseId = getCourseId(favourite);
  653. addToFavourites(root, courseId);
  654. data.originalEvent.preventDefault();
  655. });
  656. root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, (e, data) => {
  657. const favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
  658. const courseId = getCourseId(favourite);
  659. removeFromFavourites(root, courseId);
  660. data.originalEvent.preventDefault();
  661. });
  662. root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, (e, data) => {
  663. data.originalEvent.preventDefault();
  664. });
  665. root.on(CustomEvents.events.activate, SELECTORS.ACTION_HIDE_COURSE, (e, data) => {
  666. const target = $(e.target).closest(SELECTORS.ACTION_HIDE_COURSE);
  667. const courseId = getCourseId(target);
  668. hideCourse(root, courseId);
  669. data.originalEvent.preventDefault();
  670. });
  671. root.on(CustomEvents.events.activate, SELECTORS.ACTION_SHOW_COURSE, (e, data) => {
  672. const target = $(e.target).closest(SELECTORS.ACTION_SHOW_COURSE);
  673. const courseId = getCourseId(target);
  674. showCourse(root, courseId);
  675. data.originalEvent.preventDefault();
  676. });
  677. // Searching functionality event handlers.
  678. const input = page.querySelector(SELECTORS.region.searchInput);
  679. const clearIcon = page.querySelector(SELECTORS.region.clearIcon);
  680. clearIcon.addEventListener('click', () => {
  681. input.value = '';
  682. input.focus();
  683. clearSearch(clearIcon, root);
  684. });
  685. input.addEventListener('input', debounce(() => {
  686. if (input.value === '') {
  687. clearSearch(clearIcon, root);
  688. } else {
  689. activeSearch(clearIcon);
  690. initializePagedContent(root, searchFunctionalityCurry(), input.value.trim());
  691. }
  692. }, 1000));
  693. };
  694. /**
  695. * Reset the search icon and trigger the init for the block.
  696. *
  697. * @param {HTMLElement} clearIcon Our closing icon to manipulate.
  698. * @param {Object} root The myoverview block container element.
  699. */
  700. export const clearSearch = (clearIcon, root) => {
  701. clearIcon.classList.add('d-none');
  702. init(root);
  703. };
  704. /**
  705. * Change the searching icon to its' active state.
  706. *
  707. * @param {HTMLElement} clearIcon Our closing icon to manipulate.
  708. */
  709. const activeSearch = (clearIcon) => {
  710. clearIcon.classList.remove('d-none');
  711. };
  712. /**
  713. * Intialise the courses list and cards views on page load.
  714. *
  715. * @param {object} root The root element for the courses view.
  716. */
  717. export const init = root => {
  718. root = $(root);
  719. loadedPages = [];
  720. lastPage = 0;
  721. courseOffset = 0;
  722. if (!root.attr('data-init')) {
  723. const page = document.querySelector(SELECTORS.region.selectBlock);
  724. registerEventListeners(root, page);
  725. namespace = "block_myoverview_" + root.attr('id') + "_" + Math.random();
  726. root.attr('data-init', true);
  727. }
  728. initializePagedContent(root, standardFunctionalityCurry());
  729. };
  730. /**
  731. * Reset the courses views to their original
  732. * state on first page load.courseOffset
  733. *
  734. * This is called when configuration has changed for the event lists
  735. * to cause them to reload their data.
  736. *
  737. * @param {Object} root The root element for the timeline view.
  738. */
  739. export const reset = root => {
  740. if (loadedPages.length > 0) {
  741. loadedPages.forEach((courseList, index) => {
  742. let pagedContentPage = getPagedContentContainer(root, index);
  743. renderCourses(root, courseList).then((html, js) => {
  744. return Templates.replaceNodeContents(pagedContentPage, html, js);
  745. }).catch(Notification.exception);
  746. });
  747. } else {
  748. init(root);
  749. }
  750. };