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