course/amd/src/local/activitychooser/dialogue.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/>.

/**
 * 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);
    }
}