MDL-68994 user: Reset filterverb value when resetting filter
[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 29import Notification from 'core/notification';
cb2f6725 30import Pending from 'core/pending';
77ba77f1
AN
31import Selectors from './local/participantsfilter/selectors';
32import Templates from 'core/templates';
33
34/**
35 * Initialise the participants filter on the element with the given id.
36 *
37 * @param {String} participantsRegionId
38 */
39export const init = participantsRegionId => {
40 // Keep a reference to the filterset.
41 const filterSet = document.querySelector(`#${participantsRegionId}`);
42
43 // Keep a reference to all of the active filters.
44 const activeFilters = {
45 courseid: new CourseFilter('courseid', filterSet),
46 };
47
48 /**
49 * Get the filter list region.
50 *
51 * @return {HTMLElement}
52 */
53 const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
54
55 /**
56 * Add an unselected filter row.
57 *
58 * @return {Promise}
59 */
60 const addFilterRow = () => {
cb2f6725
AN
61 const pendingPromise = new Pending('core_user/participantsfilter:addFilterRow');
62
3d60881d
MH
63 const rownum = 1 + getFilterRegion().querySelectorAll(Selectors.filter.region).length;
64 return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rownum})
77ba77f1
AN
65 .then(({html, js}) => {
66 const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
67
68 return newContentNodes;
69 })
70 .then(filterRow => {
71 // Note: This is a nasty hack.
72 // We should try to find a better way of doing this.
73 // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
74 // it in place.
75 const typeList = filterSet.querySelector(Selectors.data.typeList);
76
77 filterRow.forEach(contentNode => {
78 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
79
80 if (contentTypeList) {
81 contentTypeList.innerHTML = typeList.innerHTML;
82 }
83 });
84
85 return filterRow;
86 })
87 .then(filterRow => {
88 updateFiltersOptions();
89
90 return filterRow;
91 })
cb2f6725
AN
92 .then(result => {
93 pendingPromise.resolve();
94
95 return result;
96 })
77ba77f1
AN
97 .catch(Notification.exception);
98 };
99
100 /**
101 * Get the filter data source node fro the specified filter type.
102 *
103 * @param {String} filterType
104 * @return {HTMLElement}
105 */
106 const getFilterDataSource = filterType => {
107 const filterDataNode = filterSet.querySelector(Selectors.filterset.regions.datasource);
108
109 return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
110 };
111
112 /**
113 * Add a filter to the list of active filters, performing any necessary setup.
114 *
115 * @param {HTMLElement} filterRow
116 * @param {String} filterType
084c955e
AN
117 * @param {Array} initialFilterValues The initially selected values for the filter
118 * @returns {Filter}
77ba77f1 119 */
084c955e 120 const addFilter = async(filterRow, filterType, initialFilterValues) => {
77ba77f1
AN
121 // Name the filter on the filter row.
122 filterRow.dataset.filterType = filterType;
123
124 const filterDataNode = getFilterDataSource(filterType);
125
126 // Instantiate the Filter class.
127 let Filter = GenericFilter;
128 if (filterDataNode.dataset.filterTypeClass) {
129 Filter = await import(filterDataNode.dataset.filterTypeClass);
130 }
084c955e 131 activeFilters[filterType] = new Filter(filterType, filterSet, initialFilterValues);
77ba77f1
AN
132
133 // Disable the select.
134 const typeField = filterRow.querySelector(Selectors.filter.fields.type);
084c955e 135 typeField.value = filterType;
77ba77f1
AN
136 typeField.disabled = 'disabled';
137
138 // Update the list of available filter types.
139 updateFiltersOptions();
084c955e
AN
140
141 return activeFilters[filterType];
77ba77f1
AN
142 };
143
144 /**
145 * Get the registered filter class for the named filter.
146 *
147 * @param {String} name
148 * @return {Object} See the Filter class.
149 */
150 const getFilterObject = name => {
151 return activeFilters[name];
152 };
153
154 /**
155 * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
156 * that it is replaced instead of being removed.
157 *
158 * @param {HTMLElement} filterRow
7b3026fa 159 * @param {Bool} refreshContent Whether to refresh the table content when removing
77ba77f1 160 */
7b3026fa 161 const removeOrReplaceFilterRow = (filterRow, refreshContent) => {
77ba77f1
AN
162 const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;
163
164 if (filterCount === 1) {
7b3026fa 165 replaceFilterRow(filterRow, refreshContent);
77ba77f1 166 } else {
7b3026fa 167 removeFilterRow(filterRow, refreshContent);
77ba77f1
AN
168 }
169 };
170
171 /**
172 * Remove the specified filter row and associated class.
173 *
174 * @param {HTMLElement} filterRow
7b3026fa 175 * @param {Bool} refreshContent Whether to refresh the table content when removing
77ba77f1 176 */
7b3026fa 177 const removeFilterRow = async(filterRow, refreshContent = true) => {
8503e0ee
AN
178 const filterType = filterRow.querySelector(Selectors.filter.fields.type);
179 const hasFilterValue = !!filterType.value;
180
77ba77f1
AN
181 // Remove the filter object.
182 removeFilterObject(filterRow.dataset.filterType);
183
184 // Remove the actual filter HTML.
185 filterRow.remove();
186
77ba77f1
AN
187 // Update the list of available filter types.
188 updateFiltersOptions();
3d60881d 189
7b3026fa 190 if (hasFilterValue && refreshContent) {
8503e0ee
AN
191 // Refresh the table if there was any content in this row.
192 updateTableFromFilter();
193 }
c36f37df 194
3d60881d
MH
195 // Update filter fieldset legends.
196 const filterLegends = await getAvailableFilterLegends();
197
198 getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
199 filterRow.querySelector('legend').innerText = filterLegends[index];
200 });
201
77ba77f1
AN
202 };
203
204 /**
205 * Replace the specified filter row with a new one.
206 *
207 * @param {HTMLElement} filterRow
7b3026fa 208 * @param {Bool} refreshContent Whether to refresh the table content when removing
3d60881d 209 * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
77ba77f1
AN
210 * @return {Promise}
211 */
7b3026fa 212 const replaceFilterRow = (filterRow, refreshContent = true, rowNum = 1) => {
77ba77f1
AN
213 // Remove the filter object.
214 removeFilterObject(filterRow.dataset.filterType);
215
3d60881d 216 return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
77ba77f1
AN
217 .then(({html, js}) => {
218 const newContentNodes = Templates.replaceNode(filterRow, html, js);
219
220 return newContentNodes;
221 })
222 .then(filterRow => {
223 // Note: This is a nasty hack.
224 // We should try to find a better way of doing this.
225 // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
226 // it in place.
227 const typeList = filterSet.querySelector(Selectors.data.typeList);
228
229 filterRow.forEach(contentNode => {
230 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
231
232 if (contentTypeList) {
233 contentTypeList.innerHTML = typeList.innerHTML;
234 }
235 });
236
237 return filterRow;
238 })
239 .then(filterRow => {
240 updateFiltersOptions();
241
242 return filterRow;
243 })
244 .then(filterRow => {
245 // Refresh the table.
7b3026fa
AN
246 if (refreshContent) {
247 return updateTableFromFilter();
248 } else {
249 return filterRow;
250 }
77ba77f1
AN
251 })
252 .catch(Notification.exception);
253 };
254
255 /**
256 * Remove the Filter Object from the register.
257 *
258 * @param {string} filterName The name of the filter to be removed
259 */
260 const removeFilterObject = filterName => {
261 if (filterName) {
262 const filter = getFilterObject(filterName);
263 if (filter) {
264 filter.tearDown();
265
266 // Remove from the list of active filters.
267 delete activeFilters[filterName];
268 }
269 }
270 };
271
272 /**
273 * Remove all filters.
084c955e
AN
274 *
275 * @returns {Promise}
77ba77f1 276 */
084c955e 277 const removeAllFilters = () => {
cb2f6725
AN
278 const pendingPromise = new Pending('core_user/participantsfilter:setFilterFromConfig');
279
77ba77f1 280 const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
7b3026fa 281 filters.forEach(filterRow => removeOrReplaceFilterRow(filterRow, false));
77ba77f1
AN
282
283 // Refresh the table.
cb2f6725
AN
284 return updateTableFromFilter()
285 .then(result => {
286 pendingPromise.resolve();
287
288 return result;
289 });
084c955e
AN
290 };
291
292 /**
293 * Remove any empty filters.
294 */
295 const removeEmptyFilters = () => {
296 const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
297 filters.forEach(filterRow => {
298 const filterType = filterRow.querySelector(Selectors.filter.fields.type);
299 if (!filterType.value) {
7b3026fa 300 removeOrReplaceFilterRow(filterRow, false);
084c955e
AN
301 }
302 });
77ba77f1
AN
303 };
304
305 /**
306 * Update the list of filter types to filter out those already selected.
307 */
308 const updateFiltersOptions = () => {
309 const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
310 filters.forEach(filterRow => {
311 const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
312 options.forEach(option => {
313 if (option.value === filterRow.dataset.filterType) {
314 option.classList.remove('hidden');
315 option.disabled = false;
316 } else if (activeFilters[option.value]) {
317 option.classList.add('hidden');
318 option.disabled = true;
319 } else {
320 option.classList.remove('hidden');
321 option.disabled = false;
322 }
323 });
324 });
110f3ebf
AN
325
326 // Configure the state of the "Add row" button.
327 // This button is disabled when there is a filter row available for each condition.
328 const addRowButton = filterSet.querySelector(Selectors.filterset.actions.addRow);
329 const filterDataNode = filterSet.querySelectorAll(Selectors.data.fields.all);
330 if (filterDataNode.length <= filters.length) {
331 addRowButton.setAttribute('disabled', 'disabled');
332 } else {
333 addRowButton.removeAttribute('disabled');
334 }
335
336 if (filters.length === 1) {
337 filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
338 filterSet.querySelector(Selectors.filterset.fields.join).value = 1;
eb45751b 339 filterSet.dataset.filterverb = 1;
110f3ebf
AN
340 } else {
341 filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
342 }
77ba77f1
AN
343 };
344
084c955e
AN
345 /**
346 * Set the current filter options based on a provided configuration.
347 *
348 * @param {Object} config
349 * @param {Number} config.jointype
350 * @param {Object} config.filters
cb2f6725 351 * @returns {Promise}
084c955e
AN
352 */
353 const setFilterFromConfig = config => {
354 const filterConfig = Object.entries(config.filters);
355
356 if (!filterConfig.length) {
357 // There are no filters to set from.
cb2f6725 358 return Promise.resolve();
084c955e
AN
359 }
360
361 // Set the main join type.
362 filterSet.querySelector(Selectors.filterset.fields.join).value = config.jointype;
363
364 const filterPromises = filterConfig.map(([filterType, filterData]) => {
365 if (filterType === 'courseid') {
366 // The courseid is a special case.
8503e0ee 367 return false;
084c955e
AN
368 }
369
370 const filterValues = filterData.values;
371
372 if (!filterValues.length) {
373 // There are no values for this filter.
374 // Skip it.
8503e0ee 375 return false;
084c955e
AN
376 }
377
378 return addFilterRow().then(([filterRow]) => addFilter(filterRow, filterType, filterValues));
8503e0ee
AN
379 }).filter(promise => promise);
380
381 if (!filterPromises.length) {
cb2f6725 382 return Promise.resolve();
8503e0ee 383 }
084c955e 384
cb2f6725 385 return Promise.all(filterPromises).then(() => {
084c955e
AN
386 return removeEmptyFilters();
387 })
388 .then(updateFiltersOptions)
cb2f6725 389 .then(updateTableFromFilter);
084c955e
AN
390 };
391
77ba77f1
AN
392 /**
393 * Update the Dynamic table based upon the current filter.
394 *
395 * @return {Promise}
396 */
397 const updateTableFromFilter = () => {
cb2f6725
AN
398 const pendingPromise = new Pending('core_user/participantsfilter:updateTableFromFilter');
399
dd9dea61
AN
400 const filters = {};
401 Object.values(activeFilters).forEach(filter => {
402 filters[filter.filterValue.name] = filter.filterValue;
403 });
404
77ba77f1
AN
405 return DynamicTable.setFilters(
406 DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
407 {
dd9dea61
AN
408 jointype: parseInt(filterSet.querySelector(Selectors.filterset.fields.join).value, 10),
409 filters,
77ba77f1 410 }
eb087fc5 411 )
cb2f6725
AN
412 .then(result => {
413 pendingPromise.resolve();
414
415 return result;
416 })
eb087fc5 417 .catch(Notification.exception);
77ba77f1
AN
418 };
419
3d60881d
MH
420 /**
421 * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
422 *
423 * @return {array}
424 */
425 const getAvailableFilterLegends = async() => {
426 const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
427 let requests = [];
428
429 [...Array(maxFilters)].forEach((_, rowIndex) => {
430 requests.push({
431 "key": "filterrowlegend",
432 "component": "core_user",
433 // Add 1 since rows begin at 1 (index begins at zero).
434 "param": rowIndex + 1
435 });
436 });
437
438 const legendStrings = await getStrings(requests)
439 .then(fetchedStrings => {
440 return fetchedStrings;
441 })
442 .catch(Notification.exception);
443
444 return legendStrings;
445 };
446
77ba77f1
AN
447 // Add listeners for the main actions.
448 filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
449 if (e.target.closest(Selectors.filterset.actions.addRow)) {
450 e.preventDefault();
451
452 addFilterRow();
453 }
454
455 if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
456 e.preventDefault();
457
458 updateTableFromFilter();
459 }
460
461 if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
462 e.preventDefault();
463
464 removeAllFilters();
465 }
466 });
467
468 // Add the listener to remove a single filter.
469 filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
470 if (e.target.closest(Selectors.filter.actions.remove)) {
471 e.preventDefault();
472
7b3026fa 473 removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true);
77ba77f1
AN
474 }
475 });
476
477 // Add listeners for the filter type selection.
478 filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
479 const typeField = e.target.closest(Selectors.filter.fields.type);
480 if (typeField && typeField.value) {
481 const filter = e.target.closest(Selectors.filter.region);
482
483 addFilter(filter, typeField.value);
484 }
485 });
110f3ebf
AN
486
487 filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
488 filterSet.dataset.filterverb = e.target.value;
489 });
084c955e
AN
490
491 const tableRoot = DynamicTable.getTableFromId(filterSet.dataset.tableRegion);
492 const initialFilters = DynamicTable.getFilters(tableRoot);
493 if (initialFilters) {
cb2f6725 494 const initialFilterPromise = new Pending('core_user/participantsfilter:setFilterFromConfig');
084c955e 495 // Apply the initial filter configuration.
cb2f6725
AN
496 setFilterFromConfig(initialFilters)
497 .then(() => initialFilterPromise.resolve())
498 .catch();
084c955e 499 }
77ba77f1 500};