MDL-69079 course: Handle fetch module data failures in activity chooser
[moodle.git] / course / amd / src / activitychooser.js
CommitLineData
05b27f21
MM
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/**
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 */
24
25import * as ChooserDialogue from 'core_course/local/activitychooser/dialogue';
26import * as Repository from 'core_course/local/activitychooser/repository';
27import selectors from 'core_course/local/activitychooser/selectors';
28import CustomEvents from 'core/custom_interaction_events';
29import * as Templates from 'core/templates';
30import * as ModalFactory from 'core/modal_factory';
31import {get_string as getString} from 'core/str';
32import Pending from 'core/pending';
33
e146a2ca
MM
34// Set up some JS module wide constants that can be added to in the future.
35
36// Tab config options.
37const ALLACTIVITIESRESOURCES = 0;
38const ONLYALL = 1;
39const ACTIVITIESRESOURCES = 2;
40
41// Module types.
42const ACTIVITY = 0;
43const RESOURCE = 1;
44
05b27f21
MM
45/**
46 * Set up the activity chooser.
47 *
48 * @method init
49 * @param {Number} courseId Course ID to use later on in fetchModules()
e146a2ca 50 * @param {Object} chooserConfig Any PHP config settings that we may need to reference
05b27f21 51 */
e146a2ca 52export const init = (courseId, chooserConfig) => {
05b27f21
MM
53 const pendingPromise = new Pending();
54
e146a2ca 55 registerListenerEvents(courseId, chooserConfig);
05b27f21
MM
56
57 pendingPromise.resolve();
58};
59
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
e146a2ca 65 * @param {Object} chooserConfig Any PHP config settings that we may need to reference
05b27f21 66 */
e146a2ca 67const registerListenerEvents = (courseId, chooserConfig) => {
05b27f21
MM
68 const events = [
69 'click',
70 CustomEvents.events.activate,
71 CustomEvents.events.keyboardActivate
72 ];
73
74 const fetchModuleData = (() => {
75 let innerPromise = null;
76
77 return () => {
78 if (!innerPromise) {
79 innerPromise = new Promise((resolve) => {
80 resolve(Repository.activityModules(courseId));
81 });
82 }
83
84 return innerPromise;
85 };
86 })();
87
16d77f18
MM
88 const fetchFooterData = (() => {
89 let footerInnerPromise = null;
90
91 return (sectionId) => {
92 if (!footerInnerPromise) {
93 footerInnerPromise = new Promise((resolve) => {
94 resolve(Repository.fetchFooterData(courseId, sectionId));
95 });
96 }
97
98 return footerInnerPromise;
99 };
100 })();
101
05b27f21
MM
102 CustomEvents.define(document, events);
103
104 // Display module chooser event listeners.
105 events.forEach((event) => {
106 document.addEventListener(event, async(e) => {
107 if (e.target.closest(selectors.elements.sectionmodchooser)) {
e74bcf19 108 let caller;
edf52a0e
MM
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);
e74bcf19 114
edf52a0e 115 // If we don't have a section ID use the fallback ID.
e74bcf19
MM
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 }
f2d033a2
MM
125
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 });
131
16d77f18
MM
132 const footerData = await fetchFooterData(caller.dataset.sectionid);
133 const sectionModal = buildModal(bodyPromise, footerData);
f2d033a2
MM
134
135 // Now we have a modal we should start fetching data.
3295288d
MG
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 });
143
144 // Early return if there is no module data.
145 if (!data) {
146 return;
147 }
f2d033a2
MM
148
149 // Apply the section id to all the module instance links.
c8388ead 150 const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid, caller.dataset.sectionreturnid);
05b27f21 151
f2d033a2
MM
152 ChooserDialogue.displayChooser(
153 sectionModal,
154 builtModuleData,
155 partiallyAppliedFavouriteManager(data, caller.dataset.sectionid),
16d77f18 156 footerData,
f2d033a2
MM
157 );
158
159 bodyPromiseResolver(await Templates.render(
160 'core_course/activitychooser',
e146a2ca 161 templateDataBuilder(builtModuleData, chooserConfig)
f2d033a2 162 ));
05b27f21
MM
163 }
164 });
165 });
166};
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 ID to the addoption URL
171 *
172 * @method sectionIdMapper
173 * @param {Object} webServiceData Our original data from the Web service call
f2d033a2 174 * @param {Number} id The ID of the section we need to append to the links
c8388ead 175 * @param {Number|null} sectionreturnid The ID of the section return we need to append to the links
05b27f21
MM
176 * @return {Array} [modules] with URL's built
177 */
c8388ead 178const sectionIdMapper = (webServiceData, id, sectionreturnid) => {
05b27f21
MM
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));
806e736a 181 newData.content_items.forEach((module) => {
c8388ead 182 module.link += '&section=' + id + '&sr=' + (sectionreturnid ?? 0);
05b27f21 183 });
806e736a 184 return newData.content_items;
05b27f21
MM
185};
186
05b27f21
MM
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
e146a2ca 192 * @param {Object} chooserConfig Any PHP config settings that we may need to reference
05b27f21
MM
193 * @return {Object} Our built object ready to render out
194 */
e146a2ca
MM
195const 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;
202
203 // Tab mode can be the following [All, Resources & Activities, All & Activities & Resources].
204 const tabMode = parseInt(chooserConfig.tabmode);
205
c58c23d6 206 // Filter the incoming data to find favourite & recommended modules.
6e1a4477 207 const favourites = data.filter(mod => mod.favourite === true);
e04b4be6 208 const recommended = data.filter(mod => mod.recommended === true);
c58c23d6 209
e146a2ca
MM
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;
c58c23d6 217
e146a2ca
MM
218 // We want all of the previous information but no 'All' tab.
219 if (tabMode === ACTIVITIESRESOURCES) {
220 showAll = false;
221 }
222 }
223
224 // Given the results of the above filters lets figure out what tab to set active.
c58c23d6
MM
225 // We have some favourites.
226 const favouritesFirst = !!favourites.length;
e146a2ca
MM
227 // We are in tabMode 2 without any favourites.
228 const activitiesFirst = showAll === false && favouritesFirst === false;
c58c23d6 229 // We have nothing fallback to show all modules.
e146a2ca 230 const fallback = showAll === true && favouritesFirst === false;
c58c23d6 231
05b27f21
MM
232 return {
233 'default': data,
e146a2ca
MM
234 showAll: showAll,
235 activities: activities,
236 showActivities: showActivities,
237 activitiesFirst: activitiesFirst,
238 resources: resources,
239 showResources: showResources,
c58c23d6
MM
240 favourites: favourites,
241 recommended: recommended,
242 favouritesFirst: favouritesFirst,
c58c23d6 243 fallback: fallback,
05b27f21
MM
244 };
245};
246
247/**
4883aabf 248 * Given an object we want to build a modal ready to show
05b27f21
MM
249 *
250 * @method buildModal
f2d033a2 251 * @param {Promise} bodyPromise
16d77f18 252 * @param {String|Boolean} footer Either a footer to add or nothing
f2d033a2 253 * @return {Object} The modal ready to display immediately and render body in later.
05b27f21 254 */
16d77f18 255const buildModal = (bodyPromise, footer) => {
05b27f21
MM
256 return ModalFactory.create({
257 type: ModalFactory.types.DEFAULT,
258 title: getString('addresourceoractivity'),
f2d033a2 259 body: bodyPromise,
16d77f18 260 footer: footer.customfootertemplate,
05b27f21 261 large: true,
8ee9fbca 262 scrollable: false,
05b27f21
MM
263 templateContext: {
264 classes: 'modchooser'
265 }
f2d033a2
MM
266 })
267 .then(modal => {
268 modal.show();
269 return modal;
05b27f21
MM
270 });
271};
6e1a4477
MM
272
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 */
281const nullFavouriteDomManager = (favouriteTabNav, modalBody) => {
9f1bfca2 282 favouriteTabNav.tabIndex = -1;
6e1a4477
MM
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');
9f1bfca2 287 favouriteTabNav.setAttribute('aria-selected', 'false');
6e1a4477
MM
288 const favouriteTab = modalBody.querySelector(selectors.regions.favouriteTab);
289 favouriteTab.classList.remove('active');
6e1a4477 290 const defaultTabNav = modalBody.querySelector(selectors.regions.defaultTabNav);
e146a2ca
MM
291 const activitiesTabNav = modalBody.querySelector(selectors.regions.activityTabNav);
292 if (defaultTabNav.classList.contains('d-none') === false) {
6e1a4477 293 defaultTabNav.classList.add('active');
9f1bfca2
MG
294 defaultTabNav.setAttribute('aria-selected', 'true');
295 defaultTabNav.tabIndex = 0;
296 defaultTabNav.focus();
6e1a4477
MM
297 const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);
298 defaultTab.classList.add('active');
e146a2ca
MM
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');
6e1a4477
MM
306 }
307
308 }
309};
310
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 */
320const 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);
330
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;
339
f2d033a2 340 // eslint-disable-next-line camelcase
6e1a4477
MM
341 newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);
342
343 const builtFaves = sectionIdMapper(newFaves, sectionId);
344
d2695ab2
MM
345 const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/favourites',
346 {favourites: builtFaves});
6e1a4477
MM
347
348 await Templates.replaceNodeContents(favouriteArea, html, js);
349
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 });
358
359 favouriteTabNav.classList.remove('d-none');
360 } else {
361 result.favourite = false;
362
363 const nodeToRemove = favouriteArea.querySelector(`[data-internal="${internal}"]`);
364
365 nodeToRemove.parentNode.removeChild(nodeToRemove);
366
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);
376
377 if (newFaves.length === 0) {
378 nullFavouriteDomManager(favouriteTabNav, modalBody);
379 }
380 }
381 }
382 };
383};