482466ed5ce2006b0274f0c81fce5dedeb3058cf
[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      * @param {Array} initialFilterValues The initially selected values for the filter
110      * @returns {Filter}
111      */
112     const addFilter = async(filterRow, filterType, initialFilterValues) => {
113         // Name the filter on the filter row.
114         filterRow.dataset.filterType = filterType;
116         const filterDataNode = getFilterDataSource(filterType);
118         // Instantiate the Filter class.
119         let Filter = GenericFilter;
120         if (filterDataNode.dataset.filterTypeClass) {
121             Filter = await import(filterDataNode.dataset.filterTypeClass);
122         }
123         activeFilters[filterType] = new Filter(filterType, filterSet, initialFilterValues);
125         // Disable the select.
126         const typeField = filterRow.querySelector(Selectors.filter.fields.type);
127         typeField.value = filterType;
128         typeField.disabled = 'disabled';
130         // Update the list of available filter types.
131         updateFiltersOptions();
133         return activeFilters[filterType];
134     };
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     };
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;
155         if (filterCount === 1) {
156             replaceFilterRow(filterRow);
157         } else {
158             removeFilterRow(filterRow);
159         }
160     };
162     /**
163      * Remove the specified filter row and associated class.
164      *
165      * @param {HTMLElement} filterRow
166      */
167     const removeFilterRow = async filterRow => {
168         const filterType = filterRow.querySelector(Selectors.filter.fields.type);
169         const hasFilterValue = !!filterType.value;
171         // Remove the filter object.
172         removeFilterObject(filterRow.dataset.filterType);
174         // Remove the actual filter HTML.
175         filterRow.remove();
177         // Update the list of available filter types.
178         updateFiltersOptions();
180         if (hasFilterValue) {
181             // Refresh the table if there was any content in this row.
182             updateTableFromFilter();
183         }
185         // Update filter fieldset legends.
186         const filterLegends = await getAvailableFilterLegends();
188         getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
189             filterRow.querySelector('legend').innerText = filterLegends[index];
190         });
192     };
194     /**
195      * Replace the specified filter row with a new one.
196      *
197      * @param {HTMLElement} filterRow
198      * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
199      * @return {Promise}
200      */
201     const replaceFilterRow = (filterRow, rowNum = 1) => {
202         // Remove the filter object.
203         removeFilterObject(filterRow.dataset.filterType);
205         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
206         .then(({html, js}) => {
207             const newContentNodes = Templates.replaceNode(filterRow, html, js);
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);
218             filterRow.forEach(contentNode => {
219                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
221                 if (contentTypeList) {
222                     contentTypeList.innerHTML = typeList.innerHTML;
223                 }
224             });
226             return filterRow;
227         })
228         .then(filterRow => {
229             updateFiltersOptions();
231             return filterRow;
232         })
233         .then(filterRow => {
234             // Refresh the table.
235             updateTableFromFilter();
237             return filterRow;
238         })
239         .catch(Notification.exception);
240     };
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();
253                 // Remove from the list of active filters.
254                 delete activeFilters[filterName];
255             }
256         }
257     };
259     /**
260      * Remove all filters.
261      *
262      * @returns {Promise}
263      */
264     const removeAllFilters = () => {
265         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
266         filters.forEach(filterRow => removeOrReplaceFilterRow(filterRow));
268         // Refresh the table.
269         return updateTableFromFilter();
270     };
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         });
283     };
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         });
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         }
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         }
322     };
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);
334         if (!filterConfig.length) {
335             // There are no filters to set from.
336             return;
337         }
339         // Set the main join type.
340         filterSet.querySelector(Selectors.filterset.fields.join).value = config.jointype;
342         const filterPromises = filterConfig.map(([filterType, filterData]) => {
343             if (filterType === 'courseid') {
344                 // The courseid is a special case.
345                 return false;
346             }
348             const filterValues = filterData.values;
350             if (!filterValues.length) {
351                 // There are no values for this filter.
352                 // Skip it.
353                 return false;
354             }
356             return addFilterRow().then(([filterRow]) => addFilter(filterRow, filterType, filterValues));
357         }).filter(promise => promise);
359         if (!filterPromises.length) {
360             return;
361         }
363         Promise.all(filterPromises).then(() => {
364             return removeEmptyFilters();
365         })
366         .then(updateFiltersOptions)
367         .then(updateTableFromFilter)
368         .catch();
369     };
371     /**
372      * Update the Dynamic table based upon the current filter.
373      *
374      * @return {Promise}
375      */
376     const updateTableFromFilter = () => {
377         const filters = {};
378         Object.values(activeFilters).forEach(filter => {
379             filters[filter.filterValue.name] = filter.filterValue;
380         });
382         return DynamicTable.setFilters(
383             DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
384             {
385                 jointype: parseInt(filterSet.querySelector(Selectors.filterset.fields.join).value, 10),
386                 filters,
387             }
388         )
389         .catch(Notification.exception);
390     };
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 = [];
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         });
410         const legendStrings = await getStrings(requests)
411         .then(fetchedStrings => {
412             return fetchedStrings;
413         })
414         .catch(Notification.exception);
416         return legendStrings;
417     };
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();
424             addFilterRow();
425         }
427         if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
428             e.preventDefault();
430             updateTableFromFilter();
431         }
433         if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
434             e.preventDefault();
436             removeAllFilters();
437         }
438     });
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();
445             removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
446         }
447     });
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);
455             addFilter(filter, typeField.value);
456         }
457     });
459     filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
460         filterSet.dataset.filterverb = e.target.value;
461     });
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     }
469 };