MDL-66958 core_grade: Add comparison js for grade saves
[moodle.git] / mod / forum / amd / src / local / grades / grader.js
CommitLineData
bae67469
MM
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 * @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 */
24import Templates from 'core/templates';
bae67469 25import Selectors from './local/grader/selectors';
45c0584c 26import getUserPicker from './local/grader/user_picker';
bae67469 27import {createLayout as createFullScreenWindow} from 'mod_forum/local/layout/fullscreen';
f281c616 28import getGradingPanelFunctions from './local/grader/gradingpanel';
77ee8778
AN
29import {add as addToast} from 'core/toast';
30import {get_string as getString} from 'core/str';
ce1c4701 31import {failedUpdate} from 'core_grades/grades/grader/gradingpanel/normalise';
bdcf8908 32import {addIconToContainerWithPromise} from 'core/loadingicon';
8404c2b1 33import {debounce} from 'core/utils';
f418c08c 34import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison';
bae67469
MM
35
36const templateNames = {
37 grader: {
38 app: 'mod_forum/local/grades/grader',
ce1c4701
AN
39 gradingPanel: {
40 error: 'mod_forum/local/grades/local/grader/gradingpanel/error',
41 },
8404c2b1
RW
42 searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search',
43 status: 'mod_forum/local/grades/local/grader/status',
bae67469
MM
44 },
45};
46
cc1a7689
MM
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 */
bae67469
MM
53const displayUserPicker = (root, html) => {
54 const pickerRegion = root.querySelector(Selectors.regions.pickerRegion);
55 Templates.replaceNodeContents(pickerRegion, html, '');
56};
57
cc1a7689
MM
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 */
f281c616
AN
65const fetchContentFromRender = (html, js) => {
66 return [html, js];
67};
68
cc1a7689
MM
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 */
f281c616 78const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser) => {
8404c2b1
RW
79 let firstLoad = true;
80
bae67469 81 return async(user) => {
8404c2b1 82 const spinner = firstLoad ? null : addIconToContainerWithPromise(root);
bae67469 83 const [
f281c616
AN
84 [html, js],
85 userGrade,
bae67469 86 ] = await Promise.all([
f281c616
AN
87 getContentForUser(user.id).then(fetchContentFromRender),
88 getGradeForUser(user.id),
bae67469
MM
89 ]);
90 Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);
f281c616
AN
91
92 const [
93 gradingPanelHtml,
94 gradingPanelJS
95 ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);
46d51c8c
RW
96 const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);
97 const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);
98 Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);
f418c08c
MM
99 fillInitialValues(panel.querySelector('form'));
100
46d51c8c 101 panelContainer.scrollTop = 0;
8404c2b1
RW
102 firstLoad = false;
103
104 if (spinner) {
105 spinner.resolve();
106 }
bae67469
MM
107 };
108};
109
8404c2b1
RW
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 */
117const showSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
118 bodyContainer.classList.add('hidden');
119 userPickerContainer.classList.add('hidden');
120 searchResultsContainer.classList.remove('hidden');
121};
122
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 */
130const hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
131 bodyContainer.classList.remove('hidden');
132 userPickerContainer.classList.remove('hidden');
133 searchResultsContainer.classList.add('hidden');
134};
135
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 */
143const 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};
150
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 */
158const 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};
166
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 */
174const searchForUsers = (userList, searchTerm) => {
175 if (searchTerm === '') {
176 return userList;
177 }
178
179 searchTerm = searchTerm.toLowerCase();
180
181 return userList.filter((user) => {
182 return user.fullname.toLowerCase().includes(searchTerm);
183 });
184};
185
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 */
192const renderSearchResults = async (searchResultsContainer, users) => {
193 const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});
194 Templates.replaceNodeContents(searchResultsContainer, html, js);
195};
196
cc1a7689
MM
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
8404c2b1 203 * @param {Array} userList List of users for the grader.
cc1a7689 204 */
8404c2b1 205const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {
bae67469 206 const graderContainer = graderLayout.getContainer();
8404c2b1
RW
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);
213
bae67469
MM
214 graderContainer.addEventListener('click', (e) => {
215 if (e.target.closest(Selectors.buttons.toggleFullscreen)) {
216 e.stopImmediatePropagation();
217 e.preventDefault();
218 graderLayout.toggleFullscreen();
eaee6477
AN
219
220 return;
221 }
222
223 if (e.target.closest(Selectors.buttons.closeGrader)) {
bae67469
MM
224 e.stopImmediatePropagation();
225 e.preventDefault();
226
227 graderLayout.close();
eaee6477
AN
228
229 return;
230 }
231
232 if (e.target.closest(Selectors.buttons.saveGrade)) {
233 saveGradeFunction(userPicker.currentUser);
bae67469 234 }
8404c2b1
RW
235
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 }
248
249 return;
250 }
251
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 }
bae67469 262 });
8404c2b1
RW
263
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));
bae67469
MM
269};
270
77ee8778
AN
271/**
272 * Get the function used to save a user grade.
273 *
cc1a7689 274 * @param {HTMLElement} root The container for the grader
77ee8778
AN
275 * @param {Function} setGradeForUser The function that will be called.
276 * @return {Function}
277 */
f281c616 278const getSaveUserGradeFunction = (root, setGradeForUser) => {
cc1a7689 279 return async(user) => {
77ee8778 280 try {
ce1c4701 281 root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';
77ee8778 282 const result = await setGradeForUser(user.id, root.querySelector(Selectors.regions.gradingPanel));
ce1c4701
AN
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 }
77ee8778
AN
289
290 return result;
ce1c4701
AN
291 } catch (err) {
292 displayGradingError(root, user, err);
293
294 return failedUpdate(err);
77ee8778 295 }
f281c616
AN
296 };
297};
298
ce1c4701
AN
299/**
300 * Display a grading error, typically from a failed save.
301 *
cc1a7689 302 * @param {HTMLElement} root The container for the grader
ce1c4701
AN
303 * @param {Object} user The user who was errored
304 * @param {Object} err The details of the error
305 */
306const 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 ]);
314
315 Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);
316 addToast(errorString);
317};
318
eaee6477
AN
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 */
f281c616 327export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {
4c98e56c 328 initialUserId = null, moduleName, courseName, courseUrl
bae67469
MM
329} = {}) => {
330
cc1a7689
MM
331 // We need all of these functions to be executed in series, if one step runs before another the interface
332 // will not work.
bae67469
MM
333 const [
334 graderLayout,
46d51c8c 335 {html, js},
bae67469
MM
336 userList,
337 ] = await Promise.all([
338 createFullScreenWindow({fullscreen: false, showLoader: false}),
46d51c8c
RW
339 Templates.renderForPromise(templateNames.grader.app, {
340 moduleName,
4c98e56c
RW
341 courseName,
342 courseUrl,
46d51c8c
RW
343 drawer: {show: true}
344 }),
bae67469
MM
345 getListOfUsers(),
346 ]);
347 const graderContainer = graderLayout.getContainer();
348
45c0584c
AN
349 const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);
350
46d51c8c 351 Templates.replaceNodeContents(graderContainer, html, js);
f281c616
AN
352 const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser);
353
8a09616b
RW
354 const userIds = userList.map(user => user.id);
355 const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);
45c0584c
AN
356 // Fetch the userpicker for display.
357 const userPicker = await getUserPicker(
f281c616 358 userList,
8a09616b
RW
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 },
aa04b722
AN
371 saveGradeFunction,
372 {
373 initialUserId,
374 },
f281c616 375 );
bae67469 376
eaee6477 377 // Register all event listeners.
8404c2b1 378 registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);
eaee6477 379
45c0584c
AN
380 // Display the newly created user picker.
381 displayUserPicker(graderContainer, userPicker.rootNode);
bae67469 382};
f281c616
AN
383
384export {getGradingPanelFunctions};