Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1441 ariadna 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
 * The category component.
18
 *
19
 * @module     qbank_managecategories/category
20
 * @class      qbank_managecategories/category
21
 */
22
 
23
import {BaseComponent, DragDrop} from 'core/reactive';
24
import {categorymanager} from 'qbank_managecategories/categorymanager';
25
import Templates from 'core/templates';
26
import Modal from "core/modal";
27
import {get_string as getString} from "core/str";
28
 
29
export default class extends BaseComponent {
30
 
31
    create(descriptor) {
32
        this.name = descriptor.element.id;
33
        this.selectors = {
34
            CATEGORY_LIST: '.qbank_managecategories-categorylist',
35
            CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',
36
            CATEGORY_CONTENTS: '.qbank_managecategories-item > .container',
37
            EDIT_BUTTON: '[data-action="addeditcategory"]',
38
            MOVE_BUTTON: '[role="menuitem"][data-actiontype="move"]',
39
            CONTEXT: '.qbank_managecategories-categorylist[data-contextid]',
40
            MODAL_CATEGORY_ITEM: '.modal_category_item[data-movingcategoryid]',
41
            CONTENT_AREA: '.qbank_managecategories-details',
42
            CATEGORY_ID: id => `#category-${id}`,
43
            CONTENT_CONTAINER: id => `#category-${id} .qbank_managecategories-childlistcontainer`,
44
            CHILD_LIST: id => `ul[data-categoryid="${id}"]`,
45
            PREVIOUS_SIBLING: sortorder => `:scope > [data-sortorder="${sortorder}"]`,
46
        };
47
        this.classes = {
48
            NO_BOTTOM_PADDING: 'pb-0',
49
            DRAGHANDLE: 'draghandle',
50
            DROPTARGET: 'qbank_managecategories-droptarget-before',
51
        };
52
        this.ids = {
53
            CATEGORY: id => `category-${id}`,
54
        };
55
    }
56
 
57
    stateReady() {
58
        this.initDragDrop();
59
        this.addEventListener(this.getElement(this.selectors.EDIT_BUTTON), 'click', categorymanager.showEditModal);
60
        const moveButton = this.getElement(this.selectors.MOVE_BUTTON);
61
        this.addEventListener(moveButton, 'click', this.showMoveModal);
62
    }
63
 
64
    destroy() {
65
        // The draggable element must be unregistered.
66
        this.deInitDragDrop();
67
    }
68
 
69
    /**
70
     * Remove any existing DragDrop component, and create a new one.
71
     */
72
    initDragDrop() {
73
        this.deInitDragDrop();
74
        // If the element is currently draggable, register the getDraggableData method.
75
        if (this.element.classList.contains(this.classes.DRAGHANDLE)) {
76
            this.getDraggableData = this._getDraggableData;
77
        }
78
        this.dragdrop = new DragDrop(this);
79
    }
80
 
81
    /**
82
     * If the DragDrop component is currently registered, unregister it.
83
     */
84
    deInitDragDrop() {
85
        if (this.dragdrop !== undefined) {
86
            if (this.getDraggableData !== undefined) {
87
                this.dragdrop.setDraggable(false);
88
                this.getDraggableData = undefined;
89
            }
90
            this.dragdrop.unregister();
91
            this.dragdrop = undefined;
92
        }
93
    }
94
 
95
    /**
96
     * Static method to create a component instance.
97
     *
98
     * @param {string} target the DOM main element or its ID
99
     * @param {object} selectors optional css selector overrides
100
     * @return {Component}
101
     */
102
    static init(target, selectors) {
103
        return new this({
104
            element: document.querySelector(target),
105
            selectors,
106
            reactive: categorymanager,
107
        });
108
    }
109
 
110
    /**
111
     * Return the category ID from the component's element.
112
     *
113
     * This method is referenced as getDraggableData when the component can be dragged.
114
     *
115
     * @return {{id: string}}
116
     * @private
117
     */
118
    _getDraggableData() {
119
        return {
120
            id: this.getElement().dataset.categoryid
121
        };
122
    }
123
 
124
    validateDropData() {
125
        return true;
126
    }
127
 
128
    /**
129
     * Highlight the top border of the category item.
130
     *
131
     * @param {Object} dropData
132
     */
133
    showDropZone(dropData) {
134
        if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) {
135
            // Can't drop onto itself or its own child.
136
            return false;
137
        }
138
        this.getElement().classList.add(this.classes.DROPTARGET);
139
        return true;
140
    }
141
 
142
    /**
143
     * Remove highlighting.
144
     */
145
    hideDropZone() {
146
        this.getElement().classList.remove(this.classes.DROPTARGET);
147
    }
148
 
149
    /**
150
     * Find the new position of the dropped category, and trigger the move.
151
     *
152
     * @param {Object} dropData The category being moved.
153
     * @param {Event} event The drop event.
154
     */
155
    drop(dropData, event) {
156
        const dropTarget = event.target.closest(this.selectors.CATEGORY_ITEM);
157
 
158
        if (!dropTarget) {
159
            return;
160
        }
161
 
162
        if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {
163
            // Can't drop onto your own child.
164
            return;
165
        }
166
 
167
        const source = document.getElementById(this.ids.CATEGORY(dropData.id));
168
 
169
        if (!source) {
170
            return;
171
        }
172
 
173
        const targetParentId = dropTarget.dataset.parent;
174
        const parentList = dropTarget.closest(this.selectors.CATEGORY_LIST);
175
        let precedingSibling;
176
 
177
        if (dropTarget === parentList.firstElementChild) {
178
            // Dropped at the top of the list.
179
            precedingSibling = null;
180
        } else {
181
            precedingSibling = dropTarget.previousElementSibling;
182
        }
183
 
184
        // Insert the category after the target category
185
        categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid);
186
    }
187
 
188
    getWatchers() {
189
        return [
190
            // After any update to this category, move it to the new position.
191
            {watch: `categories[${this.element.dataset.categoryid}]:updated`, handler: this.updatePosition},
192
            // When the template context is added or updated, re-render the content.
193
            {watch: `categories[${this.element.dataset.categoryid}].templatecontext:created`, handler: this.rerender},
194
            {watch: `categories[${this.element.dataset.categoryid}].templatecontext:updated`, handler: this.rerender},
195
            // When a new category is created, check whether we need to add a child list to this category.
196
            {watch: `categories:created`, handler: this.checkChildList},
197
        ];
198
    }
199
 
200
    /**
201
     * Re-render the category content.
202
     *
203
     * @param {Object} args
204
     * @param {Element} args.element
205
     * @return {Promise<Array>}
206
     */
207
    async rerender({element}) {
208
        const {html, js} = await Templates.renderForPromise(
209
            'qbank_managecategories/category_details',
210
            element.templatecontext
211
        );
212
        return Templates.replaceNodeContents(this.getElement(this.selectors.CONTENT_AREA), html, js);
213
    }
214
 
215
    /**
216
     * Render and append a new child list.
217
     *
218
     * @param {Object} context Template context, must include at least categoryid.
219
     * @return {Promise<Element>}
220
     */
221
    async createChildList(context) {
222
        const {html, js} = await Templates.renderForPromise(
223
            'qbank_managecategories/childlist',
224
            context,
225
        );
226
        const parentContainer = document.querySelector(this.selectors.CONTENT_CONTAINER(context.categoryid));
227
        await Templates.appendNodeContents(parentContainer, html, js);
228
        const childList = document.querySelector(this.selectors.CHILD_LIST(context.categoryid));
229
        childList.closest(this.selectors.CATEGORY_CONTENTS).classList.add(this.classes.NO_BOTTOM_PADDING);
230
        return childList;
231
    }
232
 
233
    /**
234
     * Move a category to its new position.
235
     *
236
     * A category may change its parent, sortorder and draghandle independently or at the same time. This method will resolve those
237
     * changes and move the element to the new position. If the parent doesn't already have a child list, one will be created.
238
     *
239
     * If the parent has changed, this will also update the state with the new child count of the old and new parents.
240
     *
241
     * @param {Object} args
242
     * @param {Object} args.element
243
     * @return {Promise<void>}
244
     */
245
    async updatePosition({element}) {
246
        // Move to a new parent category.
247
        let newParent;
248
        const originParent = document.querySelector(this.selectors.CHILD_LIST(this.getElement().dataset.parent));
249
        if (parseInt(this.getElement().dataset.parent) !== element.parent) {
250
            newParent = document.querySelector(this.selectors.CHILD_LIST(element.parent));
251
            if (!newParent) {
252
                // The target category doesn't have a child list yet. We'd better create one.
253
                newParent = await this.createChildList({categoryid: element.parent});
254
            }
255
            this.getElement().dataset.parent = element.parent;
256
        } else {
257
            newParent = this.getElement().parentElement;
258
        }
259
 
260
        // Move to a new position within the parent.
261
        let previousSibling;
262
        let nextSibling;
263
        if (newParent.firstElementChild && parseInt(element.sortorder) <= parseInt(newParent.firstElementChild.dataset.sortorder)) {
264
            // Move to the top of the list.
265
            nextSibling = newParent.firstElementChild;
266
        } else {
267
            // Move later in the list.
268
            previousSibling = newParent.querySelector(this.selectors.PREVIOUS_SIBLING(element.sortorder - 1));
269
            nextSibling = previousSibling?.nextElementSibling;
270
        }
271
 
272
        // Check if this has actually moved, or if it's just having its sortorder updated due to another element moving.
273
        const moved = (newParent !== this.getElement().parentElement || nextSibling !== this.getElement());
274
 
275
        if (moved) {
276
            if (nextSibling) {
277
                // Move to the specified position in the list.
278
                newParent.insertBefore(this.getElement(), nextSibling);
279
            } else {
280
                // Move to the end of the list (may also be the top of the list is empty).
281
                newParent.appendChild(this.getElement());
282
            }
283
        }
284
        if (originParent !== newParent) {
285
            // Update child count of old and new parent.
286
            this.reactive.stateManager.processUpdates([
287
                {
288
                    name: 'categoryLists',
289
                    action: 'put',
290
                    fields: {
291
                        id: originParent.dataset.categoryid,
292
                        childCount: originParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
293
                    }
294
                },
295
                {
296
                    name: 'categoryLists',
297
                    action: 'put',
298
                    fields: {
299
                        id: newParent.dataset.categoryid,
300
                        childCount: newParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
301
                    }
302
                }
303
            ]);
304
        }
305
 
306
        this.element.dataset.sortorder = element.sortorder;
307
 
308
        // Enable/disable dragging.
309
        const isDraggable = this.element.classList.contains(this.classes.DRAGHANDLE);
310
        if (isDraggable && !element.draghandle) {
311
            this.element.classList.remove(this.classes.DRAGHANDLE);
312
            this.initDragDrop();
313
        } else if (!isDraggable && element.draghandle) {
314
            this.element.classList.add(this.classes.DRAGHANDLE);
315
            this.initDragDrop();
316
        }
317
    }
318
 
319
    /**
320
     * Recursively create a list of all valid destinations for a current category within a parent category.
321
     *
322
     * @param {Element} item
323
     * @param {Number} movingCategoryId
324
     * @return {Array<Object>}
325
     */
326
    createMoveCategoryList(item, movingCategoryId) {
327
        const categories = [];
328
        if (item.children) {
329
            let precedingSibling = null;
330
            item.children.forEach(category => {
331
                const categoryId = parseInt(category.dataset.categoryid);
332
                // Don't create a target for the category that's moving.
333
                if (categoryId === movingCategoryId) {
334
                    return;
335
                }
336
                // Create a target to move before this child.
337
                let child = {
338
                    categoryid: categoryId,
339
                    movingcategoryid: movingCategoryId,
340
                    precedingsiblingid: precedingSibling?.dataset.categoryid ?? 0,
341
                    parent: category.dataset.parent,
342
                    categoryname: category.dataset.categoryname,
343
                    categories: null,
344
                    current: categoryId === movingCategoryId,
345
                };
346
                const childList = category.querySelector(this.selectors.CATEGORY_LIST);
347
                if (childList) {
348
                    // If the child has its own children, recursively make a list of those.
349
                    child.categories = this.createMoveCategoryList(childList, movingCategoryId);
350
                } else {
351
                    // Otherwise, create a target to move as a new child of this one.
352
                    child.categories = [
353
                        {
354
                            movingcategoryid: movingCategoryId,
355
                            precedingsiblingid: 0,
356
                            parent: categoryId,
357
                            categoryname: category.dataset.categoryname,
358
                            categories: null,
359
                            newchild: true,
360
                        }
361
                    ];
362
                }
363
                categories.push(child);
364
                precedingSibling = category;
365
            });
366
            if (precedingSibling) {
367
                const precedingId = parseInt(precedingSibling.dataset.categoryid);
368
                if (precedingId !== movingCategoryId) {
369
                    // If this is the last child of its parent, also create a target to move the category after this one.
370
                    categories.push({
371
                        movingcategoryid: movingCategoryId,
372
                        precedingsiblingid: precedingId,
373
                        parent: precedingSibling.dataset.parent,
374
                        categoryname: precedingSibling.dataset.categoryname,
375
                        categories: null,
376
                        lastchild: true,
377
                    });
378
                }
379
            }
380
        }
381
        return categories;
382
    }
383
 
384
    /**
385
     * Displays a modal containing links to move the category to a new location.
386
     *
387
     * @param {Event} e Button click event.
388
     */
389
    async showMoveModal(e) {
390
        // Return if it is not menu item.
391
        const item = e.target.closest(this.selectors.MOVE_BUTTON);
392
        if (!item) {
393
            return;
394
        }
395
        // Return if it is disabled.
396
        if (item.getAttribute('aria-disabled') === 'true') {
397
            return;
398
        }
399
 
400
        // Prevent addition click on the item.
401
        item.setAttribute('aria-disabled', true);
402
 
403
        // Build the list of move links.
404
        let moveList = {contexts: []};
405
        const contexts = document.querySelectorAll(this.selectors.CONTEXT);
406
        contexts.forEach(context => {
407
            const moveContext = {
408
                contextname: context.dataset.contextname,
409
                categories: [],
410
                hascategories: false,
411
            };
412
            moveContext.categories = this.createMoveCategoryList(context, parseInt(item.dataset.categoryid));
413
            moveContext.hascategories = moveContext.categories.length > 0;
414
            moveList.contexts.push(moveContext);
415
        });
416
 
417
        const modal = await Modal.create({
418
            title: getString('movecategory', 'qbank_managecategories', item.dataset.categoryname),
419
            body: Templates.render('qbank_managecategories/move_context_list', moveList),
420
            footer: '',
421
            show: true,
422
            large: true,
423
        });
424
        // Show modal and add click event for list items.
425
        modal.getBody()[0].addEventListener('click', e => {
426
            const target = e.target.closest(this.selectors.MODAL_CATEGORY_ITEM);
427
            if (!target) {
428
                return;
429
            }
430
            categorymanager.moveCategory(target.dataset.movingcategoryid, target.dataset.parent, target.dataset.precedingsiblingid);
431
            modal.destroy();
432
        });
433
        item.setAttribute('aria-disabled', false);
434
    }
435
 
436
    /**
437
     * Check and add a child list if needed.
438
     *
439
     * Check whether the category that has just been added has this category as its parent. If it does,
440
     * check that this category has a child list, and if not, add one.
441
     *
442
     * @param {Object} args
443
     * @param {Element} args.element The new category.
444
     * @return {Promise<Element>}
445
     */
446
    async checkChildList({element}) {
447
        if (element.parent !== this.getElement().dataset.categoryid) {
448
            return null; // Not for me.
449
        }
450
        let childList = this.getElement(this.selectors.CATEGORY_LIST);
451
        if (childList) {
452
            return null; // List already exists, it will handle adding the new category.
453
        }
454
        // Render and add a new child list containing the new category.
455
        return this.createChildList({
456
            categoryid: element.parent,
457
            children: [
458
                element.templatecontext,
459
            ]
460
        });
461
    }
462
}