Proyectos de Subversion Moodle

Rev

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