// 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/>.
import Templates from 'core/templates';
import {get_string as getString} from 'core/str';
import {disableStickyFooter, enableStickyFooter} from 'core/sticky-footer';
/**
* Base class for defining a bulk actions area within a page.
*
* @module core/bulkactions/bulk_actions
* @copyright 2023 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/** @constant {Object} The object containing the relevant selectors. */
const Selectors = {
stickyFooterContainer: '#sticky-footer',
selectedItemsCountContainer: '[data-type="bulkactions"] [data-for="bulkcount"]',
cancelBulkActionModeElement: '[data-type="bulkactions"] [data-action="bulkcancel"]',
bulkModeContainer: '[data-type="bulkactions"]',
bulkActionsContainer: '[data-type="bulkactions"] [data-for="bulktools"]'
};
export default class BulkActions {
/** @property {string|null} initialStickyFooterContent The initial content of the sticky footer. */
initialStickyFooterContent = null;
/** @property {Array} selectedItems The array of selected item elements. */
selectedItems = [];
/** @property {boolean} isBulkActionsModeEnabled Whether the bulk actions mode is enabled. */
isBulkActionsModeEnabled = false;
/**
* @property {int} maxButtons Sets the maximum number of action buttons to display. If exceeded, additional actions
* are shown in a dropdown menu.
*/
maxButtons = 5;
/**
* The class constructor.
*
* @param {int|null} maxButtons Sets the maximum number of action buttons to display. If exceeded, additional actions
* are shown in a dropdown menu.
* @returns {void}
*/
constructor(maxButtons = null) {
if (!this.getStickyFooterContainer()) {
throw new Error('Sticky footer not found.');
}
// Store any pre-existing content in the sticky footer. When bulk actions mode is enabled, this content will be
// replaced with the bulk actions content and restored when bulk actions mode is disabled.
this.initialStickyFooterContent = this.getStickyFooterContainer().innerHTML;
if (maxButtons) {
this.maxButtons = maxButtons;
}
// Register and handle the item select change event.
this.registerItemSelectChangeEvent(async() => {
this.selectedItems = this.getSelectedItems();
if (this.selectedItems.length > 0) { // At least one item is selected.
// If the bulk actions mode is already enabled only update the selected items count.
if (this.isBulkActionsModeEnabled) {
await this.updateBulkItemSelection();
} else { // Otherwise, enable the bulk action mode.
await this.enableBulkActionsMode();
}
} else { // No items are selected, disable the bulk action mode.
this.disableBulkActionsMode();
}
});
}
/**
* Returns the array of the relevant bulk action objects.
*
* @method getBulkActions
* @returns {Array}
*/
getBulkActions() {
throw new Error(`getBulkActions() must be implemented in ${this.constructor.name}`);
}
/**
* Returns the array of selected items.
*
* @method getSelectedItems
* @returns {Array}
*/
getSelectedItems() {
throw new Error(`getSelectedItems() must be implemented in ${this.constructor.name}`);
}
/**
* Adds the listener for the item select change event.
* The event handler function that is passed as a parameter should be called right after the event is triggered.
*
* @method registerItemSelectChangeEvent
* @param {function} eventHandler The event handler function.
* @returns {void}
*/
registerItemSelectChangeEvent(eventHandler) {
throw new Error(`registerItemSelectChangeEvent(${eventHandler}) must be implemented in ${this.constructor.name}`);
}
/**
* Defines the action for deselecting a selected item.
*
* The base bulk actions class supports deselecting all selected items but does not have knowledge of the type of the
* selected element. Therefore, each subclass must explicitly define the action of resetting the attributes that
* indicate a selected state.
*
* @method deselectItem
* @param {HTMLElement} selectedItem The selected element.
* @returns {void}
*/
deselectItem(selectedItem) {
throw new Error(`deselectItem(${selectedItem}) must be implemented in ${this.constructor.name}`);
}
/**
* Returns the sticky footer container.
*
* @method getStickyFooterContainer
* @returns {HTMLElement}
*/
getStickyFooterContainer() {
return document.querySelector(Selectors.stickyFooterContainer);
}
/**
* Enables the bulk action mode.
*
* @method enableBulkActionsMode
* @returns {Promise<void>}
*/
async enableBulkActionsMode() {
// Make sure that the sticky footer is enabled.
enableStickyFooter();
// Render the bulk actions content in the sticky footer container.
this.getStickyFooterContainer().innerHTML = await this.renderBulkActions();
const bulkModeContainer = this.getStickyFooterContainer().querySelector(Selectors.bulkModeContainer);
const bulkActionsContainer = bulkModeContainer.querySelector(Selectors.bulkActionsContainer);
this.getBulkActions().forEach((bulkAction) => {
// Register the listener events for each available bulk action.
bulkAction.registerListenerEvents(bulkActionsContainer);
// Set the selected items for each available bulk action.
bulkAction.setSelectedItems(this.selectedItems);
});
// Register the click listener event for the cancel bulk mode button.
bulkModeContainer.addEventListener('click', (e) => {
if (e.target.closest(Selectors.cancelBulkActionModeElement)) {
// Deselect all selected items.
this.selectedItems.forEach((item) => {
this.deselectItem(item);
});
// Disable the bulk action mode.
this.disableBulkActionsMode();
}
});
this.isBulkActionsModeEnabled = true;
}
/**
* Disables the bulk action mode.
*
* @method disableBulkActionsMode
* @returns {void}
*/
disableBulkActionsMode() {
// If there was any previous (initial) content in the sticky footer, restore it.
if (this.initialStickyFooterContent.length > 0) {
this.getStickyFooterContainer().innerHTML = this.initialStickyFooterContent;
} else { // No previous content to restore, disable the sticky footer.
disableStickyFooter();
}
this.isBulkActionsModeEnabled = false;
}
/**
* Renders the bulk actions content.
*
* @method renderBulkActions
* @returns {Promise<string>}
*/
async renderBulkActions() {
const data = {
bulkselectioncount: this.selectedItems.length,
actions: [],
moreactions: [],
hasmoreactions: false,
};
const bulkActions = this.getBulkActions();
const showMoreButton = bulkActions.length > this.maxButtons;
// Get all bulk actions and render them in order.
const actions = await Promise.all(
bulkActions.map((bulkAction, index) =>
bulkAction.renderBulkActionTrigger(
showMoreButton && (index >= this.maxButtons - 1),
index
)
)
);
// Separate rendered actions into data.actions and data.moreactions in the correct order.
actions.forEach((actionTrigger, index) => {
if (showMoreButton && (index >= this.maxButtons - 1)) {
data.moreactions.push({'actiontrigger': actionTrigger});
} else {
data.actions.push({'actiontrigger': actionTrigger});
}
});
data.hasmoreactions = data.moreactions.length > 0;
return Templates.render('core/bulkactions/bulk_actions', data);
}
/**
* Updates the selected items count in the bulk actions content.
*
* @method updateBulkItemSelection
* @returns {void}
*/
async updateBulkItemSelection() {
const bulkSelection = await getString('bulkselection', 'core', this.selectedItems.length);
document.querySelector(Selectors.selectedItemsCountContainer).innerHTML = bulkSelection;
}
}