course/format/amd/src/local/content/actions/bulkselection.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/>.

/**
 * Bulk selection auxiliar methods.
 *
 * @module     core_courseformat/local/content/actions/bulkselection
 * @class      core_courseformat/local/content/actions/bulkselection
 * @copyright  2023 Ferran Recio <ferran@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class BulkSelector {

    /**
     * The class constructor.
     * @param {CourseEditor} courseEditor the original actions component.
     */
    constructor(courseEditor) {
        this.courseEditor = courseEditor;
        this.selectors = {
            BULKCMCHECKBOX: `[data-bulkcheckbox][data-action='toggleSelectionCm']`,
            BULKSECTIONCHECKBOX: `[data-bulkcheckbox][data-action='toggleSelectionSection']`,
            CONTENT: `#region-main`,
        };
    }

    /**
     * Process a new selection.
     * @param {Number} id
     * @param {String} elementType cm or section
     * @param {Object} settings special selection settings
     * @param {Boolean} settings.all if the action is over all elements of the same type
     * @param {Boolean} settings.range if the action is over a range of elements
     */
    processNewSelection(id, elementType, settings) {
        const value = !this._isBulkSelected(id, elementType);
        if (settings.all && settings.range) {
            this.switchCurrentSelection();
            return;
        }
        if (!this._isSelectable(id, elementType)) {
            return;
        }
        if (settings.all) {
            if (elementType == 'cm') {
                this._updateBulkCmSiblings(id, value);
            } else {
                this._updateBulkSelectionAll(elementType, value);
            }
            return;
        }
        if (settings.range) {
            this._updateBulkSelectionRange(id, elementType, value);
            return;
        }
        this._updateBulkSelection([id], elementType, value);
    }

    /**
     * Switch between section and cm selection.
     */
    switchCurrentSelection() {
        const bulk = this.courseEditor.get('bulk');
        if (bulk.selectedType === '' || bulk.selection.length == 0) {
            return;
        }
        const newSelectedType = (bulk.selectedType === 'section') ? 'cm' : 'section';
        let newSelectedIds;
        if (bulk.selectedType === 'section') {
            newSelectedIds = this._getCmIdsFromSections(bulk.selection);
        } else {
            newSelectedIds = this._getSectionIdsFromCms(bulk.selection);
        }
        // Formats can display only a few activities of the section,
        // We need to select on the activities present in the page.
        const affectedIds = [];
        newSelectedIds.forEach(newId => {
            if (this._getSelector(newId, newSelectedType)) {
                affectedIds.push(newId);
            }
        });
        this.courseEditor.dispatch('bulkEnable', true);
        if (affectedIds.length != 0) {
            this._updateBulkSelection(affectedIds, newSelectedType, true);
        }
    }

    /**
     * Select all elements of the current type.
     * @param {Boolean} value the wanted selected value
     */
    selectAll(value) {
        const bulk = this.courseEditor.get('bulk');
        if (bulk.selectedType == '') {
            return;
        }
        if (!value) {
            this.courseEditor.dispatch('bulkEnable', true);
            return;
        }
        const elementType = bulk.selectedType;
        this._updateBulkSelectionAll(elementType, value);
    }

    /**
     * Checks if all selectable elements are selected.
     * @returns {Boolean} true if all are selected
     */
    checkAllSelected() {
        const bulk = this.courseEditor.get('bulk');
        if (bulk.selectedType == '') {
            return false;
        }
        return this._getContentCheckboxes(bulk.selectedType).every(bulkSelect => {
            if (bulkSelect.disabled) {
                return true;
            }
            // Some sections may not be selectale for bulk actions.
            if (bulk.selectedType == 'section') {
                const section = this.courseEditor.get('section', bulkSelect.dataset.id);
                if (!section.bulkeditable) {
                    return true;
                }
            }
            return bulk.selection.includes(bulkSelect.dataset.id);
        });
    }

    /**
     * Check if the id is part of the current bulk selection.
     * @private
     * @param {Number} id
     * @param {String} elementType
     * @returns {Boolean} if the element is present in the current selection.
     */
    _isBulkSelected(id, elementType) {
        const bulk = this.courseEditor.get('bulk');
        if (bulk.selectedType !== elementType) {
            return false;
        }
        return bulk.selection.includes(id);
    }

    /**
     * Update the current bulk selection removing or adding Ids.
     * @private
     * @param {Number[]} ids the user selected element id
     * @param {String} elementType cm or section
     * @param {Boolean} value the wanted selected value
     */
    _updateBulkSelection(ids, elementType, value) {
        let mutation = elementType;
        mutation += (value) ? 'Select' : 'Unselect';
        this.courseEditor.dispatch(mutation, ids);
    }

    /**
     * Get all content bulk selector checkboxes of one type (section/cm).
     * @private
     * @param {String} elementType section or cm
     * @returns {HTMLElement[]} an array with all checkboxes
     */
    _getContentCheckboxes(elementType) {
        const selector = (elementType == 'cm') ? this.selectors.BULKCMCHECKBOX : this.selectors.BULKSECTIONCHECKBOX;
        const checkboxes = document.querySelectorAll(`${this.selectors.CONTENT} ${selector}`);
        // Converting to array because NodeList has less iteration methods.
        return [...checkboxes];
    }

    /**
     * Validate if an element is selectable in the current page.
     * @private
     * @param {Number} id the user selected element id
     * @param {String} elementType cm or section
     * @return {Boolean}
     */
    _isSelectable(id, elementType) {
        const bulkSelect = this._getSelector(id, elementType);
        if (!bulkSelect || bulkSelect.disabled) {
            return false;
        }
        return true;
    }

    /**
     * Get as specific element checkbox.
     * @private
     * @param {Number} id
     * @param {String} elementType cm or section
     * @returns {HTMLElement|undefined}
     */
    _getSelector(id, elementType) {
        let selector = (elementType == 'cm') ? this.selectors.BULKCMCHECKBOX : this.selectors.BULKSECTIONCHECKBOX;
        selector += `[data-id='${id}']`;
        return document.querySelector(`${this.selectors.CONTENT} ${selector}`);
    }

    /**
     * Update the current bulk selection when a user uses shift to select a range.
     * @private
     * @param {Number} id the user selected element id
     * @param {String} elementType cm or section
     * @param {Boolean} value the wanted selected value
     */
    _updateBulkSelectionRange(id, elementType, value) {
        const bulk = this.courseEditor.get('bulk');
        let lastSelectedId = bulk.selection.at(-1);
        if (bulk.selectedType !== elementType || lastSelectedId == id) {
            this._updateBulkSelection([id], elementType, value);
            return;
        }
        const affectedIds = [];
        let found = 0;
        this._getContentCheckboxes(elementType).every(bulkSelect => {
            if (bulkSelect.disabled) {
                return true;
            }
            if (elementType == 'section') {
                const section = this.courseEditor.get('section', bulkSelect.dataset.id);
                if (value && !section?.bulkeditable) {
                    return true;
                }
            }
            if (bulkSelect.dataset.id == id || bulkSelect.dataset.id == lastSelectedId) {
                found++;
            }
            if (found == 0) {
                return true;
            }
            affectedIds.push(bulkSelect.dataset.id);
            return found != 2;
        });
        this._updateBulkSelection(affectedIds, elementType, value);
    }

    /**
     * Select or unselect all cm siblings.
     * @private
     * @param {Number} cmId the user selected element id
     * @param {Boolean} value the wanted selected value
     */
    _updateBulkCmSiblings(cmId, value) {
        const bulk = this.courseEditor.get('bulk');
        if (bulk.selectedType === 'section') {
            return;
        }
        const cm = this.courseEditor.get('cm', cmId);
        const section = this.courseEditor.get('section', cm.sectionid);
        // Formats can display only a few activities of the section,
        // We need to select on the activities selectable in the page.
        const affectedIds = [];
        section.cmlist.forEach(sectionCmId => {
            if (this._isSelectable(sectionCmId, 'cm')) {
                affectedIds.push(sectionCmId);
            }
        });
        this._updateBulkSelection(affectedIds, 'cm', value);
    }

    /**
     * Select or unselects al elements of the same type.
     * @private
     * @param {String} elementType section or cm
     * @param {Boolean} value if the elements must be selected or unselected.
     */
    _updateBulkSelectionAll(elementType, value) {
        const affectedIds = [];
        this._getContentCheckboxes(elementType).forEach(bulkSelect => {
            if (bulkSelect.disabled) {
                return;
            }
            if (elementType == 'section') {
                const section = this.courseEditor.get('section', bulkSelect.dataset.id);
                if (value && !section?.bulkeditable) {
                    return;
                }
            }
            affectedIds.push(bulkSelect.dataset.id);
        });
        this._updateBulkSelection(affectedIds, elementType, value);
    }

    /**
     * Get all cm ids from a specific section ids.
     * @private
     * @param {Number[]} sectionIds
     * @returns {Number[]} the cm ids
     */
    _getCmIdsFromSections(sectionIds) {
        const result = [];
        sectionIds.forEach(sectionId => {
            const section = this.courseEditor.get('section', sectionId);
            result.push(...section.cmlist);
        });
        return result;
    }

    /**
     * Get all section ids containing a specific cm ids.
     * @private
     * @param {Number[]} cmIds
     * @returns {Number[]} the section ids
     */
    _getSectionIdsFromCms(cmIds) {
        const result = new Set();
        cmIds.forEach(cmId => {
            const cm = this.courseEditor.get('cm', cmId);
            if (cm.sectionnumber == 0) {
                return;
            }
            result.add(cm.sectionid);
        });
        return [...result];
    }
}

/**
 * Process a bulk selection toggle action.
 * @method
 * @param {CourseEditor} courseEditor
 * @param {HTMLElement} target the action element
 * @param {Event} event
 * @param {String} elementType cm or section
 */
export const toggleBulkSelectionAction = function(courseEditor, target, event, elementType) {
    const id = target.dataset.id;
    if (!id) {
        return;
    }
    // When the action cames from a form element (checkbox) we should not preventDefault.
    // If we do it the changechecker module will execute the state change twice.
    if (target.dataset.preventDefault) {
        event.preventDefault();
    }
    // Using shift or alt key can produce text selection.
    document.getSelection().removeAllRanges();

    const bulkSelector = new BulkSelector(courseEditor);
    bulkSelector.processNewSelection(
        id,
        elementType,
        {
            range: event.shiftKey,
            all: event.altKey,
        }
    );
};

/**
 * Switch the current bulk selection.
 * @method
 * @param {CourseEditor} courseEditor
 */
export const switchBulkSelection = function(courseEditor) {
    const bulkSelector = new BulkSelector(courseEditor);
    bulkSelector.switchCurrentSelection();
};

/**
 * Select/unselect all element of the selected type.
 * @method
 * @param {CourseEditor} courseEditor
 * @param {Boolean} value if the elements must be selected or unselected.
 */
export const selectAllBulk = function(courseEditor, value) {
    const bulkSelector = new BulkSelector(courseEditor);
    bulkSelector.selectAll(value);
};

/**
 * Check if all possible elements are selected.
 * @method
 * @param {CourseEditor} courseEditor
 * @return {Boolean} if all elements of the current type are selected.
 */
export const checkAllBulkSelected = function(courseEditor) {
    const bulkSelector = new BulkSelector(courseEditor);
    return bulkSelector.checkAllSelected();
};