539909a756d2706964b483c41c63d1ba1cabd246
[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 Pending from 'core/pending';
31 import Selectors from './local/participantsfilter/selectors';
32 import Templates from 'core/templates';
34 /**
35  * Initialise the participants filter on the element with the given id.
36  *
37  * @param {String} participantsRegionId
38  */
39 export const init = participantsRegionId => {
40     // Keep a reference to the filterset.
41     const filterSet = document.querySelector(`#${participantsRegionId}`);
43     // Keep a reference to all of the active filters.
44     const activeFilters = {
45         courseid: new CourseFilter('courseid', filterSet),
46     };
48     /**
49      * Get the filter list region.
50      *
51      * @return {HTMLElement}
52      */
53     const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
55     /**
56      * Add an unselected filter row.
57      *
58      * @return {Promise}
59      */
60     const addFilterRow = () => {
61         const pendingPromise = new Pending('core_user/participantsfilter:addFilterRow');
63         const rownum = 1 + getFilterRegion().querySelectorAll(Selectors.filter.region).length;
64         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rownum})
65         .then(({html, js}) => {
66             const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
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);
77             filterRow.forEach(contentNode => {
78                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
80                 if (contentTypeList) {
81                     contentTypeList.innerHTML = typeList.innerHTML;
82                 }
83             });
85             return filterRow;
86         })
87         .then(filterRow => {
88             updateFiltersOptions();
90             return filterRow;
91         })
92         .then(result => {
93             pendingPromise.resolve();
95             return result;
96         })
97         .catch(Notification.exception);
98     };
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);
109         return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
110     };
112     /**
113      * Add a filter to the list of active filters, performing any necessary setup.
114      *
115      * @param {HTMLElement} filterRow
116      * @param {String} filterType
117      * @param {Array} initialFilterValues The initially selected values for the filter
118      * @returns {Filter}
119      */
120     const addFilter = async(filterRow, filterType, initialFilterValues) => {
121         // Name the filter on the filter row.
122         filterRow.dataset.filterType = filterType;
124         const filterDataNode = getFilterDataSource(filterType);
126         // Instantiate the Filter class.
127         let Filter = GenericFilter;
128         if (filterDataNode.dataset.filterTypeClass) {
129             Filter = await import(filterDataNode.dataset.filterTypeClass);
130         }
131         activeFilters[filterType] = new Filter(filterType, filterSet, initialFilterValues);
133         // Disable the select.
134         const typeField = filterRow.querySelector(Selectors.filter.fields.type);
135         typeField.value = filterType;
136         typeField.disabled = 'disabled';
138         // Update the list of available filter types.
139         updateFiltersOptions();
141         return activeFilters[filterType];
142     };
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     };
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
159      * @param {Bool} refreshContent Whether to refresh the table content when removing
160      */
161     const removeOrReplaceFilterRow = (filterRow, refreshContent) => {
162         const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;
164         if (filterCount === 1) {
165             replaceFilterRow(filterRow, refreshContent);
166         } else {
167             removeFilterRow(filterRow, refreshContent);
168         }
169     };
171     /**
172      * Remove the specified filter row and associated class.
173      *
174      * @param {HTMLElement} filterRow
175      * @param {Bool} refreshContent Whether to refresh the table content when removing
176      */
177     const removeFilterRow = async(filterRow, refreshContent = true) => {
178         const filterType = filterRow.querySelector(Selectors.filter.fields.type);
179         const hasFilterValue = !!filterType.value;
181         // Remove the filter object.
182         removeFilterObject(filterRow.dataset.filterType);
184         // Remove the actual filter HTML.
185         filterRow.remove();
187         // Update the list of available filter types.
188         updateFiltersOptions();
190         if (hasFilterValue && refreshContent) {
191             // Refresh the table if there was any content in this row.
192             updateTableFromFilter();
193         }
195         // Update filter fieldset legends.
196         const filterLegends = await getAvailableFilterLegends();
198         getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
199             filterRow.querySelector('legend').innerText = filterLegends[index];
200         });
202     };
204     /**
205      * Replace the specified filter row with a new one.
206      *
207      * @param {HTMLElement} filterRow
208      * @param {Bool} refreshContent Whether to refresh the table content when removing
209      * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
210      * @return {Promise}
211      */
212     const replaceFilterRow = (filterRow, refreshContent = true, rowNum = 1) => {
213         // Remove the filter object.
214         removeFilterObject(filterRow.dataset.filterType);
216         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
217         .then(({html, js}) => {
218             const newContentNodes = Templates.replaceNode(filterRow, html, js);
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);
229             filterRow.forEach(contentNode => {
230                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
232                 if (contentTypeList) {
233                     contentTypeList.innerHTML = typeList.innerHTML;
234                 }
235             });
237             return filterRow;
238         })
239         .then(filterRow => {
240             updateFiltersOptions();
242             return filterRow;
243         })
244         .then(filterRow => {
245             // Refresh the table.
246             if (refreshContent) {
247                 return updateTableFromFilter();
248             } else {
249                 return filterRow;
250             }
251         })
252         .catch(Notification.exception);
253     };
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();
266                 // Remove from the list of active filters.
267                 delete activeFilters[filterName];
268             }
269         }
270     };
272     /**
273      * Remove all filters.
274      *
275      * @returns {Promise}
276      */
277     const removeAllFilters = () => {
278         const pendingPromise = new Pending('core_user/participantsfilter:setFilterFromConfig');
280         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
281         filters.forEach(filterRow => removeOrReplaceFilterRow(filterRow, false));
283         // Refresh the table.
284         return updateTableFromFilter()
285         .then(result => {
286             pendingPromise.resolve();
288             return result;
289         });
290     };
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) {
300                 removeOrReplaceFilterRow(filterRow, false);
301             }
302         });
303     };
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         });
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         }
336         if (filters.length === 1) {
337             filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
338             filterSet.querySelector(Selectors.filterset.fields.join).value = 1;
339         } else {
340             filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
341         }
342     };
344     /**
345      * Set the current filter options based on a provided configuration.
346      *
347      * @param {Object} config
348      * @param {Number} config.jointype
349      * @param {Object} config.filters
350      * @returns {Promise}
351      */
352     const setFilterFromConfig = config => {
353         const filterConfig = Object.entries(config.filters);
355         if (!filterConfig.length) {
356             // There are no filters to set from.
357             return Promise.resolve();
358         }
360         // Set the main join type.
361         filterSet.querySelector(Selectors.filterset.fields.join).value = config.jointype;
363         const filterPromises = filterConfig.map(([filterType, filterData]) => {
364             if (filterType === 'courseid') {
365                 // The courseid is a special case.
366                 return false;
367             }
369             const filterValues = filterData.values;
371             if (!filterValues.length) {
372                 // There are no values for this filter.
373                 // Skip it.
374                 return false;
375             }
377             return addFilterRow().then(([filterRow]) => addFilter(filterRow, filterType, filterValues));
378         }).filter(promise => promise);
380         if (!filterPromises.length) {
381             return Promise.resolve();
382         }
384         return Promise.all(filterPromises).then(() => {
385             return removeEmptyFilters();
386         })
387         .then(updateFiltersOptions)
388         .then(updateTableFromFilter);
389     };
391     /**
392      * Update the Dynamic table based upon the current filter.
393      *
394      * @return {Promise}
395      */
396     const updateTableFromFilter = () => {
397         const pendingPromise = new Pending('core_user/participantsfilter:updateTableFromFilter');
399         const filters = {};
400         Object.values(activeFilters).forEach(filter => {
401             filters[filter.filterValue.name] = filter.filterValue;
402         });
404         return DynamicTable.setFilters(
405             DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
406             {
407                 jointype: parseInt(filterSet.querySelector(Selectors.filterset.fields.join).value, 10),
408                 filters,
409             }
410         )
411         .then(result => {
412             pendingPromise.resolve();
414             return result;
415         })
416         .catch(Notification.exception);
417     };
419     /**
420      * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
421      *
422      * @return {array}
423      */
424     const getAvailableFilterLegends = async() => {
425         const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
426         let requests = [];
428         [...Array(maxFilters)].forEach((_, rowIndex) => {
429             requests.push({
430                 "key": "filterrowlegend",
431                 "component": "core_user",
432                 // Add 1 since rows begin at 1 (index begins at zero).
433                 "param": rowIndex + 1
434             });
435         });
437         const legendStrings = await getStrings(requests)
438         .then(fetchedStrings => {
439             return fetchedStrings;
440         })
441         .catch(Notification.exception);
443         return legendStrings;
444     };
446     // Add listeners for the main actions.
447     filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
448         if (e.target.closest(Selectors.filterset.actions.addRow)) {
449             e.preventDefault();
451             addFilterRow();
452         }
454         if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
455             e.preventDefault();
457             updateTableFromFilter();
458         }
460         if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
461             e.preventDefault();
463             removeAllFilters();
464         }
465     });
467     // Add the listener to remove a single filter.
468     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
469         if (e.target.closest(Selectors.filter.actions.remove)) {
470             e.preventDefault();
472             removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true);
473         }
474     });
476     // Add listeners for the filter type selection.
477     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
478         const typeField = e.target.closest(Selectors.filter.fields.type);
479         if (typeField && typeField.value) {
480             const filter = e.target.closest(Selectors.filter.region);
482             addFilter(filter, typeField.value);
483         }
484     });
486     filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
487         filterSet.dataset.filterverb = e.target.value;
488     });
490     const tableRoot = DynamicTable.getTableFromId(filterSet.dataset.tableRegion);
491     const initialFilters = DynamicTable.getFilters(tableRoot);
492     if (initialFilters) {
493         const initialFilterPromise = new Pending('core_user/participantsfilter:setFilterFromConfig');
494         // Apply the initial filter configuration.
495         setFilterFromConfig(initialFilters)
496         .then(() => initialFilterPromise.resolve())
497         .catch();
498     }
499 };