course/amd/src/activitychooser.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. * A type of dialogue used as for choosing modules in a course.
  17. *
  18. * @module core_course/activitychooser
  19. * @copyright 2020 Mathew May <mathew.solutions>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import * as ChooserDialogue from 'core_course/local/activitychooser/dialogue';
  23. import * as Repository from 'core_course/local/activitychooser/repository';
  24. import selectors from 'core_course/local/activitychooser/selectors';
  25. import CustomEvents from 'core/custom_interaction_events';
  26. import * as Templates from 'core/templates';
  27. import {getString} from 'core/str';
  28. import Modal from 'core/modal';
  29. import Pending from 'core/pending';
  30. // Set up some JS module wide constants that can be added to in the future.
  31. // Tab config options.
  32. const ALLACTIVITIESRESOURCES = 0;
  33. const ACTIVITIESRESOURCES = 2;
  34. const ALLACTIVITIESRESOURCESREC = 3;
  35. const ONLYALLREC = 4;
  36. const ACTIVITIESRESOURCESREC = 5;
  37. // Module types.
  38. const ACTIVITY = 0;
  39. const RESOURCE = 1;
  40. let initialized = false;
  41. /**
  42. * Set up the activity chooser.
  43. *
  44. * @method init
  45. * @param {Number} courseId Course ID to use later on in fetchModules()
  46. * @param {Object} chooserConfig Any PHP config settings that we may need to reference
  47. */
  48. export const init = (courseId, chooserConfig) => {
  49. const pendingPromise = new Pending();
  50. registerListenerEvents(courseId, chooserConfig);
  51. pendingPromise.resolve();
  52. };
  53. /**
  54. * Once a selection has been made make the modal & module information and pass it along
  55. *
  56. * @method registerListenerEvents
  57. * @param {Number} courseId
  58. * @param {Object} chooserConfig Any PHP config settings that we may need to reference
  59. */
  60. const registerListenerEvents = (courseId, chooserConfig) => {
  61. // Ensure we only add our listeners once.
  62. if (initialized) {
  63. return;
  64. }
  65. const events = [
  66. 'click',
  67. CustomEvents.events.activate,
  68. CustomEvents.events.keyboardActivate
  69. ];
  70. const fetchModuleData = (() => {
  71. let innerPromises = new Map();
  72. return (sectionNum) => {
  73. if (innerPromises.has(sectionNum)) {
  74. return innerPromises.get(sectionNum);
  75. }
  76. innerPromises.set(
  77. sectionNum,
  78. new Promise((resolve) => {
  79. resolve(Repository.activityModules(courseId, sectionNum));
  80. })
  81. );
  82. return innerPromises.get(sectionNum);
  83. };
  84. })();
  85. const fetchFooterData = (() => {
  86. let footerInnerPromise = null;
  87. return (sectionNum) => {
  88. if (!footerInnerPromise) {
  89. footerInnerPromise = new Promise((resolve) => {
  90. resolve(Repository.fetchFooterData(courseId, sectionNum));
  91. });
  92. }
  93. return footerInnerPromise;
  94. };
  95. })();
  96. CustomEvents.define(document, events);
  97. // Display module chooser event listeners.
  98. events.forEach((event) => {
  99. document.addEventListener(event, async(e) => {
  100. if (e.target.closest(selectors.elements.sectionmodchooser)) {
  101. let caller;
  102. let sectionnum;
  103. // We need to know who called this.
  104. // Standard courses use the ID in the main section info.
  105. const sectionDiv = e.target.closest(selectors.elements.section);
  106. // Front page courses need some special handling.
  107. const button = e.target.closest(selectors.elements.sectionmodchooser);
  108. // If we don't have a section number use the fallback ID.
  109. // We always want the sectionDiv caller first as it keeps track of section number's after DnD changes.
  110. // The button attribute is always just a fallback for us as the section div is not always available.
  111. // A YUI change could be done maybe to only update the button attribute but we are going for minimal change here.
  112. if (sectionDiv !== null && sectionDiv.hasAttribute('data-number')) {
  113. // We check for attributes just in case of outdated contrib course formats.
  114. caller = sectionDiv;
  115. sectionnum = sectionDiv.getAttribute('data-number');
  116. } else {
  117. caller = button;
  118. if (caller.hasAttribute('data-sectionid')) {
  119. window.console.warn(
  120. 'The data-sectionid attribute has been deprecated. ' +
  121. 'Please update your code to use data-sectionnum instead.'
  122. );
  123. caller.setAttribute('data-sectionnum', caller.dataset.sectionid);
  124. }
  125. sectionnum = caller.dataset.sectionnum;
  126. }
  127. // We want to show the modal instantly but loading whilst waiting for our data.
  128. let bodyPromiseResolver;
  129. const bodyPromise = new Promise(resolve => {
  130. bodyPromiseResolver = resolve;
  131. });
  132. const footerData = await fetchFooterData(sectionnum);
  133. const sectionModal = buildModal(bodyPromise, footerData);
  134. // Now we have a modal we should start fetching data.
  135. // If an error occurs while fetching the data, display the error within the modal.
  136. const data = await fetchModuleData(sectionnum).catch(async(e) => {
  137. const errorTemplateData = {
  138. 'errormessage': e.message
  139. };
  140. bodyPromiseResolver(await Templates.render('core_course/local/activitychooser/error', errorTemplateData));
  141. });
  142. // Early return if there is no module data.
  143. if (!data) {
  144. return;
  145. }
  146. // Apply the section num to all the module instance links.
  147. const builtModuleData = sectionMapper(
  148. data,
  149. sectionnum,
  150. caller.dataset.sectionreturnnum,
  151. caller.dataset.beforemod
  152. );
  153. ChooserDialogue.displayChooser(
  154. sectionModal,
  155. builtModuleData,
  156. partiallyAppliedFavouriteManager(data, sectionnum),
  157. footerData,
  158. );
  159. bodyPromiseResolver(await Templates.render(
  160. 'core_course/activitychooser',
  161. templateDataBuilder(builtModuleData, chooserConfig)
  162. ));
  163. }
  164. });
  165. });
  166. initialized = true;
  167. };
  168. /**
  169. * Given the web service data and an ID we want to make a deep copy
  170. * of the WS data then add on the section num to the addoption URL
  171. *
  172. * @method sectionMapper
  173. * @param {Object} webServiceData Our original data from the Web service call
  174. * @param {Number} num The number of the section we need to append to the links
  175. * @param {Number|null} sectionreturnnum The number of the section return we need to append to the links
  176. * @param {Number|null} beforemod The ID of the cm we need to append to the links
  177. * @return {Array} [modules] with URL's built
  178. */
  179. const sectionMapper = (webServiceData, num, sectionreturnnum, beforemod) => {
  180. // We need to take a fresh deep copy of the original data as an object is a reference type.
  181. const newData = JSON.parse(JSON.stringify(webServiceData));
  182. newData.content_items.forEach((module) => {
  183. module.link += '&section=' + num + '&beforemod=' + (beforemod ?? 0);
  184. if (sectionreturnnum) {
  185. module.link += '&sr=' + sectionreturnnum;
  186. }
  187. });
  188. return newData.content_items;
  189. };
  190. /**
  191. * Given an array of modules we want to figure out where & how to place them into our template object
  192. *
  193. * @method templateDataBuilder
  194. * @param {Array} data our modules to manipulate into a Templatable object
  195. * @param {Object} chooserConfig Any PHP config settings that we may need to reference
  196. * @return {Object} Our built object ready to render out
  197. */
  198. const templateDataBuilder = (data, chooserConfig) => {
  199. // Setup of various bits and pieces we need to mutate before throwing it to the wolves.
  200. let activities = [];
  201. let resources = [];
  202. let showAll = true;
  203. let showActivities = false;
  204. let showResources = false;
  205. // Tab mode can be the following [All, Resources & Activities, All & Activities & Resources].
  206. const tabMode = parseInt(chooserConfig.tabmode);
  207. // Filter the incoming data to find favourite & recommended modules.
  208. const favourites = data.filter(mod => mod.favourite === true);
  209. const recommended = data.filter(mod => mod.recommended === true);
  210. // Whether the activities and resources tabs should be displayed or not.
  211. const showActivitiesAndResources = (tabMode) => {
  212. const acceptableModes = [
  213. ALLACTIVITIESRESOURCES,
  214. ALLACTIVITIESRESOURCESREC,
  215. ACTIVITIESRESOURCES,
  216. ACTIVITIESRESOURCESREC,
  217. ];
  218. return acceptableModes.indexOf(tabMode) !== -1;
  219. };
  220. // These modes need Activity & Resource tabs.
  221. if (showActivitiesAndResources(tabMode)) {
  222. // Filter the incoming data to find activities then resources.
  223. activities = data.filter(mod => mod.archetype === ACTIVITY);
  224. resources = data.filter(mod => mod.archetype === RESOURCE);
  225. showActivities = true;
  226. showResources = true;
  227. // We want all of the previous information but no 'All' tab.
  228. if (tabMode === ACTIVITIESRESOURCES || tabMode === ACTIVITIESRESOURCESREC) {
  229. showAll = false;
  230. }
  231. }
  232. const recommendedBeforeTabs = [
  233. ALLACTIVITIESRESOURCESREC,
  234. ONLYALLREC,
  235. ACTIVITIESRESOURCESREC,
  236. ];
  237. // Whether the recommended tab should be displayed before the All/Activities/Resources tabs.
  238. const recommendedBeginning = recommendedBeforeTabs.indexOf(tabMode) !== -1;
  239. // Given the results of the above filters lets figure out what tab to set active.
  240. // We have some favourites.
  241. const favouritesFirst = !!favourites.length;
  242. const recommendedFirst = favouritesFirst === false && recommendedBeginning === true && !!recommended.length;
  243. // We are in tabMode 2 without any favourites.
  244. const activitiesFirst = showAll === false && favouritesFirst === false && recommendedFirst === false;
  245. // We have nothing fallback to show all modules.
  246. const fallback = showAll === true && favouritesFirst === false && recommendedFirst === false;
  247. return {
  248. 'default': data,
  249. showAll: showAll,
  250. activities: activities,
  251. showActivities: showActivities,
  252. activitiesFirst: activitiesFirst,
  253. resources: resources,
  254. showResources: showResources,
  255. favourites: favourites,
  256. recommended: recommended,
  257. recommendedFirst: recommendedFirst,
  258. recommendedBeginning: recommendedBeginning,
  259. favouritesFirst: favouritesFirst,
  260. fallback: fallback,
  261. };
  262. };
  263. /**
  264. * Given an object we want to build a modal ready to show
  265. *
  266. * @method buildModal
  267. * @param {Promise} body
  268. * @param {String|Boolean} footer Either a footer to add or nothing
  269. * @return {Object} The modal ready to display immediately and render body in later.
  270. */
  271. const buildModal = (body, footer) => Modal.create({
  272. body,
  273. title: getString('addresourceoractivity'),
  274. footer: footer.customfootertemplate,
  275. large: true,
  276. scrollable: false,
  277. templateContext: {
  278. classes: 'modchooser'
  279. },
  280. show: true,
  281. });
  282. /**
  283. * A small helper function to handle the case where there are no more favourites
  284. * and we need to mess a bit with the available tabs in the chooser
  285. *
  286. * @method nullFavouriteDomManager
  287. * @param {HTMLElement} favouriteTabNav Dom node of the favourite tab nav
  288. * @param {HTMLElement} modalBody Our current modals' body
  289. */
  290. const nullFavouriteDomManager = (favouriteTabNav, modalBody) => {
  291. favouriteTabNav.tabIndex = -1;
  292. favouriteTabNav.classList.add('d-none');
  293. // Need to set active to an available tab.
  294. if (favouriteTabNav.classList.contains('active')) {
  295. favouriteTabNav.classList.remove('active');
  296. favouriteTabNav.setAttribute('aria-selected', 'false');
  297. const favouriteTab = modalBody.querySelector(selectors.regions.favouriteTab);
  298. favouriteTab.classList.remove('active');
  299. const defaultTabNav = modalBody.querySelector(selectors.regions.defaultTabNav);
  300. const activitiesTabNav = modalBody.querySelector(selectors.regions.activityTabNav);
  301. if (defaultTabNav.classList.contains('d-none') === false) {
  302. defaultTabNav.classList.add('active');
  303. defaultTabNav.setAttribute('aria-selected', 'true');
  304. defaultTabNav.tabIndex = 0;
  305. defaultTabNav.focus();
  306. const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);
  307. defaultTab.classList.add('active');
  308. } else {
  309. activitiesTabNav.classList.add('active');
  310. activitiesTabNav.setAttribute('aria-selected', 'true');
  311. activitiesTabNav.tabIndex = 0;
  312. activitiesTabNav.focus();
  313. const activitiesTab = modalBody.querySelector(selectors.regions.activityTab);
  314. activitiesTab.classList.add('active');
  315. }
  316. }
  317. };
  318. /**
  319. * Export a curried function where the builtModules has been applied.
  320. * We have our array of modules so we can rerender the favourites area and have all of the items sorted.
  321. *
  322. * @method partiallyAppliedFavouriteManager
  323. * @param {Array} moduleData This is our raw WS data that we need to manipulate
  324. * @param {Number} sectionnum We need this to add the sectionnum to the URL's in the faves area after rerender
  325. * @return {Function} partially applied function so we can manipulate DOM nodes easily & update our internal array
  326. */
  327. const partiallyAppliedFavouriteManager = (moduleData, sectionnum) => {
  328. /**
  329. * Curried function that is being returned.
  330. *
  331. * @param {String} internal Internal name of the module to manage
  332. * @param {Boolean} favourite Is the caller adding a favourite or removing one?
  333. * @param {HTMLElement} modalBody What we need to update whilst we are here
  334. */
  335. return async(internal, favourite, modalBody) => {
  336. const favouriteArea = modalBody.querySelector(selectors.render.favourites);
  337. // eslint-disable-next-line max-len
  338. const favouriteButtons = modalBody.querySelectorAll(`[data-internal="${internal}"] ${selectors.actions.optionActions.manageFavourite}`);
  339. const favouriteTabNav = modalBody.querySelector(selectors.regions.favouriteTabNav);
  340. const result = moduleData.content_items.find(({name}) => name === internal);
  341. const newFaves = {};
  342. if (result) {
  343. if (favourite) {
  344. result.favourite = true;
  345. // eslint-disable-next-line camelcase
  346. newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);
  347. const builtFaves = sectionMapper(newFaves, sectionnum);
  348. const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/favourites',
  349. {favourites: builtFaves});
  350. await Templates.replaceNodeContents(favouriteArea, html, js);
  351. Array.from(favouriteButtons).forEach((element) => {
  352. element.classList.remove('text-muted');
  353. element.classList.add('text-primary');
  354. element.dataset.favourited = 'true';
  355. element.setAttribute('aria-pressed', true);
  356. element.firstElementChild.classList.remove('fa-star-o');
  357. element.firstElementChild.classList.add('fa-star');
  358. });
  359. favouriteTabNav.classList.remove('d-none');
  360. } else {
  361. result.favourite = false;
  362. const nodeToRemove = favouriteArea.querySelector(`[data-internal="${internal}"]`);
  363. nodeToRemove.parentNode.removeChild(nodeToRemove);
  364. Array.from(favouriteButtons).forEach((element) => {
  365. element.classList.add('text-muted');
  366. element.classList.remove('text-primary');
  367. element.dataset.favourited = 'false';
  368. element.setAttribute('aria-pressed', false);
  369. element.firstElementChild.classList.remove('fa-star');
  370. element.firstElementChild.classList.add('fa-star-o');
  371. });
  372. const newFaves = moduleData.content_items.filter(mod => mod.favourite === true);
  373. if (newFaves.length === 0) {
  374. nullFavouriteDomManager(favouriteTabNav, modalBody);
  375. }
  376. }
  377. }
  378. };
  379. };