question/bank/managecategories/amd/src/category.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * The category component.
 *
 * @module     qbank_managecategories/category
 * @class      qbank_managecategories/category
 */

import {BaseComponent, DragDrop} from 'core/reactive';
import {categorymanager} from 'qbank_managecategories/categorymanager';
import Templates from 'core/templates';
import Modal from "core/modal";
import {get_string as getString} from "core/str";

export default class extends BaseComponent {

    create(descriptor) {
        this.name = descriptor.element.id;
        this.selectors = {
            CATEGORY_LIST: '.qbank_managecategories-categorylist',
            CATEGORY_ITEM: '.qbank_managecategories-item[data-categoryid]',
            CATEGORY_CONTENTS: '.qbank_managecategories-item > .container',
            EDIT_BUTTON: '[data-action="addeditcategory"]',
            MOVE_BUTTON: '[role="menuitem"][data-actiontype="move"]',
            CONTEXT: '.qbank_managecategories-categorylist[data-contextid]',
            MODAL_CATEGORY_ITEM: '.modal_category_item[data-movingcategoryid]',
            CONTENT_AREA: '.qbank_managecategories-details',
            CATEGORY_ID: id => `#category-${id}`,
            CONTENT_CONTAINER: id => `#category-${id} .qbank_managecategories-childlistcontainer`,
            CHILD_LIST: id => `ul[data-categoryid="${id}"]`,
            PREVIOUS_SIBLING: sortorder => `:scope > [data-sortorder="${sortorder}"]`,
        };
        this.classes = {
            NO_BOTTOM_PADDING: 'pb-0',
            DRAGHANDLE: 'draghandle',
            DROPTARGET: 'qbank_managecategories-droptarget-before',
        };
        this.ids = {
            CATEGORY: id => `category-${id}`,
        };
    }

    stateReady() {
        this.initDragDrop();
        this.addEventListener(this.getElement(this.selectors.EDIT_BUTTON), 'click', categorymanager.showEditModal);
        const moveButton = this.getElement(this.selectors.MOVE_BUTTON);
        this.addEventListener(moveButton, 'click', this.showMoveModal);
    }

    destroy() {
        // The draggable element must be unregistered.
        this.deInitDragDrop();
    }

    /**
     * Remove any existing DragDrop component, and create a new one.
     */
    initDragDrop() {
        this.deInitDragDrop();
        // If the element is currently draggable, register the getDraggableData method.
        if (this.element.classList.contains(this.classes.DRAGHANDLE)) {
            this.getDraggableData = this._getDraggableData;
        }
        this.dragdrop = new DragDrop(this);
    }

    /**
     * If the DragDrop component is currently registered, unregister it.
     */
    deInitDragDrop() {
        if (this.dragdrop !== undefined) {
            if (this.getDraggableData !== undefined) {
                this.dragdrop.setDraggable(false);
                this.getDraggableData = undefined;
            }
            this.dragdrop.unregister();
            this.dragdrop = undefined;
        }
    }

    /**
     * Static method to create a component instance.
     *
     * @param {string} target the DOM main element or its ID
     * @param {object} selectors optional css selector overrides
     * @return {Component}
     */
    static init(target, selectors) {
        return new this({
            element: document.querySelector(target),
            selectors,
            reactive: categorymanager,
        });
    }

    /**
     * Return the category ID from the component's element.
     *
     * This method is referenced as getDraggableData when the component can be dragged.
     *
     * @return {{id: string}}
     * @private
     */
    _getDraggableData() {
        return {
            id: this.getElement().dataset.categoryid
        };
    }

    validateDropData() {
        return true;
    }

    /**
     * Highlight the top border of the category item.
     *
     * @param {Object} dropData
     */
    showDropZone(dropData) {
        if (this.getElement().closest(this.selectors.CATEGORY_ID(dropData.id))) {
            // Can't drop onto itself or its own child.
            return false;
        }
        this.getElement().classList.add(this.classes.DROPTARGET);
        return true;
    }

    /**
     * Remove highlighting.
     */
    hideDropZone() {
        this.getElement().classList.remove(this.classes.DROPTARGET);
    }

    /**
     * Find the new position of the dropped category, and trigger the move.
     *
     * @param {Object} dropData The category being moved.
     * @param {Event} event The drop event.
     */
    drop(dropData, event) {
        const dropTarget = event.target.closest(this.selectors.CATEGORY_ITEM);

        if (!dropTarget) {
            return;
        }

        if (dropTarget.closest(this.selectors.CATEGORY_ID(dropData.id))) {
            // Can't drop onto your own child.
            return;
        }

        const source = document.getElementById(this.ids.CATEGORY(dropData.id));

        if (!source) {
            return;
        }

        const targetParentId = dropTarget.dataset.parent;
        const parentList = dropTarget.closest(this.selectors.CATEGORY_LIST);
        let precedingSibling;

        if (dropTarget === parentList.firstElementChild) {
            // Dropped at the top of the list.
            precedingSibling = null;
        } else {
            precedingSibling = dropTarget.previousElementSibling;
        }

        // Insert the category after the target category
        categorymanager.moveCategory(dropData.id, targetParentId, precedingSibling?.dataset.categoryid);
    }

    getWatchers() {
        return [
            // After any update to this category, move it to the new position.
            {watch: `categories[${this.element.dataset.categoryid}]:updated`, handler: this.updatePosition},
            // When the template context is added or updated, re-render the content.
            {watch: `categories[${this.element.dataset.categoryid}].templatecontext:created`, handler: this.rerender},
            {watch: `categories[${this.element.dataset.categoryid}].templatecontext:updated`, handler: this.rerender},
            // When a new category is created, check whether we need to add a child list to this category.
            {watch: `categories:created`, handler: this.checkChildList},
        ];
    }

    /**
     * Re-render the category content.
     *
     * @param {Object} args
     * @param {Element} args.element
     * @return {Promise<Array>}
     */
    async rerender({element}) {
        const {html, js} = await Templates.renderForPromise(
            'qbank_managecategories/category_details',
            element.templatecontext
        );
        return Templates.replaceNodeContents(this.getElement(this.selectors.CONTENT_AREA), html, js);
    }

    /**
     * Render and append a new child list.
     *
     * @param {Object} context Template context, must include at least categoryid.
     * @return {Promise<Element>}
     */
    async createChildList(context) {
        const {html, js} = await Templates.renderForPromise(
            'qbank_managecategories/childlist',
            context,
        );
        const parentContainer = document.querySelector(this.selectors.CONTENT_CONTAINER(context.categoryid));
        await Templates.appendNodeContents(parentContainer, html, js);
        const childList = document.querySelector(this.selectors.CHILD_LIST(context.categoryid));
        childList.closest(this.selectors.CATEGORY_CONTENTS).classList.add(this.classes.NO_BOTTOM_PADDING);
        return childList;
    }

    /**
     * Move a category to its new position.
     *
     * A category may change its parent, sortorder and draghandle independently or at the same time. This method will resolve those
     * changes and move the element to the new position. If the parent doesn't already have a child list, one will be created.
     *
     * If the parent has changed, this will also update the state with the new child count of the old and new parents.
     *
     * @param {Object} args
     * @param {Object} args.element
     * @return {Promise<void>}
     */
    async updatePosition({element}) {
        // Move to a new parent category.
        let newParent;
        const originParent = document.querySelector(this.selectors.CHILD_LIST(this.getElement().dataset.parent));
        if (parseInt(this.getElement().dataset.parent) !== element.parent) {
            newParent = document.querySelector(this.selectors.CHILD_LIST(element.parent));
            if (!newParent) {
                // The target category doesn't have a child list yet. We'd better create one.
                newParent = await this.createChildList({categoryid: element.parent});
            }
            this.getElement().dataset.parent = element.parent;
        } else {
            newParent = this.getElement().parentElement;
        }

        // Move to a new position within the parent.
        let previousSibling;
        let nextSibling;
        if (newParent.firstElementChild && parseInt(element.sortorder) <= parseInt(newParent.firstElementChild.dataset.sortorder)) {
            // Move to the top of the list.
            nextSibling = newParent.firstElementChild;
        } else {
            // Move later in the list.
            previousSibling = newParent.querySelector(this.selectors.PREVIOUS_SIBLING(element.sortorder - 1));
            nextSibling = previousSibling?.nextElementSibling;
        }

        // Check if this has actually moved, or if it's just having its sortorder updated due to another element moving.
        const moved = (newParent !== this.getElement().parentElement || nextSibling !== this.getElement());

        if (moved) {
            if (nextSibling) {
                // Move to the specified position in the list.
                newParent.insertBefore(this.getElement(), nextSibling);
            } else {
                // Move to the end of the list (may also be the top of the list is empty).
                newParent.appendChild(this.getElement());
            }
        }
        if (originParent !== newParent) {
            // Update child count of old and new parent.
            this.reactive.stateManager.processUpdates([
                {
                    name: 'categoryLists',
                    action: 'put',
                    fields: {
                        id: originParent.dataset.categoryid,
                        childCount: originParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
                    }
                },
                {
                    name: 'categoryLists',
                    action: 'put',
                    fields: {
                        id: newParent.dataset.categoryid,
                        childCount: newParent.querySelectorAll(this.selectors.CATEGORY_ITEM).length
                    }
                }
            ]);
        }

        this.element.dataset.sortorder = element.sortorder;

        // Enable/disable dragging.
        const isDraggable = this.element.classList.contains(this.classes.DRAGHANDLE);
        if (isDraggable && !element.draghandle) {
            this.element.classList.remove(this.classes.DRAGHANDLE);
            this.initDragDrop();
        } else if (!isDraggable && element.draghandle) {
            this.element.classList.add(this.classes.DRAGHANDLE);
            this.initDragDrop();
        }
    }

    /**
     * Recursively create a list of all valid destinations for a current category within a parent category.
     *
     * @param {Element} item
     * @param {Number} movingCategoryId
     * @return {Array<Object>}
     */
    createMoveCategoryList(item, movingCategoryId) {
        const categories = [];
        if (item.children) {
            let precedingSibling = null;
            item.children.forEach(category => {
                const categoryId = parseInt(category.dataset.categoryid);
                // Don't create a target for the category that's moving.
                if (categoryId === movingCategoryId) {
                    return;
                }
                // Create a target to move before this child.
                let child = {
                    categoryid: categoryId,
                    movingcategoryid: movingCategoryId,
                    precedingsiblingid: precedingSibling?.dataset.categoryid ?? 0,
                    parent: category.dataset.parent,
                    categoryname: category.dataset.categoryname,
                    categories: null,
                    current: categoryId === movingCategoryId,
                };
                const childList = category.querySelector(this.selectors.CATEGORY_LIST);
                if (childList) {
                    // If the child has its own children, recursively make a list of those.
                    child.categories = this.createMoveCategoryList(childList, movingCategoryId);
                } else {
                    // Otherwise, create a target to move as a new child of this one.
                    child.categories = [
                        {
                            movingcategoryid: movingCategoryId,
                            precedingsiblingid: 0,
                            parent: categoryId,
                            categoryname: category.dataset.categoryname,
                            categories: null,
                            newchild: true,
                        }
                    ];
                }
                categories.push(child);
                precedingSibling = category;
            });
            if (precedingSibling) {
                const precedingId = parseInt(precedingSibling.dataset.categoryid);
                if (precedingId !== movingCategoryId) {
                    // If this is the last child of its parent, also create a target to move the category after this one.
                    categories.push({
                        movingcategoryid: movingCategoryId,
                        precedingsiblingid: precedingId,
                        parent: precedingSibling.dataset.parent,
                        categoryname: precedingSibling.dataset.categoryname,
                        categories: null,
                        lastchild: true,
                    });
                }
            }
        }
        return categories;
    }

    /**
     * Displays a modal containing links to move the category to a new location.
     *
     * @param {Event} e Button click event.
     */
    async showMoveModal(e) {
        // Return if it is not menu item.
        const item = e.target.closest(this.selectors.MOVE_BUTTON);
        if (!item) {
            return;
        }
        // Return if it is disabled.
        if (item.getAttribute('aria-disabled') === 'true') {
            return;
        }

        // Prevent addition click on the item.
        item.setAttribute('aria-disabled', true);

        // Build the list of move links.
        let moveList = {contexts: []};
        const contexts = document.querySelectorAll(this.selectors.CONTEXT);
        contexts.forEach(context => {
            const moveContext = {
                contextname: context.dataset.contextname,
                categories: [],
                hascategories: false,
            };
            moveContext.categories = this.createMoveCategoryList(context, parseInt(item.dataset.categoryid));
            moveContext.hascategories = moveContext.categories.length > 0;
            moveList.contexts.push(moveContext);
        });

        const modal = await Modal.create({
            title: getString('movecategory', 'qbank_managecategories', item.dataset.categoryname),
            body: Templates.render('qbank_managecategories/move_context_list', moveList),
            footer: '',
            show: true,
            large: true,
        });
        // Show modal and add click event for list items.
        modal.getBody()[0].addEventListener('click', e => {
            const target = e.target.closest(this.selectors.MODAL_CATEGORY_ITEM);
            if (!target) {
                return;
            }
            categorymanager.moveCategory(target.dataset.movingcategoryid, target.dataset.parent, target.dataset.precedingsiblingid);
            modal.destroy();
        });
        item.setAttribute('aria-disabled', false);
    }

    /**
     * Check and add a child list if needed.
     *
     * Check whether the category that has just been added has this category as its parent. If it does,
     * check that this category has a child list, and if not, add one.
     *
     * @param {Object} args
     * @param {Element} args.element The new category.
     * @return {Promise<Element>}
     */
    async checkChildList({element}) {
        if (element.parent !== this.getElement().dataset.categoryid) {
            return null; // Not for me.
        }
        let childList = this.getElement(this.selectors.CATEGORY_LIST);
        if (childList) {
            return null; // List already exists, it will handle adding the new category.
        }
        // Render and add a new child list containing the new category.
        return this.createChildList({
            categoryid: element.parent,
            children: [
                element.templatecontext,
            ]
        });
    }
}