Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 * Data filter management.
18
 *
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
22
 */
23
 
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';
33
 
34
export default class {
35
 
36
    /**
37
     * Initialise the filter on the element with the given filterSet and callback.
38
     *
39
     * @param {HTMLElement} filterSet The filter element.
40
     * @param {Function} applyCallback Callback function when updateTableFromFilter
41
     */
42
    constructor(filterSet, applyCallback) {
43
 
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),
49
        };
50
    }
51
 
52
    /**
53
     * Initialise event listeners to the filter.
54
     */
55
    init() {
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)) {
59
                e.preventDefault();
60
 
61
                this.addFilterRow();
62
            }
63
 
64
            if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
65
                e.preventDefault();
66
                this.updateTableFromFilter();
67
            }
68
 
69
            if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
70
                e.preventDefault();
71
 
72
                this.removeAllFilters();
73
            }
74
        });
75
 
76
        // Add the listener to remove a single filter.
77
        this.filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
78
            if (e.target.closest(Selectors.filter.actions.remove)) {
79
                e.preventDefault();
80
 
81
                this.removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true);
82
            }
83
        });
84
 
85
        // Add listeners for the filter type selection.
86
        let filterRegion = jQuery(this.getFilterRegion());
87
        CustomEvents.define(filterRegion, [CustomEvents.events.accessibleChange]);
88
        filterRegion.on(CustomEvents.events.accessibleChange, e => {
89
            const typeField = e.target.closest(Selectors.filter.fields.type);
90
            if (typeField && typeField.value) {
91
                const filter = e.target.closest(Selectors.filter.region);
92
 
93
                this.addFilter(filter, typeField.value);
94
            }
95
        });
96
 
97
        this.filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
98
            this.filterSet.dataset.filterverb = e.target.value;
99
        });
100
    }
101
 
102
    /**
103
     * Get the filter list region.
104
     *
105
     * @return {HTMLElement}
106
     */
107
    getFilterRegion() {
108
        return this.filterSet.querySelector(Selectors.filterset.regions.filterlist);
109
    }
110
 
111
    /**
112
     * Add a filter row.
113
     *
114
     * @param {Object} filterdata Optional, data for adding for row with an existing filter.
115
     * @return {Promise}
116
     */
117
    addFilterRow(filterdata = {}) {
118
        const pendingPromise = new Pending('core/datafilter:addFilterRow');
119
        const rownum = filterdata.rownum ?? 1 + this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
120
        return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rownum})
121
            .then(({html, js}) => {
122
                const newContentNodes = Templates.appendNodeContents(this.getFilterRegion(), html, js);
123
 
124
                return newContentNodes;
125
            })
126
            .then(filterRow => {
127
                // Note: This is a nasty hack.
128
                // We should try to find a better way of doing this.
129
                // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
130
                // it in place.
131
                const typeList = this.filterSet.querySelector(Selectors.data.typeList);
132
 
133
                filterRow.forEach(contentNode => {
134
                    const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
135
 
136
                    if (contentTypeList) {
137
                        contentTypeList.innerHTML = typeList.innerHTML;
138
                    }
139
                });
140
 
141
                return filterRow;
142
            })
143
            .then(filterRow => {
144
                this.updateFiltersOptions();
145
 
146
                return filterRow;
147
            })
148
            .then(result => {
149
                pendingPromise.resolve();
150
 
151
                // If an existing filter is passed in, add it. Otherwise, leave the row empty.
152
                if (filterdata.filtertype) {
153
                    result.forEach(filter => {
154
                        this.addFilter(filter, filterdata.filtertype, filterdata.values,
155
                            filterdata.jointype, filterdata.filteroptions);
156
                    });
157
                }
158
                return result;
159
            })
160
            .catch(Notification.exception);
161
    }
162
 
163
    /**
164
     * Get the filter data source node fro the specified filter type.
165
     *
166
     * @param {String} filterType
167
     * @return {HTMLElement}
168
     */
169
    getFilterDataSource(filterType) {
170
        const filterDataNode = this.filterSet.querySelector(Selectors.filterset.regions.datasource);
171
 
172
        return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
173
    }
174
 
175
    /**
176
     * Add a filter to the list of active filters, performing any necessary setup.
177
     *
178
     * @param {HTMLElement} filterRow
179
     * @param {String} filterType
180
     * @param {Array} initialFilterValues The initially selected values for the filter
181
     * @param {String} filterJoin
182
     * @param {Object} filterOptions
183
     * @returns {Filter}
184
     */
185
    async addFilter(filterRow, filterType, initialFilterValues, filterJoin, filterOptions) {
186
        // Name the filter on the filter row.
187
        filterRow.dataset.filterType = filterType;
188
 
189
        const filterDataNode = this.getFilterDataSource(filterType);
190
 
191
        // Instantiate the Filter class.
192
        let Filter = GenericFilter;
193
        if (filterDataNode.dataset.filterTypeClass) {
1441 ariadna 194
 
195
            // Ensure the filter class passed through exists, otherwise the filtering will break.
196
            try {
197
                Filter = await import(filterDataNode.dataset.filterTypeClass);
198
            } catch (error) {
199
                Notification.exception(error);
200
            }
201
 
1 efrain 202
        }
203
        this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues, filterOptions);
204
 
205
        // Disable the select.
206
        const typeField = filterRow.querySelector(Selectors.filter.fields.type);
207
        typeField.value = filterType;
208
        typeField.disabled = 'disabled';
209
        // Update the join list.
210
        this.updateJoinList(JSON.parse(filterDataNode.dataset.joinList), filterRow);
211
        const joinField = filterRow.querySelector(Selectors.filter.fields.join);
212
        if (!isNaN(filterJoin)) {
213
            joinField.value = filterJoin;
214
        }
215
        // Update the list of available filter types.
216
        this.updateFiltersOptions();
217
 
218
        return this.activeFilters[filterType];
219
    }
220
 
221
    /**
222
     * Get the registered filter class for the named filter.
223
     *
224
     * @param {String} name
225
     * @return {Object} See the Filter class.
226
     */
227
    getFilterObject(name) {
228
        return this.activeFilters[name];
229
    }
230
 
231
    /**
232
     * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
233
     * that it is replaced instead of being removed.
234
     *
235
     * @param {HTMLElement} filterRow
236
     * @param {Bool} refreshContent Whether to refresh the table content when removing
237
     */
238
    removeOrReplaceFilterRow(filterRow, refreshContent) {
239
        const filterCount = this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
240
        if (filterCount === 1) {
241
            this.replaceFilterRow(filterRow, refreshContent);
242
        } else {
243
            this.removeFilterRow(filterRow, refreshContent);
244
        }
245
    }
246
 
247
    /**
248
     * Remove the specified filter row and associated class.
249
     *
250
     * @param {HTMLElement} filterRow
251
     * @param {Bool} refreshContent Whether to refresh the table content when removing
252
     */
253
    async removeFilterRow(filterRow, refreshContent = true) {
254
        if (filterRow.querySelector(Selectors.data.required)) {
255
            return;
256
        }
257
        const filterType = filterRow.querySelector(Selectors.filter.fields.type);
258
        const hasFilterValue = !!filterType.value;
259
 
260
        // Remove the filter object.
261
        this.removeFilterObject(filterRow.dataset.filterType);
262
 
263
        // Remove the actual filter HTML.
264
        filterRow.remove();
265
 
266
        // Update the list of available filter types.
267
        this.updateFiltersOptions();
268
 
269
        if (hasFilterValue && refreshContent) {
270
            // Refresh the table if there was any content in this row.
271
            this.updateTableFromFilter();
272
        }
273
 
274
        // Update filter fieldset legends.
275
        const filterLegends = await this.getAvailableFilterLegends();
276
 
277
        this.getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
278
            filterRow.querySelector('legend').innerText = filterLegends[index];
279
        });
280
 
281
    }
282
 
283
    /**
284
     * Replace the specified filter row with a new one.
285
     *
286
     * @param {HTMLElement} filterRow
287
     * @param {Bool} refreshContent Whether to refresh the table content when removing
288
     * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
289
     * @return {Promise}
290
     */
291
    replaceFilterRow(filterRow, refreshContent = true, rowNum = 1) {
292
        if (filterRow.querySelector(Selectors.data.required)) {
293
            return;
294
        }
295
        // Remove the filter object.
296
        this.removeFilterObject(filterRow.dataset.filterType);
297
 
298
        return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rowNum})
299
            .then(({html, js}) => {
300
                const newContentNodes = Templates.replaceNode(filterRow, html, js);
301
 
302
                return newContentNodes;
303
            })
304
            .then(filterRow => {
305
                // Note: This is a nasty hack.
306
                // We should try to find a better way of doing this.
307
                // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
308
                // it in place.
309
                const typeList = this.filterSet.querySelector(Selectors.data.typeList);
310
 
311
                filterRow.forEach(contentNode => {
312
                    const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
313
 
314
                    if (contentTypeList) {
315
                        contentTypeList.innerHTML = typeList.innerHTML;
316
                    }
317
                });
318
 
319
                return filterRow;
320
            })
321
            .then(filterRow => {
322
                this.updateFiltersOptions();
323
 
324
                return filterRow;
325
            })
326
            .then(filterRow => {
327
                // Refresh the table.
328
                if (refreshContent) {
329
                    return this.updateTableFromFilter();
330
                } else {
331
                    return filterRow;
332
                }
333
            })
334
            .catch(Notification.exception);
335
    }
336
 
337
    /**
338
     * Remove the Filter Object from the register.
339
     *
340
     * @param {string} filterName The name of the filter to be removed
341
     */
342
    removeFilterObject(filterName) {
343
        if (filterName) {
344
            const filter = this.getFilterObject(filterName);
345
            if (filter) {
346
                filter.tearDown();
347
 
348
                // Remove from the list of active filters.
349
                delete this.activeFilters[filterName];
350
            }
351
        }
352
    }
353
 
354
    /**
355
     * Remove all filters.
356
     *
357
     * @returns {Promise}
358
     */
359
    removeAllFilters() {
360
        const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
361
        filters.forEach(filterRow => this.removeOrReplaceFilterRow(filterRow, false));
362
 
363
        // Refresh the table.
364
        return this.updateTableFromFilter();
365
    }
366
 
367
    /**
368
     * Remove any empty filters.
369
     */
370
    removeEmptyFilters() {
371
        const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
372
        filters.forEach(filterRow => {
373
            const filterType = filterRow.querySelector(Selectors.filter.fields.type);
374
            if (!filterType.value) {
375
                this.removeOrReplaceFilterRow(filterRow, false);
376
            }
377
        });
378
    }
379
 
380
    /**
381
     * Update the list of filter types to filter out those already selected.
382
     */
383
    updateFiltersOptions() {
384
        const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
385
        filters.forEach(filterRow => {
386
            const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
387
            options.forEach(option => {
388
                if (option.value === filterRow.dataset.filterType) {
389
                    option.classList.remove('hidden');
390
                    option.disabled = false;
391
                } else if (this.activeFilters[option.value]) {
392
                    option.classList.add('hidden');
393
                    option.disabled = true;
394
                } else {
395
                    option.classList.remove('hidden');
396
                    option.disabled = false;
397
                }
398
            });
399
        });
400
 
401
        // Configure the state of the "Add row" button.
402
        // This button is disabled when there is a filter row available for each condition.
403
        const addRowButton = this.filterSet.querySelector(Selectors.filterset.actions.addRow);
404
        const filterDataNode = this.filterSet.querySelectorAll(Selectors.data.fields.all);
405
        if (filterDataNode.length <= filters.length) {
406
            addRowButton.setAttribute('disabled', 'disabled');
407
        } else {
408
            addRowButton.removeAttribute('disabled');
409
        }
410
 
411
        if (filters.length === 1) {
412
            this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
413
            this.filterSet.querySelector(Selectors.filterset.fields.join).value = 2;
414
            this.filterSet.dataset.filterverb = 2;
415
        } else {
416
            this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
417
        }
418
    }
419
 
420
    /**
421
     * Update the Dynamic table based upon the current filter.
1441 ariadna 422
     *
423
     * @param {bool} validate Should we validate the filters? We might want to skip this if the filters won't have changed,
424
     *     for example for pagination/sorting.
1 efrain 425
     */
1441 ariadna 426
    updateTableFromFilter(validate = true) {
1 efrain 427
        const pendingPromise = new Pending('core/datafilter:updateTableFromFilter');
428
 
429
        const filters = {};
1441 ariadna 430
        let valid = true;
1 efrain 431
        Object.values(this.activeFilters).forEach(filter => {
1441 ariadna 432
            if (validate) {
433
                valid = valid && filter.validate();
434
            }
1 efrain 435
            filters[filter.filterValue.name] = filter.filterValue;
436
        });
1441 ariadna 437
        if (validate) {
438
            valid = valid && document.querySelector(Selectors.filter.region).closest('form').reportValidity();
439
        }
440
        if (this.applyCallback && valid) {
1 efrain 441
            this.applyCallback(filters, pendingPromise);
1441 ariadna 442
        } else {
443
            pendingPromise.resolve();
1 efrain 444
        }
445
    }
446
 
447
    /**
448
     * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
449
     *
450
     * @return {array}
451
     */
452
    async getAvailableFilterLegends() {
453
        const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
454
        let requests = [];
455
 
456
        [...Array(maxFilters)].forEach((_, rowIndex) => {
457
            requests.push({
458
                "key": "filterrowlegend",
459
                "component": "core",
460
                // Add 1 since rows begin at 1 (index begins at zero).
461
                "param": rowIndex + 1
462
            });
463
        });
464
 
465
        const legendStrings = await getStrings(requests)
466
            .then(fetchedStrings => {
467
                return fetchedStrings;
468
            })
469
            .catch(Notification.exception);
470
 
471
        return legendStrings;
472
    }
473
 
474
    /**
475
     * Update the list of join types for a filter.
476
     *
477
     * This will update the list of join types based on the allowed types defined for a filter.
478
     * If only one type is allowed, the list will be hidden.
479
     *
480
     * @param {Array} filterJoinList Array of join types, a subset of the regularJoinList array in this function.
481
     * @param {Element} filterRow The row being updated.
482
     */
483
    updateJoinList(filterJoinList, filterRow) {
484
        const regularJoinList = [0, 1, 2];
485
        // If a join list was specified for this filter, find the default join list and disable the options that are not allowed
486
        // for this filter.
487
        if (filterJoinList.length !== 0) {
488
            const joinField = filterRow.querySelector(Selectors.filter.fields.join);
489
            // Check each option from the default list, and disable the option in this filter row if it is not allowed
490
            // for this filter.
491
            regularJoinList.forEach((join) => {
492
                if (!filterJoinList.includes(join)) {
493
                    joinField.options[join].classList.add('hidden');
494
                    joinField.options[join].disabled = true;
495
                }
496
            });
497
            // Now remove the disabled options, and hide the select list of there is only one option left.
498
            joinField.options.forEach((element, index) => {
499
                if (element.disabled) {
500
                    joinField.options[index] = null;
501
                }
502
            });
503
            if (joinField.options.length === 1) {
504
                joinField.hidden = true;
505
            }
506
        }
507
    }
508
}