Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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