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
 * This module will tie together all of the different calls the gradable module will make.
18
 *
19
 * @module     mod_forum/local/grades/grader
20
 * @copyright  2019 Mathew May <mathew.solutions>
21
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
import Templates from 'core/templates';
24
import Selectors from './local/grader/selectors';
25
import getUserPicker from './local/grader/user_picker';
26
import {createLayout as createFullScreenWindow} from 'mod_forum/local/layout/fullscreen';
27
import getGradingPanelFunctions from './local/grader/gradingpanel';
28
import {add as addToast} from 'core/toast';
29
import {addNotification} from 'core/notification';
30
import {getString} from 'core/str';
31
import {failedUpdate} from 'core_grades/grades/grader/gradingpanel/normalise';
32
import {addIconToContainerWithPromise} from 'core/loadingicon';
33
import {debounce} from 'core/utils';
34
import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison';
35
import Modal from 'core/modal_cancel';
36
import {subscribe} from 'core/pubsub';
37
import DrawerEvents from 'core/drawer_events';
38
 
39
const templateNames = {
40
    grader: {
41
        app: 'mod_forum/local/grades/grader',
42
        gradingPanel: {
43
            error: 'mod_forum/local/grades/local/grader/gradingpanel/error',
44
        },
45
        searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search',
46
        status: 'mod_forum/local/grades/local/grader/status',
47
    },
48
};
49
 
50
/**
51
 * Helper function that replaces the user picker placeholder with what we get back from the user picker class.
52
 *
53
 * @param {HTMLElement} root
54
 * @param {String} html
55
 */
56
const displayUserPicker = (root, html) => {
57
    const pickerRegion = root.querySelector(Selectors.regions.pickerRegion);
58
    Templates.replaceNodeContents(pickerRegion, html, '');
59
};
60
 
61
/**
62
 * To be removed, this is now done as a part of Templates.renderForPromise()
63
 *
64
 * @param {String} html
65
 * @param {String} js
66
 * @returns {array} An array containing the HTML, and JS.
67
 */
68
const fetchContentFromRender = (html, js) => {
69
    return [html, js];
70
};
71
 
72
/**
73
 * Here we build the function that is passed to the user picker that'll handle updating the user content area
74
 * of the grading interface.
75
 *
76
 * @param {HTMLElement} root
77
 * @param {Function} getContentForUser
78
 * @param {Function} getGradeForUser
79
 * @param {Function} saveGradeForUser
80
 * @return {Function}
81
 */
82
const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser, saveGradeForUser) => {
83
    let firstLoad = true;
84
 
85
    return async(user) => {
86
        const spinner = firstLoad ? null : addIconToContainerWithPromise(root);
87
        const [
88
            [html, js],
89
            userGrade,
90
        ] = await Promise.all([
91
            getContentForUser(user.id).then(fetchContentFromRender),
92
            getGradeForUser(user.id),
93
        ]);
94
        Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);
95
 
96
        const [
97
            gradingPanelHtml,
98
            gradingPanelJS
99
        ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);
100
        const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);
101
        const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);
102
        Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);
103
 
104
        const form = panel.querySelector('form');
105
        fillInitialValues(form);
106
 
107
        form.addEventListener('submit', event => {
108
            saveGradeForUser(user);
109
            event.preventDefault();
110
        });
111
 
112
        panelContainer.scrollTop = 0;
113
        firstLoad = false;
114
 
115
        if (spinner) {
116
            spinner.resolve();
117
        }
118
        return userGrade;
119
    };
120
};
121
 
122
/**
123
 * Show the search results container and hide the user picker and body content.
124
 *
125
 * @param {HTMLElement} bodyContainer The container element for the body content
126
 * @param {HTMLElement} userPickerContainer The container element for the user picker
127
 * @param {HTMLElement} searchResultsContainer The container element for the search results
128
 */
129
const showSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
130
    bodyContainer.classList.add('hidden');
131
    userPickerContainer.classList.add('hidden');
132
    searchResultsContainer.classList.remove('hidden');
133
};
134
 
135
/**
136
 * Hide the search results container and show the user picker and body content.
137
 *
138
 * @param {HTMLElement} bodyContainer The container element for the body content
139
 * @param {HTMLElement} userPickerContainer The container element for the user picker
140
 * @param {HTMLElement} searchResultsContainer The container element for the search results
141
 */
142
const hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
143
    bodyContainer.classList.remove('hidden');
144
    userPickerContainer.classList.remove('hidden');
145
    searchResultsContainer.classList.add('hidden');
146
};
147
 
148
/**
149
 * Toggles the visibility of the user search.
150
 *
151
 * @param {HTMLElement} toggleSearchButton The button that toggles the search
152
 * @param {HTMLElement} searchContainer The container element for the user search
153
 * @param {HTMLElement} searchInput The input element for searching
154
 */
155
const showUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
156
    searchContainer.classList.remove('collapsed');
157
    toggleSearchButton.setAttribute('aria-expanded', 'true');
158
    toggleSearchButton.classList.add('expand');
159
    toggleSearchButton.classList.remove('collapse');
160
 
161
    // Hide the grading info container from screen reader.
162
    const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
163
    gradingInfoContainer.setAttribute('aria-hidden', 'true');
164
 
165
    // Hide the collapse grading drawer button from screen reader.
166
    const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);
167
    collapseGradingDrawer.setAttribute('aria-hidden', 'true');
168
    collapseGradingDrawer.setAttribute('tabindex', '-1');
169
 
170
    searchInput.focus();
171
};
172
 
173
/**
174
 * Toggles the visibility of the user search.
175
 *
176
 * @param {HTMLElement} toggleSearchButton The button that toggles the search
177
 * @param {HTMLElement} searchContainer The container element for the user search
178
 * @param {HTMLElement} searchInput The input element for searching
179
 */
180
const hideUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
181
    searchContainer.classList.add('collapsed');
182
    toggleSearchButton.setAttribute('aria-expanded', 'false');
183
    toggleSearchButton.classList.add('collapse');
184
    toggleSearchButton.classList.remove('expand');
185
    toggleSearchButton.focus();
186
 
187
    // Show the grading info container to screen reader.
188
    const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
189
    gradingInfoContainer.removeAttribute('aria-hidden');
190
 
191
    // Show the collapse grading drawer button from screen reader.
192
    const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);
193
    collapseGradingDrawer.removeAttribute('aria-hidden');
194
    collapseGradingDrawer.setAttribute('tabindex', '0');
195
 
196
    searchInput.value = '';
197
};
198
 
199
/**
200
 * Find the list of users who's names include the given search term.
201
 *
202
 * @param {Array} userList List of users for the grader
203
 * @param {String} searchTerm The search term to match
204
 * @return {Array}
205
 */
206
const searchForUsers = (userList, searchTerm) => {
207
    if (searchTerm === '') {
208
        return userList;
209
    }
210
 
211
    searchTerm = searchTerm.toLowerCase();
212
 
213
    return userList.filter((user) => {
214
        return user.fullname.toLowerCase().includes(searchTerm);
215
    });
216
};
217
 
218
/**
219
 * Render the list of users in the search results area.
220
 *
221
 * @param {HTMLElement} searchResultsContainer The container element for search results
222
 * @param {Array} users The list of users to display
223
 */
224
const renderSearchResults = async(searchResultsContainer, users) => {
225
    const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});
226
    Templates.replaceNodeContents(searchResultsContainer, html, js);
227
};
228
 
229
/**
230
 * Add click handlers to the buttons in the header of the grading interface.
231
 *
232
 * @param {HTMLElement} graderLayout
233
 * @param {Object} userPicker
234
 * @param {Function} saveGradeFunction
235
 * @param {Array} userList List of users for the grader.
236
 */
237
const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {
238
    const graderContainer = graderLayout.getContainer();
239
    const toggleSearchButton = graderContainer.querySelector(Selectors.buttons.toggleSearch);
240
    const searchInputContainer = graderContainer.querySelector(Selectors.regions.userSearchContainer);
241
    const searchInput = searchInputContainer.querySelector(Selectors.regions.userSearchInput);
242
    const bodyContainer = graderContainer.querySelector(Selectors.regions.bodyContainer);
243
    const userPickerContainer = graderContainer.querySelector(Selectors.regions.pickerRegion);
244
    const searchResultsContainer = graderContainer.querySelector(Selectors.regions.searchResultsContainer);
245
 
246
    graderContainer.addEventListener('click', (e) => {
247
        if (e.target.closest(Selectors.buttons.toggleFullscreen)) {
248
            e.stopImmediatePropagation();
249
            e.preventDefault();
250
            graderLayout.toggleFullscreen();
251
 
252
            return;
253
        }
254
 
255
        if (e.target.closest(Selectors.buttons.closeGrader)) {
256
            e.stopImmediatePropagation();
257
            e.preventDefault();
258
 
259
            graderLayout.close();
260
 
261
            return;
262
        }
263
 
264
        if (e.target.closest(Selectors.buttons.saveGrade)) {
265
            saveGradeFunction(userPicker.currentUser);
266
        }
267
 
268
        if (e.target.closest(Selectors.buttons.toggleSearch)) {
269
            if (toggleSearchButton.getAttribute('aria-expanded') === 'true') {
270
                // Search is open so let's close it.
271
                hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
272
                hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
273
                searchResultsContainer.innerHTML = '';
274
            } else {
275
                // Search is closed so let's open it.
276
                showUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
277
                showSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
278
                renderSearchResults(searchResultsContainer, userList);
279
            }
280
 
281
            return;
282
        }
283
 
284
        const selectUserButton = e.target.closest(Selectors.buttons.selectUser);
285
        if (selectUserButton) {
286
            const userId = selectUserButton.getAttribute('data-userid');
287
            const user = userList.find(user => user.id == userId);
288
            userPicker.setUserId(userId);
289
            userPicker.showUser(user);
290
            hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
291
            hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
292
            searchResultsContainer.innerHTML = '';
293
        }
294
    });
295
 
296
    // Debounce the search input so that it only executes 300 milliseconds after the user has finished typing.
297
    searchInput.addEventListener('input', debounce(() => {
298
        const users = searchForUsers(userList, searchInput.value);
299
        renderSearchResults(searchResultsContainer, users);
300
    }, 300));
301
 
302
    // Remove the right margin of the content container when the grading panel is hidden so that it expands to full-width.
303
    subscribe(DrawerEvents.DRAWER_HIDDEN, (drawerRoot) => {
304
        const gradingPanel = drawerRoot[0];
305
        if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {
306
            setContentContainerMargin(graderContainer, 0);
307
        }
308
    });
309
 
310
    // Bring back the right margin of the content container when the grading panel is shown to give space for the grading panel.
311
    subscribe(DrawerEvents.DRAWER_SHOWN, (drawerRoot) => {
312
        const gradingPanel = drawerRoot[0];
313
        if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {
314
            setContentContainerMargin(graderContainer, gradingPanel.offsetWidth);
315
        }
316
    });
317
};
318
 
319
/**
320
 * Adjusts the right margin of the content container.
321
 *
322
 * @param {HTMLElement} graderContainer The container for the grader app.
323
 * @param {Number} rightMargin The right margin value.
324
 */
325
const setContentContainerMargin = (graderContainer, rightMargin) => {
326
    const contentContainer = graderContainer.querySelector(Selectors.regions.moduleContainer);
327
    if (contentContainer) {
328
        contentContainer.style.marginRight = `${rightMargin}px`;
329
    }
330
};
331
 
332
/**
333
 * Get the function used to save a user grade.
334
 *
335
 * @param {HTMLElement} root The container for the grader
336
 * @param {Function} setGradeForUser The function that will be called.
337
 * @return {Function}
338
 */
339
const getSaveUserGradeFunction = (root, setGradeForUser) => {
340
    return async(user) => {
341
        try {
342
            root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';
343
            const result = await setGradeForUser(
344
                user.id,
345
                root.querySelector(Selectors.values.sendStudentNotifications).value,
346
                root.querySelector(Selectors.regions.gradingPanel)
347
            );
348
            if (result.success) {
349
                addToast(await getString('grades:gradesavedfor', 'mod_forum', user));
350
            }
351
            if (result.failed) {
352
                displayGradingError(root, user, result.error);
353
            }
354
 
355
            return result;
356
        } catch (err) {
357
            displayGradingError(root, user, err);
358
 
359
            return failedUpdate(err);
360
        }
361
    };
362
};
363
 
364
/**
365
 * Display a grading error, typically from a failed save.
366
 *
367
 * @param {HTMLElement} root The container for the grader
368
 * @param {Object} user The user who was errored
369
 * @param {Object} err The details of the error
370
 */
371
const displayGradingError = async(root, user, err) => {
372
    const [
373
        {html, js},
374
        errorString
375
    ] = await Promise.all([
376
        Templates.renderForPromise(templateNames.grader.gradingPanel.error, {error: err}),
377
        await getString('grades:gradesavefailed', 'mod_forum', {error: err.message, ...user}),
378
    ]);
379
 
380
    Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);
381
    addToast(errorString, {type: 'warning'});
382
};
383
 
384
/**
385
 * Launch the grader interface with the specified parameters.
386
 *
387
 * @param {Function} getListOfUsers A function to get the list of users
388
 * @param {Function} getContentForUser A function to get the content for a specific user
389
 * @param {Function} getGradeForUser A function get the grade details for a specific user
390
 * @param {Function} setGradeForUser A function to set the grade for a specific user
391
 * @param {Object} preferences Preferences for the launch function
392
 * @param {Number} preferences.initialUserId
393
 * @param {string} preferences.moduleName
394
 * @param {string} preferences.courseName
395
 * @param {string} preferences.courseUrl
396
 * @param {boolean} preferences.sendStudentNotifications
397
 * @param {null|HTMLElement} preferences.focusOnClose
398
 */
399
export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {
400
    initialUserId = null,
401
    moduleName,
402
    courseName,
403
    courseUrl,
404
    sendStudentNotifications,
405
    focusOnClose = null,
406
} = {}) => {
407
 
408
    // We need all of these functions to be executed in series, if one step runs before another the interface
409
    // will not work.
410
 
411
    // We need this promise to resolve separately so that we can avoid loading the whole interface if there are no users.
412
    const userList = await getListOfUsers();
413
    if (!userList.length) {
414
        addNotification({
415
            message: await getString('nouserstograde', 'core_grades'),
416
            type: "error",
417
        });
418
        return;
419
    }
420
 
421
    // Now that we have confirmed there are at least some users let's boot up the grader interface.
422
    const [
423
        graderLayout,
424
        {html, js},
425
    ] = await Promise.all([
426
        createFullScreenWindow({
427
            fullscreen: false,
428
            showLoader: false,
429
            focusOnClose,
430
        }),
431
        Templates.renderForPromise(templateNames.grader.app, {
432
            moduleName,
433
            courseName,
434
            courseUrl,
435
            drawer: {show: true},
436
            defaultsendnotifications: sendStudentNotifications,
437
        }),
438
    ]);
439
 
440
    const graderContainer = graderLayout.getContainer();
441
 
442
    const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);
443
 
444
    Templates.replaceNodeContents(graderContainer, html, js);
445
    const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser, saveGradeFunction);
446
 
447
    const userIds = userList.map(user => user.id);
448
    const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);
449
    // Fetch the userpicker for display.
450
    const userPicker = await getUserPicker(
451
        userList,
452
        async(user) => {
453
            const userGrade = await updateUserContent(user);
454
            const renderContext = {
455
                status: userGrade.hasgrade,
456
                index: userIds.indexOf(user.id) + 1,
457
                total: userList.length
458
            };
459
            Templates.render(templateNames.grader.status, renderContext).then(html => {
460
                statusContainer.innerHTML = html;
461
                return html;
462
            }).catch();
463
        },
464
        saveGradeFunction,
465
        {
466
            initialUserId,
467
        },
468
    );
469
 
470
    // Register all event listeners.
471
    registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);
472
 
473
    // Display the newly created user picker.
474
    displayUserPicker(graderContainer, userPicker.rootNode);
475
};
476
 
477
/**
478
 * Show the grade for a specific user.
479
 *
480
 * @param {Function} getGradeForUser A function get the grade details for a specific user
481
 * @param {Number} userid The ID of a specific user
482
 * @param {String} moduleName the name of the module
483
 * @param {object} param
484
 * @param {null|HTMLElement} param.focusOnClose
485
 */
486
export const view = async(getGradeForUser, userid, moduleName, {
487
    focusOnClose = null,
488
} = {}) => {
489
 
490
    const userGrade = await getGradeForUser(userid);
491
 
492
    const [
493
        modal,
494
        gradeTemplateData
495
    ] = await Promise.all([
496
        Modal.create({
497
            title: moduleName,
498
            large: true,
499
            removeOnClose: true,
500
            returnElement: focusOnClose,
501
            show: true,
502
            body: Templates.render('mod_forum/local/grades/view_grade', userGrade),
503
        }),
504
        renderGradeTemplate(userGrade)
505
    ]);
506
 
507
    const bodyPromise = await modal.getBodyPromise();
508
    const gradeReplace = bodyPromise[0].querySelector('[data-region="grade-template"]');
509
    Templates.replaceNodeContents(gradeReplace, gradeTemplateData.html, gradeTemplateData.js);
510
};
511
 
512
const renderGradeTemplate = (userGrade) => Templates.renderForPromise(userGrade.templatename, userGrade.grade);
513
 
514
export {getGradingPanelFunctions};