Proyectos de Subversion Moodle

Rev

Rev 11 | | 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
 * Course index main component.
18
 *
19
 * @module     core_courseformat/local/content
20
 * @class      core_courseformat/local/content
21
 * @copyright  2020 Ferran Recio <ferran@moodle.com>
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
import {BaseComponent} from 'core/reactive';
1441 ariadna 26
import Collapse from 'theme_boost/bootstrap/collapse';
27
import {throttle, debounce} from 'core/utils';
1 efrain 28
import {getCurrentCourseEditor} from 'core_courseformat/courseeditor';
29
import Config from 'core/config';
30
import inplaceeditable from 'core/inplace_editable';
31
import Section from 'core_courseformat/local/content/section';
32
import CmItem from 'core_courseformat/local/content/section/cmitem';
33
import Fragment from 'core/fragment';
34
import Templates from 'core/templates';
35
import DispatchActions from 'core_courseformat/local/content/actions';
36
import * as CourseEvents from 'core_course/events';
37
import Pending from 'core/pending';
1441 ariadna 38
import log from "core/log";
1 efrain 39
 
40
export default class Component extends BaseComponent {
41
 
42
    /**
43
     * Constructor hook.
44
     *
45
     * @param {Object} descriptor the component descriptor
46
     */
47
    create(descriptor) {
48
        // Optional component name for debugging.
49
        this.name = 'course_format';
50
        // Default query selectors.
51
        this.selectors = {
52
            SECTION: `[data-for='section']`,
53
            SECTION_ITEM: `[data-for='section_title']`,
54
            SECTION_CMLIST: `[data-for='cmlist']`,
55
            COURSE_SECTIONLIST: `[data-for='course_sectionlist']`,
56
            CM: `[data-for='cmitem']`,
57
            TOGGLER: `[data-action="togglecoursecontentsection"]`,
1441 ariadna 58
            COLLAPSE: `[data-bs-toggle="collapse"]`,
1 efrain 59
            TOGGLEALL: `[data-toggle="toggleall"]`,
60
            // Formats can override the activity tag but a default one is needed to create new elements.
61
            ACTIVITYTAG: 'li',
62
            SECTIONTAG: 'li',
63
        };
64
        this.selectorGenerators = {
65
            cmNameFor: (id) => `[data-cm-name-for='${id}']`,
66
            sectionNameFor: (id) => `[data-section-name-for='${id}']`,
67
        };
68
        // Default classes to toggle on refresh.
69
        this.classes = {
70
            COLLAPSED: `collapsed`,
71
            // Course content classes.
72
            ACTIVITY: `activity`,
73
            STATEDREADY: `stateready`,
74
            SECTION: `section`,
75
        };
76
        // Array to save dettached elements during element resorting.
77
        this.dettachedCms = {};
78
        this.dettachedSections = {};
79
        // Index of sections and cms components.
80
        this.sections = {};
81
        this.cms = {};
1441 ariadna 82
        // The section number and ID of the displayed page.
83
        this.sectionReturn = descriptor?.sectionReturn ?? null;
84
        this.pageSectionId = descriptor?.pageSectionId ?? null;
1 efrain 85
        this.debouncedReloads = new Map();
86
    }
87
 
88
    /**
89
     * Static method to create a component instance form the mustahce template.
90
     *
91
     * @param {string} target the DOM main element or its ID
92
     * @param {object} selectors optional css selector overrides
1441 ariadna 93
     * @param {number} sectionReturn the section number of the displayed page
94
     * @param {number} pageSectionId the section ID of the displayed page
1 efrain 95
     * @return {Component}
96
     */
1441 ariadna 97
    static init(target, selectors, sectionReturn, pageSectionId) {
98
        let element = document.querySelector(target);
99
        // TODO Remove this if condition as part of MDL-83851.
100
        if (!element) {
101
            log.debug('Init component with id is deprecated, use a query selector instead.');
102
            element = document.getElementById(target);
103
        }
1 efrain 104
        return new Component({
1441 ariadna 105
            element,
1 efrain 106
            reactive: getCurrentCourseEditor(),
107
            selectors,
108
            sectionReturn,
1441 ariadna 109
            pageSectionId,
1 efrain 110
        });
111
    }
112
 
113
    /**
114
     * Initial state ready method.
115
     *
116
     * @param {Object} state the state data
117
     */
118
    stateReady(state) {
119
        this._indexContents();
120
        // Activate section togglers.
121
        this.addEventListener(this.element, 'click', this._sectionTogglers);
122
 
123
        // Collapse/Expand all sections button.
124
        const toogleAll = this.getElement(this.selectors.TOGGLEALL);
125
        if (toogleAll) {
126
 
127
            // Ensure collapse menu button adds aria-controls attribute referring to each collapsible element.
128
            const collapseElements = this.getElements(this.selectors.COLLAPSE);
129
            const collapseElementIds = [...collapseElements].map(element => element.id);
130
            toogleAll.setAttribute('aria-controls', collapseElementIds.join(' '));
131
 
132
            this.addEventListener(toogleAll, 'click', this._allSectionToggler);
133
            this.addEventListener(toogleAll, 'keydown', e => {
134
                // Collapse/expand all sections when Space key is pressed on the toggle button.
135
                if (e.key === ' ') {
136
                    this._allSectionToggler(e);
137
                }
138
            });
139
            this._refreshAllSectionsToggler(state);
140
        }
141
 
142
        if (this.reactive.supportComponents) {
143
            // Actions are only available in edit mode.
144
            if (this.reactive.isEditing) {
145
                new DispatchActions(this);
146
            }
147
 
148
            // Mark content as state ready.
149
            this.element.classList.add(this.classes.STATEDREADY);
150
        }
151
 
152
        // Capture completion events.
153
        this.addEventListener(
154
            this.element,
155
            CourseEvents.manualCompletionToggled,
156
            this._completionHandler
157
        );
158
 
159
        // Capture page scroll to update page item.
160
        this.addEventListener(
161
            document,
162
            "scroll",
1441 ariadna 163
            throttle(this._scrollHandler.bind(this), 50)
1 efrain 164
        );
165
    }
166
 
167
    /**
168
     * Setup sections toggler.
169
     *
170
     * Toggler click is delegated to the main course content element because new sections can
171
     * appear at any moment and this way we prevent accidental double bindings.
172
     *
173
     * @param {Event} event the triggered event
174
     */
175
    _sectionTogglers(event) {
176
        const sectionlink = event.target.closest(this.selectors.TOGGLER);
177
        const closestCollapse = event.target.closest(this.selectors.COLLAPSE);
178
        // Assume that chevron is the only collapse toggler in a section heading;
179
        // I think this is the most efficient way to verify at the moment.
180
        const isChevron = closestCollapse?.closest(this.selectors.SECTION_ITEM);
181
 
182
        if (sectionlink || isChevron) {
183
 
184
            const section = event.target.closest(this.selectors.SECTION);
185
            const toggler = section.querySelector(this.selectors.COLLAPSE);
1441 ariadna 186
            let isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;
187
            // If the click was on the chevron, Bootstrap already toggled the section before this event.
188
            if (isChevron) {
189
                isCollapsed = !isCollapsed;
190
            }
1 efrain 191
 
192
            const sectionId = section.getAttribute('data-id');
193
            this.reactive.dispatch(
194
                'sectionContentCollapsed',
195
                [sectionId],
196
                !isCollapsed,
197
            );
198
        }
199
    }
200
 
201
    /**
202
     * Handle the collapse/expand all sections button.
203
     *
204
     * Toggler click is delegated to the main course content element because new sections can
205
     * appear at any moment and this way we prevent accidental double bindings.
206
     *
207
     * @param {Event} event the triggered event
208
     */
209
    _allSectionToggler(event) {
210
        event.preventDefault();
211
 
212
        const target = event.target.closest(this.selectors.TOGGLEALL);
213
        const isAllCollapsed = target.classList.contains(this.classes.COLLAPSED);
214
 
215
        const course = this.reactive.get('course');
216
        this.reactive.dispatch(
217
            'sectionContentCollapsed',
218
            course.sectionlist ?? [],
219
            !isAllCollapsed
220
        );
221
    }
222
 
223
    /**
224
     * Return the component watchers.
225
     *
226
     * @returns {Array} of watchers
227
     */
228
    getWatchers() {
229
        // Section return is a global page variable but most formats define it just before start printing
230
        // the course content. This is the reason why we define this page setting here.
1441 ariadna 231
        this.reactive.sectionReturn = this?.sectionReturn ?? null;
232
        this.reactive.pageSectionId = this?.pageSectionId ?? null;
1 efrain 233
 
234
        // Check if the course format is compatible with reactive components.
235
        if (!this.reactive.supportComponents) {
236
            return [];
237
        }
238
        return [
239
            // State changes that require to reload some course modules.
240
            {watch: `cm.visible:updated`, handler: this._reloadCm},
241
            {watch: `cm.stealth:updated`, handler: this._reloadCm},
242
            {watch: `cm.sectionid:updated`, handler: this._reloadCm},
243
            {watch: `cm.indent:updated`, handler: this._reloadCm},
244
            {watch: `cm.groupmode:updated`, handler: this._reloadCm},
245
            {watch: `cm.name:updated`, handler: this._refreshCmName},
246
            // Update section number and title.
247
            {watch: `section.number:updated`, handler: this._refreshSectionNumber},
248
            {watch: `section.title:updated`, handler: this._refreshSectionTitle},
249
            // Collapse and expand sections.
250
            {watch: `section.contentcollapsed:updated`, handler: this._refreshSectionCollapsed},
251
            // Sections and cm sorting.
252
            {watch: `transaction:start`, handler: this._startProcessing},
253
            {watch: `course.sectionlist:updated`, handler: this._refreshCourseSectionlist},
254
            {watch: `section.cmlist:updated`, handler: this._refreshSectionCmlist},
255
            // Section visibility.
256
            {watch: `section.visible:updated`, handler: this._reloadSection},
257
            // Reindex sections and cms.
258
            {watch: `state:updated`, handler: this._indexContents},
259
        ];
260
    }
261
 
262
    /**
263
     * Update a course module name on the whole page.
264
     *
265
     * @param {object} param
266
     * @param {Object} param.element details the update details.
267
     */
268
    _refreshCmName({element}) {
269
        // Update classes.
270
        // Replace the text content of the cm name.
271
        const allCmNamesFor = this.getElements(
272
            this.selectorGenerators.cmNameFor(element.id)
273
        );
274
        allCmNamesFor.forEach((cmNameFor) => {
275
            cmNameFor.textContent = element.name;
276
        });
277
    }
278
 
279
    /**
1441 ariadna 280
     * Update section collapsed state via bootstrap if necessary.
1 efrain 281
     *
1441 ariadna 282
     * Formats that do not use bootstrap must override this method in order to keep the section
1 efrain 283
     * toggling working.
284
     *
285
     * @param {object} args
286
     * @param {Object} args.state The state data
287
     * @param {Object} args.element The element to update
288
     */
289
    _refreshSectionCollapsed({state, element}) {
290
        const target = this.getElement(this.selectors.SECTION, element.id);
291
        if (!target) {
292
            throw new Error(`Unknown section with ID ${element.id}`);
293
        }
294
        // Check if it is already done.
295
        const toggler = target.querySelector(this.selectors.COLLAPSE);
296
        const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;
297
 
298
        if (element.contentcollapsed !== isCollapsed) {
299
            let collapsibleId = toggler.dataset.target ?? toggler.getAttribute("href");
300
            if (!collapsibleId) {
301
                return;
302
            }
303
            collapsibleId = collapsibleId.replace('#', '');
304
            const collapsible = document.getElementById(collapsibleId);
305
            if (!collapsible) {
306
                return;
307
            }
1441 ariadna 308
            if (element.contentcollapsed) {
309
                Collapse.getOrCreateInstance(collapsible, {toggle: false}).hide();
310
            } else {
311
                Collapse.getOrCreateInstance(collapsible, {toggle: false}).show();
312
            }
1 efrain 313
        }
314
 
315
        this._refreshAllSectionsToggler(state);
316
    }
317
 
318
    /**
319
     * Refresh the collapse/expand all sections element.
320
     *
321
     * @param {Object} state The state data
322
     */
323
    _refreshAllSectionsToggler(state) {
324
        const target = this.getElement(this.selectors.TOGGLEALL);
325
        if (!target) {
326
            return;
327
        }
1441 ariadna 328
 
329
        const sectionIsCollapsible = this._getCollapsibleSections();
330
 
1 efrain 331
        // Check if we have all sections collapsed/expanded.
332
        let allcollapsed = true;
333
        let allexpanded = true;
334
        state.section.forEach(
335
            section => {
1441 ariadna 336
                if (sectionIsCollapsible[section.id]) {
337
                    allcollapsed = allcollapsed && section.contentcollapsed;
338
                    allexpanded = allexpanded && !section.contentcollapsed;
339
                }
1 efrain 340
            }
341
        );
342
        if (allcollapsed) {
343
            target.classList.add(this.classes.COLLAPSED);
344
            target.setAttribute('aria-expanded', false);
345
        }
346
        if (allexpanded) {
347
            target.classList.remove(this.classes.COLLAPSED);
348
            target.setAttribute('aria-expanded', true);
349
        }
350
    }
351
 
352
    /**
1441 ariadna 353
     * Find collapsible sections.
354
     */
355
    _getCollapsibleSections() {
356
        let sectionIsCollapsible = {};
357
        const togglerDoms = this.element.querySelectorAll(this.selectors.COLLAPSE);
358
        for (let togglerDom of togglerDoms) {
359
            const headerDom = togglerDom.closest(this.selectors.SECTION_ITEM);
360
            if (headerDom) {
361
                sectionIsCollapsible[headerDom.dataset.id] = true;
362
            }
363
        }
364
        return sectionIsCollapsible;
365
    }
366
 
367
    /**
1 efrain 368
     * Setup the component to start a transaction.
369
     *
370
     * Some of the course actions replaces the current DOM element with a new one before updating the
371
     * course state. This means the component cannot preload any index properly until the transaction starts.
372
     *
373
     */
374
    _startProcessing() {
375
        // During a section or cm sorting, some elements could be dettached from the DOM and we
376
        // need to store somewhare in case they are needed later.
377
        this.dettachedCms = {};
378
        this.dettachedSections = {};
379
    }
380
 
381
    /**
382
     * Activity manual completion listener.
383
     *
384
     * @param {Event} event the custom ecent
385
     */
386
    _completionHandler({detail}) {
387
        if (detail === undefined) {
388
            return;
389
        }
390
        this.reactive.dispatch('cmCompletion', [detail.cmid], detail.completed);
391
    }
392
 
393
    /**
394
     * Check the current page scroll and update the active element if necessary.
395
     */
396
    _scrollHandler() {
397
        const pageOffset = window.scrollY;
398
        const items = this.reactive.getExporter().allItemsArray(this.reactive.state);
399
        // Check what is the active element now.
400
        let pageItem = null;
401
        items.every(item => {
402
            const index = (item.type === 'section') ? this.sections : this.cms;
403
            if (index[item.id] === undefined) {
404
                return true;
405
            }
406
 
407
            const element = index[item.id].element;
408
            pageItem = item;
409
            return pageOffset >= element.offsetTop;
410
        });
411
        if (pageItem) {
412
            this.reactive.dispatch('setPageItem', pageItem.type, pageItem.id);
413
        }
414
    }
415
 
416
    /**
417
     * Update a course section when the section number changes.
418
     *
419
     * The courseActions module used for most course section tools still depends on css classes and
420
     * section numbers (not id). To prevent inconsistencies when a section is moved, we need to refresh
421
     * the
422
     *
423
     * Course formats can override the section title rendering so the frontend depends heavily on backend
424
     * rendering. Luckily in edit mode we can trigger a title update using the inplace_editable module.
425
     *
426
     * @param {Object} param
427
     * @param {Object} param.element details the update details.
428
     */
429
    _refreshSectionNumber({element}) {
430
        // Find the element.
431
        const target = this.getElement(this.selectors.SECTION, element.id);
432
        if (!target) {
433
            // Job done. Nothing to refresh.
434
            return;
435
        }
436
        // Update section numbers in all data, css and YUI attributes.
437
        target.id = `section-${element.number}`;
438
        // YUI uses section number as section id in data-sectionid, in principle if a format use components
439
        // don't need this sectionid attribute anymore, but we keep the compatibility in case some plugin
440
        // use it for legacy purposes.
441
        target.dataset.sectionid = element.number;
442
        // The data-number is the attribute used by components to store the section number.
443
        target.dataset.number = element.number;
444
 
445
        // Update title and title inplace editable, if any.
446
        const inplace = inplaceeditable.getInplaceEditable(target.querySelector(this.selectors.SECTION_ITEM));
447
        if (inplace) {
448
            // The course content HTML can be modified at any moment, so the function need to do some checkings
449
            // to make sure the inplace editable still represents the same itemid.
450
            const currentvalue = inplace.getValue();
451
            const currentitemid = inplace.getItemId();
452
            // Unnamed sections must be recalculated.
453
            if (inplace.getValue() === '') {
454
                // The value to send can be an empty value if it is a default name.
455
                if (currentitemid == element.id && (currentvalue != element.rawtitle || element.rawtitle == '')) {
456
                    inplace.setValue(element.rawtitle);
457
                }
458
            }
459
        }
460
    }
461
 
462
    /**
463
     * Update a course section name on the whole page.
464
     *
465
     * @param {object} param
466
     * @param {Object} param.element details the update details.
467
     */
468
    _refreshSectionTitle({element}) {
469
        // Replace the text content of the section name in the whole page.
470
        const allSectionNamesFor = document.querySelectorAll(
471
            this.selectorGenerators.sectionNameFor(element.id)
472
        );
473
        allSectionNamesFor.forEach((sectionNameFor) => {
474
            sectionNameFor.textContent = element.title;
475
        });
476
    }
477
 
478
    /**
479
     * Refresh a section cm list.
480
     *
481
     * @param {Object} param
1441 ariadna 482
     * @param {Object} param.state the full state object.
1 efrain 483
     * @param {Object} param.element details the update details.
484
     */
1441 ariadna 485
    _refreshSectionCmlist({state, element}) {
1 efrain 486
        const cmlist = element.cmlist ?? [];
487
        const section = this.getElement(this.selectors.SECTION, element.id);
488
        const listparent = section?.querySelector(this.selectors.SECTION_CMLIST);
489
        // A method to create a fake element to be replaced when the item is ready.
490
        const createCm = this._createCmItem.bind(this);
491
        if (listparent) {
492
            this._fixOrder(listparent, cmlist, this.selectors.CM, this.dettachedCms, createCm);
493
        }
1441 ariadna 494
        this._refreshAllSectionsToggler(state);
1 efrain 495
    }
496
 
497
    /**
498
     * Refresh the section list.
499
     *
500
     * @param {Object} param
501
     * @param {Object} param.state the full state object.
502
     */
503
    _refreshCourseSectionlist({state}) {
504
        // If we have a section return means we only show a single section so no need to fix order.
1441 ariadna 505
        if ((this.reactive?.sectionReturn ?? this.reactive?.pageSectionId) !== null) {
1 efrain 506
            return;
507
        }
508
        const sectionlist = this.reactive.getExporter().listedSectionIds(state);
509
        const listparent = this.getElement(this.selectors.COURSE_SECTIONLIST);
510
        // For now section cannot be created at a frontend level.
511
        const createSection = this._createSectionItem.bind(this);
512
        if (listparent) {
513
            this._fixOrder(listparent, sectionlist, this.selectors.SECTION, this.dettachedSections, createSection);
514
        }
1441 ariadna 515
        this._refreshAllSectionsToggler(state);
1 efrain 516
    }
517
 
518
    /**
519
     * Regenerate content indexes.
520
     *
521
     * This method is used when a legacy action refresh some content element.
522
     */
523
    _indexContents() {
524
        // Find unindexed sections.
525
        this._scanIndex(
526
            this.selectors.SECTION,
527
            this.sections,
528
            (item) => {
529
                return new Section(item);
530
            }
531
        );
532
 
533
        // Find unindexed cms.
534
        this._scanIndex(
535
            this.selectors.CM,
536
            this.cms,
537
            (item) => {
538
                return new CmItem(item);
539
            }
540
        );
541
    }
542
 
543
    /**
544
     * Reindex a content (section or cm) of the course content.
545
     *
546
     * This method is used internally by _indexContents.
547
     *
548
     * @param {string} selector the DOM selector to scan
549
     * @param {*} index the index attribute to update
550
     * @param {*} creationhandler method to create a new indexed element
551
     */
552
    _scanIndex(selector, index, creationhandler) {
553
        const items = this.getElements(`${selector}:not([data-indexed])`);
554
        items.forEach((item) => {
555
            if (!item?.dataset?.id) {
556
                return;
557
            }
558
            // Delete previous item component.
559
            if (index[item.dataset.id] !== undefined) {
560
                index[item.dataset.id].unregister();
561
            }
562
            // Create the new component.
563
            index[item.dataset.id] = creationhandler({
564
                ...this,
565
                element: item,
566
            });
567
            // Mark as indexed.
568
            item.dataset.indexed = true;
569
        });
570
    }
571
 
572
    /**
573
     * Reload a course module contents.
574
     *
575
     * Most course module HTML is still strongly backend dependant.
576
     * Some changes require to get a new version of the module.
577
     *
578
     * @param {object} param0 the watcher details
579
     * @param {object} param0.element the state object
580
     */
581
    _reloadCm({element}) {
582
        if (!this.getElement(this.selectors.CM, element.id)) {
583
            return;
584
        }
585
        const debouncedReload = this._getDebouncedReloadCm(element.id);
586
        debouncedReload();
587
    }
588
 
589
    /**
590
     * Generate or get a reload CM debounced function.
591
     * @param {Number} cmId
592
     * @returns {Function} the debounced reload function
593
     */
594
    _getDebouncedReloadCm(cmId) {
595
        const pendingKey = `courseformat/content:reloadCm_${cmId}`;
596
        let debouncedReload = this.debouncedReloads.get(pendingKey);
597
        if (debouncedReload) {
598
            return debouncedReload;
599
        }
600
        const reload = () => {
601
            const pendingReload = new Pending(pendingKey);
602
            this.debouncedReloads.delete(pendingKey);
603
            const cmitem = this.getElement(this.selectors.CM, cmId);
604
            if (!cmitem) {
605
                return pendingReload.resolve();
606
            }
607
            const promise = Fragment.loadFragment(
608
                'core_courseformat',
609
                'cmitem',
610
                Config.courseContextId,
611
                {
612
                    id: cmId,
613
                    courseid: Config.courseId,
1441 ariadna 614
                    sr: this.reactive?.sectionReturn ?? null,
615
                    pagesectionid: this.reactive?.pageSectionId ?? null,
1 efrain 616
                }
617
            );
618
            promise.then((html, js) => {
619
                // Other state change can reload the CM or the section before this one.
620
                if (!document.contains(cmitem)) {
621
                    pendingReload.resolve();
622
                    return false;
623
                }
624
                Templates.replaceNode(cmitem, html, js);
625
                this._indexContents();
626
                pendingReload.resolve();
627
                return true;
628
            }).catch(() => {
629
                pendingReload.resolve();
630
            });
631
            return pendingReload;
632
        };
633
        debouncedReload = debounce(
634
            reload,
635
            200,
636
            {
637
                cancel: true, pending: true
638
            }
639
        );
640
        this.debouncedReloads.set(pendingKey, debouncedReload);
641
        return debouncedReload;
642
    }
643
 
644
    /**
645
     * Cancel the active reload CM debounced function, if any.
646
     * @param {Number} cmId
647
     */
648
    _cancelDebouncedReloadCm(cmId) {
649
        const pendingKey = `courseformat/content:reloadCm_${cmId}`;
650
        const debouncedReload = this.debouncedReloads.get(pendingKey);
651
        if (!debouncedReload) {
652
            return;
653
        }
654
        debouncedReload.cancel();
655
        this.debouncedReloads.delete(pendingKey);
656
    }
657
 
658
    /**
659
     * Reload a course section contents.
660
     *
661
     * Section HTML is still strongly backend dependant.
662
     * Some changes require to get a new version of the section.
663
     *
664
     * @param {details} param0 the watcher details
665
     * @param {object} param0.element the state object
666
     */
667
    _reloadSection({element}) {
668
        const pendingReload = new Pending(`courseformat/content:reloadSection_${element.id}`);
669
        const sectionitem = this.getElement(this.selectors.SECTION, element.id);
670
        if (sectionitem) {
671
            // Cancel any pending reload because the section will reload cms too.
672
            for (const cmId of element.cmlist) {
673
                this._cancelDebouncedReloadCm(cmId);
674
            }
675
            const promise = Fragment.loadFragment(
676
                'core_courseformat',
677
                'section',
678
                Config.courseContextId,
679
                {
680
                    id: element.id,
681
                    courseid: Config.courseId,
1441 ariadna 682
                    sr: this.reactive?.sectionReturn ?? null,
683
                    pagesectionid: this.reactive?.pageSectionId ?? null,
1 efrain 684
                }
685
            );
686
            promise.then((html, js) => {
687
                Templates.replaceNode(sectionitem, html, js);
688
                this._indexContents();
689
                pendingReload.resolve();
690
            }).catch(() => {
691
                pendingReload.resolve();
692
            });
693
        }
694
    }
695
 
696
    /**
697
     * Create a new course module item in a section.
698
     *
699
     * Thos method will append a fake item in the container and trigger an ajax request to
700
     * replace the fake element by the real content.
701
     *
702
     * @param {Element} container the container element (section)
703
     * @param {Number} cmid the course-module ID
704
     * @returns {Element} the created element
705
     */
706
    _createCmItem(container, cmid) {
707
        const newItem = document.createElement(this.selectors.ACTIVITYTAG);
708
        newItem.dataset.for = 'cmitem';
709
        newItem.dataset.id = cmid;
710
        // The legacy actions.js requires a specific ID and class to refresh the CM.
711
        newItem.id = `module-${cmid}`;
712
        newItem.classList.add(this.classes.ACTIVITY);
713
        container.append(newItem);
714
        this._reloadCm({
715
            element: this.reactive.get('cm', cmid),
716
        });
717
        return newItem;
718
    }
719
 
720
    /**
721
     * Create a new section item.
722
     *
723
     * This method will append a fake item in the container and trigger an ajax request to
724
     * replace the fake element by the real content.
725
     *
726
     * @param {Element} container the container element (section)
727
     * @param {Number} sectionid the course-module ID
728
     * @returns {Element} the created element
729
     */
730
    _createSectionItem(container, sectionid) {
731
        const section = this.reactive.get('section', sectionid);
732
        const newItem = document.createElement(this.selectors.SECTIONTAG);
733
        newItem.dataset.for = 'section';
734
        newItem.dataset.id = sectionid;
735
        newItem.dataset.number = section.number;
736
        // The legacy actions.js requires a specific ID and class to refresh the section.
737
        newItem.id = `section-${sectionid}`;
738
        newItem.classList.add(this.classes.SECTION);
739
        container.append(newItem);
740
        this._reloadSection({
741
            element: section,
742
        });
743
        return newItem;
744
    }
745
 
746
    /**
747
     * Fix/reorder the section or cms order.
748
     *
749
     * @param {Element} container the HTML element to reorder.
750
     * @param {Array} neworder an array with the ids order
751
     * @param {string} selector the element selector
752
     * @param {Object} dettachedelements a list of dettached elements
753
     * @param {function} createMethod method to create missing elements
754
     */
755
    async _fixOrder(container, neworder, selector, dettachedelements, createMethod) {
756
        if (container === undefined) {
757
            return;
758
        }
759
 
760
        // Empty lists should not be visible.
761
        if (!neworder.length) {
762
            container.classList.add('hidden');
763
            container.innerHTML = '';
764
            return;
765
        }
766
 
767
        // Grant the list is visible (in case it was empty).
768
        container.classList.remove('hidden');
769
 
770
        // Move the elements in order at the beginning of the list.
771
        neworder.forEach((itemid, index) => {
772
            let item = this.getElement(selector, itemid) ?? dettachedelements[itemid] ?? createMethod(container, itemid);
773
            if (item === undefined) {
774
                // Missing elements cannot be sorted.
775
                return;
776
            }
777
            // Get the current elemnt at that position.
778
            const currentitem = container.children[index];
779
            if (currentitem === undefined) {
780
                container.append(item);
781
                return;
782
            }
783
            if (currentitem !== item) {
784
                container.insertBefore(item, currentitem);
785
            }
786
        });
787
 
788
        // Remove the remaining elements.
1441 ariadna 789
        const orphanElements = [];
1 efrain 790
        while (container.children.length > neworder.length) {
791
            const lastchild = container.lastChild;
1441 ariadna 792
            // Any orphan element is always displayed after the listed elements.
793
            // Also, some third-party plugins can use a fake dndupload-preview indicator.
794
            if (lastchild?.classList?.contains('dndupload-preview') || lastchild.dataset?.orphan) {
795
                orphanElements.push(lastchild);
1 efrain 796
            } else {
797
                dettachedelements[lastchild?.dataset?.id ?? 0] = lastchild;
798
            }
799
            container.removeChild(lastchild);
800
        }
1441 ariadna 801
        // Restore orphan elements.
802
        orphanElements.forEach((element) => {
803
            container.append(element);
804
        });
1 efrain 805
    }
806
}