MDL-66958 core_grade: Add comparison js for grade saves
[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 {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';
36 const templateNames = {
37     grader: {
38         app: 'mod_forum/local/grades/grader',
39         gradingPanel: {
40             error: 'mod_forum/local/grades/local/grader/gradingpanel/error',
41         },
42         searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search',
43         status: 'mod_forum/local/grades/local/grader/status',
44     },
45 };
47 /**
48  * Helper function that replaces the user picker placeholder with what we get back from the user picker class.
49  *
50  * @param {HTMLElement} root
51  * @param {String} html
52  */
53 const displayUserPicker = (root, html) => {
54     const pickerRegion = root.querySelector(Selectors.regions.pickerRegion);
55     Templates.replaceNodeContents(pickerRegion, html, '');
56 };
58 /**
59  * To be removed, this is now done as a part of Templates.renderForPromise()
60  *
61  * @param {String} html
62  * @param {String} js
63  * @return {[*, *]}
64  */
65 const fetchContentFromRender = (html, js) => {
66     return [html, js];
67 };
69 /**
70  * Here we build the function that is passed to the user picker that'll handle updating the user content area
71  * of the grading interface.
72  *
73  * @param {HTMLElement} root
74  * @param {Function} getContentForUser
75  * @param {Function} getGradeForUser
76  * @return {Function}
77  */
78 const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser) => {
79     let firstLoad = true;
81     return async(user) => {
82         const spinner = firstLoad ? null : addIconToContainerWithPromise(root);
83         const [
84             [html, js],
85             userGrade,
86         ] = await Promise.all([
87             getContentForUser(user.id).then(fetchContentFromRender),
88             getGradeForUser(user.id),
89         ]);
90         Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);
92         const [
93             gradingPanelHtml,
94             gradingPanelJS
95         ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);
96         const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);
97         const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);
98         Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);
99         fillInitialValues(panel.querySelector('form'));
101         panelContainer.scrollTop = 0;
102         firstLoad = false;
104         if (spinner) {
105             spinner.resolve();
106         }
107     };
108 };
110 /**
111  * Show the search results container and hide the user picker and body content.
112  *
113  * @param {HTMLElement} bodyContainer The container element for the body content
114  * @param {HTMLElement} userPickerContainer The container element for the user picker
115  * @param {HTMLElement} searchResultsContainer The container element for the search results
116  */
117 const showSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
118     bodyContainer.classList.add('hidden');
119     userPickerContainer.classList.add('hidden');
120     searchResultsContainer.classList.remove('hidden');
121 };
123 /**
124  * Hide the search results container and show 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 hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
131     bodyContainer.classList.remove('hidden');
132     userPickerContainer.classList.remove('hidden');
133     searchResultsContainer.classList.add('hidden');
134 };
136 /**
137  * Toggles the visibility of the user search.
138  *
139  * @param {HTMLElement} toggleSearchButton The button that toggles the search
140  * @param {HTMLElement} searchContainer The container element for the user search
141  * @param {HTMLElement} searchInput The input element for searching
142  */
143 const showUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
144     searchContainer.classList.remove('collapsed');
145     toggleSearchButton.setAttribute('aria-expanded', 'true');
146     toggleSearchButton.classList.add('expand');
147     toggleSearchButton.classList.remove('collapse');
148     searchInput.focus();
149 };
151 /**
152  * Toggles the visibility of the user search.
153  *
154  * @param {HTMLElement} toggleSearchButton The button that toggles the search
155  * @param {HTMLElement} searchContainer The container element for the user search
156  * @param {HTMLElement} searchInput The input element for searching
157  */
158 const hideUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
159     searchContainer.classList.add('collapsed');
160     toggleSearchButton.setAttribute('aria-expanded', 'false');
161     toggleSearchButton.classList.add('collapse');
162     toggleSearchButton.classList.remove('expand');
163     toggleSearchButton.focus();
164     searchInput.value = '';
165 };
167 /**
168  * Find the list of users who's names include the given search term.
169  *
170  * @param {Array} userList List of users for the grader
171  * @param {String} searchTerm The search term to match
172  * @return {Array}
173  */
174 const searchForUsers = (userList, searchTerm) => {
175     if (searchTerm === '') {
176         return userList;
177     }
179     searchTerm = searchTerm.toLowerCase();
181     return userList.filter((user) => {
182         return user.fullname.toLowerCase().includes(searchTerm);
183     });
184 };
186 /**
187  * Render the list of users in the search results area.
188  *
189  * @param {HTMLElement} searchResultsContainer The container element for search results
190  * @param {Array} users The list of users to display
191  */
192 const renderSearchResults = async (searchResultsContainer, users) => {
193     const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});
194     Templates.replaceNodeContents(searchResultsContainer, html, js);
195 };
197 /**
198  * Add click handlers to the buttons in the header of the grading interface.
199  *
200  * @param {HTMLElement} graderLayout
201  * @param {Object} userPicker
202  * @param {Function} saveGradeFunction
203  * @param {Array} userList List of users for the grader.
204  */
205 const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {
206     const graderContainer = graderLayout.getContainer();
207     const toggleSearchButton = graderContainer.querySelector(Selectors.buttons.toggleSearch);
208     const searchInputContainer = graderContainer.querySelector(Selectors.regions.userSearchContainer);
209     const searchInput = searchInputContainer.querySelector(Selectors.regions.userSearchInput);
210     const bodyContainer = graderContainer.querySelector(Selectors.regions.bodyContainer);
211     const userPickerContainer = graderContainer.querySelector(Selectors.regions.pickerRegion);
212     const searchResultsContainer = graderContainer.querySelector(Selectors.regions.searchResultsContainer);
214     graderContainer.addEventListener('click', (e) => {
215         if (e.target.closest(Selectors.buttons.toggleFullscreen)) {
216             e.stopImmediatePropagation();
217             e.preventDefault();
218             graderLayout.toggleFullscreen();
220             return;
221         }
223         if (e.target.closest(Selectors.buttons.closeGrader)) {
224             e.stopImmediatePropagation();
225             e.preventDefault();
227             graderLayout.close();
229             return;
230         }
232         if (e.target.closest(Selectors.buttons.saveGrade)) {
233             saveGradeFunction(userPicker.currentUser);
234         }
236         if (e.target.closest(Selectors.buttons.toggleSearch)) {
237             if (toggleSearchButton.getAttribute('aria-expanded') === 'true') {
238                 // Search is open so let's close it.
239                 hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
240                 hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
241                 searchResultsContainer.innerHTML = '';
242             } else {
243                 // Search is closed so let's open it.
244                 showUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
245                 showSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
246                 renderSearchResults(searchResultsContainer, userList);
247             }
249             return;
250         }
252         const selectUserButton = e.target.closest(Selectors.buttons.selectUser);
253         if (selectUserButton) {
254             const userId = selectUserButton.getAttribute('data-userid');
255             const user = userList.find(user => user.id == userId);
256             userPicker.setUserId(userId);
257             userPicker.showUser(user);
258             hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
259             hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
260             searchResultsContainer.innerHTML = '';
261         }
262     });
264     // Debounce the search input so that it only executes 300 milliseconds after the user has finished typing.
265     searchInput.addEventListener('input', debounce(() => {
266         const users = searchForUsers(userList, searchInput.value);
267         renderSearchResults(searchResultsContainer, users);
268     }, 300));
269 };
271 /**
272  * Get the function used to save a user grade.
273  *
274  * @param {HTMLElement} root The container for the grader
275  * @param {Function} setGradeForUser The function that will be called.
276  * @return {Function}
277  */
278 const getSaveUserGradeFunction = (root, setGradeForUser) => {
279     return async(user) => {
280         try {
281             root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';
282             const result = await setGradeForUser(user.id, root.querySelector(Selectors.regions.gradingPanel));
283             if (result.success) {
284                 addToast(await getString('grades:gradesavedfor', 'mod_forum', user));
285             }
286             if (result.failed) {
287                 displayGradingError(root, user, result.error);
288             }
290             return result;
291         } catch (err) {
292             displayGradingError(root, user, err);
294             return failedUpdate(err);
295         }
296     };
297 };
299 /**
300  * Display a grading error, typically from a failed save.
301  *
302  * @param {HTMLElement} root The container for the grader
303  * @param {Object} user The user who was errored
304  * @param {Object} err The details of the error
305  */
306 const displayGradingError = async(root, user, err) => {
307     const [
308         {html, js},
309         errorString
310     ] = await Promise.all([
311         Templates.renderForPromise(templateNames.grader.gradingPanel.error, {error: err}),
312         await getString('grades:gradesavefailed', 'mod_forum', {error: err.message, ...user}),
313     ]);
315     Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);
316     addToast(errorString);
317 };
319 /**
320  * Launch the grader interface with the specified parameters.
321  *
322  * @param {Function} getListOfUsers A function to get the list of users
323  * @param {Function} getContentForUser A function to get the content for a specific user
324  * @param {Function} getGradeForUser A function get the grade details for a specific user
325  * @param {Function} setGradeForUser A function to set the grade for a specific user
326  */
327 export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {
328     initialUserId = null, moduleName, courseName, courseUrl
329 } = {}) => {
331     // We need all of these functions to be executed in series, if one step runs before another the interface
332     // will not work.
333     const [
334         graderLayout,
335         {html, js},
336         userList,
337     ] = await Promise.all([
338         createFullScreenWindow({fullscreen: false, showLoader: false}),
339         Templates.renderForPromise(templateNames.grader.app, {
340             moduleName,
341             courseName,
342             courseUrl,
343             drawer: {show: true}
344         }),
345         getListOfUsers(),
346     ]);
347     const graderContainer = graderLayout.getContainer();
349     const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);
351     Templates.replaceNodeContents(graderContainer, html, js);
352     const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser);
354     const userIds = userList.map(user => user.id);
355     const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);
356     // Fetch the userpicker for display.
357     const userPicker = await getUserPicker(
358         userList,
359         user => {
360             const renderContext = {
361                 status: null,
362                 index: userIds.indexOf(user.id) + 1,
363                 total: userList.length
364             };
365             Templates.render(templateNames.grader.status, renderContext).then(html => {
366                 statusContainer.innerHTML = html;
367                 return html;
368             }).catch();
369             updateUserContent(user);
370         },
371         saveGradeFunction,
372         {
373             initialUserId,
374         },
375     );
377     // Register all event listeners.
378     registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);
380     // Display the newly created user picker.
381     displayUserPicker(graderContainer, userPicker.rootNode);
382 };
384 export {getGradingPanelFunctions};