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
 * Course state actions dispatcher.
18
 *
19
 * This module captures all data-dispatch links in the course content and dispatch the proper
20
 * state mutation, including any confirmation and modal required.
21
 *
22
 * @module     core_courseformat/local/content/actions
23
 * @class      core_courseformat/local/content/actions
24
 * @copyright  2021 Ferran Recio <ferran@moodle.com>
25
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26
 */
27
 
28
import {BaseComponent} from 'core/reactive';
1441 ariadna 29
import {eventTypes} from 'core/local/inplace_editable/events';
30
import Collapse from 'theme_boost/bootstrap/collapse';
31
import log from 'core/log';
1 efrain 32
import Modal from 'core/modal';
33
import ModalSaveCancel from 'core/modal_save_cancel';
34
import ModalDeleteCancel from 'core/modal_delete_cancel';
1441 ariadna 35
import ModalCopyToClipboard from 'core/modal_copy_to_clipboard';
1 efrain 36
import ModalEvents from 'core/modal_events';
37
import Templates from 'core/templates';
38
import {prefetchStrings} from 'core/prefetch';
39
import {getString} from 'core/str';
40
import {getFirst} from 'core/normalise';
41
import {toggleBulkSelectionAction} from 'core_courseformat/local/content/actions/bulkselection';
42
import * as CourseEvents from 'core_course/events';
43
import Pending from 'core/pending';
44
import ContentTree from 'core_courseformat/local/courseeditor/contenttree';
45
// The jQuery module is only used for interacting with Boostrap 4. It can we removed when MDL-71979 is integrated.
1441 ariadna 46
import Notification from "core/notification";
1 efrain 47
 
48
// Load global strings.
49
prefetchStrings('core', ['movecoursesection', 'movecoursemodule', 'confirm', 'delete']);
50
 
51
// Mutations are dispatched by the course content actions.
52
// Formats can use this module addActions static method to add custom actions.
53
// Direct mutations can be simple strings (mutation) name or functions.
54
const directMutations = {
55
    sectionHide: 'sectionHide',
56
    sectionShow: 'sectionShow',
57
    cmHide: 'cmHide',
58
    cmShow: 'cmShow',
59
    cmStealth: 'cmStealth',
60
    cmMoveRight: 'cmMoveRight',
61
    cmMoveLeft: 'cmMoveLeft',
62
    cmNoGroups: 'cmNoGroups',
63
    cmSeparateGroups: 'cmSeparateGroups',
64
    cmVisibleGroups: 'cmVisibleGroups',
65
};
66
 
67
export default class extends BaseComponent {
68
 
69
    /**
70
     * Constructor hook.
71
     */
72
    create() {
73
        // Optional component name for debugging.
74
        this.name = 'content_actions';
75
        // Default query selectors.
76
        this.selectors = {
77
            ACTIONLINK: `[data-action]`,
78
            // Move modal selectors.
79
            SECTIONLINK: `[data-for='section']`,
80
            CMLINK: `[data-for='cm']`,
81
            SECTIONNODE: `[data-for='sectionnode']`,
1441 ariadna 82
            MODALTOGGLER: `[data-bs-toggle='collapse']`,
1 efrain 83
            ADDSECTION: `[data-action='addSection']`,
84
            CONTENTTREE: `#destination-selector`,
85
            ACTIONMENU: `.action-menu`,
1441 ariadna 86
            ACTIONMENUTOGGLER: `[data-bs-toggle="dropdown"]`,
1 efrain 87
            // Availability modal selectors.
88
            OPTIONSRADIO: `[type='radio']`,
1441 ariadna 89
            COURSEADDSECTION: `#course-addsection`,
90
            MAXSECTIONSWARNING: `[data-region='max-sections-warning']`,
91
            ADDSECTIONREGION: `[data-region='section-addsection']`,
1 efrain 92
        };
93
        // Component css classes.
94
        this.classes = {
1441 ariadna 95
            DISABLED: `disabled`,
96
            ITALIC: `fst-italic`,
97
            DISPLAYNONE: `d-none`,
1 efrain 98
        };
99
    }
100
 
101
    /**
102
     * Add extra actions to the module.
103
     *
104
     * @param {array} actions array of methods to execute
105
     */
106
    static addActions(actions) {
107
        for (const [action, mutationReference] of Object.entries(actions)) {
108
            if (typeof mutationReference !== 'function' && typeof mutationReference !== 'string') {
109
                throw new Error(`${action} action must be a mutation name or a function`);
110
            }
111
            directMutations[action] = mutationReference;
112
        }
113
    }
114
 
115
    /**
116
     * Initial state ready method.
117
     *
118
     * @param {Object} state the state data.
119
     *
120
     */
121
    stateReady(state) {
122
        // Delegate dispatch clicks.
123
        this.addEventListener(
124
            this.element,
125
            'click',
126
            this._dispatchClick
127
        );
128
        // Check section limit.
129
        this._checkSectionlist({state});
130
        // Add an Event listener to recalculate limits it if a section HTML is altered.
131
        this.addEventListener(
132
            this.element,
133
            CourseEvents.sectionRefreshed,
134
            () => this._checkSectionlist({state})
135
        );
1441 ariadna 136
        // Any inplace editable update needs state refresh.
137
        this.addEventListener(
138
            this.element,
139
            eventTypes.elementUpdated,
140
            this._inplaceEditableHandler
141
        );
1 efrain 142
    }
143
 
144
    /**
145
     * Return the component watchers.
146
     *
147
     * @returns {Array} of watchers
148
     */
149
    getWatchers() {
150
        return [
151
            // Check section limit.
152
            {watch: `course.sectionlist:updated`, handler: this._checkSectionlist},
153
        ];
154
    }
155
 
156
    _dispatchClick(event) {
157
        const target = event.target.closest(this.selectors.ACTIONLINK);
158
        if (!target) {
159
            return;
160
        }
161
        if (target.classList.contains(this.classes.DISABLED)) {
162
            event.preventDefault();
163
            return;
164
        }
165
 
166
        // Invoke proper method.
167
        const actionName = target.dataset.action;
168
        const methodName = this._actionMethodName(actionName);
169
 
170
        if (this[methodName] !== undefined) {
171
            this[methodName](target, event);
172
            return;
173
        }
174
 
175
        // Check direct mutations or mutations handlers.
176
        if (directMutations[actionName] !== undefined) {
177
            if (typeof directMutations[actionName] === 'function') {
178
                directMutations[actionName](target, event);
179
                return;
180
            }
181
            this._requestMutationAction(target, event, directMutations[actionName]);
182
            return;
183
        }
184
    }
185
 
186
    _actionMethodName(name) {
187
        const requestName = name.charAt(0).toUpperCase() + name.slice(1);
188
        return `_request${requestName}`;
189
    }
190
 
191
    /**
192
     * Check the section list and disable some options if needed.
193
     *
194
     * @param {Object} detail the update details.
195
     * @param {Object} detail.state the state object.
196
     */
197
    _checkSectionlist({state}) {
198
        // Disable "add section" actions if the course max sections has been exceeded.
199
        this._setAddSectionLocked(state.course.sectionlist.length > state.course.maxsections);
200
    }
201
 
202
    /**
1441 ariadna 203
     * Handle inplace editable updates.
204
     *
205
     * @param {Event} event the triggered event
206
     * @private
207
     */
208
    _inplaceEditableHandler(event) {
209
        const itemtype = event.detail?.ajaxreturn?.itemtype;
210
        const itemid = parseInt(event.detail?.ajaxreturn?.itemid);
211
        if (!Number.isFinite(itemid) || !itemtype) {
212
            return;
213
        }
214
 
215
        if (itemtype === 'activityname') {
216
            this.reactive.dispatch('cmState', [itemid]);
217
            return;
218
        }
219
        // Sections uses sectionname for normal sections and sectionnamenl for the no link sections.
220
        if (itemtype === 'sectionname' || itemtype === 'sectionnamenl') {
221
            this.reactive.dispatch('sectionState', [itemid]);
222
            return;
223
        }
224
    }
225
 
226
    /**
1 efrain 227
     * Return the ids represented by this element.
228
     *
229
     * Depending on the dataset attributes the action could represent a single id
230
     * or a bulk actions with all the current selected ids.
231
     *
232
     * @param {HTMLElement} target
233
     * @returns {Number[]} array of Ids
234
     */
235
    _getTargetIds(target) {
236
        let ids = [];
237
        if (target?.dataset?.id) {
238
            ids.push(target.dataset.id);
239
        }
240
        const bulkType = target?.dataset?.bulk;
241
        if (!bulkType) {
242
            return ids;
243
        }
244
        const bulk = this.reactive.get('bulk');
245
        if (bulk.enabled && bulk.selectedType === bulkType) {
246
            ids = [...ids, ...bulk.selection];
247
        }
248
        return ids;
249
    }
250
 
251
    /**
252
     * Handle a move section request.
253
     *
254
     * @param {Element} target the dispatch action element
255
     * @param {Event} event the triggered event
256
     */
257
    async _requestMoveSection(target, event) {
258
        // Check we have an id.
259
        const sectionIds = this._getTargetIds(target);
260
        if (sectionIds.length == 0) {
261
            return;
262
        }
263
 
264
        event.preventDefault();
265
 
266
        const pendingModalReady = new Pending(`courseformat/actions:prepareMoveSectionModal`);
267
 
268
        // The section edit menu to refocus on end.
269
        const editTools = this._getClosestActionMenuToogler(target);
270
 
271
        // Collect section information from the state.
272
        const exporter = this.reactive.getExporter();
273
        const data = exporter.course(this.reactive.state);
274
        let titleText = null;
275
 
276
        // Add the target section id and title.
277
        let sectionInfo = null;
278
        if (sectionIds.length == 1) {
279
            sectionInfo = this.reactive.get('section', sectionIds[0]);
280
            data.sectionid = sectionInfo.id;
281
            data.sectiontitle = sectionInfo.title;
282
            data.information = await this.reactive.getFormatString('sectionmove_info', data.sectiontitle);
283
            titleText = this.reactive.getFormatString('sectionmove_title');
284
        } else {
285
            data.information = await this.reactive.getFormatString('sectionsmove_info', sectionIds.length);
286
            titleText = this.reactive.getFormatString('sectionsmove_title');
287
        }
288
 
289
 
290
        // Create the modal.
291
        // Build the modal parameters from the event data.
292
        const modal = await this._modalBodyRenderedPromise(Modal, {
293
            title: titleText,
294
            body: Templates.render('core_courseformat/local/content/movesection', data),
295
        });
296
 
297
        const modalBody = getFirst(modal.getBody());
298
 
299
        // Disable current selected section ids.
300
        sectionIds.forEach(sectionId => {
301
            const currentElement = modalBody.querySelector(`${this.selectors.SECTIONLINK}[data-id='${sectionId}']`);
302
            this._disableLink(currentElement);
303
        });
304
 
305
        // Setup keyboard navigation.
306
        new ContentTree(
307
            modalBody.querySelector(this.selectors.CONTENTTREE),
308
            {
309
                SECTION: this.selectors.SECTIONNODE,
310
                TOGGLER: this.selectors.MODALTOGGLER,
311
                COLLAPSE: this.selectors.MODALTOGGLER,
312
            },
313
            true
314
        );
315
 
316
        // Capture click.
317
        modalBody.addEventListener('click', (event) => {
1441 ariadna 318
            const target = event.target.closest('a');
319
            if (!target || target.dataset.for != 'section' || target.dataset.id === undefined) {
1 efrain 320
                return;
321
            }
322
            if (target.getAttribute('aria-disabled')) {
323
                return;
324
            }
325
            event.preventDefault();
326
            this.reactive.dispatch('sectionMoveAfter', sectionIds, target.dataset.id);
327
            this._destroyModal(modal, editTools);
328
        });
329
 
330
        pendingModalReady.resolve();
331
    }
332
 
333
    /**
334
     * Handle a move cm request.
335
     *
336
     * @param {Element} target the dispatch action element
337
     * @param {Event} event the triggered event
338
     */
339
    async _requestMoveCm(target, event) {
340
        // Check we have an id.
341
        const cmIds = this._getTargetIds(target);
342
        if (cmIds.length == 0) {
343
            return;
344
        }
345
 
346
        event.preventDefault();
347
 
348
        const pendingModalReady = new Pending(`courseformat/actions:prepareMoveCmModal`);
349
 
350
        // The section edit menu to refocus on end.
351
        const editTools = this._getClosestActionMenuToogler(target);
352
 
353
        // Collect information from the state.
354
        const exporter = this.reactive.getExporter();
355
        const data = exporter.course(this.reactive.state);
356
 
357
        let titleText = null;
358
        if (cmIds.length == 1) {
359
            const cmInfo = this.reactive.get('cm', cmIds[0]);
360
            data.cmid = cmInfo.id;
361
            data.cmname = cmInfo.name;
362
            data.information = await this.reactive.getFormatString('cmmove_info', data.cmname);
1441 ariadna 363
            if (cmInfo.hasdelegatedsection) {
364
                titleText = this.reactive.getFormatString('cmmove_subsectiontitle');
365
            } else {
366
                titleText = this.reactive.getFormatString('cmmove_title');
367
            }
1 efrain 368
        } else {
369
            data.information = await this.reactive.getFormatString('cmsmove_info', cmIds.length);
370
            titleText = this.reactive.getFormatString('cmsmove_title');
371
        }
372
 
373
        // Create the modal.
374
        // Build the modal parameters from the event data.
375
        const modal = await this._modalBodyRenderedPromise(Modal, {
376
            title: titleText,
377
            body: Templates.render('core_courseformat/local/content/movecm', data),
378
        });
379
 
380
        const modalBody = getFirst(modal.getBody());
381
 
382
        // Disable current selected section ids.
383
        cmIds.forEach(cmId => {
384
            const currentElement = modalBody.querySelector(`${this.selectors.CMLINK}[data-id='${cmId}']`);
385
            this._disableLink(currentElement);
386
        });
387
 
388
        // Setup keyboard navigation.
389
        new ContentTree(
390
            modalBody.querySelector(this.selectors.CONTENTTREE),
391
            {
392
                SECTION: this.selectors.SECTIONNODE,
393
                TOGGLER: this.selectors.MODALTOGGLER,
394
                COLLAPSE: this.selectors.MODALTOGGLER,
395
                ENTER: this.selectors.SECTIONLINK,
396
            }
397
        );
398
 
399
        cmIds.forEach(cmId => {
1441 ariadna 400
            const cmInfo = this.reactive.get('cm', cmId);
401
            let selector;
402
            if (!cmInfo.hasdelegatedsection) {
403
                selector = `${this.selectors.CMLINK}[data-id='${cmId}']`;
404
            } else {
405
                selector = `${this.selectors.SECTIONLINK}[data-id='${cmInfo.sectionid}']`;
1 efrain 406
            }
1441 ariadna 407
            const currentElement = modalBody.querySelector(selector);
408
            this._expandCmMoveModalParentSections(modalBody, currentElement);
1 efrain 409
        });
410
 
411
        modalBody.addEventListener('click', (event) => {
1441 ariadna 412
            const target = event.target.closest('a');
413
            if (!target || target.dataset.for === undefined || target.dataset.id === undefined) {
1 efrain 414
                return;
415
            }
416
            if (target.getAttribute('aria-disabled')) {
417
                return;
418
            }
419
            event.preventDefault();
420
 
421
            let targetSectionId;
422
            let targetCmId;
1441 ariadna 423
            let droppedCmIds = [...cmIds];
1 efrain 424
            if (target.dataset.for == 'cm') {
425
                const dropData = exporter.cmDraggableData(this.reactive.state, target.dataset.id);
426
                targetSectionId = dropData.sectionid;
427
                targetCmId = dropData.nextcmid;
428
            } else {
429
                const section = this.reactive.get('section', target.dataset.id);
430
                targetSectionId = target.dataset.id;
431
                targetCmId = section?.cmlist[0];
432
            }
1441 ariadna 433
            const section = this.reactive.get('section', targetSectionId);
434
            if (section.component) {
435
                // Remove cmIds which are not allowed to be moved to this delegated section (mostly
436
                // all other delegated cm).
437
                droppedCmIds = droppedCmIds.filter(cmId => {
438
                    const cmInfo = this.reactive.get('cm', cmId);
439
                    return !cmInfo.hasdelegatedsection;
440
                });
441
            }
442
            if (droppedCmIds.length === 0) {
443
                return; // No cm to move.
444
            }
445
            this.reactive.dispatch('cmMove', droppedCmIds, targetSectionId, targetCmId);
1 efrain 446
            this._destroyModal(modal, editTools);
447
        });
448
 
449
        pendingModalReady.resolve();
450
    }
451
 
452
    /**
1441 ariadna 453
     * Expand all the modal tree branches that contains the element.
454
     *
455
     * @private
456
     * @param {HTMLElement} modalBody the modal body element
457
     * @param {HTMLElement} element the element to display
458
     */
459
    _expandCmMoveModalParentSections(modalBody, element) {
460
        const sectionnode = element.closest(this.selectors.SECTIONNODE);
461
        if (!sectionnode) {
462
            return;
463
        }
464
 
465
        const toggler = sectionnode.querySelector(this.selectors.MODALTOGGLER);
466
        let collapsibleId = toggler.dataset.target ?? toggler.getAttribute('href');
467
        if (collapsibleId) {
468
            // We cannot be sure we have # in the id element name.
469
            collapsibleId = collapsibleId.replace('#', '');
470
            const expandNode = modalBody.querySelector(`#${collapsibleId}`);
471
            new Collapse(expandNode, {toggle: false}).show();
472
        }
473
 
474
        // Section are a tree structure, we need to expand all the parents.
475
        this._expandCmMoveModalParentSections(modalBody, sectionnode.parentElement);
476
    }
477
 
478
    /**
1 efrain 479
     * Handle a create section request.
480
     *
481
     * @param {Element} target the dispatch action element
482
     * @param {Event} event the triggered event
483
     */
484
    async _requestAddSection(target, event) {
485
        event.preventDefault();
486
        this.reactive.dispatch('addSection', target.dataset.id ?? 0);
487
    }
488
 
489
    /**
1441 ariadna 490
     * Handle a create subsection request.
491
     *
492
     * @deprecated since Moodle 5.0 MDL-83469.
493
     * @todo MDL-83851 This will be deleted in Moodle 6.0.
494
     * @param {Element} target the dispatch action element
495
     * @param {Event} event the triggered event
496
     */
497
    async _requestAddModule(target, event) {
498
        log.debug('AddModule action is deprecated. Use newModule instead');
499
        event.preventDefault();
500
        this.reactive.dispatch('addModule', target.dataset.modname, target.dataset.sectionnum, target.dataset.beforemod);
501
    }
502
 
503
    /**
504
     * Handle a new create subsection request.
505
     *
506
     * @param {Element} target the dispatch action element
507
     * @param {Event} event the triggered event
508
     */
509
    async _requestNewModule(target, event) {
510
        event.preventDefault();
511
        this.reactive.dispatch('newModule', target.dataset.modname, target.dataset.sectionid, target.dataset.beforemod);
512
    }
513
 
514
    /**
1 efrain 515
     * Handle a delete section request.
516
     *
517
     * @param {Element} target the dispatch action element
518
     * @param {Event} event the triggered event
519
     */
520
    async _requestDeleteSection(target, event) {
521
        const sectionIds = this._getTargetIds(target);
522
        if (sectionIds.length == 0) {
523
            return;
524
        }
525
 
526
        event.preventDefault();
527
 
528
        // We don't need confirmation to delete empty sections.
529
        let needsConfirmation = sectionIds.some(sectionId => {
530
            const sectionInfo = this.reactive.get('section', sectionId);
531
            const cmList = sectionInfo.cmlist ?? [];
532
            return (cmList.length || sectionInfo.hassummary || sectionInfo.rawtitle);
533
        });
534
        if (!needsConfirmation) {
1441 ariadna 535
            this._dispatchSectionDelete(sectionIds, target);
1 efrain 536
            return;
537
        }
538
 
539
        let bodyText = null;
540
        let titleText = null;
541
        if (sectionIds.length == 1) {
542
            titleText = this.reactive.getFormatString('sectiondelete_title');
543
            const sectionInfo = this.reactive.get('section', sectionIds[0]);
544
            bodyText = this.reactive.getFormatString('sectiondelete_info', {name: sectionInfo.title});
545
        } else {
546
            titleText = this.reactive.getFormatString('sectionsdelete_title');
547
            bodyText = this.reactive.getFormatString('sectionsdelete_info', {count: sectionIds.length});
548
        }
549
 
550
        const modal = await this._modalBodyRenderedPromise(ModalDeleteCancel, {
551
            title: titleText,
552
            body: bodyText,
553
        });
554
 
555
        modal.getRoot().on(
556
            ModalEvents.delete,
557
            e => {
558
                // Stop the default save button behaviour which is to close the modal.
559
                e.preventDefault();
560
                modal.destroy();
1441 ariadna 561
                this._dispatchSectionDelete(sectionIds, target);
1 efrain 562
            }
563
        );
564
    }
565
 
566
    /**
1441 ariadna 567
     * Dispatch the section delete action and handle the redirection if necessary.
568
     *
569
     * @param {Array} sectionIds  the IDs of the sections to delete.
570
     * @param {Element} target the dispatch action element
571
     */
572
    async _dispatchSectionDelete(sectionIds, target) {
573
        await this.reactive.dispatch('sectionDelete', sectionIds);
574
        if (target.baseURI.includes('section.php')) {
575
            // Redirect to the course main page if the section is the current page.
576
            window.location.href = this.reactive.get('course').baseurl;
577
        }
578
    }
579
 
580
    /**
1 efrain 581
     * Handle a toggle cm selection.
582
     *
583
     * @param {Element} target the dispatch action element
584
     * @param {Event} event the triggered event
585
     */
586
    async _requestToggleSelectionCm(target, event) {
587
        toggleBulkSelectionAction(this.reactive, target, event, 'cm');
588
    }
589
 
590
    /**
591
     * Handle a toggle section selection.
592
     *
593
     * @param {Element} target the dispatch action element
594
     * @param {Event} event the triggered event
595
     */
596
    async _requestToggleSelectionSection(target, event) {
597
        toggleBulkSelectionAction(this.reactive, target, event, 'section');
598
    }
599
 
600
    /**
601
     * Basic mutation action helper.
602
     *
603
     * @param {Element} target the dispatch action element
604
     * @param {Event} event the triggered event
605
     * @param {string} mutationName the mutation name
606
     */
607
    async _requestMutationAction(target, event, mutationName) {
608
        if (!target.dataset.id && target.dataset.for !== 'bulkaction') {
609
            return;
610
        }
611
        event.preventDefault();
612
        if (target.dataset.for === 'bulkaction') {
613
            // If the mutation is a bulk action we use the current selection.
614
            this.reactive.dispatch(mutationName, this.reactive.get('bulk').selection);
615
        } else {
616
            this.reactive.dispatch(mutationName, [target.dataset.id]);
617
        }
618
    }
619
 
620
    /**
1441 ariadna 621
     * Handle a course permalink modal request.
622
     *
623
     * @param {Element} target the dispatch action element
624
     * @param {Event} event the triggered event
625
     */
626
    _requestPermalink(target, event) {
627
        event.preventDefault();
628
        ModalCopyToClipboard.create(
629
            {
630
                text: target.getAttribute('href'),
631
            },
632
            getString('sectionlink', 'course')
633
        );
634
        return;
635
    }
636
 
637
    /**
1 efrain 638
     * Handle a course module duplicate request.
639
     *
640
     * @param {Element} target the dispatch action element
641
     * @param {Event} event the triggered event
642
     */
643
    async _requestCmDuplicate(target, event) {
644
        const cmIds = this._getTargetIds(target);
645
        if (cmIds.length == 0) {
646
            return;
647
        }
648
        const sectionId = target.dataset.sectionid ?? null;
649
        event.preventDefault();
650
        this.reactive.dispatch('cmDuplicate', cmIds, sectionId);
651
    }
652
 
653
    /**
654
     * Handle a delete cm request.
655
     *
656
     * @param {Element} target the dispatch action element
657
     * @param {Event} event the triggered event
658
     */
659
    async _requestCmDelete(target, event) {
660
        const cmIds = this._getTargetIds(target);
661
        if (cmIds.length == 0) {
662
            return;
663
        }
664
 
665
        event.preventDefault();
666
 
667
        let bodyText = null;
668
        let titleText = null;
1441 ariadna 669
        let delegatedsection = null;
1 efrain 670
        if (cmIds.length == 1) {
671
            const cmInfo = this.reactive.get('cm', cmIds[0]);
1441 ariadna 672
            if (cmInfo.hasdelegatedsection) {
673
                delegatedsection = cmInfo.delegatesectionid;
674
                titleText = this.reactive.getFormatString('cmdelete_subsectiontitle');
675
                bodyText = getString(
676
                    'sectiondelete_info',
677
                    'core_courseformat',
678
                    {
679
                        type: cmInfo.modname,
680
                        name: cmInfo.name,
681
                    }
682
                );
683
            } else {
684
                titleText = this.reactive.getFormatString('cmdelete_title');
685
                bodyText = getString(
686
                    'cmdelete_info',
687
                    'core_courseformat',
688
                    {
689
                        type: cmInfo.modname,
690
                        name: cmInfo.name,
691
                    }
692
                );
693
            }
1 efrain 694
        } else {
695
            titleText = getString('cmsdelete_title', 'core_courseformat');
696
            bodyText = getString(
697
                'cmsdelete_info',
698
                'core_courseformat',
699
                {count: cmIds.length}
700
            );
701
        }
702
 
703
        const modal = await this._modalBodyRenderedPromise(ModalDeleteCancel, {
704
            title: titleText,
705
            body: bodyText,
706
        });
707
 
708
        modal.getRoot().on(
709
            ModalEvents.delete,
710
            e => {
711
                // Stop the default save button behaviour which is to close the modal.
712
                e.preventDefault();
713
                modal.destroy();
714
                this.reactive.dispatch('cmDelete', cmIds);
1441 ariadna 715
                if (cmIds.length == 1 && delegatedsection && target.baseURI.includes('section.php')) {
716
                    // Redirect to the course main page if the subsection is the current page.
717
                    let parameters = new URLSearchParams(window.location.search);
718
                    if (parameters.has('id') && parameters.get('id') == delegatedsection) {
719
                        this._dispatchSectionDelete([delegatedsection], target);
720
                    }
721
                }
1 efrain 722
            }
723
        );
724
    }
725
 
726
    /**
727
     * Handle a cm availability change request.
728
     *
729
     * @param {Element} target the dispatch action element
730
     */
731
    async _requestCmAvailability(target) {
732
        const cmIds = this._getTargetIds(target);
733
        if (cmIds.length == 0) {
734
            return;
735
        }
736
        // Show the availability modal to decide which action to trigger.
737
        const exporter = this.reactive.getExporter();
738
        const data = {
739
            allowstealth: exporter.canUseStealth(this.reactive.state, cmIds),
740
        };
741
        const modal = await this._modalBodyRenderedPromise(ModalSaveCancel, {
742
            title: getString('availability', 'core'),
743
            body: Templates.render('core_courseformat/local/content/cm/availabilitymodal', data),
744
            saveButtonText: getString('apply', 'core'),
745
        });
746
 
747
        this._setupMutationRadioButtonModal(modal, cmIds);
748
    }
749
 
750
    /**
751
     * Handle a section availability change request.
752
     *
753
     * @param {Element} target the dispatch action element
754
     */
755
    async _requestSectionAvailability(target) {
756
        const sectionIds = this._getTargetIds(target);
757
        if (sectionIds.length == 0) {
758
            return;
759
        }
760
        const title = (sectionIds.length == 1) ? 'sectionavailability_title' : 'sectionsavailability_title';
761
        // Show the availability modal to decide which action to trigger.
762
        const modal = await this._modalBodyRenderedPromise(ModalSaveCancel, {
763
            title: this.reactive.getFormatString(title),
764
            body: Templates.render('core_courseformat/local/content/section/availabilitymodal', []),
765
            saveButtonText: getString('apply', 'core'),
766
        });
767
 
768
        this._setupMutationRadioButtonModal(modal, sectionIds);
769
    }
770
 
771
    /**
772
     * Add events to a mutation selector radio buttons modal.
773
     * @param {Modal} modal
774
     * @param {Number[]} ids the section or cm ids to apply the mutation
775
     */
776
    _setupMutationRadioButtonModal(modal, ids) {
777
        // The save button is not enabled until the user selects an option.
778
        modal.setButtonDisabled('save', true);
779
 
780
        const submitFunction = (radio) => {
781
            const mutation = radio?.value;
782
            if (!mutation) {
783
                return false;
784
            }
785
            this.reactive.dispatch(mutation, ids);
786
            return true;
787
        };
788
 
789
        const modalBody = getFirst(modal.getBody());
790
        const radioOptions = modalBody.querySelectorAll(this.selectors.OPTIONSRADIO);
791
        radioOptions.forEach(radio => {
792
            radio.addEventListener('change', () => {
793
                modal.setButtonDisabled('save', false);
794
            });
795
            radio.parentNode.addEventListener('click', () => {
796
                radio.checked = true;
797
                modal.setButtonDisabled('save', false);
798
            });
799
            radio.parentNode.addEventListener('dblclick', dbClickEvent => {
800
                if (submitFunction(radio)) {
801
                    dbClickEvent.preventDefault();
802
                    modal.destroy();
803
                }
804
            });
805
        });
806
 
807
        modal.getRoot().on(
808
            ModalEvents.save,
809
            () => {
810
                const radio = modalBody.querySelector(`${this.selectors.OPTIONSRADIO}:checked`);
811
                submitFunction(radio);
812
            }
813
        );
814
    }
815
 
816
    /**
817
     * Disable all add sections actions.
818
     *
819
     * @param {boolean} locked the new locked value.
820
     */
821
    _setAddSectionLocked(locked) {
1441 ariadna 822
        const targets = this.getElements(this.selectors.ADDSECTIONREGION);
1 efrain 823
        targets.forEach(element => {
824
            element.classList.toggle(this.classes.DISABLED, locked);
1441 ariadna 825
            const addSectionElement = element.querySelector(this.selectors.ADDSECTION);
826
            addSectionElement.classList.toggle(this.classes.DISABLED, locked);
827
            this.setElementLocked(addSectionElement, locked);
828
            // We tweak the element to show a tooltip as a title attribute.
829
            if (locked) {
830
                getString('sectionaddmax', 'core_courseformat')
831
                    .then((text) => addSectionElement.setAttribute('title', text))
832
                    .catch(Notification.exception);
833
                addSectionElement.style.pointerEvents = null; // Unlocks the pointer events.
834
                addSectionElement.style.userSelect = null; // Unlocks the pointer events.
835
            } else {
836
                addSectionElement.setAttribute('title', addSectionElement.dataset.addSections);
837
            }
1 efrain 838
        });
1441 ariadna 839
        const courseAddSection = this.getElement(this.selectors.COURSEADDSECTION);
840
        if (courseAddSection) {
841
            const addSection = courseAddSection.querySelector(this.selectors.ADDSECTION);
842
            addSection.classList.toggle(this.classes.DISPLAYNONE, locked);
843
            const noMoreSections = courseAddSection.querySelector(this.selectors.MAXSECTIONSWARNING);
844
            noMoreSections.classList.toggle(this.classes.DISPLAYNONE, !locked);
845
        }
1 efrain 846
    }
847
 
848
    /**
849
     * Replace an element with a copy with a different tag name.
850
     *
851
     * @param {Element} element the original element
852
     */
853
    _disableLink(element) {
854
        if (element) {
855
            element.style.pointerEvents = 'none';
856
            element.style.userSelect = 'none';
857
            element.classList.add(this.classes.DISABLED);
858
            element.classList.add(this.classes.ITALIC);
859
            element.setAttribute('aria-disabled', true);
860
            element.addEventListener('click', event => event.preventDefault());
861
        }
862
    }
863
 
864
    /**
865
     * Render a modal and return a body ready promise.
866
     *
867
     * @param {Modal} ModalClass the modal class
868
     * @param {object} modalParams the modal params
869
     * @return {Promise} the modal body ready promise
870
     */
871
    _modalBodyRenderedPromise(ModalClass, modalParams) {
872
        return new Promise((resolve, reject) => {
873
            ModalClass.create(modalParams).then((modal) => {
874
                modal.setRemoveOnClose(true);
875
                // Handle body loading event.
876
                modal.getRoot().on(ModalEvents.bodyRendered, () => {
877
                    resolve(modal);
878
                });
879
                // Configure some extra modal params.
880
                if (modalParams.saveButtonText !== undefined) {
881
                    modal.setSaveButtonText(modalParams.saveButtonText);
882
                }
883
                if (modalParams.deleteButtonText !== undefined) {
884
                    modal.setDeleteButtonText(modalParams.saveButtonText);
885
                }
886
                modal.show();
887
                return;
888
            }).catch(() => {
889
                reject(`Cannot load modal content`);
890
            });
891
        });
892
    }
893
 
894
    /**
895
     * Hide and later destroy a modal.
896
     *
897
     * Behat will fail if we remove the modal while some boostrap collapse is executing.
898
     *
899
     * @param {Modal} modal
900
     * @param {HTMLElement} element the dom element to focus on.
901
     */
902
    _destroyModal(modal, element) {
903
        modal.hide();
904
        const pendingDestroy = new Pending(`courseformat/actions:destroyModal`);
905
        if (element) {
906
            element.focus();
907
        }
908
        setTimeout(() =>{
909
            modal.destroy();
910
            pendingDestroy.resolve();
911
        }, 500);
912
    }
913
 
914
    /**
915
     * Get the closest actions menu toggler to an action element.
916
     *
917
     * @param {HTMLElement} element the action link element
918
     * @returns {HTMLElement|undefined}
919
     */
920
    _getClosestActionMenuToogler(element) {
921
        const actionMenu = element.closest(this.selectors.ACTIONMENU);
922
        if (!actionMenu) {
923
            return undefined;
924
        }
925
        return actionMenu.querySelector(this.selectors.ACTIONMENUTOGGLER);
926
    }
927
}