MDL-68977 user: Normalise participant table filterdata
[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
084c955e
AN
109 * @param {Array} initialFilterValues The initially selected values for the filter
110 * @returns {Filter}
77ba77f1 111 */
084c955e 112 const addFilter = async(filterRow, filterType, initialFilterValues) => {
77ba77f1
AN
113 // Name the filter on the filter row.
114 filterRow.dataset.filterType = filterType;
115
116 const filterDataNode = getFilterDataSource(filterType);
117
118 // Instantiate the Filter class.
119 let Filter = GenericFilter;
120 if (filterDataNode.dataset.filterTypeClass) {
121 Filter = await import(filterDataNode.dataset.filterTypeClass);
122 }
084c955e 123 activeFilters[filterType] = new Filter(filterType, filterSet, initialFilterValues);
77ba77f1
AN
124
125 // Disable the select.
126 const typeField = filterRow.querySelector(Selectors.filter.fields.type);
084c955e 127 typeField.value = filterType;
77ba77f1
AN
128 typeField.disabled = 'disabled';
129
130 // Update the list of available filter types.
131 updateFiltersOptions();
084c955e
AN
132
133 return activeFilters[filterType];
77ba77f1
AN
134 };
135
136 /**
137 * Get the registered filter class for the named filter.
138 *
139 * @param {String} name
140 * @return {Object} See the Filter class.
141 */
142 const getFilterObject = name => {
143 return activeFilters[name];
144 };
145
146 /**
147 * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
148 * that it is replaced instead of being removed.
149 *
150 * @param {HTMLElement} filterRow
151 */
152 const removeOrReplaceFilterRow = filterRow => {
153 const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;
154
155 if (filterCount === 1) {
156 replaceFilterRow(filterRow);
157 } else {
158 removeFilterRow(filterRow);
159 }
160 };
161
162 /**
163 * Remove the specified filter row and associated class.
164 *
165 * @param {HTMLElement} filterRow
166 */
3d60881d 167 const removeFilterRow = async filterRow => {
77ba77f1
AN
168 // Remove the filter object.
169 removeFilterObject(filterRow.dataset.filterType);
170
171 // Remove the actual filter HTML.
172 filterRow.remove();
173
77ba77f1
AN
174 // Update the list of available filter types.
175 updateFiltersOptions();
3d60881d 176
c36f37df
MH
177 // Refresh the table.
178 updateTableFromFilter();
179
3d60881d
MH
180 // Update filter fieldset legends.
181 const filterLegends = await getAvailableFilterLegends();
182
183 getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
184 filterRow.querySelector('legend').innerText = filterLegends[index];
185 });
186
77ba77f1
AN
187 };
188
189 /**
190 * Replace the specified filter row with a new one.
191 *
192 * @param {HTMLElement} filterRow
3d60881d 193 * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
77ba77f1
AN
194 * @return {Promise}
195 */
3d60881d 196 const replaceFilterRow = (filterRow, rowNum = 1) => {
77ba77f1
AN
197 // Remove the filter object.
198 removeFilterObject(filterRow.dataset.filterType);
199
3d60881d 200 return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
77ba77f1
AN
201 .then(({html, js}) => {
202 const newContentNodes = Templates.replaceNode(filterRow, html, js);
203
204 return newContentNodes;
205 })
206 .then(filterRow => {
207 // Note: This is a nasty hack.
208 // We should try to find a better way of doing this.
209 // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
210 // it in place.
211 const typeList = filterSet.querySelector(Selectors.data.typeList);
212
213 filterRow.forEach(contentNode => {
214 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
215
216 if (contentTypeList) {
217 contentTypeList.innerHTML = typeList.innerHTML;
218 }
219 });
220
221 return filterRow;
222 })
223 .then(filterRow => {
224 updateFiltersOptions();
225
226 return filterRow;
227 })
228 .then(filterRow => {
229 // Refresh the table.
230 updateTableFromFilter();
231
232 return filterRow;
233 })
234 .catch(Notification.exception);
235 };
236
237 /**
238 * Remove the Filter Object from the register.
239 *
240 * @param {string} filterName The name of the filter to be removed
241 */
242 const removeFilterObject = filterName => {
243 if (filterName) {
244 const filter = getFilterObject(filterName);
245 if (filter) {
246 filter.tearDown();
247
248 // Remove from the list of active filters.
249 delete activeFilters[filterName];
250 }
251 }
252 };
253
254 /**
255 * Remove all filters.
084c955e
AN
256 *
257 * @returns {Promise}
77ba77f1 258 */
084c955e 259 const removeAllFilters = () => {
77ba77f1 260 const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
084c955e 261 filters.forEach(filterRow => removeOrReplaceFilterRow(filterRow));
77ba77f1
AN
262
263 // Refresh the table.
084c955e
AN
264 return updateTableFromFilter();
265 };
266
267 /**
268 * Remove any empty filters.
269 */
270 const removeEmptyFilters = () => {
271 const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
272 filters.forEach(filterRow => {
273 const filterType = filterRow.querySelector(Selectors.filter.fields.type);
274 if (!filterType.value) {
275 removeOrReplaceFilterRow(filterRow);
276 }
277 });
77ba77f1
AN
278 };
279
280 /**
281 * Update the list of filter types to filter out those already selected.
282 */
283 const updateFiltersOptions = () => {
284 const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
285 filters.forEach(filterRow => {
286 const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
287 options.forEach(option => {
288 if (option.value === filterRow.dataset.filterType) {
289 option.classList.remove('hidden');
290 option.disabled = false;
291 } else if (activeFilters[option.value]) {
292 option.classList.add('hidden');
293 option.disabled = true;
294 } else {
295 option.classList.remove('hidden');
296 option.disabled = false;
297 }
298 });
299 });
110f3ebf
AN
300
301 // Configure the state of the "Add row" button.
302 // This button is disabled when there is a filter row available for each condition.
303 const addRowButton = filterSet.querySelector(Selectors.filterset.actions.addRow);
304 const filterDataNode = filterSet.querySelectorAll(Selectors.data.fields.all);
305 if (filterDataNode.length <= filters.length) {
306 addRowButton.setAttribute('disabled', 'disabled');
307 } else {
308 addRowButton.removeAttribute('disabled');
309 }
310
311 if (filters.length === 1) {
312 filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
313 filterSet.querySelector(Selectors.filterset.fields.join).value = 1;
314 } else {
315 filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
316 }
77ba77f1
AN
317 };
318
084c955e
AN
319 /**
320 * Set the current filter options based on a provided configuration.
321 *
322 * @param {Object} config
323 * @param {Number} config.jointype
324 * @param {Object} config.filters
325 */
326 const setFilterFromConfig = config => {
327 const filterConfig = Object.entries(config.filters);
328
329 if (!filterConfig.length) {
330 // There are no filters to set from.
331 return;
332 }
333
334 // Set the main join type.
335 filterSet.querySelector(Selectors.filterset.fields.join).value = config.jointype;
336
337 const filterPromises = filterConfig.map(([filterType, filterData]) => {
338 if (filterType === 'courseid') {
339 // The courseid is a special case.
340 return Promise.resolve();
341 }
342
343 const filterValues = filterData.values;
344
345 if (!filterValues.length) {
346 // There are no values for this filter.
347 // Skip it.
348 return Promise.resolve();
349 }
350
351 return addFilterRow().then(([filterRow]) => addFilter(filterRow, filterType, filterValues));
352 });
353
354 Promise.all(filterPromises).then(() => {
355 return removeEmptyFilters();
356 })
357 .then(updateFiltersOptions)
358 .then(updateTableFromFilter)
359 .catch();
360 };
361
77ba77f1
AN
362 /**
363 * Update the Dynamic table based upon the current filter.
364 *
365 * @return {Promise}
366 */
367 const updateTableFromFilter = () => {
dd9dea61
AN
368 const filters = {};
369 Object.values(activeFilters).forEach(filter => {
370 filters[filter.filterValue.name] = filter.filterValue;
371 });
372
77ba77f1
AN
373 return DynamicTable.setFilters(
374 DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
375 {
dd9dea61
AN
376 jointype: parseInt(filterSet.querySelector(Selectors.filterset.fields.join).value, 10),
377 filters,
77ba77f1 378 }
eb087fc5
AN
379 )
380 .catch(Notification.exception);
77ba77f1
AN
381 };
382
3d60881d
MH
383 /**
384 * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
385 *
386 * @return {array}
387 */
388 const getAvailableFilterLegends = async() => {
389 const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
390 let requests = [];
391
392 [...Array(maxFilters)].forEach((_, rowIndex) => {
393 requests.push({
394 "key": "filterrowlegend",
395 "component": "core_user",
396 // Add 1 since rows begin at 1 (index begins at zero).
397 "param": rowIndex + 1
398 });
399 });
400
401 const legendStrings = await getStrings(requests)
402 .then(fetchedStrings => {
403 return fetchedStrings;
404 })
405 .catch(Notification.exception);
406
407 return legendStrings;
408 };
409
77ba77f1
AN
410 // Add listeners for the main actions.
411 filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
412 if (e.target.closest(Selectors.filterset.actions.addRow)) {
413 e.preventDefault();
414
415 addFilterRow();
416 }
417
418 if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
419 e.preventDefault();
420
421 updateTableFromFilter();
422 }
423
424 if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
425 e.preventDefault();
426
427 removeAllFilters();
428 }
429 });
430
431 // Add the listener to remove a single filter.
432 filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
433 if (e.target.closest(Selectors.filter.actions.remove)) {
434 e.preventDefault();
435
436 removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
437 }
438 });
439
440 // Add listeners for the filter type selection.
441 filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
442 const typeField = e.target.closest(Selectors.filter.fields.type);
443 if (typeField && typeField.value) {
444 const filter = e.target.closest(Selectors.filter.region);
445
446 addFilter(filter, typeField.value);
447 }
448 });
110f3ebf
AN
449
450 filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
451 filterSet.dataset.filterverb = e.target.value;
452 });
084c955e
AN
453
454 const tableRoot = DynamicTable.getTableFromId(filterSet.dataset.tableRegion);
455 const initialFilters = DynamicTable.getFilters(tableRoot);
456 if (initialFilters) {
457 // Apply the initial filter configuration.
458 setFilterFromConfig(initialFilters);
459 }
77ba77f1 460};