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