MDL-68612 user: Participants filter row accessibility improvements
[moodle.git] / user / amd / src / participantsfilter.js
CommitLineData
77ba77f1
AN
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 * 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 */
24
25import CourseFilter from './local/participantsfilter/filtertypes/courseid';
26import * as DynamicTable from 'core_table/dynamic';
27import GenericFilter from './local/participantsfilter/filter';
3d60881d 28import {get_strings as getStrings} from 'core/str';
77ba77f1
AN
29import Notification from 'core/notification';
30import Selectors from './local/participantsfilter/selectors';
31import Templates from 'core/templates';
32
33/**
34 * Initialise the participants filter on the element with the given id.
35 *
36 * @param {String} participantsRegionId
37 */
38export const init = participantsRegionId => {
39 // Keep a reference to the filterset.
40 const filterSet = document.querySelector(`#${participantsRegionId}`);
41
42 // Keep a reference to all of the active filters.
43 const activeFilters = {
44 courseid: new CourseFilter('courseid', filterSet),
45 };
46
47 /**
48 * Get the filter list region.
49 *
50 * @return {HTMLElement}
51 */
52 const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
53
54 /**
55 * Add an unselected filter row.
56 *
57 * @return {Promise}
58 */
59 const addFilterRow = () => {
3d60881d
MH
60 const rownum = 1 + getFilterRegion().querySelectorAll(Selectors.filter.region).length;
61 return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rownum})
77ba77f1
AN
62 .then(({html, js}) => {
63 const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
64
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);
73
74 filterRow.forEach(contentNode => {
75 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
76
77 if (contentTypeList) {
78 contentTypeList.innerHTML = typeList.innerHTML;
79 }
80 });
81
82 return filterRow;
83 })
84 .then(filterRow => {
85 updateFiltersOptions();
86
87 return filterRow;
88 })
89 .catch(Notification.exception);
90 };
91
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);
100
101 return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
102 };
103
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;
113
114 const filterDataNode = getFilterDataSource(filterType);
115
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);
122
123 // Disable the select.
124 const typeField = filterRow.querySelector(Selectors.filter.fields.type);
125 typeField.disabled = 'disabled';
126
127 // Update the list of available filter types.
128 updateFiltersOptions();
129 };
130
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 };
140
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;
149
150 if (filterCount === 1) {
151 replaceFilterRow(filterRow);
152 } else {
153 removeFilterRow(filterRow);
154 }
155 };
156
157 /**
158 * Remove the specified filter row and associated class.
159 *
160 * @param {HTMLElement} filterRow
161 */
3d60881d 162 const removeFilterRow = async filterRow => {
77ba77f1
AN
163 // Remove the filter object.
164 removeFilterObject(filterRow.dataset.filterType);
165
166 // Remove the actual filter HTML.
167 filterRow.remove();
168
169 // Refresh the table.
170 updateTableFromFilter();
171
172 // Update the list of available filter types.
173 updateFiltersOptions();
3d60881d
MH
174
175 // Update filter fieldset legends.
176 const filterLegends = await getAvailableFilterLegends();
177
178 getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
179 filterRow.querySelector('legend').innerText = filterLegends[index];
180 });
181
77ba77f1
AN
182 };
183
184 /**
185 * Replace the specified filter row with a new one.
186 *
187 * @param {HTMLElement} filterRow
3d60881d 188 * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
77ba77f1
AN
189 * @return {Promise}
190 */
3d60881d 191 const replaceFilterRow = (filterRow, rowNum = 1) => {
77ba77f1
AN
192 // Remove the filter object.
193 removeFilterObject(filterRow.dataset.filterType);
194
3d60881d 195 return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
77ba77f1
AN
196 .then(({html, js}) => {
197 const newContentNodes = Templates.replaceNode(filterRow, html, js);
198
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);
207
208 filterRow.forEach(contentNode => {
209 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
210
211 if (contentTypeList) {
212 contentTypeList.innerHTML = typeList.innerHTML;
213 }
214 });
215
216 return filterRow;
217 })
218 .then(filterRow => {
219 updateFiltersOptions();
220
221 return filterRow;
222 })
223 .then(filterRow => {
224 // Refresh the table.
225 updateTableFromFilter();
226
227 return filterRow;
228 })
229 .catch(Notification.exception);
230 };
231
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();
242
243 // Remove from the list of active filters.
244 delete activeFilters[filterName];
245 }
246 }
247 };
248
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 });
257
258 // Refresh the table.
259 updateTableFromFilter();
260 };
261
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 });
110f3ebf
AN
282
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 }
292
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 }
77ba77f1
AN
299 };
300
301 /**
302 * Update the Dynamic table based upon the current filter.
303 *
304 * @return {Promise}
305 */
306 const updateTableFromFilter = () => {
77ba77f1
AN
307 return DynamicTable.setFilters(
308 DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
309 {
310 filters: Object.values(activeFilters).map(filter => filter.filterValue),
110f3ebf 311 jointype: filterSet.querySelector(Selectors.filterset.fields.join).value,
77ba77f1
AN
312 }
313 );
314 };
315
3d60881d
MH
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 = [];
324
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 });
333
334 const legendStrings = await getStrings(requests)
335 .then(fetchedStrings => {
336 return fetchedStrings;
337 })
338 .catch(Notification.exception);
339
340 return legendStrings;
341 };
342
77ba77f1
AN
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();
347
348 addFilterRow();
349 }
350
351 if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
352 e.preventDefault();
353
354 updateTableFromFilter();
355 }
356
357 if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
358 e.preventDefault();
359
360 removeAllFilters();
361 }
362 });
363
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();
368
369 removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
370 }
371 });
372
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);
378
379 addFilter(filter, typeField.value);
380 }
381 });
110f3ebf
AN
382
383 filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
384 filterSet.dataset.filterverb = e.target.value;
385 });
77ba77f1 386};