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