weekly release 4.1dev
[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
bae67469
MM
20 * @copyright 2019 Mathew May <mathew.solutions>
21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22 */
23import Templates from 'core/templates';
bae67469 24import Selectors from './local/grader/selectors';
45c0584c 25import getUserPicker from './local/grader/user_picker';
bae67469 26import {createLayout as createFullScreenWindow} from 'mod_forum/local/layout/fullscreen';
f281c616 27import getGradingPanelFunctions from './local/grader/gradingpanel';
77ee8778 28import {add as addToast} from 'core/toast';
fc741e03 29import {addNotification} from 'core/notification';
77ee8778 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';
07d8669d
MM
35import * as Modal from 'core/modal_factory';
36import * as ModalEvents from 'core/modal_events';
f63503a6
JP
37import {subscribe} from 'core/pubsub';
38import DrawerEvents from 'core/drawer_events';
bae67469
MM
39
40const templateNames = {
41 grader: {
42 app: 'mod_forum/local/grades/grader',
ce1c4701
AN
43 gradingPanel: {
44 error: 'mod_forum/local/grades/local/grader/gradingpanel/error',
45 },
8404c2b1
RW
46 searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search',
47 status: 'mod_forum/local/grades/local/grader/status',
bae67469
MM
48 },
49};
50
cc1a7689
MM
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 */
bae67469
MM
57const displayUserPicker = (root, html) => {
58 const pickerRegion = root.querySelector(Selectors.regions.pickerRegion);
59 Templates.replaceNodeContents(pickerRegion, html, '');
60};
61
cc1a7689
MM
62/**
63 * To be removed, this is now done as a part of Templates.renderForPromise()
64 *
65 * @param {String} html
66 * @param {String} js
70dcc608 67 * @returns {array} An array containing the HTML, and JS.
cc1a7689 68 */
f281c616
AN
69const fetchContentFromRender = (html, js) => {
70 return [html, js];
71};
72
cc1a7689
MM
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
630a1b0e 80 * @param {Function} saveGradeForUser
cc1a7689
MM
81 * @return {Function}
82 */
630a1b0e 83const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser, saveGradeForUser) => {
8404c2b1
RW
84 let firstLoad = true;
85
bae67469 86 return async(user) => {
8404c2b1 87 const spinner = firstLoad ? null : addIconToContainerWithPromise(root);
bae67469 88 const [
f281c616
AN
89 [html, js],
90 userGrade,
bae67469 91 ] = await Promise.all([
f281c616
AN
92 getContentForUser(user.id).then(fetchContentFromRender),
93 getGradeForUser(user.id),
bae67469
MM
94 ]);
95 Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);
f281c616
AN
96
97 const [
98 gradingPanelHtml,
99 gradingPanelJS
100 ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);
46d51c8c
RW
101 const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);
102 const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);
103 Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);
630a1b0e
AN
104
105 const form = panel.querySelector('form');
106 fillInitialValues(form);
107
108 form.addEventListener('submit', event => {
109 saveGradeForUser(user);
110 event.preventDefault();
111 });
f418c08c 112
46d51c8c 113 panelContainer.scrollTop = 0;
8404c2b1
RW
114 firstLoad = false;
115
116 if (spinner) {
117 spinner.resolve();
118 }
6ebf994c 119 return userGrade;
bae67469
MM
120 };
121};
122
8404c2b1
RW
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 */
130const showSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
131 bodyContainer.classList.add('hidden');
132 userPickerContainer.classList.add('hidden');
133 searchResultsContainer.classList.remove('hidden');
134};
135
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 */
143const hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
144 bodyContainer.classList.remove('hidden');
145 userPickerContainer.classList.remove('hidden');
146 searchResultsContainer.classList.add('hidden');
147};
148
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 */
156const 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');
bed9308e
JP
161
162 // Hide the grading info container from screen reader.
163 const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
164 gradingInfoContainer.setAttribute('aria-hidden', 'true');
165
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');
170
8404c2b1
RW
171 searchInput.focus();
172};
173
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 */
181const 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();
bed9308e
JP
187
188 // Show the grading info container to screen reader.
189 const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
190 gradingInfoContainer.removeAttribute('aria-hidden');
191
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');
196
8404c2b1
RW
197 searchInput.value = '';
198};
199
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 */
207const searchForUsers = (userList, searchTerm) => {
208 if (searchTerm === '') {
209 return userList;
210 }
211
212 searchTerm = searchTerm.toLowerCase();
213
214 return userList.filter((user) => {
215 return user.fullname.toLowerCase().includes(searchTerm);
216 });
217};
218
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 */
4395ef46 225const renderSearchResults = async(searchResultsContainer, users) => {
8404c2b1
RW
226 const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});
227 Templates.replaceNodeContents(searchResultsContainer, html, js);
228};
229
cc1a7689
MM
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
8404c2b1 236 * @param {Array} userList List of users for the grader.
cc1a7689 237 */
8404c2b1 238const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {
bae67469 239 const graderContainer = graderLayout.getContainer();
8404c2b1
RW
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);
246
bae67469
MM
247 graderContainer.addEventListener('click', (e) => {
248 if (e.target.closest(Selectors.buttons.toggleFullscreen)) {
249 e.stopImmediatePropagation();
250 e.preventDefault();
251 graderLayout.toggleFullscreen();
eaee6477
AN
252
253 return;
254 }
255
256 if (e.target.closest(Selectors.buttons.closeGrader)) {
bae67469
MM
257 e.stopImmediatePropagation();
258 e.preventDefault();
259
260 graderLayout.close();
eaee6477
AN
261
262 return;
263 }
264
265 if (e.target.closest(Selectors.buttons.saveGrade)) {
266 saveGradeFunction(userPicker.currentUser);
bae67469 267 }
8404c2b1
RW
268
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 }
281
282 return;
283 }
284
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 }
bae67469 295 });
8404c2b1
RW
296
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));
f63503a6
JP
302
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 });
310
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};
319
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 */
326const setContentContainerMargin = (graderContainer, rightMargin) => {
327 const contentContainer = graderContainer.querySelector(Selectors.regions.moduleContainer);
328 if (contentContainer) {
329 contentContainer.style.marginRight = `${rightMargin}px`;
330 }
bae67469
MM
331};
332
77ee8778
AN
333/**
334 * Get the function used to save a user grade.
335 *
cc1a7689 336 * @param {HTMLElement} root The container for the grader
77ee8778
AN
337 * @param {Function} setGradeForUser The function that will be called.
338 * @return {Function}
339 */
f281c616 340const getSaveUserGradeFunction = (root, setGradeForUser) => {
cc1a7689 341 return async(user) => {
77ee8778 342 try {
ce1c4701 343 root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';
c67a54e6
SR
344 const result = await setGradeForUser(
345 user.id,
d543b01b
SL
346 root.querySelector(Selectors.values.sendStudentNotifications).value,
347 root.querySelector(Selectors.regions.gradingPanel)
c67a54e6 348 );
ce1c4701
AN
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 }
77ee8778
AN
355
356 return result;
ce1c4701
AN
357 } catch (err) {
358 displayGradingError(root, user, err);
359
360 return failedUpdate(err);
77ee8778 361 }
f281c616
AN
362 };
363};
364
ce1c4701
AN
365/**
366 * Display a grading error, typically from a failed save.
367 *
cc1a7689 368 * @param {HTMLElement} root The container for the grader
ce1c4701
AN
369 * @param {Object} user The user who was errored
370 * @param {Object} err The details of the error
371 */
372const 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 ]);
380
381 Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);
e8d7817f 382 addToast(errorString, {type: 'warning'});
ce1c4701
AN
383};
384
eaee6477
AN
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
34931214
AN
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
eaee6477 399 */
f281c616 400export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {
4395ef46
AN
401 initialUserId = null,
402 moduleName,
403 courseName,
404 courseUrl,
405 sendStudentNotifications,
406 focusOnClose = null,
bae67469
MM
407} = {}) => {
408
cc1a7689
MM
409 // We need all of these functions to be executed in series, if one step runs before another the interface
410 // will not work.
fc741e03
MM
411
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 }
421
422 // Now that we have confirmed there are at least some users let's boot up the grader interface.
bae67469
MM
423 const [
424 graderLayout,
46d51c8c 425 {html, js},
bae67469 426 ] = await Promise.all([
4395ef46
AN
427 createFullScreenWindow({
428 fullscreen: false,
429 showLoader: false,
430 focusOnClose,
431 }),
46d51c8c
RW
432 Templates.renderForPromise(templateNames.grader.app, {
433 moduleName,
4c98e56c
RW
434 courseName,
435 courseUrl,
b6bf1e8e
SR
436 drawer: {show: true},
437 defaultsendnotifications: sendStudentNotifications,
46d51c8c 438 }),
bae67469 439 ]);
fc741e03 440
bae67469
MM
441 const graderContainer = graderLayout.getContainer();
442
45c0584c
AN
443 const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);
444
46d51c8c 445 Templates.replaceNodeContents(graderContainer, html, js);
630a1b0e 446 const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser, saveGradeFunction);
f281c616 447
8a09616b
RW
448 const userIds = userList.map(user => user.id);
449 const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);
45c0584c
AN
450 // Fetch the userpicker for display.
451 const userPicker = await getUserPicker(
f281c616 452 userList,
6ebf994c
MM
453 async(user) => {
454 const userGrade = await updateUserContent(user);
8a09616b 455 const renderContext = {
6ebf994c 456 status: userGrade.hasgrade,
8a09616b
RW
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();
8a09616b 464 },
aa04b722
AN
465 saveGradeFunction,
466 {
467 initialUserId,
468 },
f281c616 469 );
bae67469 470
eaee6477 471 // Register all event listeners.
8404c2b1 472 registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);
eaee6477 473
45c0584c
AN
474 // Display the newly created user picker.
475 displayUserPicker(graderContainer, userPicker.rootNode);
bae67469 476};
f281c616 477
07d8669d
MM
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
4395ef46 483 * @param {String} moduleName the name of the module
34931214
AN
484 * @param {object} param
485 * @param {null|HTMLElement} param.focusOnClose
07d8669d 486 */
799418ad
AN
487export const view = async(getGradeForUser, userid, moduleName, {
488 focusOnClose = null,
489} = {}) => {
07d8669d
MM
490
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 ]);
502
503 const spinner = addIconToContainerWithPromise(modal.getRoot());
504
505 // Handle hidden event.
506 modal.getRoot().on(ModalEvents.hidden, function() {
507 // Destroy when hidden.
508 modal.destroy();
799418ad
AN
509 if (focusOnClose) {
510 try {
511 focusOnClose.focus();
512 } catch (e) {
513 // eslint-disable-line
514 }
515 }
07d8669d
MM
516 });
517
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);
522
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};
530
531const renderGradeTemplate = async(userGrade) => {
532 const {html, js} = await Templates.renderForPromise(userGrade.templatename, userGrade.grade);
533 return [html, js];
534};
f281c616 535export {getGradingPanelFunctions};