MDL-68612 user: Participants filter row accessibility improvements
[moodle.git] / user / amd / src / participantsfilter.js
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/>.
16 /**
17  * Participants filter managemnet.
18  *
19  * @module     core_user/participants_filter
20  * @package    core_user
21  * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 import CourseFilter from './local/participantsfilter/filtertypes/courseid';
26 import * as DynamicTable from 'core_table/dynamic';
27 import GenericFilter from './local/participantsfilter/filter';
28 import {get_strings as getStrings} from 'core/str';
29 import Notification from 'core/notification';
30 import Selectors from './local/participantsfilter/selectors';
31 import Templates from 'core/templates';
33 /**
34  * Initialise the participants filter on the element with the given id.
35  *
36  * @param {String} participantsRegionId
37  */
38 export const init = participantsRegionId => {
39     // Keep a reference to the filterset.
40     const filterSet = document.querySelector(`#${participantsRegionId}`);
42     // Keep a reference to all of the active filters.
43     const activeFilters = {
44         courseid: new CourseFilter('courseid', filterSet),
45     };
47     /**
48      * Get the filter list region.
49      *
50      * @return {HTMLElement}
51      */
52     const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
54     /**
55      * Add an unselected filter row.
56      *
57      * @return {Promise}
58      */
59     const addFilterRow = () => {
60         const rownum = 1 + getFilterRegion().querySelectorAll(Selectors.filter.region).length;
61         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rownum})
62         .then(({html, js}) => {
63             const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
65             return newContentNodes;
66         })
67         .then(filterRow => {
68             // Note: This is a nasty hack.
69             // We should try to find a better way of doing this.
70             // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
71             // it in place.
72             const typeList = filterSet.querySelector(Selectors.data.typeList);
74             filterRow.forEach(contentNode => {
75                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
77                 if (contentTypeList) {
78                     contentTypeList.innerHTML = typeList.innerHTML;
79                 }
80             });
82             return filterRow;
83         })
84         .then(filterRow => {
85             updateFiltersOptions();
87             return filterRow;
88         })
89         .catch(Notification.exception);
90     };
92     /**
93      * Get the filter data source node fro the specified filter type.
94      *
95      * @param {String} filterType
96      * @return {HTMLElement}
97      */
98     const getFilterDataSource = filterType => {
99         const filterDataNode = filterSet.querySelector(Selectors.filterset.regions.datasource);
101         return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
102     };
104     /**
105      * Add a filter to the list of active filters, performing any necessary setup.
106      *
107      * @param {HTMLElement} filterRow
108      * @param {String} filterType
109      */
110     const addFilter = async(filterRow, filterType) => {
111         // Name the filter on the filter row.
112         filterRow.dataset.filterType = filterType;
114         const filterDataNode = getFilterDataSource(filterType);
116         // Instantiate the Filter class.
117         let Filter = GenericFilter;
118         if (filterDataNode.dataset.filterTypeClass) {
119             Filter = await import(filterDataNode.dataset.filterTypeClass);
120         }
121         activeFilters[filterType] = new Filter(filterType, filterSet);
123         // Disable the select.
124         const typeField = filterRow.querySelector(Selectors.filter.fields.type);
125         typeField.disabled = 'disabled';
127         // Update the list of available filter types.
128         updateFiltersOptions();
129     };
131     /**
132      * Get the registered filter class for the named filter.
133      *
134      * @param {String} name
135      * @return {Object} See the Filter class.
136      */
137     const getFilterObject = name => {
138         return activeFilters[name];
139     };
141     /**
142      * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
143      * that it is replaced instead of being removed.
144      *
145      * @param {HTMLElement} filterRow
146      */
147     const removeOrReplaceFilterRow = filterRow => {
148         const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;
150         if (filterCount === 1) {
151             replaceFilterRow(filterRow);
152         } else {
153             removeFilterRow(filterRow);
154         }
155     };
157     /**
158      * Remove the specified filter row and associated class.
159      *
160      * @param {HTMLElement} filterRow
161      */
162     const removeFilterRow = async filterRow => {
163         // Remove the filter object.
164         removeFilterObject(filterRow.dataset.filterType);
166         // Remove the actual filter HTML.
167         filterRow.remove();
169         // Refresh the table.
170         updateTableFromFilter();
172         // Update the list of available filter types.
173         updateFiltersOptions();
175         // Update filter fieldset legends.
176         const filterLegends = await getAvailableFilterLegends();
178         getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
179             filterRow.querySelector('legend').innerText = filterLegends[index];
180         });
182     };
184     /**
185      * Replace the specified filter row with a new one.
186      *
187      * @param {HTMLElement} filterRow
188      * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
189      * @return {Promise}
190      */
191     const replaceFilterRow = (filterRow, rowNum = 1) => {
192         // Remove the filter object.
193         removeFilterObject(filterRow.dataset.filterType);
195         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
196         .then(({html, js}) => {
197             const newContentNodes = Templates.replaceNode(filterRow, html, js);
199             return newContentNodes;
200         })
201         .then(filterRow => {
202             // Note: This is a nasty hack.
203             // We should try to find a better way of doing this.
204             // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
205             // it in place.
206             const typeList = filterSet.querySelector(Selectors.data.typeList);
208             filterRow.forEach(contentNode => {
209                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
211                 if (contentTypeList) {
212                     contentTypeList.innerHTML = typeList.innerHTML;
213                 }
214             });
216             return filterRow;
217         })
218         .then(filterRow => {
219             updateFiltersOptions();
221             return filterRow;
222         })
223         .then(filterRow => {
224             // Refresh the table.
225             updateTableFromFilter();
227             return filterRow;
228         })
229         .catch(Notification.exception);
230     };
232     /**
233      * Remove the Filter Object from the register.
234      *
235      * @param {string} filterName The name of the filter to be removed
236      */
237     const removeFilterObject = filterName => {
238         if (filterName) {
239             const filter = getFilterObject(filterName);
240             if (filter) {
241                 filter.tearDown();
243                 // Remove from the list of active filters.
244                 delete activeFilters[filterName];
245             }
246         }
247     };
249     /**
250      * Remove all filters.
251      */
252     const removeAllFilters = async() => {
253         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
254         filters.forEach((filterRow) => {
255             removeOrReplaceFilterRow(filterRow);
256         });
258         // Refresh the table.
259         updateTableFromFilter();
260     };
262     /**
263      * Update the list of filter types to filter out those already selected.
264      */
265     const updateFiltersOptions = () => {
266         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
267         filters.forEach(filterRow => {
268             const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
269             options.forEach(option => {
270                 if (option.value === filterRow.dataset.filterType) {
271                     option.classList.remove('hidden');
272                     option.disabled = false;
273                 } else if (activeFilters[option.value]) {
274                     option.classList.add('hidden');
275                     option.disabled = true;
276                 } else {
277                     option.classList.remove('hidden');
278                     option.disabled = false;
279                 }
280             });
281         });
283         // Configure the state of the "Add row" button.
284         // This button is disabled when there is a filter row available for each condition.
285         const addRowButton = filterSet.querySelector(Selectors.filterset.actions.addRow);
286         const filterDataNode = filterSet.querySelectorAll(Selectors.data.fields.all);
287         if (filterDataNode.length <= filters.length) {
288             addRowButton.setAttribute('disabled', 'disabled');
289         } else {
290             addRowButton.removeAttribute('disabled');
291         }
293         if (filters.length === 1) {
294             filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
295             filterSet.querySelector(Selectors.filterset.fields.join).value = 1;
296         } else {
297             filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
298         }
299     };
301     /**
302      * Update the Dynamic table based upon the current filter.
303      *
304      * @return {Promise}
305      */
306     const updateTableFromFilter = () => {
307         return DynamicTable.setFilters(
308             DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
309             {
310                 filters: Object.values(activeFilters).map(filter => filter.filterValue),
311                 jointype: filterSet.querySelector(Selectors.filterset.fields.join).value,
312             }
313         );
314     };
316     /**
317      * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
318      *
319      * @return {array}
320      */
321     const getAvailableFilterLegends = async() => {
322         const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
323         let requests = [];
325         [...Array(maxFilters)].forEach((_, rowIndex) => {
326             requests.push({
327                 "key": "filterrowlegend",
328                 "component": "core_user",
329                 // Add 1 since rows begin at 1 (index begins at zero).
330                 "param": rowIndex + 1
331             });
332         });
334         const legendStrings = await getStrings(requests)
335         .then(fetchedStrings => {
336             return fetchedStrings;
337         })
338         .catch(Notification.exception);
340         return legendStrings;
341     };
343     // Add listeners for the main actions.
344     filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
345         if (e.target.closest(Selectors.filterset.actions.addRow)) {
346             e.preventDefault();
348             addFilterRow();
349         }
351         if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
352             e.preventDefault();
354             updateTableFromFilter();
355         }
357         if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
358             e.preventDefault();
360             removeAllFilters();
361         }
362     });
364     // Add the listener to remove a single filter.
365     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
366         if (e.target.closest(Selectors.filter.actions.remove)) {
367             e.preventDefault();
369             removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
370         }
371     });
373     // Add listeners for the filter type selection.
374     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
375         const typeField = e.target.closest(Selectors.filter.fields.type);
376         if (typeField && typeField.value) {
377             const filter = e.target.closest(Selectors.filter.region);
379             addFilter(filter, typeField.value);
380         }
381     });
383     filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
384         filterSet.dataset.filterverb = e.target.value;
385     });
386 };