MDL-67144 mod_forum: If no users exist do not show the grading interface
[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 29import {add as addToast} from 'core/toast';
fc741e03 30import {addNotification} from 'core/notification';
77ee8778 31import {get_string as getString} from 'core/str';
ce1c4701 32import {failedUpdate} from 'core_grades/grades/grader/gradingpanel/normalise';
bdcf8908 33import {addIconToContainerWithPromise} from 'core/loadingicon';
8404c2b1 34import {debounce} from 'core/utils';
f418c08c 35import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison';
07d8669d
MM
36import * as Modal from 'core/modal_factory';
37import * as ModalEvents from 'core/modal_events';
bae67469
MM
38
39const templateNames = {
40 grader: {
41 app: 'mod_forum/local/grades/grader',
ce1c4701
AN
42 gradingPanel: {
43 error: 'mod_forum/local/grades/local/grader/gradingpanel/error',
44 },
8404c2b1
RW
45 searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search',
46 status: 'mod_forum/local/grades/local/grader/status',
bae67469
MM
47 },
48};
49
cc1a7689
MM
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 */
bae67469
MM
56const displayUserPicker = (root, html) => {
57 const pickerRegion = root.querySelector(Selectors.regions.pickerRegion);
58 Templates.replaceNodeContents(pickerRegion, html, '');
59};
60
cc1a7689
MM
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 */
f281c616
AN
68const fetchContentFromRender = (html, js) => {
69 return [html, js];
70};
71
cc1a7689
MM
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 */
f281c616 81const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser) => {
8404c2b1
RW
82 let firstLoad = true;
83
bae67469 84 return async(user) => {
8404c2b1 85 const spinner = firstLoad ? null : addIconToContainerWithPromise(root);
bae67469 86 const [
f281c616
AN
87 [html, js],
88 userGrade,
bae67469 89 ] = await Promise.all([
f281c616
AN
90 getContentForUser(user.id).then(fetchContentFromRender),
91 getGradeForUser(user.id),
bae67469
MM
92 ]);
93 Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);
f281c616
AN
94
95 const [
96 gradingPanelHtml,
97 gradingPanelJS
98 ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);
46d51c8c
RW
99 const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);
100 const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);
101 Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);
f418c08c
MM
102 fillInitialValues(panel.querySelector('form'));
103
46d51c8c 104 panelContainer.scrollTop = 0;
8404c2b1
RW
105 firstLoad = false;
106
107 if (spinner) {
108 spinner.resolve();
109 }
bae67469
MM
110 };
111};
112
8404c2b1
RW
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 */
120const showSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
121 bodyContainer.classList.add('hidden');
122 userPickerContainer.classList.add('hidden');
123 searchResultsContainer.classList.remove('hidden');
124};
125
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 */
133const hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
134 bodyContainer.classList.remove('hidden');
135 userPickerContainer.classList.remove('hidden');
136 searchResultsContainer.classList.add('hidden');
137};
138
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 */
146const 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};
153
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 */
161const 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};
169
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 */
177const searchForUsers = (userList, searchTerm) => {
178 if (searchTerm === '') {
179 return userList;
180 }
181
182 searchTerm = searchTerm.toLowerCase();
183
184 return userList.filter((user) => {
185 return user.fullname.toLowerCase().includes(searchTerm);
186 });
187};
188
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 */
4395ef46 195const renderSearchResults = async(searchResultsContainer, users) => {
8404c2b1
RW
196 const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});
197 Templates.replaceNodeContents(searchResultsContainer, html, js);
198};
199
cc1a7689
MM
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
8404c2b1 206 * @param {Array} userList List of users for the grader.
cc1a7689 207 */
8404c2b1 208const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {
bae67469 209 const graderContainer = graderLayout.getContainer();
8404c2b1
RW
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);
216
bae67469
MM
217 graderContainer.addEventListener('click', (e) => {
218 if (e.target.closest(Selectors.buttons.toggleFullscreen)) {
219 e.stopImmediatePropagation();
220 e.preventDefault();
221 graderLayout.toggleFullscreen();
eaee6477
AN
222
223 return;
224 }
225
226 if (e.target.closest(Selectors.buttons.closeGrader)) {
bae67469
MM
227 e.stopImmediatePropagation();
228 e.preventDefault();
229
230 graderLayout.close();
eaee6477
AN
231
232 return;
233 }
234
235 if (e.target.closest(Selectors.buttons.saveGrade)) {
236 saveGradeFunction(userPicker.currentUser);
bae67469 237 }
8404c2b1
RW
238
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 }
251
252 return;
253 }
254
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 }
bae67469 265 });
8404c2b1
RW
266
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));
bae67469
MM
272};
273
77ee8778
AN
274/**
275 * Get the function used to save a user grade.
276 *
cc1a7689 277 * @param {HTMLElement} root The container for the grader
77ee8778
AN
278 * @param {Function} setGradeForUser The function that will be called.
279 * @return {Function}
280 */
f281c616 281const getSaveUserGradeFunction = (root, setGradeForUser) => {
cc1a7689 282 return async(user) => {
77ee8778 283 try {
ce1c4701 284 root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';
c67a54e6
SR
285 const result = await setGradeForUser(
286 user.id,
d543b01b
SL
287 root.querySelector(Selectors.values.sendStudentNotifications).value,
288 root.querySelector(Selectors.regions.gradingPanel)
c67a54e6 289 );
ce1c4701
AN
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 }
77ee8778
AN
296
297 return result;
ce1c4701
AN
298 } catch (err) {
299 displayGradingError(root, user, err);
300
301 return failedUpdate(err);
77ee8778 302 }
f281c616
AN
303 };
304};
305
ce1c4701
AN
306/**
307 * Display a grading error, typically from a failed save.
308 *
cc1a7689 309 * @param {HTMLElement} root The container for the grader
ce1c4701
AN
310 * @param {Object} user The user who was errored
311 * @param {Object} err The details of the error
312 */
313const 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 ]);
321
322 Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);
323 addToast(errorString);
324};
325
eaee6477
AN
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
4395ef46 333 * @param {Object} Preferences for the launch function
eaee6477 334 */
f281c616 335export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {
4395ef46
AN
336 initialUserId = null,
337 moduleName,
338 courseName,
339 courseUrl,
340 sendStudentNotifications,
341 focusOnClose = null,
bae67469
MM
342} = {}) => {
343
cc1a7689
MM
344 // We need all of these functions to be executed in series, if one step runs before another the interface
345 // will not work.
fc741e03
MM
346
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 }
356
357 // Now that we have confirmed there are at least some users let's boot up the grader interface.
bae67469
MM
358 const [
359 graderLayout,
46d51c8c 360 {html, js},
bae67469 361 ] = await Promise.all([
4395ef46
AN
362 createFullScreenWindow({
363 fullscreen: false,
364 showLoader: false,
365 focusOnClose,
366 }),
46d51c8c
RW
367 Templates.renderForPromise(templateNames.grader.app, {
368 moduleName,
4c98e56c
RW
369 courseName,
370 courseUrl,
b6bf1e8e
SR
371 drawer: {show: true},
372 defaultsendnotifications: sendStudentNotifications,
46d51c8c 373 }),
bae67469 374 ]);
fc741e03 375
bae67469
MM
376 const graderContainer = graderLayout.getContainer();
377
45c0584c
AN
378 const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);
379
46d51c8c 380 Templates.replaceNodeContents(graderContainer, html, js);
f281c616
AN
381 const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser);
382
8a09616b
RW
383 const userIds = userList.map(user => user.id);
384 const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);
45c0584c
AN
385 // Fetch the userpicker for display.
386 const userPicker = await getUserPicker(
f281c616 387 userList,
8a09616b
RW
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 },
aa04b722
AN
400 saveGradeFunction,
401 {
402 initialUserId,
403 },
f281c616 404 );
bae67469 405
eaee6477 406 // Register all event listeners.
8404c2b1 407 registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);
eaee6477 408
45c0584c
AN
409 // Display the newly created user picker.
410 displayUserPicker(graderContainer, userPicker.rootNode);
bae67469 411};
f281c616 412
07d8669d
MM
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
4395ef46 418 * @param {String} moduleName the name of the module
07d8669d 419 */
799418ad
AN
420export const view = async(getGradeForUser, userid, moduleName, {
421 focusOnClose = null,
422} = {}) => {
07d8669d
MM
423
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 ]);
435
436 const spinner = addIconToContainerWithPromise(modal.getRoot());
437
438 // Handle hidden event.
439 modal.getRoot().on(ModalEvents.hidden, function() {
440 // Destroy when hidden.
441 modal.destroy();
799418ad
AN
442 if (focusOnClose) {
443 try {
444 focusOnClose.focus();
445 } catch (e) {
446 // eslint-disable-line
447 }
448 }
07d8669d
MM
449 });
450
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);
455
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};
463
464const renderGradeTemplate = async(userGrade) => {
465 const {html, js} = await Templates.renderForPromise(userGrade.templatename, userGrade.grade);
466 return [html, js];
467};
f281c616 468export {getGradingPanelFunctions};