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/>.
  15. /**
  16. * This module will tie together all of the different calls the gradable module will make.
  17. *
  18. * @module mod_forum/local/grades/grader
  19. * @copyright 2019 Mathew May <mathew.solutions>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import Templates from 'core/templates';
  23. import Selectors from './local/grader/selectors';
  24. import getUserPicker from './local/grader/user_picker';
  25. import {createLayout as createFullScreenWindow} from 'mod_forum/local/layout/fullscreen';
  26. import getGradingPanelFunctions from './local/grader/gradingpanel';
  27. import {add as addToast} from 'core/toast';
  28. import {addNotification} from 'core/notification';
  29. import {getString} from 'core/str';
  30. import {failedUpdate} from 'core_grades/grades/grader/gradingpanel/normalise';
  31. import {addIconToContainerWithPromise} from 'core/loadingicon';
  32. import {debounce} from 'core/utils';
  33. import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison';
  34. import Modal from 'core/modal_cancel';
  35. import {subscribe} from 'core/pubsub';
  36. import DrawerEvents from 'core/drawer_events';
  37. const templateNames = {
  38. grader: {
  39. app: 'mod_forum/local/grades/grader',
  40. gradingPanel: {
  41. error: 'mod_forum/local/grades/local/grader/gradingpanel/error',
  42. },
  43. searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search',
  44. status: 'mod_forum/local/grades/local/grader/status',
  45. },
  46. };
  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. };
  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. * @returns {array} An array containing the HTML, and JS.
  63. */
  64. const fetchContentFromRender = (html, js) => {
  65. return [html, js];
  66. };
  67. /**
  68. * Here we build the function that is passed to the user picker that'll handle updating the user content area
  69. * of the grading interface.
  70. *
  71. * @param {HTMLElement} root
  72. * @param {Function} getContentForUser
  73. * @param {Function} getGradeForUser
  74. * @param {Function} saveGradeForUser
  75. * @return {Function}
  76. */
  77. const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser, saveGradeForUser) => {
  78. let firstLoad = true;
  79. return async(user) => {
  80. const spinner = firstLoad ? null : addIconToContainerWithPromise(root);
  81. const [
  82. [html, js],
  83. userGrade,
  84. ] = await Promise.all([
  85. getContentForUser(user.id).then(fetchContentFromRender),
  86. getGradeForUser(user.id),
  87. ]);
  88. Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);
  89. const [
  90. gradingPanelHtml,
  91. gradingPanelJS
  92. ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);
  93. const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);
  94. const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);
  95. Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);
  96. const form = panel.querySelector('form');
  97. fillInitialValues(form);
  98. form.addEventListener('submit', event => {
  99. saveGradeForUser(user);
  100. event.preventDefault();
  101. });
  102. panelContainer.scrollTop = 0;
  103. firstLoad = false;
  104. if (spinner) {
  105. spinner.resolve();
  106. }
  107. return userGrade;
  108. };
  109. };
  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. };
  122. /**
  123. * Hide the search results container and show the user picker and body content.
  124. *
  125. * @param {HTMLElement} bodyContainer The container element for the body content
  126. * @param {HTMLElement} userPickerContainer The container element for the user picker
  127. * @param {HTMLElement} searchResultsContainer The container element for the search results
  128. */
  129. const hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {
  130. bodyContainer.classList.remove('hidden');
  131. userPickerContainer.classList.remove('hidden');
  132. searchResultsContainer.classList.add('hidden');
  133. };
  134. /**
  135. * Toggles the visibility of the user search.
  136. *
  137. * @param {HTMLElement} toggleSearchButton The button that toggles the search
  138. * @param {HTMLElement} searchContainer The container element for the user search
  139. * @param {HTMLElement} searchInput The input element for searching
  140. */
  141. const showUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
  142. searchContainer.classList.remove('collapsed');
  143. toggleSearchButton.setAttribute('aria-expanded', 'true');
  144. toggleSearchButton.classList.add('expand');
  145. toggleSearchButton.classList.remove('collapse');
  146. // Hide the grading info container from screen reader.
  147. const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
  148. gradingInfoContainer.setAttribute('aria-hidden', 'true');
  149. // Hide the collapse grading drawer button from screen reader.
  150. const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);
  151. collapseGradingDrawer.setAttribute('aria-hidden', 'true');
  152. collapseGradingDrawer.setAttribute('tabindex', '-1');
  153. searchInput.focus();
  154. };
  155. /**
  156. * Toggles the visibility of the user search.
  157. *
  158. * @param {HTMLElement} toggleSearchButton The button that toggles the search
  159. * @param {HTMLElement} searchContainer The container element for the user search
  160. * @param {HTMLElement} searchInput The input element for searching
  161. */
  162. const hideUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {
  163. searchContainer.classList.add('collapsed');
  164. toggleSearchButton.setAttribute('aria-expanded', 'false');
  165. toggleSearchButton.classList.add('collapse');
  166. toggleSearchButton.classList.remove('expand');
  167. toggleSearchButton.focus();
  168. // Show the grading info container to screen reader.
  169. const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
  170. gradingInfoContainer.removeAttribute('aria-hidden');
  171. // Show the collapse grading drawer button from screen reader.
  172. const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);
  173. collapseGradingDrawer.removeAttribute('aria-hidden');
  174. collapseGradingDrawer.setAttribute('tabindex', '0');
  175. searchInput.value = '';
  176. };
  177. /**
  178. * Find the list of users who's names include the given search term.
  179. *
  180. * @param {Array} userList List of users for the grader
  181. * @param {String} searchTerm The search term to match
  182. * @return {Array}
  183. */
  184. const searchForUsers = (userList, searchTerm) => {
  185. if (searchTerm === '') {
  186. return userList;
  187. }
  188. searchTerm = searchTerm.toLowerCase();
  189. return userList.filter((user) => {
  190. return user.fullname.toLowerCase().includes(searchTerm);
  191. });
  192. };
  193. /**
  194. * Render the list of users in the search results area.
  195. *
  196. * @param {HTMLElement} searchResultsContainer The container element for search results
  197. * @param {Array} users The list of users to display
  198. */
  199. const renderSearchResults = async(searchResultsContainer, users) => {
  200. const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});
  201. Templates.replaceNodeContents(searchResultsContainer, html, js);
  202. };
  203. /**
  204. * Add click handlers to the buttons in the header of the grading interface.
  205. *
  206. * @param {HTMLElement} graderLayout
  207. * @param {Object} userPicker
  208. * @param {Function} saveGradeFunction
  209. * @param {Array} userList List of users for the grader.
  210. */
  211. const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {
  212. const graderContainer = graderLayout.getContainer();
  213. const toggleSearchButton = graderContainer.querySelector(Selectors.buttons.toggleSearch);
  214. const searchInputContainer = graderContainer.querySelector(Selectors.regions.userSearchContainer);
  215. const searchInput = searchInputContainer.querySelector(Selectors.regions.userSearchInput);
  216. const bodyContainer = graderContainer.querySelector(Selectors.regions.bodyContainer);
  217. const userPickerContainer = graderContainer.querySelector(Selectors.regions.pickerRegion);
  218. const searchResultsContainer = graderContainer.querySelector(Selectors.regions.searchResultsContainer);
  219. graderContainer.addEventListener('click', (e) => {
  220. if (e.target.closest(Selectors.buttons.toggleFullscreen)) {
  221. e.stopImmediatePropagation();
  222. e.preventDefault();
  223. graderLayout.toggleFullscreen();
  224. return;
  225. }
  226. if (e.target.closest(Selectors.buttons.closeGrader)) {
  227. e.stopImmediatePropagation();
  228. e.preventDefault();
  229. graderLayout.close();
  230. return;
  231. }
  232. if (e.target.closest(Selectors.buttons.saveGrade)) {
  233. saveGradeFunction(userPicker.currentUser);
  234. }
  235. if (e.target.closest(Selectors.buttons.toggleSearch)) {
  236. if (toggleSearchButton.getAttribute('aria-expanded') === 'true') {
  237. // Search is open so let's close it.
  238. hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
  239. hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
  240. searchResultsContainer.innerHTML = '';
  241. } else {
  242. // Search is closed so let's open it.
  243. showUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);
  244. showSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);
  245. renderSearchResults(searchResultsContainer, userList);
  246. }
  247. return;
  248. }
  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. });
  260. // Debounce the search input so that it only executes 300 milliseconds after the user has finished typing.
  261. searchInput.addEventListener('input', debounce(() => {
  262. const users = searchForUsers(userList, searchInput.value);
  263. renderSearchResults(searchResultsContainer, users);
  264. }, 300));
  265. // Remove the right margin of the content container when the grading panel is hidden so that it expands to full-width.
  266. subscribe(DrawerEvents.DRAWER_HIDDEN, (drawerRoot) => {
  267. const gradingPanel = drawerRoot[0];
  268. if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {
  269. setContentContainerMargin(graderContainer, 0);
  270. }
  271. });
  272. // Bring back the right margin of the content container when the grading panel is shown to give space for the grading panel.
  273. subscribe(DrawerEvents.DRAWER_SHOWN, (drawerRoot) => {
  274. const gradingPanel = drawerRoot[0];
  275. if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {
  276. setContentContainerMargin(graderContainer, gradingPanel.offsetWidth);
  277. }
  278. });
  279. };
  280. /**
  281. * Adjusts the right margin of the content container.
  282. *
  283. * @param {HTMLElement} graderContainer The container for the grader app.
  284. * @param {Number} rightMargin The right margin value.
  285. */
  286. const setContentContainerMargin = (graderContainer, rightMargin) => {
  287. const contentContainer = graderContainer.querySelector(Selectors.regions.moduleContainer);
  288. if (contentContainer) {
  289. contentContainer.style.marginRight = `${rightMargin}px`;
  290. }
  291. };
  292. /**
  293. * Get the function used to save a user grade.
  294. *
  295. * @param {HTMLElement} root The container for the grader
  296. * @param {Function} setGradeForUser The function that will be called.
  297. * @return {Function}
  298. */
  299. const getSaveUserGradeFunction = (root, setGradeForUser) => {
  300. return async(user) => {
  301. try {
  302. root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';
  303. const result = await setGradeForUser(
  304. user.id,
  305. root.querySelector(Selectors.values.sendStudentNotifications).value,
  306. root.querySelector(Selectors.regions.gradingPanel)
  307. );
  308. if (result.success) {
  309. addToast(await getString('grades:gradesavedfor', 'mod_forum', user));
  310. }
  311. if (result.failed) {
  312. displayGradingError(root, user, result.error);
  313. }
  314. return result;
  315. } catch (err) {
  316. displayGradingError(root, user, err);
  317. return failedUpdate(err);
  318. }
  319. };
  320. };
  321. /**
  322. * Display a grading error, typically from a failed save.
  323. *
  324. * @param {HTMLElement} root The container for the grader
  325. * @param {Object} user The user who was errored
  326. * @param {Object} err The details of the error
  327. */
  328. const displayGradingError = async(root, user, err) => {
  329. const [
  330. {html, js},
  331. errorString
  332. ] = await Promise.all([
  333. Templates.renderForPromise(templateNames.grader.gradingPanel.error, {error: err}),
  334. await getString('grades:gradesavefailed', 'mod_forum', {error: err.message, ...user}),
  335. ]);
  336. Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);
  337. addToast(errorString, {type: 'warning'});
  338. };
  339. /**
  340. * Launch the grader interface with the specified parameters.
  341. *
  342. * @param {Function} getListOfUsers A function to get the list of users
  343. * @param {Function} getContentForUser A function to get the content for a specific user
  344. * @param {Function} getGradeForUser A function get the grade details for a specific user
  345. * @param {Function} setGradeForUser A function to set the grade for a specific user
  346. * @param {Object} preferences Preferences for the launch function
  347. * @param {Number} preferences.initialUserId
  348. * @param {string} preferences.moduleName
  349. * @param {string} preferences.courseName
  350. * @param {string} preferences.courseUrl
  351. * @param {boolean} preferences.sendStudentNotifications
  352. * @param {null|HTMLElement} preferences.focusOnClose
  353. */
  354. export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {
  355. initialUserId = null,
  356. moduleName,
  357. courseName,
  358. courseUrl,
  359. sendStudentNotifications,
  360. focusOnClose = null,
  361. } = {}) => {
  362. // We need all of these functions to be executed in series, if one step runs before another the interface
  363. // will not work.
  364. // We need this promise to resolve separately so that we can avoid loading the whole interface if there are no users.
  365. const userList = await getListOfUsers();
  366. if (!userList.length) {
  367. addNotification({
  368. message: await getString('nouserstograde', 'core_grades'),
  369. type: "error",
  370. });
  371. return;
  372. }
  373. // Now that we have confirmed there are at least some users let's boot up the grader interface.
  374. const [
  375. graderLayout,
  376. {html, js},
  377. ] = await Promise.all([
  378. createFullScreenWindow({
  379. fullscreen: false,
  380. showLoader: false,
  381. focusOnClose,
  382. }),
  383. Templates.renderForPromise(templateNames.grader.app, {
  384. moduleName,
  385. courseName,
  386. courseUrl,
  387. drawer: {show: true},
  388. defaultsendnotifications: sendStudentNotifications,
  389. }),
  390. ]);
  391. const graderContainer = graderLayout.getContainer();
  392. const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);
  393. Templates.replaceNodeContents(graderContainer, html, js);
  394. const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser, saveGradeFunction);
  395. const userIds = userList.map(user => user.id);
  396. const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);
  397. // Fetch the userpicker for display.
  398. const userPicker = await getUserPicker(
  399. userList,
  400. async(user) => {
  401. const userGrade = await updateUserContent(user);
  402. const renderContext = {
  403. status: userGrade.hasgrade,
  404. index: userIds.indexOf(user.id) + 1,
  405. total: userList.length
  406. };
  407. Templates.render(templateNames.grader.status, renderContext).then(html => {
  408. statusContainer.innerHTML = html;
  409. return html;
  410. }).catch();
  411. },
  412. saveGradeFunction,
  413. {
  414. initialUserId,
  415. },
  416. );
  417. // Register all event listeners.
  418. registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);
  419. // Display the newly created user picker.
  420. displayUserPicker(graderContainer, userPicker.rootNode);
  421. };
  422. /**
  423. * Show the grade for a specific user.
  424. *
  425. * @param {Function} getGradeForUser A function get the grade details for a specific user
  426. * @param {Number} userid The ID of a specific user
  427. * @param {String} moduleName the name of the module
  428. * @param {object} param
  429. * @param {null|HTMLElement} param.focusOnClose
  430. */
  431. export const view = async(getGradeForUser, userid, moduleName, {
  432. focusOnClose = null,
  433. } = {}) => {
  434. const userGrade = await getGradeForUser(userid);
  435. const [
  436. modal,
  437. gradeTemplateData
  438. ] = await Promise.all([
  439. Modal.create({
  440. title: moduleName,
  441. large: true,
  442. removeOnClose: true,
  443. returnElement: focusOnClose,
  444. show: true,
  445. body: Templates.render('mod_forum/local/grades/view_grade', userGrade),
  446. }),
  447. renderGradeTemplate(userGrade)
  448. ]);
  449. const bodyPromise = await modal.getBodyPromise();
  450. const gradeReplace = bodyPromise[0].querySelector('[data-region="grade-template"]');
  451. Templates.replaceNodeContents(gradeReplace, gradeTemplateData.html, gradeTemplateData.js);
  452. };
  453. const renderGradeTemplate = (userGrade) => Templates.renderForPromise(userGrade.templatename, userGrade.grade);
  454. export {getGradingPanelFunctions};