Proyectos de Subversion Moodle

Rev

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