// 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/>.
/**
* A type of dialogue used as for choosing options.
*
* @module core_course/local/activitychooser/dialogue
* @copyright 2019 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// import {addIconToContainer} from 'core/loadingicon';
import {debounce} from 'core/utils';
import DialogueDom from 'core_course/local/activitychooser/dialoguedom';
import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';
import Exporter from 'core_course/local/activitychooser/exporter';
import {getFirst} from 'core/normalise';
import {getString} from 'core/str';
import Modal from 'core/modal';
import * as ModalEvents from 'core/modal_events';
import Notification from 'core/notification';
import * as Repository from 'core_course/local/activitychooser/repository';
import selectors from 'core_course/local/activitychooser/selectors';
import * as Templates from 'core/templates';
const getPlugin = pluginName => import(pluginName);
/**
* Display the activity chooser modal.
*
* @method displayActivityChooser
* @param {Promise} footerDataPromise Promise for the footer data.
* @param {Promise} modulesDataPromise Promise for the modules data.
*/
export async function displayActivityChooserModal(
footerDataPromise,
modulesDataPromise,
) {
// We want to show the modal instantly but loading whilst waiting for our data.
let bodyPromiseResolver;
const bodyPromise = new Promise(resolve => {
bodyPromiseResolver = resolve;
});
const footerData = await footerDataPromise;
const sectionModal = Modal.create({
body: bodyPromise,
title: getString('addresourceoractivity'),
footer: footerData.customfootertemplate,
large: true,
scrollable: false,
templateContext: {
classes: 'modchooser'
},
show: true,
});
try {
const modulesData = await modulesDataPromise;
if (!modulesData) {
return;
}
const modal = await sectionModal;
const dialogue = new ActivityChooserDialogue(modal, modulesData, footerData);
const templateData = await dialogue.exporter.getModChooserTemplateData(modulesData);
bodyPromiseResolver(await Templates.render('core_course/activitychooser', templateData));
} catch (error) {
const errorTemplateData = {
'errormessage': error.message
};
bodyPromiseResolver(
await Templates.render('core_course/local/activitychooser/error', errorTemplateData)
);
return;
}
}
/**
* Display the module chooser.
*
* @deprecated since Moodle 5.1
* @todo Remove the method in Moodle 6.0 (MDL-85655).
* @method displayChooser
* @param {Promise} modalPromise Our created modal for the section
* @param {Array} sectionModules An array of all of the built module information
* @param {Function} partialFavourite Partially applied function we need to manage favourite status
* @param {Object} footerData Our base footer object.
*/
export const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => {
window.console.warn(
'The displayChooser function is deprecated. ' +
'Please displayActivityChooserModal instead.'
);
// Register event listeners.
modalPromise.then(modal => {
new ActivityChooserDialogue(modal, sectionModules, footerData);
return modal;
}).catch(Notification.exception);
};
/**
* Activity Chooser Dialogue class.
*
* @private
*/
class ActivityChooserDialogue {
/**
* Constructor for the ActivityChooserDialogue class.
* @param {Modal} modal The modal object.
* @param {Object} modulesData The data for the modules.
* @param {Object} footerData The data for the footer.
*/
constructor(modal, modulesData, footerData) {
this.modal = modal;
this.dialogueDom = null; // We cannot init until we have the modal body loaded.
this.footerData = footerData;
this.exporter = new Exporter();
// This attribute marks when the tab content is dirty and needs to be refreshed when the user changes the tab.
// We don't want the content to be updated while the user is managing their favourites.
this.isFavouriteTabDirty = false;
// Make a map so we can quickly fetch a specific module's object for either rendering or searching.
this.mappedModules = new Map();
modulesData.forEach((module) => {
this.mappedModules.set(module.componentname + '_' + module.link, module);
});
this.init();
}
/**
* Initialise the activity chooser dialogue.
*
* @return {Promise} A promise that resolves when the modal is ready.
*/
async init() {
const modalBody = getFirst(await this.modal.getBodyPromise());
this.dialogueDom = new DialogueDom(this, modalBody, this.exporter);
this.registerModalListenerEvents();
this.setupKeyboardAccessibility();
// We want to focus on the action select when the dialog is closed.
this.modal.getRoot().on(ModalEvents.hidden, () => {
this.modal.destroy();
});
}
/**
* Register chooser related event listeners.
*
* @returns {Promise} A promise that resolves when events are registered
*/
async registerModalListenerEvents() {
const modalRoot = getFirst(this.modal.getRoot());
// Changing the tab should cancel any active search.
modalRoot.addEventListener(
'shown.bs.tab',
(event) => {
// The all tab has the search result, so we do not want to clear the search input.
if (event.target.closest(selectors.regions.allTabNav)) {
return;
}
const searchInput = this.dialogueDom.getSearchInputElement();
if (searchInput.value.length > 0) {
searchInput.value = "";
this.toggleSearchResultsView(searchInput.value);
}
},
);
// Add the listener for clicks on the full modal.
modalRoot.addEventListener(
'click',
this.handleModalClick.bind(this),
);
// Add a listener for an input change in the activity chooser's search bar.
const searchInput = this.dialogueDom.getSearchInputElement();
searchInput.addEventListener(
'input',
debounce(
() => {
this.toggleSearchResultsView(searchInput.value);
},
300,
{pending: true},
),
);
this.dialogueDom.initBootstrapComponents();
// Handle focus when a new tab is shown.
modalRoot.addEventListener('shown.bs.tab', (event) => {
if (event.relatedTarget) {
this.dialogueDom.disableFocusAllChooserOptions(event.relatedTarget);
}
this.dialogueDom.initActiveTabNavigation();
});
// Update the favourite tab content when the user changes the tab.
modalRoot.addEventListener('shown.bs.tab', () => {
if (this.isFavouriteTabDirty && !this.dialogueDom.isFavoutiteTabActive()) {
this.refreshFavouritesTabContent();
}
});
this.dialogueDom.initActiveTabNavigation();
const modalFooter = getFirst(await this.modal.getFooterPromise());
// Add the listener for clicks on the footer.
modalFooter.addEventListener(
'click',
this.handleFooterClick.bind(this),
);
}
/**
* Handle the click event on the footer of the modal.
*
* @param {Object} event The event object
* @return {Promise} A promise that resolves when the event is handled
*/
async handleFooterClick(event) {
if (this.footerData.footer === true) {
const footerjs = await getPlugin(this.footerData.customfooterjs);
await footerjs.footerClickListener(event, this.footerData, this.modal);
}
}
/**
* Modal click handler.
*
* @param {Object} event The event object
* @return {Promise} A promise that resolves when the event is handled
*/
async handleModalClick(event) {
const target = event.target;
if (target.closest(selectors.actions.optionActions.showSummary)) {
this.handleShowSummary(target);
}
if (target.closest(selectors.actions.optionActions.manageFavourite)) {
await this.handleFavouriteClick(target);
}
// From the help screen go back to the module overview.
if (target.matches(selectors.actions.closeOption)) {
this.dialogueDom.hideModuleHelp(target.dataset.modname);
}
// The "clear search" button is triggered.
if (target.closest(selectors.actions.clearSearch)) {
this.handleClearSearch();
}
}
/**
* Show the summary of a module when the user clicks on the "show summary" button.
*
* @param {HTMLElement} target The target element that triggered the event
*/
handleShowSummary(target) {
const module = this.dialogueDom.getClosestChooserOption(target);
const moduleName = module.dataset.modname;
const moduleData = this.mappedModules.get(moduleName);
// We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.
moduleData.showFooter = this.modal.hasFooterContent();
this.dialogueDom.showModuleHelp(moduleData, this.modal);
}
/**
* Handle the favourite state of a module when the user clicks on the "starred" button.
*
* @param {HTMLElement} target The target element that triggered the event
* @return {Promise} A promise that resolves when the event is handled
*/
async handleFavouriteClick(target) {
const caller = target.closest(selectors.actions.optionActions.manageFavourite);
const id = caller.dataset.id;
const name = caller.dataset.name;
const internal = caller.dataset.internal;
const isFavourite = caller.dataset.favourited;
// Switch on fave or not.
if (isFavourite === 'true') {
await Repository.unfavouriteModule(name, id);
this.updateFavouriteItemValue(internal, false);
} else {
await Repository.favouriteModule(name, id);
this.updateFavouriteItemValue(internal, true);
}
}
/**
* Handle a clear search action.
*/
handleClearSearch() {
const searchInput = this.dialogueDom.getSearchInputElement();
searchInput.value = "";
searchInput.focus();
this.toggleSearchResultsView(searchInput.value);
}
/**
* Set up our tabindex information across the chooser.
*
* @method setupKeyboardAccessibility
*/
setupKeyboardAccessibility() {
const mainElement = getFirst(this.modal.getModal());
mainElement.tabIndex = -1;
mainElement.addEventListener('keydown', (e) => {
const currentOption = this.dialogueDom.getClosestChooserOption(e.target);
if (currentOption === null) {
return;
}
// Check for enter/ space triggers for showing the help.
if (e.keyCode === enter || e.keyCode === space) {
if (e.target.matches(selectors.actions.optionActions.showSummary)) {
e.preventDefault();
this.handleShowSummary(e.target);
}
}
if (e.keyCode === arrowRight) {
e.preventDefault();
this.dialogueDom.focusNextChooserOption(currentOption);
}
if (e.keyCode === arrowLeft) {
e.preventDefault();
this.dialogueDom.focusPreviousChooserOption(currentOption);
}
if (e.keyCode === home) {
e.preventDefault();
this.dialogueDom.focusFirstChooserOption(currentOption);
}
if (e.keyCode === end) {
e.preventDefault();
this.dialogueDom.focusLastChooserOption(currentOption);
}
});
}
/**
* Toggle (display/hide) the search results depending on the value of the search query
*
* @method toggleSearchResultsView
* @param {String} searchQuery The search query
*/
async toggleSearchResultsView(searchQuery) {
const searchResultsData = this.searchModules(searchQuery);
if (searchQuery.length > 0) {
await this.dialogueDom.refreshSearchResults(searchResultsData);
this.dialogueDom.showAllActivitiesTab(true);
} else {
this.dialogueDom.cleanSearchResults();
}
}
/**
* Return the list of modules which have a name or description that matches the given search term.
*
* @method searchModules
* @param {String} searchTerm The search term to match
* @return {Array}
*/
searchModules(searchTerm) {
if (searchTerm === '') {
return this.mappedModules;
}
searchTerm = searchTerm.toLowerCase();
const searchResults = [];
this.mappedModules.forEach((activity) => {
const activityName = activity.title.toLowerCase();
const activityDesc = activity.help.toLowerCase();
if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {
searchResults.push(activity);
}
});
return searchResults;
}
/**
* Update the favourite item value in the mapped modules.
*
* @param {String} internal The internal name of the module.
* @param {Boolean} favourite Whether the module is a favourite or not.
* @return {Promise} A promise that resolves when the item is updated.
*/
async updateFavouriteItemValue(internal, favourite) {
const moduleItem = this.mappedModules.find(({name}) => name === internal);
if (!moduleItem) {
return;
}
moduleItem.favourite = favourite;
this.dialogueDom.updateItemStarredIcons(internal, favourite);
if (this.dialogueDom.isFavoutiteTabActive()) {
this.isFavouriteTabDirty = true;
} else {
this.refreshFavouritesTabContent();
}
}
/**
* Refresh the favourites tab content.
*
* Note: this method will also hide the favourites tab if there are no favourite modules
* to keep the modal consistent.
*
* @return {Promise} A promise that resolves when the content is refreshed.
*/
async refreshFavouritesTabContent() {
this.isFavouriteTabDirty = false;
const favouriteCount = this.mappedModules.filter(mod => mod.favourite === true).size;
this.dialogueDom.toggleFavouriteTabDisplay(favouriteCount > 0);
await this.dialogueDom.refreshFavouritesTabContent(this.mappedModules);
}
}