Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * A widget to search users or grade items within the gradebook.
18
 *
19
 * @module    core_grades/searchwidget/basewidget
20
 * @copyright 2022 Mathew May <mathew.solutions>
21
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
import {debounce} from 'core/utils';
24
import * as Templates from 'core/templates';
25
import * as Selectors from 'core_grades/searchwidget/selectors';
26
import Notification from 'core/notification';
27
import Log from 'core/log';
28
 
29
/**
30
 * Build the base searching widget.
31
 *
32
 * @method init
33
 * @param {HTMLElement} widgetContentContainer The selector for the widget container element.
34
 * @param {Promise} bodyPromise The promise from the callee of the contents to place in the widget container.
35
 * @param {Array} data An array of all the data generated by the callee.
36
 * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
37
 * @param {string|null} unsearchableContent The content rendered in a non-searchable area.
38
 * @param {Function|null} afterSelect Callback executed after an item is selected.
39
 */
40
export const init = async(
41
    widgetContentContainer,
42
    bodyPromise,
43
    data,
44
    searchFunc,
45
    unsearchableContent = null,
46
    afterSelect = null,
47
) => {
48
    Log.debug('The core_grades/searchwidget/basewidget component is deprecated. Please refer to core/search_combobox() instead.');
49
    bodyPromise.then(async(bodyContent) => {
50
        // Render the body content.
51
        widgetContentContainer.innerHTML = bodyContent;
52
 
53
        // Render the unsearchable content if defined.
54
        if (unsearchableContent) {
55
            const unsearchableContentContainer = widgetContentContainer.querySelector(Selectors.regions.unsearchableContent);
56
            unsearchableContentContainer.innerHTML += unsearchableContent;
57
        }
58
 
59
        const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
60
        // Display a loader until the search results are rendered.
61
        await showLoader(searchResultsContainer);
62
        // Render the search results.
63
        await renderSearchResults(searchResultsContainer, data);
64
 
65
        registerListenerEvents(widgetContentContainer, data, searchFunc, afterSelect);
66
 
67
    }).catch(Notification.exception);
68
};
69
 
70
/**
71
 * Register the event listeners for the search widget.
72
 *
73
 * @method registerListenerEvents
74
 * @param {HTMLElement} widgetContentContainer The selector for the widget container element.
75
 * @param {Array} data An array of all the data generated by the callee.
76
 * @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
77
 * @param {Function|null} afterSelect Callback executed after an item is selected.
78
 */
79
export const registerListenerEvents = (widgetContentContainer, data, searchFunc, afterSelect = null) => {
80
    const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
81
    const searchInput = widgetContentContainer.querySelector(Selectors.actions.search);
82
 
83
    if (!searchInput) {
84
        // Too late. The widget is already closed and its content is empty.
85
        return;
86
    }
87
 
88
    // We want to focus on the first known user interable element within the dropdown.
89
    searchInput.focus();
90
    const clearSearchButton = widgetContentContainer.querySelector(Selectors.actions.clearSearch);
91
 
92
    // The search input is triggered.
93
    searchInput.addEventListener('input', debounce(async() => {
94
        // If search query is present display the 'clear search' button, otherwise hide it.
95
        if (searchInput.value.length > 0) {
96
            clearSearchButton.classList.remove('d-none');
97
        } else {
98
            clearSearchButton.classList.add('d-none');
99
        }
100
        // Remove aria-activedescendant when the available options change.
101
        searchInput.removeAttribute('aria-activedescendant');
102
        // Display the search results.
103
        await renderSearchResults(
104
            searchResultsContainer,
105
            debounceCallee(
106
                searchInput.value,
107
                data,
108
                searchFunc()
109
            )
110
        );
111
    }, 300));
112
 
113
    // Clear search is triggered.
114
    clearSearchButton.addEventListener('click', async(e) => {
115
        e.stopPropagation();
116
        // Clear the entered search query in the search bar.
117
        searchInput.value = "";
118
        searchInput.focus();
119
        clearSearchButton.classList.add('d-none');
120
 
121
        // Remove aria-activedescendant when the available options change.
122
        searchInput.removeAttribute('aria-activedescendant');
123
 
124
        // Display all results.
125
        await renderSearchResults(
126
            searchResultsContainer,
127
            debounceCallee(
128
                searchInput.value,
129
                data,
130
                searchFunc()
131
            )
132
        );
133
    });
134
 
135
    const inputElement = document.getElementById(searchInput.dataset.inputElement);
136
    if (inputElement && afterSelect) {
137
        inputElement.addEventListener('change', e => {
138
            const selectedOption = widgetContentContainer.querySelector(
139
                Selectors.elements.getSearchWidgetSelectOption(searchInput),
140
            );
141
 
142
            if (selectedOption) {
143
                afterSelect(e.target.value);
144
            }
145
        });
146
    }
147
 
148
    // Backward compatibility. Handle the click event for the following cases:
149
    // - When we have <li> tags without an afterSelect callback function being provided (old js).
150
    // - When we have <a> tags without href (old template).
151
    widgetContentContainer.addEventListener('click', e => {
152
        const deprecatedOption = e.target.closest(
153
            'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
154
        );
155
        if (deprecatedOption) {
156
            // We are in one of these situations:
157
            // - We have <li> tags without an afterSelect callback function being provided.
158
            // - We have <a> tags without href.
159
            if (inputElement && afterSelect) {
160
                afterSelect(deprecatedOption.dataset.value);
161
            } else {
162
                const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
163
                location.href = url;
164
            }
165
        }
166
    });
167
 
168
    // Backward compatibility. Handle the keydown event for the following cases:
169
    // - When we have <li> tags without an afterSelect callback function being provided (old js).
170
    // - When we have <a> tags without href (old template).
171
    widgetContentContainer.addEventListener('keydown', e => {
172
        const deprecatedOption = e.target.closest(
173
            'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
174
        );
175
        if (deprecatedOption && (e.key === ' ' || e.key === 'Enter')) {
176
            // We are in one of these situations:
177
            // - We have <li> tags without an afterSelect callback function being provided.
178
            // - We have <a> tags without href.
179
            e.preventDefault();
180
            if (inputElement && afterSelect) {
181
                afterSelect(deprecatedOption.dataset.value);
182
            } else {
183
                const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
184
                location.href = url;
185
            }
186
        }
187
    });
188
};
189
 
190
/**
191
 * Renders the loading placeholder for the search widget.
192
 *
193
 * @method showLoader
194
 * @param {HTMLElement} container The DOM node where we'll render the loading placeholder.
195
 */
196
export const showLoader = async(container) => {
197
    container.innerHTML = '';
198
    const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/loading', {});
199
    Templates.replaceNodeContents(container, html, js);
200
};
201
 
202
/**
203
 * We have a small helper that'll call the curried search function allowing callers to filter
204
 * the data set however we want rather than defining how data must be filtered.
205
 *
206
 * @method debounceCallee
207
 * @param {String} searchValue The input from the user that we'll search against.
208
 * @param {Array} data An array of all the data generated by the callee.
209
 * @param {Function} searchFunction Partially applied function we need to manage search the passed dataset.
210
 * @return {Array} The filtered subset of the provided data that we'll then render into the results.
211
 */
212
const debounceCallee = (searchValue, data, searchFunction) => {
213
    if (searchValue.length > 0) { // Search query is present.
214
        return searchFunction(data, searchValue);
215
    }
216
    return data;
217
};
218
 
219
/**
220
 * Given the output of the callers' search function, render out the results into the search results container.
221
 *
222
 * @method renderSearchResults
223
 * @param {HTMLElement} searchResultsContainer The DOM node of the widget where we'll render the provided results.
224
 * @param {Array} searchResultsData The filtered subset of the provided data that we'll then render into the results.
225
 */
226
const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
227
    const templateData = {
228
        'searchresults': searchResultsData,
229
    };
230
    // Build up the html & js ready to place into the help section.
231
    const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/searchresults', templateData);
232
    await Templates.replaceNodeContents(searchResultsContainer, html, js);
233
 
234
    // Backward compatibility.
235
    if (searchResultsContainer.getAttribute('role') !== 'listbox') {
236
        const deprecatedOptions = searchResultsContainer.querySelectorAll(
237
            'a.dropdown-item[role="menuitem"][href=""], .dropdown-item[role="option"]:not([href])'
238
        );
239
        for (const option of deprecatedOptions) {
240
            option.tabIndex = 0;
241
            option.removeAttribute('href');
242
        }
243
    }
244
};
245
 
246
/**
247
 * We want to create the basic promises and hooks that the caller will implement, so we can build the search widget
248
 * ahead of time and allow the caller to resolve their promises once complete.
249
 *
250
 * @method promisesAndResolvers
251
 * @returns {{bodyPromise: Promise, bodyPromiseResolver}}
252
 */
253
export const promisesAndResolvers = () => {
254
    // We want to show the widget instantly but loading whilst waiting for our data.
255
    let bodyPromiseResolver;
256
    const bodyPromise = new Promise(resolve => {
257
        bodyPromiseResolver = resolve;
258
    });
259
 
260
    return {bodyPromiseResolver, bodyPromise};
261
};