MDL-68169 user: Limit the number of filter conditions
[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 Notification from 'core/notification';
29 import Selectors from './local/participantsfilter/selectors';
30 import Templates from 'core/templates';
32 /**
33  * Initialise the participants filter on the element with the given id.
34  *
35  * @param {String} participantsRegionId
36  */
37 export const init = participantsRegionId => {
38     // Keep a reference to the filterset.
39     const filterSet = document.querySelector(`#${participantsRegionId}`);
41     // Keep a reference to all of the active filters.
42     const activeFilters = {
43         courseid: new CourseFilter('courseid', filterSet),
44     };
46     /**
47      * Get the filter list region.
48      *
49      * @return {HTMLElement}
50      */
51     const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
53     /**
54      * Add an unselected filter row.
55      *
56      * @return {Promise}
57      */
58     const addFilterRow = () => {
59         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
60         .then(({html, js}) => {
61             const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
63             return newContentNodes;
64         })
65         .then(filterRow => {
66             // Note: This is a nasty hack.
67             // We should try to find a better way of doing this.
68             // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
69             // it in place.
70             const typeList = filterSet.querySelector(Selectors.data.typeList);
72             filterRow.forEach(contentNode => {
73                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
75                 if (contentTypeList) {
76                     contentTypeList.innerHTML = typeList.innerHTML;
77                 }
78             });
80             return filterRow;
81         })
82         .then(filterRow => {
83             updateFiltersOptions();
85             return filterRow;
86         })
87         .catch(Notification.exception);
88     };
90     /**
91      * Get the filter data source node fro the specified filter type.
92      *
93      * @param {String} filterType
94      * @return {HTMLElement}
95      */
96     const getFilterDataSource = filterType => {
97         const filterDataNode = filterSet.querySelector(Selectors.filterset.regions.datasource);
99         return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
100     };
102     /**
103      * Add a filter to the list of active filters, performing any necessary setup.
104      *
105      * @param {HTMLElement} filterRow
106      * @param {String} filterType
107      */
108     const addFilter = async(filterRow, filterType) => {
109         // Name the filter on the filter row.
110         filterRow.dataset.filterType = filterType;
112         const filterDataNode = getFilterDataSource(filterType);
114         // Instantiate the Filter class.
115         let Filter = GenericFilter;
116         if (filterDataNode.dataset.filterTypeClass) {
117             Filter = await import(filterDataNode.dataset.filterTypeClass);
118         }
119         activeFilters[filterType] = new Filter(filterType, filterSet);
121         // Disable the select.
122         const typeField = filterRow.querySelector(Selectors.filter.fields.type);
123         typeField.disabled = 'disabled';
125         // Update the list of available filter types.
126         updateFiltersOptions();
127     };
129     /**
130      * Get the registered filter class for the named filter.
131      *
132      * @param {String} name
133      * @return {Object} See the Filter class.
134      */
135     const getFilterObject = name => {
136         return activeFilters[name];
137     };
139     /**
140      * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
141      * that it is replaced instead of being removed.
142      *
143      * @param {HTMLElement} filterRow
144      */
145     const removeOrReplaceFilterRow = filterRow => {
146         const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;
148         if (filterCount === 1) {
149             replaceFilterRow(filterRow);
150         } else {
151             removeFilterRow(filterRow);
152         }
153     };
155     /**
156      * Remove the specified filter row and associated class.
157      *
158      * @param {HTMLElement} filterRow
159      */
160     const removeFilterRow = filterRow => {
161         // Remove the filter object.
162         removeFilterObject(filterRow.dataset.filterType);
164         // Remove the actual filter HTML.
165         filterRow.remove();
167         // Refresh the table.
168         updateTableFromFilter();
170         // Update the list of available filter types.
171         updateFiltersOptions();
172     };
174     /**
175      * Replace the specified filter row with a new one.
176      *
177      * @param {HTMLElement} filterRow
178      * @return {Promise}
179      */
180     const replaceFilterRow = filterRow => {
181         // Remove the filter object.
182         removeFilterObject(filterRow.dataset.filterType);
184         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
185         .then(({html, js}) => {
186             const newContentNodes = Templates.replaceNode(filterRow, html, js);
188             return newContentNodes;
189         })
190         .then(filterRow => {
191             // Note: This is a nasty hack.
192             // We should try to find a better way of doing this.
193             // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
194             // it in place.
195             const typeList = filterSet.querySelector(Selectors.data.typeList);
197             filterRow.forEach(contentNode => {
198                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
200                 if (contentTypeList) {
201                     contentTypeList.innerHTML = typeList.innerHTML;
202                 }
203             });
205             return filterRow;
206         })
207         .then(filterRow => {
208             updateFiltersOptions();
210             return filterRow;
211         })
212         .then(filterRow => {
213             // Refresh the table.
214             updateTableFromFilter();
216             return filterRow;
217         })
218         .catch(Notification.exception);
219     };
221     /**
222      * Remove the Filter Object from the register.
223      *
224      * @param {string} filterName The name of the filter to be removed
225      */
226     const removeFilterObject = filterName => {
227         if (filterName) {
228             const filter = getFilterObject(filterName);
229             if (filter) {
230                 filter.tearDown();
232                 // Remove from the list of active filters.
233                 delete activeFilters[filterName];
234             }
235         }
236     };
238     /**
239      * Remove all filters.
240      */
241     const removeAllFilters = async() => {
242         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
243         filters.forEach((filterRow) => {
244             removeOrReplaceFilterRow(filterRow);
245         });
247         // Refresh the table.
248         updateTableFromFilter();
249     };
251     /**
252      * Update the list of filter types to filter out those already selected.
253      */
254     const updateFiltersOptions = () => {
255         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
256         filters.forEach(filterRow => {
257             const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
258             options.forEach(option => {
259                 if (option.value === filterRow.dataset.filterType) {
260                     option.classList.remove('hidden');
261                     option.disabled = false;
262                 } else if (activeFilters[option.value]) {
263                     option.classList.add('hidden');
264                     option.disabled = true;
265                 } else {
266                     option.classList.remove('hidden');
267                     option.disabled = false;
268                 }
269             });
270         });
272         // Configure the state of the "Add row" button.
273         // This button is disabled when there is a filter row available for each condition.
274         const addRowButton = filterSet.querySelector(Selectors.filterset.actions.addRow);
275         const filterDataNode = filterSet.querySelectorAll(Selectors.data.fields.all);
276         if (filterDataNode.length <= filters.length) {
277             addRowButton.setAttribute('disabled', 'disabled');
278         } else {
279             addRowButton.removeAttribute('disabled');
280         }
282         if (filters.length === 1) {
283             filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
284             filterSet.querySelector(Selectors.filterset.fields.join).value = 1;
285         } else {
286             filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
287         }
288     };
290     /**
291      * Update the Dynamic table based upon the current filter.
292      *
293      * @return {Promise}
294      */
295     const updateTableFromFilter = () => {
296         return DynamicTable.setFilters(
297             DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
298             {
299                 filters: Object.values(activeFilters).map(filter => filter.filterValue),
300                 jointype: filterSet.querySelector(Selectors.filterset.fields.join).value,
301             }
302         );
303     };
305     // Add listeners for the main actions.
306     filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
307         if (e.target.closest(Selectors.filterset.actions.addRow)) {
308             e.preventDefault();
310             addFilterRow();
311         }
313         if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
314             e.preventDefault();
316             updateTableFromFilter();
317         }
319         if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
320             e.preventDefault();
322             removeAllFilters();
323         }
324     });
326     // Add the listener to remove a single filter.
327     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
328         if (e.target.closest(Selectors.filter.actions.remove)) {
329             e.preventDefault();
331             removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
332         }
333     });
335     // Add listeners for the filter type selection.
336     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
337         const typeField = e.target.closest(Selectors.filter.fields.type);
338         if (typeField && typeField.value) {
339             const filter = e.target.closest(Selectors.filter.region);
341             addFilter(filter, typeField.value);
342         }
343     });
345     filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
346         filterSet.dataset.filterverb = e.target.value;
347     });
348 };