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