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