Merge branch 'MDL-68963-master' of git://github.com/bmbrands/moodle
[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         // Remove the filter object.
169         removeFilterObject(filterRow.dataset.filterType);
171         // Remove the actual filter HTML.
172         filterRow.remove();
174         // Update the list of available filter types.
175         updateFiltersOptions();
177         // Refresh the table.
178         updateTableFromFilter();
180         // Update filter fieldset legends.
181         const filterLegends = await getAvailableFilterLegends();
183         getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
184             filterRow.querySelector('legend').innerText = filterLegends[index];
185         });
187     };
189     /**
190      * Replace the specified filter row with a new one.
191      *
192      * @param {HTMLElement} filterRow
193      * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
194      * @return {Promise}
195      */
196     const replaceFilterRow = (filterRow, rowNum = 1) => {
197         // Remove the filter object.
198         removeFilterObject(filterRow.dataset.filterType);
200         return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
201         .then(({html, js}) => {
202             const newContentNodes = Templates.replaceNode(filterRow, html, js);
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);
213             filterRow.forEach(contentNode => {
214                 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
216                 if (contentTypeList) {
217                     contentTypeList.innerHTML = typeList.innerHTML;
218                 }
219             });
221             return filterRow;
222         })
223         .then(filterRow => {
224             updateFiltersOptions();
226             return filterRow;
227         })
228         .then(filterRow => {
229             // Refresh the table.
230             updateTableFromFilter();
232             return filterRow;
233         })
234         .catch(Notification.exception);
235     };
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();
248                 // Remove from the list of active filters.
249                 delete activeFilters[filterName];
250             }
251         }
252     };
254     /**
255      * Remove all filters.
256      *
257      * @returns {Promise}
258      */
259     const removeAllFilters = () => {
260         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
261         filters.forEach(filterRow => removeOrReplaceFilterRow(filterRow));
263         // Refresh the table.
264         return updateTableFromFilter();
265     };
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         });
278     };
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         });
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         }
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         }
317     };
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);
329         if (!filterConfig.length) {
330             // There are no filters to set from.
331             return;
332         }
334         // Set the main join type.
335         filterSet.querySelector(Selectors.filterset.fields.join).value = config.jointype;
337         const filterPromises = filterConfig.map(([filterType, filterData]) => {
338             if (filterType === 'courseid') {
339                 // The courseid is a special case.
340                 return Promise.resolve();
341             }
343             const filterValues = filterData.values;
345             if (!filterValues.length) {
346                 // There are no values for this filter.
347                 // Skip it.
348                 return Promise.resolve();
349             }
351             return addFilterRow().then(([filterRow]) => addFilter(filterRow, filterType, filterValues));
352         });
354         Promise.all(filterPromises).then(() => {
355             return removeEmptyFilters();
356         })
357         .then(updateFiltersOptions)
358         .then(updateTableFromFilter)
359         .catch();
360     };
362     /**
363      * Update the Dynamic table based upon the current filter.
364      *
365      * @return {Promise}
366      */
367     const updateTableFromFilter = () => {
368         return DynamicTable.setFilters(
369             DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
370             {
371                 filters: Object.values(activeFilters).map(filter => filter.filterValue),
372                 jointype: filterSet.querySelector(Selectors.filterset.fields.join).value,
373             }
374         )
375         .catch(Notification.exception);
376     };
378     /**
379      * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
380      *
381      * @return {array}
382      */
383     const getAvailableFilterLegends = async() => {
384         const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
385         let requests = [];
387         [...Array(maxFilters)].forEach((_, rowIndex) => {
388             requests.push({
389                 "key": "filterrowlegend",
390                 "component": "core_user",
391                 // Add 1 since rows begin at 1 (index begins at zero).
392                 "param": rowIndex + 1
393             });
394         });
396         const legendStrings = await getStrings(requests)
397         .then(fetchedStrings => {
398             return fetchedStrings;
399         })
400         .catch(Notification.exception);
402         return legendStrings;
403     };
405     // Add listeners for the main actions.
406     filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
407         if (e.target.closest(Selectors.filterset.actions.addRow)) {
408             e.preventDefault();
410             addFilterRow();
411         }
413         if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
414             e.preventDefault();
416             updateTableFromFilter();
417         }
419         if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
420             e.preventDefault();
422             removeAllFilters();
423         }
424     });
426     // Add the listener to remove a single filter.
427     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
428         if (e.target.closest(Selectors.filter.actions.remove)) {
429             e.preventDefault();
431             removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
432         }
433     });
435     // Add listeners for the filter type selection.
436     filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
437         const typeField = e.target.closest(Selectors.filter.fields.type);
438         if (typeField && typeField.value) {
439             const filter = e.target.closest(Selectors.filter.region);
441             addFilter(filter, typeField.value);
442         }
443     });
445     filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
446         filterSet.dataset.filterverb = e.target.value;
447     });
449     const tableRoot = DynamicTable.getTableFromId(filterSet.dataset.tableRegion);
450     const initialFilters = DynamicTable.getFilters(tableRoot);
451     if (initialFilters) {
452         // Apply the initial filter configuration.
453         setFilterFromConfig(initialFilters);
454     }
455 };