MDL-67144 mod_forum: If no users exist do not show the grading interface
[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  * @package    mod_forum
21  * @copyright  2019 Mathew May <mathew.solutions>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 import Templates from 'core/templates';
25 import Selectors from './local/grader/selectors';
26 import getUserPicker from './local/grader/user_picker';
27 import {createLayout as createFullScreenWindow} from 'mod_forum/local/layout/fullscreen';
28 import getGradingPanelFunctions from './local/grader/gradingpanel';
29 import {add as addToast} from 'core/toast';
30 import {addNotification} from 'core/notification';
31 import {get_string as getString} from 'core/str';
32 import {failedUpdate} from 'core_grades/grades/grader/gradingpanel/normalise';
33 import {addIconToContainerWithPromise} from 'core/loadingicon';
34 import {debounce} from 'core/utils';
35 import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison';
36 import * as Modal from 'core/modal_factory';
37 import * as ModalEvents from 'core/modal_events';
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 };
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 };
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  * @return {[*, *]}
67  */
68 const fetchContentFromRender = (html, js) => {
69     return [html, js];
70 };
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  * @return {Function}
80  */
81 const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser) => {
82     let firstLoad = true;
84     return async(user) => {
85         const spinner = firstLoad ? null : addIconToContainerWithPromise(root);
86         const [
87             [html, js],
88             userGrade,
89         ] = await Promise.all([
90             getContentForUser(user.id).then(fetchContentFromRender),
91             getGradeForUser(user.id),
92         ]);
93         Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);
95         const [
96             gradingPanelHtml,
97             gradingPanelJS
98         ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);
99         const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);
100         const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);
101         Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);
102         fillInitialValues(panel.querySelector('form'));
104         panelContainer.scrollTop = 0;
105         firstLoad = false;
107         if (spinner) {
108             spinner.resolve();
109         }
110     };
111 };
113 /**
114  * Show the search results container and hide the user picker and body content.
115  *
116  * @param {HTMLElement} bodyContainer The container element for the body content
117  * @param {HTMLElement} userPickerContainer The container element for the user picker
118  * @param {HTMLElement} searchResultsContainer The container element for the search results
119  */
120 const showSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
121     bodyContainer.classList.add('hidden');
122     userPickerContainer.classList.add('hidden');
123     searchResultsContainer.classList.remove('hidden');
124 };
126 /**
127  * Hide the search results container and show the user picker and body content.
128  *
129  * @param {HTMLElement} bodyContainer The container element for the body content
130  * @param {HTMLElement} userPickerContainer The container element for the user picker
131  * @param {HTMLElement} searchResultsContainer The container element for the search results
132  */
133 const hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
134     bodyContainer.classList.remove('hidden');
135     userPickerContainer.classList.remove('hidden');
136     searchResultsContainer.classList.add('hidden');
137 };
139 /**
140  * Toggles the visibility of the user search.
141  *
142  * @param {HTMLElement} toggleSearchButton The button that toggles the search
143  * @param {HTMLElement} searchContainer The container element for the user search
144  * @param {HTMLElement} searchInput The input element for searching
145  */
146 const showUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
147     searchContainer.classList.remove('collapsed');
148     toggleSearchButton.setAttribute('aria-expanded', 'true');
149     toggleSearchButton.classList.add('expand');
150     toggleSearchButton.classList.remove('collapse');
151     searchInput.focus();
152 };
154 /**
155  * Toggles the visibility of the user search.
156  *
157  * @param {HTMLElement} toggleSearchButton The button that toggles the search
158  * @param {HTMLElement} searchContainer The container element for the user search
159  * @param {HTMLElement} searchInput The input element for searching
160  */
161 const hideUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
162     searchContainer.classList.add('collapsed');
163     toggleSearchButton.setAttribute('aria-expanded', 'false');
164     toggleSearchButton.classList.add('collapse');
165     toggleSearchButton.classList.remove('expand');
166     toggleSearchButton.focus();
167     searchInput.value = '';
168 };
170 /**
171  * Find the list of users who's names include the given search term.
172  *
173  * @param {Array} userList List of users for the grader
174  * @param {String} searchTerm The search term to match
175  * @return {Array}
176  */
177 const searchForUsers = (userList, searchTerm) => {
178     if (searchTerm === '') {
179         return userList;
180     }
182     searchTerm = searchTerm.toLowerCase();
184     return userList.filter((user) => {
185         return user.fullname.toLowerCase().includes(searchTerm);
186     });
187 };
189 /**
190  * Render the list of users in the search results area.
191  *
192  * @param {HTMLElement} searchResultsContainer The container element for search results
193  * @param {Array} users The list of users to display
194  */
195 const renderSearchResults = async(searchResultsContainer, users) => {
196     const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});
197     Templates.replaceNodeContents(searchResultsContainer, html, js);
198 };
200 /**
201  * Add click handlers to the buttons in the header of the grading interface.
202  *
203  * @param {HTMLElement} graderLayout
204  * @param {Object} userPicker
205  * @param {Function} saveGradeFunction
206  * @param {Array} userList List of users for the grader.
207  */
208 const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {
209     const graderContainer = graderLayout.getContainer();
210     const toggleSearchButton = graderContainer.querySelector(Selectors.buttons.toggleSearch);
211     const searchInputContainer = graderContainer.querySelector(Selectors.regions.userSearchContainer);
212     const searchInput = searchInputContainer.querySelector(Selectors.regions.userSearchInput);
213     const bodyContainer = graderContainer.querySelector(Selectors.regions.bodyContainer);
214     const userPickerContainer = graderContainer.querySelector(Selectors.regions.pickerRegion);
215     const searchResultsContainer = graderContainer.querySelector(Selectors.regions.searchResultsContainer);
217     graderContainer.addEventListener('click', (e) => {
218         if (e.target.closest(Selectors.buttons.toggleFullscreen)) {
219             e.stopImmediatePropagation();
220             e.preventDefault();
221             graderLayout.toggleFullscreen();
223             return;
224         }
226         if (e.target.closest(Selectors.buttons.closeGrader)) {
227             e.stopImmediatePropagation();
228             e.preventDefault();
230             graderLayout.close();
232             return;
233         }
235         if (e.target.closest(Selectors.buttons.saveGrade)) {
236             saveGradeFunction(userPicker.currentUser);
237         }
239         if (e.target.closest(Selectors.buttons.toggleSearch)) {
240             if (toggleSearchButton.getAttribute('aria-expanded') === 'true') {
241                 // Search is open so let's close it.
242                 hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
243                 hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
244                 searchResultsContainer.innerHTML = '';
245             } else {
246                 // Search is closed so let's open it.
247                 showUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
248                 showSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
249                 renderSearchResults(searchResultsContainer, userList);
250             }
252             return;
253         }
255         const selectUserButton = e.target.closest(Selectors.buttons.selectUser);
256         if (selectUserButton) {
257             const userId = selectUserButton.getAttribute('data-userid');
258             const user = userList.find(user => user.id == userId);
259             userPicker.setUserId(userId);
260             userPicker.showUser(user);
261             hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
262             hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
263             searchResultsContainer.innerHTML = '';
264         }
265     });
267     // Debounce the search input so that it only executes 300 milliseconds after the user has finished typing.
268     searchInput.addEventListener('input', debounce(() => {
269         const users = searchForUsers(userList, searchInput.value);
270         renderSearchResults(searchResultsContainer, users);
271     }, 300));
272 };
274 /**
275  * Get the function used to save a user grade.
276  *
277  * @param {HTMLElement} root The container for the grader
278  * @param {Function} setGradeForUser The function that will be called.
279  * @return {Function}
280  */
281 const getSaveUserGradeFunction = (root, setGradeForUser) => {
282     return async(user) => {
283         try {
284             root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';
285             const result = await setGradeForUser(
286                 user.id,
287                 root.querySelector(Selectors.values.sendStudentNotifications).value,
288                 root.querySelector(Selectors.regions.gradingPanel)
289             );
290             if (result.success) {
291                 addToast(await getString('grades:gradesavedfor', 'mod_forum', user));
292             }
293             if (result.failed) {
294                 displayGradingError(root, user, result.error);
295             }
297             return result;
298         } catch (err) {
299             displayGradingError(root, user, err);
301             return failedUpdate(err);
302         }
303     };
304 };
306 /**
307  * Display a grading error, typically from a failed save.
308  *
309  * @param {HTMLElement} root The container for the grader
310  * @param {Object} user The user who was errored
311  * @param {Object} err The details of the error
312  */
313 const displayGradingError = async(root, user, err) => {
314     const [
315         {html, js},
316         errorString
317     ] = await Promise.all([
318         Templates.renderForPromise(templateNames.grader.gradingPanel.error, {error: err}),
319         await getString('grades:gradesavefailed', 'mod_forum', {error: err.message, ...user}),
320     ]);
322     Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);
323     addToast(errorString);
324 };
326 /**
327  * Launch the grader interface with the specified parameters.
328  *
329  * @param {Function} getListOfUsers A function to get the list of users
330  * @param {Function} getContentForUser A function to get the content for a specific user
331  * @param {Function} getGradeForUser A function get the grade details for a specific user
332  * @param {Function} setGradeForUser A function to set the grade for a specific user
333  * @param {Object} Preferences for the launch function
334  */
335 export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {
336     initialUserId = null,
337     moduleName,
338     courseName,
339     courseUrl,
340     sendStudentNotifications,
341     focusOnClose = null,
342 } = {}) => {
344     // We need all of these functions to be executed in series, if one step runs before another the interface
345     // will not work.
347     // We need this promise to resolve separately so that we can avoid loading the whole interface if there are no users.
348     const userList = await getListOfUsers();
349     if (!userList.length) {
350         addNotification({
351             message: await getString('nouserstograde', 'core_grades'),
352             type: "error",
353         });
354         return;
355     }
357     // Now that we have confirmed there are at least some users let's boot up the grader interface.
358     const [
359         graderLayout,
360         {html, js},
361     ] = await Promise.all([
362         createFullScreenWindow({
363             fullscreen: false,
364             showLoader: false,
365             focusOnClose,
366         }),
367         Templates.renderForPromise(templateNames.grader.app, {
368             moduleName,
369             courseName,
370             courseUrl,
371             drawer: {show: true},
372             defaultsendnotifications: sendStudentNotifications,
373         }),
374     ]);
376     const graderContainer = graderLayout.getContainer();
378     const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);
380     Templates.replaceNodeContents(graderContainer, html, js);
381     const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser);
383     const userIds = userList.map(user => user.id);
384     const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);
385     // Fetch the userpicker for display.
386     const userPicker = await getUserPicker(
387         userList,
388         user => {
389             const renderContext = {
390                 status: null,
391                 index: userIds.indexOf(user.id) + 1,
392                 total: userList.length
393             };
394             Templates.render(templateNames.grader.status, renderContext).then(html => {
395                 statusContainer.innerHTML = html;
396                 return html;
397             }).catch();
398             updateUserContent(user);
399         },
400         saveGradeFunction,
401         {
402             initialUserId,
403         },
404     );
406     // Register all event listeners.
407     registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);
409     // Display the newly created user picker.
410     displayUserPicker(graderContainer, userPicker.rootNode);
411 };
413 /**
414  * Show the grade for a specific user.
415  *
416  * @param {Function} getGradeForUser A function get the grade details for a specific user
417  * @param {Number} userid The ID of a specific user
418  * @param {String} moduleName the name of the module
419  */
420 export const view = async(getGradeForUser, userid, moduleName, {
421     focusOnClose = null,
422 } = {}) => {
424     const [
425         userGrade,
426         modal,
427     ] = await Promise.all([
428         getGradeForUser(userid),
429         Modal.create({
430             title: moduleName,
431             large: true,
432             type: Modal.types.CANCEL
433         }),
434     ]);
436     const spinner = addIconToContainerWithPromise(modal.getRoot());
438     // Handle hidden event.
439     modal.getRoot().on(ModalEvents.hidden, function() {
440         // Destroy when hidden.
441         modal.destroy();
442         if (focusOnClose) {
443             try {
444                 focusOnClose.focus();
445             } catch (e) {
446                 // eslint-disable-line
447             }
448         }
449     });
451     modal.show();
452     const output = document.createElement('div');
453     const {html, js} = await Templates.renderForPromise('mod_forum/local/grades/view_grade', userGrade);
454     Templates.replaceNodeContents(output, html, js);
456     // Note: We do not use await here because it messes with the Modal transitions.
457     const [gradeHTML, gradeJS] = await renderGradeTemplate(userGrade);
458     const gradeReplace = output.querySelector('[data-region="grade-template"]');
459     Templates.replaceNodeContents(gradeReplace, gradeHTML, gradeJS);
460     modal.setBody(output.outerHTML);
461     spinner.resolve();
462 };
464 const renderGradeTemplate = async(userGrade) => {
465     const {html, js} = await Templates.renderForPromise(userGrade.templatename, userGrade.grade);
466     return [html, js];
467 };
468 export {getGradingPanelFunctions};