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
 * Allow the user to show and hide columns of the report at will.
18
 *
19
 * @module    gradereport_grader/collapse
20
 * @copyright 2023 Mathew May <mathew.solutions>
21
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
import * as Repository from 'gradereport_grader/collapse/repository';
24
import search_combobox from 'core/comboboxsearch/search_combobox';
25
import {renderForPromise, replaceNodeContents, replaceNode} from 'core/templates';
26
import {debounce} from 'core/utils';
1441 ariadna 27
import Dropdown from 'theme_boost/bootstrap/dropdown';
1 efrain 28
import {getStrings} from 'core/str';
29
import CustomEvents from "core/custom_interaction_events";
30
import storage from 'core/localstorage';
31
import {addIconToContainer} from 'core/loadingicon';
32
import Notification from 'core/notification';
33
import Pending from 'core/pending';
34
 
35
// Contain our selectors within this file until they could be of use elsewhere.
36
const selectors = {
37
    component: '.collapse-columns',
38
    formDropdown: '.columnsdropdownform',
39
    formItems: {
40
        cancel: 'cancel',
41
        save: 'save',
42
        checked: 'input[type="checkbox"]:checked',
43
        currentlyUnchecked: 'input[type="checkbox"]:not([data-action="selectall"])',
44
    },
45
    hider: 'hide',
46
    expand: 'expand',
47
    colVal: '[data-col]',
48
    itemVal: '[data-itemid]',
49
    content: '[data-collapse="content"]',
50
    sort: '[data-collapse="sort"]',
51
    expandbutton: '[data-collapse="expandbutton"]',
52
    rangerowcell: '[data-collapse="rangerowcell"]',
53
    avgrowcell: '[data-collapse="avgrowcell"]',
54
    menu: '[data-collapse="menu"]',
55
    icons: '.data-collapse_gradeicons',
56
    count: '[data-collapse="count"]',
57
    placeholder: '.collapsecolumndropdown [data-region="placeholder"]',
58
    fullDropdown: '.collapsecolumndropdown',
59
    searchResultContainer: '.searchresultitemscontainer',
1441 ariadna 60
    cellMenuButton: '.cellmenubtn',
1 efrain 61
};
62
 
63
const countIndicator = document.querySelector(selectors.count);
64
 
65
export default class ColumnSearch extends search_combobox {
66
 
67
    userID = -1;
68
    courseID = null;
69
    defaultSort = '';
70
 
71
    nodes = [];
72
 
73
    gradeStrings = null;
74
    userStrings = null;
75
    stringMap = [];
76
 
77
    static init(userID, courseID, defaultSort) {
78
        return new ColumnSearch(userID, courseID, defaultSort);
79
    }
80
 
81
    constructor(userID, courseID, defaultSort) {
82
        super();
83
        this.userID = userID;
84
        this.courseID = courseID;
85
        this.defaultSort = defaultSort;
86
        this.component = document.querySelector(selectors.component);
87
 
88
        const pendingPromise = new Pending();
89
        // Display a loader whilst collapsing appropriate columns (based on the locally stored state for the current user).
90
        addIconToContainer(document.querySelector('.gradeparent')).then((loader) => {
91
            setTimeout(() => {
92
                // Get the users' checked columns to change.
93
                this.getDataset().forEach((item) => {
94
                    this.nodesUpdate(item);
95
                });
96
                this.renderDefault();
97
 
98
                // Once the grade categories have been re-collapsed, remove the loader and display the Gradebook setup content.
99
                loader.remove();
100
                document.querySelector('.gradereport-grader-table').classList.remove('d-none');
101
            }, 10);
102
        }).then(() => pendingPromise.resolve()).catch(Notification.exception);
103
 
1441 ariadna 104
        this.component.addEventListener('hide.bs.dropdown', () => {
1 efrain 105
            const searchResultContainer = this.component.querySelector(selectors.searchResultContainer);
106
            searchResultContainer.scrollTop = 0;
107
 
108
            // Use setTimeout to make sure the following code is executed after the click event is handled.
109
            setTimeout(() => {
110
                if (this.searchInput.value !== '') {
111
                    this.searchInput.value = '';
112
                    this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));
113
                }
114
            });
115
        });
116
    }
117
 
118
    /**
119
     * The overall div that contains the searching widget.
120
     *
121
     * @returns {string}
122
     */
123
    componentSelector() {
124
        return '.collapse-columns';
125
    }
126
 
127
    /**
128
     * The dropdown div that contains the searching widget result space.
129
     *
130
     * @returns {string}
131
     */
132
    dropdownSelector() {
133
        return '.searchresultitemscontainer';
134
    }
135
 
136
    /**
137
     * Return the dataset that we will be searching upon.
138
     *
139
     * @returns {Array}
140
     */
141
    getDataset() {
142
        if (!this.dataset) {
143
            const cols = this.fetchDataset();
144
            this.dataset = JSON.parse(cols) ? JSON.parse(cols).split(',') : [];
145
        }
146
        this.datasetSize = this.dataset.length;
147
        return this.dataset;
148
    }
149
 
150
    /**
151
     * Get the data we will be searching against in this component.
152
     *
153
     * @returns {string}
154
     */
155
    fetchDataset() {
156
        return storage.get(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`);
157
    }
158
 
159
    /**
160
     * Given a user performs an action, update the users' preferences.
161
     */
162
    setPreferences() {
163
        storage.set(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`,
164
            JSON.stringify(this.getDataset().join(','))
165
        );
166
    }
167
 
168
    /**
169
     * Register clickable event listeners.
170
     */
171
    registerClickHandlers() {
172
        // Register click events within the component.
173
        this.component.addEventListener('click', this.clickHandler.bind(this));
174
 
175
        document.addEventListener('click', this.docClickHandler.bind(this));
176
    }
177
 
178
    /**
179
     * The handler for when a user interacts with the component.
180
     *
181
     * @param {MouseEvent} e The triggering event that we are working with.
182
     */
183
    clickHandler(e) {
184
        super.clickHandler(e);
185
        // Prevent BS from closing the dropdown if they click elsewhere within the dropdown besides the form.
186
        if (e.target.closest(selectors.fullDropdown)) {
187
            e.stopPropagation();
188
        }
189
    }
190
 
191
    /**
192
     * Externally defined click function to improve memory handling.
193
     *
194
     * @param {MouseEvent} e
195
     * @returns {Promise<void>}
196
     */
197
    async docClickHandler(e) {
198
        if (e.target.dataset.hider === selectors.hider) {
199
            e.preventDefault();
1441 ariadna 200
            const pendingPromise = new Pending('gradereport_grader/collapse:docClickHandler:hide');
1 efrain 201
            const desiredToHide = e.target.closest(selectors.colVal) ?
202
                e.target.closest(selectors.colVal)?.dataset.col :
203
                e.target.closest(selectors.itemVal)?.dataset.itemid;
204
            const idx = this.getDataset().indexOf(desiredToHide);
205
            if (idx === -1) {
206
                this.getDataset().push(desiredToHide);
207
            }
208
            await this.prefcountpipe();
209
 
1441 ariadna 210
            await this.nodesUpdate(desiredToHide);
211
            pendingPromise.resolve();
1 efrain 212
        }
213
 
214
        if (e.target.closest('button')?.dataset.hider === selectors.expand) {
215
            e.preventDefault();
1441 ariadna 216
            const pendingPromise = new Pending('gradereport_grader/collapse:docClickHandler:expand');
1 efrain 217
            const desiredToHide = e.target.closest(selectors.colVal) ?
218
                e.target.closest(selectors.colVal)?.dataset.col :
219
                e.target.closest(selectors.itemVal)?.dataset.itemid;
220
            const idx = this.getDataset().indexOf(desiredToHide);
221
            this.getDataset().splice(idx, 1);
222
 
223
            await this.prefcountpipe();
224
 
1441 ariadna 225
            await this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.col);
226
            await this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.itemid);
227
            pendingPromise.resolve();
1 efrain 228
        }
229
    }
230
 
231
    /**
232
     * Handle any keyboard inputs.
233
     */
234
    registerInputEvents() {
235
        // Register & handle the text input.
236
        this.searchInput.addEventListener('input', debounce(async() => {
237
            if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {
238
                window.console.warn(`Search term matches input value - skipping`);
239
                // Debounce can happen multiple times quickly.
240
                return;
241
            }
242
            this.setSearchTerms(this.searchInput.value);
243
            // We can also require a set amount of input before search.
244
            if (this.searchInput.value === '') {
245
                // Hide the "clear" search button in the search bar.
246
                this.clearSearchButton.classList.add('d-none');
247
            } else {
248
                // Display the "clear" search button in the search bar.
249
                this.clearSearchButton.classList.remove('d-none');
250
            }
251
            const pendingPromise = new Pending();
252
            // User has given something for us to filter against.
253
            await this.filterrenderpipe().then(() => {
254
                pendingPromise.resolve();
255
                return true;
256
            });
257
        }, 300, {pending: true}));
258
    }
259
 
260
    /**
261
     * Handle the form submission within the dropdown.
262
     */
263
    registerFormEvents() {
264
        const form = this.component.querySelector(selectors.formDropdown);
265
        const events = [
266
            'click',
267
            CustomEvents.events.activate,
268
            CustomEvents.events.keyboardActivate
269
        ];
270
        CustomEvents.define(document, events);
271
 
272
        const selectall = form.querySelector('[data-action="selectall"]');
273
 
274
        // Register clicks & keyboard form handling.
275
        events.forEach((event) => {
276
            const submitBtn = form.querySelector(`[data-action="${selectors.formItems.save}"`);
277
            form.addEventListener(event, (e) => {
278
                // Stop Bootstrap from being clever.
279
                e.stopPropagation();
280
                const input = e.target.closest('input');
281
                if (input) {
282
                    // If the user is unchecking an item, we need to uncheck the select all if it's checked.
283
                    if (selectall.checked && !input.checked) {
284
                        selectall.checked = false;
285
                    }
286
                    const checkedCount = Array.from(form.querySelectorAll(selectors.formItems.checked)).length;
287
                    // Check if any are clicked or not then change disabled.
288
                    submitBtn.disabled = checkedCount <= 0;
289
                }
290
            }, false);
291
 
292
            // Stop Bootstrap from being clever.
293
            this.searchInput.addEventListener(event, e => e.stopPropagation());
294
            this.clearSearchButton.addEventListener(event, async(e) => {
295
                e.stopPropagation();
296
                this.searchInput.value = '';
297
                this.setSearchTerms(this.searchInput.value);
298
                await this.filterrenderpipe();
299
            });
300
            selectall.addEventListener(event, (e) => {
301
                // Stop Bootstrap from being clever.
302
                e.stopPropagation();
303
                if (!selectall.checked) {
304
                    const touncheck = Array.from(form.querySelectorAll(selectors.formItems.checked));
305
                    touncheck.forEach(item => {
306
                        item.checked = false;
307
                    });
308
                    submitBtn.disabled = true;
309
                } else {
310
                    const currentUnchecked = Array.from(form.querySelectorAll(selectors.formItems.currentlyUnchecked));
311
                    currentUnchecked.forEach(item => {
312
                        item.checked = true;
313
                    });
314
                    submitBtn.disabled = false;
315
                }
316
            });
317
        });
318
 
319
        form.addEventListener('submit', async(e) => {
320
            e.preventDefault();
321
            if (e.submitter.dataset.action === selectors.formItems.cancel) {
1441 ariadna 322
                Dropdown.getOrCreateInstance(this.component).toggle();
1 efrain 323
                return;
324
            }
325
            // Get the users' checked columns to change.
326
            const checkedItems = [...form.elements].filter(item => item.checked);
327
            checkedItems.forEach((item) => {
328
                const idx = this.getDataset().indexOf(item.dataset.collapse);
329
                this.getDataset().splice(idx, 1);
330
                this.nodesUpdate(item.dataset.collapse);
331
            });
332
            // Reset the check all & submit to false just in case.
333
            selectall.checked = false;
334
            e.submitter.disabled = true;
335
            await this.prefcountpipe();
336
        });
337
    }
338
 
1441 ariadna 339
    async nodesUpdate(item) {
1 efrain 340
        const colNodesToHide = [...document.querySelectorAll(`[data-col="${item}"]`)];
341
        const itemIDNodesToHide = [...document.querySelectorAll(`[data-itemid="${item}"]`)];
1441 ariadna 342
        const elements = [...colNodesToHide, ...itemIDNodesToHide];
343
        if (elements && elements.length) {
344
            const pendingPromise = new Pending('gradereport_grader/collapse:nodesUpdate:' + item);
345
            this.updateDisplay(elements).then(() => pendingPromise.resolve()).catch(Notification.exception);
346
        }
1 efrain 347
    }
348
 
349
    /**
350
     * Update the user preferences, count display then render the results.
351
     *
352
     * @returns {Promise<void>}
353
     */
354
    async prefcountpipe() {
355
        this.setPreferences();
356
        this.countUpdate();
357
        await this.filterrenderpipe();
358
    }
359
 
360
    /**
361
     * Dictate to the search component how and what we want to match upon.
362
     *
363
     * @param {Array} filterableData
364
     * @returns {Array} An array of objects containing the system reference and the user readable value.
365
     */
366
    async filterDataset(filterableData) {
367
        const stringUserMap = await this.fetchRequiredUserStrings();
368
        const stringGradeMap = await this.fetchRequiredGradeStrings();
369
        // Custom user profile fields are not in our string map and need a bit of extra love.
370
        const customFieldMap = this.fetchCustomFieldValues();
371
        this.stringMap = new Map([...stringGradeMap, ...stringUserMap, ...customFieldMap]);
372
 
373
        const searching = filterableData.map(s => {
374
            const mapObj = this.stringMap.get(s);
375
            if (mapObj === undefined) {
376
                return {key: s, string: s};
377
            }
378
            return {
379
                key: s,
380
                string: mapObj.itemname ?? this.stringMap.get(s),
381
                category: mapObj.category ?? '',
382
            };
383
        });
384
        // Sometimes we just want to show everything.
385
        if (this.getPreppedSearchTerm() === '') {
386
            return searching;
387
        }
388
        // Other times we want to actually filter the content.
389
        return searching.filter((col) => {
390
            return col.string.toString().toLowerCase().includes(this.getPreppedSearchTerm());
391
        });
392
    }
393
 
394
    /**
395
     * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.
396
     */
397
    filterMatchDataset() {
398
        this.setMatchedResults(
399
            this.getMatchedResults().map((column) => {
400
                return {
401
                    name: column.key,
402
                    displayName: column.string ?? column.key,
403
                    category: column.category ?? '',
404
                };
405
            })
406
        );
407
    }
408
 
409
    /**
410
     * With an array of nodes, switch their classes and values.
1441 ariadna 411
     *
412
     * @param {Array} elements The elements to update.
1 efrain 413
     */
1441 ariadna 414
    async updateDisplay(elements) {
415
        const promises = [];
416
        elements.forEach((element) => {
417
            promises.push(this.updateDisplayForElement(element));
418
        });
1 efrain 419
 
1441 ariadna 420
        await Promise.all(promises);
421
    }
1 efrain 422
 
1441 ariadna 423
    /**
424
     * Update display for given element, switch its classes and values.
425
     *
426
     * @param {HTMLElement} element The element to update.
427
     */
428
    async updateDisplayForElement(element) {
429
        const content = element.querySelector(selectors.content);
430
        const sort = element.querySelector(selectors.sort);
431
        const expandButton = element.querySelector(selectors.expandbutton);
432
        const rangeRowCell = element.querySelector(selectors.rangerowcell);
433
        const avgRowCell = element.querySelector(selectors.avgrowcell);
434
        const cellMenuButton = element.querySelector(selectors.cellMenuButton);
435
        const nodeSet = [
436
            element.querySelector(selectors.menu),
437
            element.querySelector(selectors.icons),
438
            content
439
        ];
440
 
441
        // This can be further improved to reduce redundant similar calls.
442
        if (element.classList.contains('cell')) {
443
            // The column is actively being sorted, lets reset that and reload the page.
444
            if (sort !== null) {
445
                window.location = this.defaultSort;
446
            }
447
            if (content === null) {
448
                // If it's not a content cell, it must be an overall average or a range cell.
449
                const rowCell = avgRowCell ?? rangeRowCell;
450
 
451
                rowCell?.classList.toggle('d-none');
452
            } else if (content.classList.contains('d-none')) {
453
                // We should always have content but some cells do not contain menus or other actions.
454
                element.classList.remove('collapsed');
455
                // If there are many nodes, apply the following.
456
                if (content.childNodes.length > 1) {
457
                    content.classList.add('d-flex');
1 efrain 458
                }
1441 ariadna 459
                nodeSet.forEach(node => {
460
                    node?.classList.remove('d-none');
461
                });
462
                expandButton?.classList.add('d-none');
463
                cellMenuButton?.focus();
464
            } else {
465
                element.classList.add('collapsed');
466
                content.classList.remove('d-flex');
467
                nodeSet.forEach(node => {
468
                    node?.classList.add('d-none');
469
                });
470
                expandButton?.classList.remove('d-none');
1 efrain 471
            }
1441 ariadna 472
        }
1 efrain 473
    }
474
 
475
    /**
476
     * Update the visual count of collapsed columns or hide the count all together.
477
     */
478
    countUpdate() {
479
        countIndicator.textContent = this.getDatasetSize();
480
        if (this.getDatasetSize() > 0) {
481
            this.component.parentElement.classList.add('d-flex');
482
            this.component.parentElement.classList.remove('d-none');
483
        } else {
484
            this.component.parentElement.classList.remove('d-flex');
485
            this.component.parentElement.classList.add('d-none');
486
        }
487
    }
488
 
489
    /**
490
     * Build the content then replace the node by default we want our form to exist.
491
     */
492
    async renderDefault() {
493
        this.setMatchedResults(await this.filterDataset(this.getDataset()));
494
        this.filterMatchDataset();
495
 
496
        // Update the collapsed button pill.
497
        this.countUpdate();
498
        const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', {
499
            'instance': this.instance,
500
            'results': this.getMatchedResults(),
501
            'userid': this.userID,
502
        });
503
        replaceNode(selectors.placeholder, html, js);
504
        this.updateNodes();
505
 
506
        // Given we now have the body, we can set up more triggers.
507
        this.registerFormEvents();
508
        this.registerInputEvents();
509
 
510
        // Add a small BS listener so that we can set the focus correctly on open.
1441 ariadna 511
        this.component.addEventListener('shown.bs.dropdown', () => {
1 efrain 512
            this.searchInput.focus({preventScroll: true});
513
            this.selectallEnable();
514
        });
515
    }
516
 
517
    /**
518
     * Build the content then replace the node.
519
     */
520
    async renderDropdown() {
521
        const {html, js} = await renderForPromise('gradereport_grader/collapse/collapseresults', {
522
            instance: this.instance,
523
            'results': this.getMatchedResults(),
524
            'searchTerm': this.getSearchTerm(),
525
        });
526
        replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
527
        this.selectallEnable();
528
        // Reset the expand button to be disabled as we have re-rendered the dropdown.
529
        const form = this.component.querySelector(selectors.formDropdown);
530
        const expandButton = form.querySelector(`[data-action="${selectors.formItems.save}"`);
531
        expandButton.disabled = true;
532
    }
533
 
534
    /**
535
     * Given we render the dropdown, Determine if we want to enable the select all checkbox.
536
     */
537
    selectallEnable() {
538
        const form = this.component.querySelector(selectors.formDropdown);
539
        const selectall = form.querySelector('[data-action="selectall"]');
540
        selectall.disabled = this.getMatchedResults().length === 0;
541
    }
542
 
543
    /**
544
     * If we have any custom user profile fields, grab their system & readable names to add to our string map.
545
     *
546
     * @returns {array<string,*>} An array of associated string arrays ready for our map.
547
     */
548
    fetchCustomFieldValues() {
549
        const customFields = document.querySelectorAll('[data-collapse-name]');
550
        // Cast from NodeList to array to grab all the values.
551
        return [...customFields].map(field => [field.parentElement.dataset.col, field.dataset.collapseName]);
552
    }
553
 
554
    /**
555
     * Given the set of profile fields we can possibly search, fetch their strings,
556
     * so we can report to screen readers the field that matched.
557
     *
558
     * @returns {Promise<void>}
559
     */
560
    fetchRequiredUserStrings() {
561
        if (!this.userStrings) {
562
            const requiredStrings = [
563
                'username',
564
                'firstname',
565
                'lastname',
566
                'email',
567
                'city',
568
                'country',
569
                'department',
570
                'institution',
571
                'idnumber',
572
                'phone1',
573
                'phone2',
574
            ];
575
            this.userStrings = getStrings(requiredStrings.map((key) => ({key})))
576
                .then((stringArray) => new Map(
577
                    requiredStrings.map((key, index) => ([key, stringArray[index]]))
578
                ));
579
        }
580
        return this.userStrings;
581
    }
582
 
583
    /**
584
     * Given the set of gradable items we can possibly search, fetch their strings,
585
     * so we can report to screen readers the field that matched.
586
     *
587
     * @returns {Promise<void>}
588
     */
589
    fetchRequiredGradeStrings() {
590
        if (!this.gradeStrings) {
591
            this.gradeStrings = Repository.gradeItems(this.courseID)
592
                .then((result) => new Map(
593
                    result.gradeItems.map(key => ([key.id, key]))
594
                ));
595
        }
596
        return this.gradeStrings;
597
    }
598
}