grade/amd/src/searchwidget/basewidget.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 widget to search users or grade items within the gradebook.
 *
 * @module    core_grades/searchwidget/basewidget
 * @copyright 2022 Mathew May <mathew.solutions>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
import {debounce} from 'core/utils';
import * as Templates from 'core/templates';
import * as Selectors from 'core_grades/searchwidget/selectors';
import Notification from 'core/notification';

/**
 * Build the base searching widget.
 *
 * @method init
 * @param {HTMLElement} widgetContentContainer The selector for the widget container element.
 * @param {Promise} bodyPromise The promise from the callee of the contents to place in the widget container.
 * @param {Array} data An array of all the data generated by the callee.
 * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
 * @param {string|null} unsearchableContent The content rendered in a non-searchable area.
 * @param {Function|null} afterSelect Callback executed after an item is selected.
 */
export const init = async(
    widgetContentContainer,
    bodyPromise,
    data,
    searchFunc,
    unsearchableContent = null,
    afterSelect = null,
) => {
    bodyPromise.then(async(bodyContent) => {
        // Render the body content.
        widgetContentContainer.innerHTML = bodyContent;

        // Render the unsearchable content if defined.
        if (unsearchableContent) {
            const unsearchableContentContainer = widgetContentContainer.querySelector(Selectors.regions.unsearchableContent);
            unsearchableContentContainer.innerHTML += unsearchableContent;
        }

        const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
        // Display a loader until the search results are rendered.
        await showLoader(searchResultsContainer);
        // Render the search results.
        await renderSearchResults(searchResultsContainer, data);

        registerListenerEvents(widgetContentContainer, data, searchFunc, afterSelect);

    }).catch(Notification.exception);
};

/**
 * Register the event listeners for the search widget.
 *
 * @method registerListenerEvents
 * @param {HTMLElement} widgetContentContainer The selector for the widget container element.
 * @param {Array} data An array of all the data generated by the callee.
 * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
 * @param {Function|null} afterSelect Callback executed after an item is selected.
 */
export const registerListenerEvents = (widgetContentContainer, data, searchFunc, afterSelect = null) => {
    const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
    const searchInput = widgetContentContainer.querySelector(Selectors.actions.search);

    if (!searchInput) {
        // Too late. The widget is already closed and its content is empty.
        return;
    }

    // We want to focus on the first known user interable element within the dropdown.
    searchInput.focus();
    const clearSearchButton = widgetContentContainer.querySelector(Selectors.actions.clearSearch);

    // The search input is triggered.
    searchInput.addEventListener('input', debounce(async() => {
        // If search query is present display the 'clear search' button, otherwise hide it.
        if (searchInput.value.length > 0) {
            clearSearchButton.classList.remove('d-none');
        } else {
            clearSearchButton.classList.add('d-none');
        }
        // Remove aria-activedescendant when the available options change.
        searchInput.removeAttribute('aria-activedescendant');
        // Display the search results.
        await renderSearchResults(
            searchResultsContainer,
            debounceCallee(
                searchInput.value,
                data,
                searchFunc()
            )
        );
    }, 300));

    // Clear search is triggered.
    clearSearchButton.addEventListener('click', async(e) => {
        e.stopPropagation();
        // Clear the entered search query in the search bar.
        searchInput.value = "";
        searchInput.focus();
        clearSearchButton.classList.add('d-none');

        // Remove aria-activedescendant when the available options change.
        searchInput.removeAttribute('aria-activedescendant');

        // Display all results.
        await renderSearchResults(
            searchResultsContainer,
            debounceCallee(
                searchInput.value,
                data,
                searchFunc()
            )
        );
    });

    const inputElement = document.getElementById(searchInput.dataset.inputElement);
    if (inputElement && afterSelect) {
        inputElement.addEventListener('change', e => {
            const selectedOption = widgetContentContainer.querySelector(
                Selectors.elements.getSearchWidgetSelectOption(searchInput),
            );

            if (selectedOption) {
                afterSelect(e.target.value);
            }
        });
    }

    // Backward compatibility. Handle the click event for the following cases:
    // - When we have <li> tags without an afterSelect callback function being provided (old js).
    // - When we have <a> tags without href (old template).
    widgetContentContainer.addEventListener('click', e => {
        const deprecatedOption = e.target.closest(
            'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
        );
        if (deprecatedOption) {
            // We are in one of these situations:
            // - We have <li> tags without an afterSelect callback function being provided.
            // - We have <a> tags without href.
            if (inputElement && afterSelect) {
                afterSelect(deprecatedOption.dataset.value);
            } else {
                const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
                location.href = url;
            }
        }
    });

    // Backward compatibility. Handle the keydown event for the following cases:
    // - When we have <li> tags without an afterSelect callback function being provided (old js).
    // - When we have <a> tags without href (old template).
    widgetContentContainer.addEventListener('keydown', e => {
        const deprecatedOption = e.target.closest(
            'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
        );
        if (deprecatedOption && (e.key === ' ' || e.key === 'Enter')) {
            // We are in one of these situations:
            // - We have <li> tags without an afterSelect callback function being provided.
            // - We have <a> tags without href.
            e.preventDefault();
            if (inputElement && afterSelect) {
                afterSelect(deprecatedOption.dataset.value);
            } else {
                const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
                location.href = url;
            }
        }
    });
};

/**
 * Renders the loading placeholder for the search widget.
 *
 * @method showLoader
 * @param {HTMLElement} container The DOM node where we'll render the loading placeholder.
 */
export const showLoader = async(container) => {
    container.innerHTML = '';
    const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/loading', {});
    Templates.replaceNodeContents(container, html, js);
};

/**
 * We have a small helper that'll call the curried search function allowing callers to filter
 * the data set however we want rather than defining how data must be filtered.
 *
 * @method debounceCallee
 * @param {String} searchValue The input from the user that we'll search against.
 * @param {Array} data An array of all the data generated by the callee.
 * @param {Function} searchFunction Partially applied function we need to manage search the passed dataset.
 * @return {Array} The filtered subset of the provided data that we'll then render into the results.
 */
const debounceCallee = (searchValue, data, searchFunction) => {
    if (searchValue.length > 0) { // Search query is present.
        return searchFunction(data, searchValue);
    }
    return data;
};

/**
 * Given the output of the callers' search function, render out the results into the search results container.
 *
 * @method renderSearchResults
 * @param {HTMLElement} searchResultsContainer The DOM node of the widget where we'll render the provided results.
 * @param {Array} searchResultsData The filtered subset of the provided data that we'll then render into the results.
 */
const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
    const templateData = {
        'searchresults': searchResultsData,
    };
    // Build up the html & js ready to place into the help section.
    const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/searchresults', templateData);
    await Templates.replaceNodeContents(searchResultsContainer, html, js);

    // Backward compatibility.
    if (searchResultsContainer.getAttribute('role') !== 'listbox') {
        const deprecatedOptions = searchResultsContainer.querySelectorAll(
            'a.dropdown-item[role="menuitem"][href=""], .dropdown-item[role="option"]:not([href])'
        );
        for (const option of deprecatedOptions) {
            option.tabIndex = 0;
            option.removeAttribute('href');
        }
    }
};

/**
 * We want to create the basic promises and hooks that the caller will implement, so we can build the search widget
 * ahead of time and allow the caller to resolve their promises once complete.
 *
 * @method promisesAndResolvers
 * @returns {{bodyPromise: Promise, bodyPromiseResolver}}
 */
export const promisesAndResolvers = () => {
    // We want to show the widget instantly but loading whilst waiting for our data.
    let bodyPromiseResolver;
    const bodyPromise = new Promise(resolve => {
        bodyPromiseResolver = resolve;
    });

    return {bodyPromiseResolver, bodyPromise};
};