1 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
17 * Data filter management.
19 * @module core/datafilter
20 * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 import CourseFilter from 'core/datafilter/filtertypes/courseid';
25 import GenericFilter from 'core/datafilter/filtertype';
26 import {getStrings} from 'core/str';
27 import Notification from 'core/notification';
28 import Pending from 'core/pending';
29 import Selectors from 'core/datafilter/selectors';
30 import Templates from 'core/templates';
31 import CustomEvents from 'core/custom_interaction_events';
32 import jQuery from 'jquery';
34 export default class {
37 * Initialise the filter on the element with the given filterSet and callback.
39 * @param {HTMLElement} filterSet The filter element.
40 * @param {Function} applyCallback Callback function when updateTableFromFilter
42 constructor(filterSet, applyCallback) {
44 this.filterSet = filterSet;
45 this.applyCallback = applyCallback;
46 // Keep a reference to all of the active filters.
47 this.activeFilters = {
48 courseid: new CourseFilter('courseid', filterSet),
53 * Initialise event listeners to the filter.
56 // Add listeners for the main actions.
57 this.filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
58 if (e.target.closest(Selectors.filterset.actions.addRow)) {
64 if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
67 this.updateTableFromFilter();
70 if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
73 this.removeAllFilters();
77 // Add the listener to remove a single filter.
78 this.filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
79 if (e.target.closest(Selectors.filter.actions.remove)) {
82 this.removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true);
86 // Add listeners for the filter type selection.
87 let filterRegion = jQuery(this.getFilterRegion());
88 CustomEvents.define(filterRegion, [CustomEvents.events.accessibleChange]);
89 filterRegion.on(CustomEvents.events.accessibleChange, e => {
90 const typeField = e.target.closest(Selectors.filter.fields.type);
91 if (typeField && typeField.value) {
92 const filter = e.target.closest(Selectors.filter.region);
94 this.addFilter(filter, typeField.value);
98 this.filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
99 this.filterSet.dataset.filterverb = e.target.value;
104 * Get the filter list region.
106 * @return {HTMLElement}
109 return this.filterSet.querySelector(Selectors.filterset.regions.filterlist);
115 * @param {Object} filterdata Optional, data for adding for row with an existing filter.
118 addFilterRow(filterdata = {}) {
119 const pendingPromise = new Pending('core/datafilter:addFilterRow');
120 const rownum = filterdata.rownum ?? 1 + this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
121 return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rownum})
122 .then(({html, js}) => {
123 const newContentNodes = Templates.appendNodeContents(this.getFilterRegion(), html, js);
125 return newContentNodes;
128 // Note: This is a nasty hack.
129 // We should try to find a better way of doing this.
130 // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
132 const typeList = this.filterSet.querySelector(Selectors.data.typeList);
134 filterRow.forEach(contentNode => {
135 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
137 if (contentTypeList) {
138 contentTypeList.innerHTML = typeList.innerHTML;
145 this.updateFiltersOptions();
150 pendingPromise.resolve();
152 // If an existing filter is passed in, add it. Otherwise, leave the row empty.
153 if (filterdata.filtertype) {
154 result.forEach(filter => {
155 this.addFilter(filter, filterdata.filtertype, filterdata.values,
156 filterdata.jointype, filterdata.filteroptions);
161 .catch(Notification.exception);
165 * Get the filter data source node fro the specified filter type.
167 * @param {String} filterType
168 * @return {HTMLElement}
170 getFilterDataSource(filterType) {
171 const filterDataNode = this.filterSet.querySelector(Selectors.filterset.regions.datasource);
173 return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
177 * Add a filter to the list of active filters, performing any necessary setup.
179 * @param {HTMLElement} filterRow
180 * @param {String} filterType
181 * @param {Array} initialFilterValues The initially selected values for the filter
182 * @param {String} filterJoin
183 * @param {Object} filterOptions
186 async addFilter(filterRow, filterType, initialFilterValues, filterJoin, filterOptions) {
187 // Name the filter on the filter row.
188 filterRow.dataset.filterType = filterType;
190 const filterDataNode = this.getFilterDataSource(filterType);
192 // Instantiate the Filter class.
193 let Filter = GenericFilter;
194 if (filterDataNode.dataset.filterTypeClass) {
195 Filter = await import(filterDataNode.dataset.filterTypeClass);
197 this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues, filterOptions);
199 // Disable the select.
200 const typeField = filterRow.querySelector(Selectors.filter.fields.type);
201 typeField.value = filterType;
202 typeField.disabled = 'disabled';
203 // Update the join list.
204 this.updateJoinList(JSON.parse(filterDataNode.dataset.joinList), filterRow);
205 const joinField = filterRow.querySelector(Selectors.filter.fields.join);
206 if (!isNaN(filterJoin)) {
207 joinField.value = filterJoin;
209 // Update the list of available filter types.
210 this.updateFiltersOptions();
212 return this.activeFilters[filterType];
216 * Get the registered filter class for the named filter.
218 * @param {String} name
219 * @return {Object} See the Filter class.
221 getFilterObject(name) {
222 return this.activeFilters[name];
226 * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
227 * that it is replaced instead of being removed.
229 * @param {HTMLElement} filterRow
230 * @param {Bool} refreshContent Whether to refresh the table content when removing
232 removeOrReplaceFilterRow(filterRow, refreshContent) {
233 const filterCount = this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
234 if (filterCount === 1) {
235 this.replaceFilterRow(filterRow, refreshContent);
237 this.removeFilterRow(filterRow, refreshContent);
242 * Remove the specified filter row and associated class.
244 * @param {HTMLElement} filterRow
245 * @param {Bool} refreshContent Whether to refresh the table content when removing
247 async removeFilterRow(filterRow, refreshContent = true) {
248 if (filterRow.querySelector(Selectors.data.required)) {
251 const filterType = filterRow.querySelector(Selectors.filter.fields.type);
252 const hasFilterValue = !!filterType.value;
254 // Remove the filter object.
255 this.removeFilterObject(filterRow.dataset.filterType);
257 // Remove the actual filter HTML.
260 // Update the list of available filter types.
261 this.updateFiltersOptions();
263 if (hasFilterValue && refreshContent) {
264 // Refresh the table if there was any content in this row.
265 this.updateTableFromFilter();
268 // Update filter fieldset legends.
269 const filterLegends = await this.getAvailableFilterLegends();
271 this.getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
272 filterRow.querySelector('legend').innerText = filterLegends[index];
278 * Replace the specified filter row with a new one.
280 * @param {HTMLElement} filterRow
281 * @param {Bool} refreshContent Whether to refresh the table content when removing
282 * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
285 replaceFilterRow(filterRow, refreshContent = true, rowNum = 1) {
286 if (filterRow.querySelector(Selectors.data.required)) {
289 // Remove the filter object.
290 this.removeFilterObject(filterRow.dataset.filterType);
292 return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rowNum})
293 .then(({html, js}) => {
294 const newContentNodes = Templates.replaceNode(filterRow, html, js);
296 return newContentNodes;
299 // Note: This is a nasty hack.
300 // We should try to find a better way of doing this.
301 // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
303 const typeList = this.filterSet.querySelector(Selectors.data.typeList);
305 filterRow.forEach(contentNode => {
306 const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
308 if (contentTypeList) {
309 contentTypeList.innerHTML = typeList.innerHTML;
316 this.updateFiltersOptions();
321 // Refresh the table.
322 if (refreshContent) {
323 return this.updateTableFromFilter();
328 .catch(Notification.exception);
332 * Remove the Filter Object from the register.
334 * @param {string} filterName The name of the filter to be removed
336 removeFilterObject(filterName) {
338 const filter = this.getFilterObject(filterName);
342 // Remove from the list of active filters.
343 delete this.activeFilters[filterName];
349 * Remove all filters.
354 const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
355 filters.forEach(filterRow => this.removeOrReplaceFilterRow(filterRow, false));
357 // Refresh the table.
358 return this.updateTableFromFilter();
362 * Remove any empty filters.
364 removeEmptyFilters() {
365 const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
366 filters.forEach(filterRow => {
367 const filterType = filterRow.querySelector(Selectors.filter.fields.type);
368 if (!filterType.value) {
369 this.removeOrReplaceFilterRow(filterRow, false);
375 * Update the list of filter types to filter out those already selected.
377 updateFiltersOptions() {
378 const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
379 filters.forEach(filterRow => {
380 const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
381 options.forEach(option => {
382 if (option.value === filterRow.dataset.filterType) {
383 option.classList.remove('hidden');
384 option.disabled = false;
385 } else if (this.activeFilters[option.value]) {
386 option.classList.add('hidden');
387 option.disabled = true;
389 option.classList.remove('hidden');
390 option.disabled = false;
395 // Configure the state of the "Add row" button.
396 // This button is disabled when there is a filter row available for each condition.
397 const addRowButton = this.filterSet.querySelector(Selectors.filterset.actions.addRow);
398 const filterDataNode = this.filterSet.querySelectorAll(Selectors.data.fields.all);
399 if (filterDataNode.length <= filters.length) {
400 addRowButton.setAttribute('disabled', 'disabled');
402 addRowButton.removeAttribute('disabled');
405 if (filters.length === 1) {
406 this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
407 this.filterSet.querySelector(Selectors.filterset.fields.join).value = 2;
408 this.filterSet.dataset.filterverb = 2;
410 this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
415 * Update the Dynamic table based upon the current filter.
417 updateTableFromFilter() {
418 const pendingPromise = new Pending('core/datafilter:updateTableFromFilter');
421 Object.values(this.activeFilters).forEach(filter => {
422 filters[filter.filterValue.name] = filter.filterValue;
425 if (this.applyCallback) {
426 this.applyCallback(filters, pendingPromise);
431 * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
435 async getAvailableFilterLegends() {
436 const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
439 [...Array(maxFilters)].forEach((_, rowIndex) => {
441 "key": "filterrowlegend",
443 // Add 1 since rows begin at 1 (index begins at zero).
444 "param": rowIndex + 1
448 const legendStrings = await getStrings(requests)
449 .then(fetchedStrings => {
450 return fetchedStrings;
452 .catch(Notification.exception);
454 return legendStrings;
458 * Update the list of join types for a filter.
460 * This will update the list of join types based on the allowed types defined for a filter.
461 * If only one type is allowed, the list will be hidden.
463 * @param {Array} filterJoinList Array of join types, a subset of the regularJoinList array in this function.
464 * @param {Element} filterRow The row being updated.
466 updateJoinList(filterJoinList, filterRow) {
467 const regularJoinList = [0, 1, 2];
468 // If a join list was specified for this filter, find the default join list and disable the options that are not allowed
470 if (filterJoinList.length !== 0) {
471 const joinField = filterRow.querySelector(Selectors.filter.fields.join);
472 // Check each option from the default list, and disable the option in this filter row if it is not allowed
474 regularJoinList.forEach((join) => {
475 if (!filterJoinList.includes(join)) {
476 joinField.options[join].classList.add('hidden');
477 joinField.options[join].disabled = true;
480 // Now remove the disabled options, and hide the select list of there is only one option left.
481 joinField.options.forEach((element, index) => {
482 if (element.disabled) {
483 joinField.options[index] = null;
486 if (joinField.options.length === 1) {
487 joinField.hidden = true;