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