Proyectos de Subversion Moodle

Rev

| 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
 
67
                this.updateTableFromFilter();
68
            }
69
 
70
            if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
71
                e.preventDefault();
72
 
73
                this.removeAllFilters();
74
            }
75
        });
76
 
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)) {
80
                e.preventDefault();
81
 
82
                this.removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true);
83
            }
84
        });
85
 
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);
93
 
94
                this.addFilter(filter, typeField.value);
95
            }
96
        });
97
 
98
        this.filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
99
            this.filterSet.dataset.filterverb = e.target.value;
100
        });
101
    }
102
 
103
    /**
104
     * Get the filter list region.
105
     *
106
     * @return {HTMLElement}
107
     */
108
    getFilterRegion() {
109
        return this.filterSet.querySelector(Selectors.filterset.regions.filterlist);
110
    }
111
 
112
    /**
113
     * Add a filter row.
114
     *
115
     * @param {Object} filterdata Optional, data for adding for row with an existing filter.
116
     * @return {Promise}
117
     */
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);
124
 
125
                return newContentNodes;
126
            })
127
            .then(filterRow => {
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
131
                // it in place.
132
                const typeList = this.filterSet.querySelector(Selectors.data.typeList);
133
 
134
                filterRow.forEach(contentNode => {
135
                    const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
136
 
137
                    if (contentTypeList) {
138
                        contentTypeList.innerHTML = typeList.innerHTML;
139
                    }
140
                });
141
 
142
                return filterRow;
143
            })
144
            .then(filterRow => {
145
                this.updateFiltersOptions();
146
 
147
                return filterRow;
148
            })
149
            .then(result => {
150
                pendingPromise.resolve();
151
 
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);
157
                    });
158
                }
159
                return result;
160
            })
161
            .catch(Notification.exception);
162
    }
163
 
164
    /**
165
     * Get the filter data source node fro the specified filter type.
166
     *
167
     * @param {String} filterType
168
     * @return {HTMLElement}
169
     */
170
    getFilterDataSource(filterType) {
171
        const filterDataNode = this.filterSet.querySelector(Selectors.filterset.regions.datasource);
172
 
173
        return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
174
    }
175
 
176
    /**
177
     * Add a filter to the list of active filters, performing any necessary setup.
178
     *
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
184
     * @returns {Filter}
185
     */
186
    async addFilter(filterRow, filterType, initialFilterValues, filterJoin, filterOptions) {
187
        // Name the filter on the filter row.
188
        filterRow.dataset.filterType = filterType;
189
 
190
        const filterDataNode = this.getFilterDataSource(filterType);
191
 
192
        // Instantiate the Filter class.
193
        let Filter = GenericFilter;
194
        if (filterDataNode.dataset.filterTypeClass) {
195
            Filter = await import(filterDataNode.dataset.filterTypeClass);
196
        }
197
        this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues, filterOptions);
198
 
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;
208
        }
209
        // Update the list of available filter types.
210
        this.updateFiltersOptions();
211
 
212
        return this.activeFilters[filterType];
213
    }
214
 
215
    /**
216
     * Get the registered filter class for the named filter.
217
     *
218
     * @param {String} name
219
     * @return {Object} See the Filter class.
220
     */
221
    getFilterObject(name) {
222
        return this.activeFilters[name];
223
    }
224
 
225
    /**
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.
228
     *
229
     * @param {HTMLElement} filterRow
230
     * @param {Bool} refreshContent Whether to refresh the table content when removing
231
     */
232
    removeOrReplaceFilterRow(filterRow, refreshContent) {
233
        const filterCount = this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
234
        if (filterCount === 1) {
235
            this.replaceFilterRow(filterRow, refreshContent);
236
        } else {
237
            this.removeFilterRow(filterRow, refreshContent);
238
        }
239
    }
240
 
241
    /**
242
     * Remove the specified filter row and associated class.
243
     *
244
     * @param {HTMLElement} filterRow
245
     * @param {Bool} refreshContent Whether to refresh the table content when removing
246
     */
247
    async removeFilterRow(filterRow, refreshContent = true) {
248
        if (filterRow.querySelector(Selectors.data.required)) {
249
            return;
250
        }
251
        const filterType = filterRow.querySelector(Selectors.filter.fields.type);
252
        const hasFilterValue = !!filterType.value;
253
 
254
        // Remove the filter object.
255
        this.removeFilterObject(filterRow.dataset.filterType);
256
 
257
        // Remove the actual filter HTML.
258
        filterRow.remove();
259
 
260
        // Update the list of available filter types.
261
        this.updateFiltersOptions();
262
 
263
        if (hasFilterValue && refreshContent) {
264
            // Refresh the table if there was any content in this row.
265
            this.updateTableFromFilter();
266
        }
267
 
268
        // Update filter fieldset legends.
269
        const filterLegends = await this.getAvailableFilterLegends();
270
 
271
        this.getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
272
            filterRow.querySelector('legend').innerText = filterLegends[index];
273
        });
274
 
275
    }
276
 
277
    /**
278
     * Replace the specified filter row with a new one.
279
     *
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).
283
     * @return {Promise}
284
     */
285
    replaceFilterRow(filterRow, refreshContent = true, rowNum = 1) {
286
        if (filterRow.querySelector(Selectors.data.required)) {
287
            return;
288
        }
289
        // Remove the filter object.
290
        this.removeFilterObject(filterRow.dataset.filterType);
291
 
292
        return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rowNum})
293
            .then(({html, js}) => {
294
                const newContentNodes = Templates.replaceNode(filterRow, html, js);
295
 
296
                return newContentNodes;
297
            })
298
            .then(filterRow => {
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
302
                // it in place.
303
                const typeList = this.filterSet.querySelector(Selectors.data.typeList);
304
 
305
                filterRow.forEach(contentNode => {
306
                    const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
307
 
308
                    if (contentTypeList) {
309
                        contentTypeList.innerHTML = typeList.innerHTML;
310
                    }
311
                });
312
 
313
                return filterRow;
314
            })
315
            .then(filterRow => {
316
                this.updateFiltersOptions();
317
 
318
                return filterRow;
319
            })
320
            .then(filterRow => {
321
                // Refresh the table.
322
                if (refreshContent) {
323
                    return this.updateTableFromFilter();
324
                } else {
325
                    return filterRow;
326
                }
327
            })
328
            .catch(Notification.exception);
329
    }
330
 
331
    /**
332
     * Remove the Filter Object from the register.
333
     *
334
     * @param {string} filterName The name of the filter to be removed
335
     */
336
    removeFilterObject(filterName) {
337
        if (filterName) {
338
            const filter = this.getFilterObject(filterName);
339
            if (filter) {
340
                filter.tearDown();
341
 
342
                // Remove from the list of active filters.
343
                delete this.activeFilters[filterName];
344
            }
345
        }
346
    }
347
 
348
    /**
349
     * Remove all filters.
350
     *
351
     * @returns {Promise}
352
     */
353
    removeAllFilters() {
354
        const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
355
        filters.forEach(filterRow => this.removeOrReplaceFilterRow(filterRow, false));
356
 
357
        // Refresh the table.
358
        return this.updateTableFromFilter();
359
    }
360
 
361
    /**
362
     * Remove any empty filters.
363
     */
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);
370
            }
371
        });
372
    }
373
 
374
    /**
375
     * Update the list of filter types to filter out those already selected.
376
     */
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;
388
                } else {
389
                    option.classList.remove('hidden');
390
                    option.disabled = false;
391
                }
392
            });
393
        });
394
 
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');
401
        } else {
402
            addRowButton.removeAttribute('disabled');
403
        }
404
 
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;
409
        } else {
410
            this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
411
        }
412
    }
413
 
414
    /**
415
     * Update the Dynamic table based upon the current filter.
416
     */
417
    updateTableFromFilter() {
418
        const pendingPromise = new Pending('core/datafilter:updateTableFromFilter');
419
 
420
        const filters = {};
421
        Object.values(this.activeFilters).forEach(filter => {
422
            filters[filter.filterValue.name] = filter.filterValue;
423
        });
424
 
425
        if (this.applyCallback) {
426
            this.applyCallback(filters, pendingPromise);
427
        }
428
    }
429
 
430
    /**
431
     * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
432
     *
433
     * @return {array}
434
     */
435
    async getAvailableFilterLegends() {
436
        const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
437
        let requests = [];
438
 
439
        [...Array(maxFilters)].forEach((_, rowIndex) => {
440
            requests.push({
441
                "key": "filterrowlegend",
442
                "component": "core",
443
                // Add 1 since rows begin at 1 (index begins at zero).
444
                "param": rowIndex + 1
445
            });
446
        });
447
 
448
        const legendStrings = await getStrings(requests)
449
            .then(fetchedStrings => {
450
                return fetchedStrings;
451
            })
452
            .catch(Notification.exception);
453
 
454
        return legendStrings;
455
    }
456
 
457
    /**
458
     * Update the list of join types for a filter.
459
     *
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.
462
     *
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.
465
     */
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
469
        // for this filter.
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
473
            // for this filter.
474
            regularJoinList.forEach((join) => {
475
                if (!filterJoinList.includes(join)) {
476
                    joinField.options[join].classList.add('hidden');
477
                    joinField.options[join].disabled = true;
478
                }
479
            });
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;
484
                }
485
            });
486
            if (joinField.options.length === 1) {
487
                joinField.hidden = true;
488
            }
489
        }
490
    }
491
}