MDL-68169 user: Limit the number of filter conditions
[moodle.git] / user / amd / src / participantsfilter.js
CommitLineData
77ba77f1
AN
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/>.
15
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 */
24
25import CourseFilter from './local/participantsfilter/filtertypes/courseid';
26import * as DynamicTable from 'core_table/dynamic';
27import GenericFilter from './local/participantsfilter/filter';
28import Notification from 'core/notification';
29import Selectors from './local/participantsfilter/selectors';
30import Templates from 'core/templates';
31
32/**
33 * Initialise the participants filter on the element with the given id.
34 *
35 * @param {String} participantsRegionId
36 */
37export const init = participantsRegionId => {
38 // Keep a reference to the filterset.
39 const filterSet = document.querySelector(`#${participantsRegionId}`);
40
41 // Keep a reference to all of the active filters.
42 const activeFilters = {
43 courseid: new CourseFilter('courseid', filterSet),
44 };
45
46 /**
47 * Get the filter list region.
48 *
49 * @return {HTMLElement}
50 */
51 const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
52
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);
62
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);
71
72 filterRow.forEach(contentNode => {
73 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
74
75 if (contentTypeList) {
76 contentTypeList.innerHTML = typeList.innerHTML;
77 }
78 });
79
80 return filterRow;
81 })
82 .then(filterRow => {
83 updateFiltersOptions();
84
85 return filterRow;
86 })
87 .catch(Notification.exception);
88 };
89
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);
98
99 return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
100 };
101
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;
111
112 const filterDataNode = getFilterDataSource(filterType);
113
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);
120
121 // Disable the select.
122 const typeField = filterRow.querySelector(Selectors.filter.fields.type);
123 typeField.disabled = 'disabled';
124
125 // Update the list of available filter types.
126 updateFiltersOptions();
127 };
128
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 };
138
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;
147
148 if (filterCount === 1) {
149 replaceFilterRow(filterRow);
150 } else {
151 removeFilterRow(filterRow);
152 }
153 };
154
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);
163
164 // Remove the actual filter HTML.
165 filterRow.remove();
166
167 // Refresh the table.
168 updateTableFromFilter();
169
170 // Update the list of available filter types.
171 updateFiltersOptions();
172 };
173
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);
183
184 return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
185 .then(({html, js}) => {
186 const newContentNodes = Templates.replaceNode(filterRow, html, js);
187
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);
196
197 filterRow.forEach(contentNode => {
198 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
199
200 if (contentTypeList) {
201 contentTypeList.innerHTML = typeList.innerHTML;
202 }
203 });
204
205 return filterRow;
206 })
207 .then(filterRow => {
208 updateFiltersOptions();
209
210 return filterRow;
211 })
212 .then(filterRow => {
213 // Refresh the table.
214 updateTableFromFilter();
215
216 return filterRow;
217 })
218 .catch(Notification.exception);
219 };
220
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();
231
232 // Remove from the list of active filters.
233 delete activeFilters[filterName];
234 }
235 }
236 };
237
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 });
246
247 // Refresh the table.
248 updateTableFromFilter();
249 };
250
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 });
110f3ebf
AN
271
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 }
281
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 }
77ba77f1
AN
288 };
289
290 /**
291 * Update the Dynamic table based upon the current filter.
292 *
293 * @return {Promise}
294 */
295 const updateTableFromFilter = () => {
77ba77f1
AN
296 return DynamicTable.setFilters(
297 DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
298 {
299 filters: Object.values(activeFilters).map(filter => filter.filterValue),
110f3ebf 300 jointype: filterSet.querySelector(Selectors.filterset.fields.join).value,
77ba77f1
AN
301 }
302 );
303 };
304
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();
309
310 addFilterRow();
311 }
312
313 if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
314 e.preventDefault();
315
316 updateTableFromFilter();
317 }
318
319 if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
320 e.preventDefault();
321
322 removeAllFilters();
323 }
324 });
325
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();
330
331 removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
332 }
333 });
334
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);
340
341 addFilter(filter, typeField.value);
342 }
343 });
110f3ebf
AN
344
345 filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
346 filterSet.dataset.filterverb = e.target.value;
347 });
77ba77f1 348};