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