| [ Index ] |
PHP Cross Reference of Moodle 310 |
[Summary view] [Print] [Text view]
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 * @copyright 2019 Mathew May <mathew.solutions> 21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 22 */ 23 import Templates from 'core/templates'; 24 import Selectors from './local/grader/selectors'; 25 import getUserPicker from './local/grader/user_picker'; 26 import {createLayout as createFullScreenWindow} from 'mod_forum/local/layout/fullscreen'; 27 import getGradingPanelFunctions from './local/grader/gradingpanel'; 28 import {add as addToast} from 'core/toast'; 29 import {addNotification} from 'core/notification'; 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'; 34 import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison'; 35 import * as Modal from 'core/modal_factory'; 36 import * as ModalEvents from 'core/modal_events'; 37 import {subscribe} from 'core/pubsub'; 38 import DrawerEvents from 'core/drawer_events'; 39 40 const templateNames = { 41 grader: { 42 app: 'mod_forum/local/grades/grader', 43 gradingPanel: { 44 error: 'mod_forum/local/grades/local/grader/gradingpanel/error', 45 }, 46 searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search', 47 status: 'mod_forum/local/grades/local/grader/status', 48 }, 49 }; 50 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 */ 57 const displayUserPicker = (root, html) => { 58 const pickerRegion = root.querySelector(Selectors.regions.pickerRegion); 59 Templates.replaceNodeContents(pickerRegion, html, ''); 60 }; 61 62 /** 63 * To be removed, this is now done as a part of Templates.renderForPromise() 64 * 65 * @param {String} html 66 * @param {String} js 67 * @returns {array} An array containing the HTML, and JS. 68 */ 69 const fetchContentFromRender = (html, js) => { 70 return [html, js]; 71 }; 72 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 80 * @param {Function} saveGradeForUser 81 * @return {Function} 82 */ 83 const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser, saveGradeForUser) => { 84 let firstLoad = true; 85 86 return async(user) => { 87 const spinner = firstLoad ? null : addIconToContainerWithPromise(root); 88 const [ 89 [html, js], 90 userGrade, 91 ] = await Promise.all([ 92 getContentForUser(user.id).then(fetchContentFromRender), 93 getGradeForUser(user.id), 94 ]); 95 Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js); 96 97 const [ 98 gradingPanelHtml, 99 gradingPanelJS 100 ] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender); 101 const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer); 102 const panel = panelContainer.querySelector(Selectors.regions.gradingPanel); 103 Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS); 104 105 const form = panel.querySelector('form'); 106 fillInitialValues(form); 107 108 form.addEventListener('submit', event => { 109 saveGradeForUser(user); 110 event.preventDefault(); 111 }); 112 113 panelContainer.scrollTop = 0; 114 firstLoad = false; 115 116 if (spinner) { 117 spinner.resolve(); 118 } 119 return userGrade; 120 }; 121 }; 122 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 */ 130 const 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 */ 143 const 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 */ 156 const 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'); 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 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 */ 181 const 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(); 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 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 */ 207 const 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 */ 225 const renderSearchResults = async(searchResultsContainer, users) => { 226 const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users}); 227 Templates.replaceNodeContents(searchResultsContainer, html, js); 228 }; 229 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 236 * @param {Array} userList List of users for the grader. 237 */ 238 const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => { 239 const graderContainer = graderLayout.getContainer(); 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 247 graderContainer.addEventListener('click', (e) => { 248 if (e.target.closest(Selectors.buttons.toggleFullscreen)) { 249 e.stopImmediatePropagation(); 250 e.preventDefault(); 251 graderLayout.toggleFullscreen(); 252 253 return; 254 } 255 256 if (e.target.closest(Selectors.buttons.closeGrader)) { 257 e.stopImmediatePropagation(); 258 e.preventDefault(); 259 260 graderLayout.close(); 261 262 return; 263 } 264 265 if (e.target.closest(Selectors.buttons.saveGrade)) { 266 saveGradeFunction(userPicker.currentUser); 267 } 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 } 295 }); 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)); 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 */ 326 const setContentContainerMargin = (graderContainer, rightMargin) => { 327 const contentContainer = graderContainer.querySelector(Selectors.regions.moduleContainer); 328 if (contentContainer) { 329 contentContainer.style.marginRight = `$rightMargin}px`; 330 } 331 }; 332 333 /** 334 * Get the function used to save a user grade. 335 * 336 * @param {HTMLElement} root The container for the grader 337 * @param {Function} setGradeForUser The function that will be called. 338 * @return {Function} 339 */ 340 const getSaveUserGradeFunction = (root, setGradeForUser) => { 341 return async(user) => { 342 try { 343 root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = ''; 344 const result = await setGradeForUser( 345 user.id, 346 root.querySelector(Selectors.values.sendStudentNotifications).value, 347 root.querySelector(Selectors.regions.gradingPanel) 348 ); 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 } 355 356 return result; 357 } catch (err) { 358 displayGradingError(root, user, err); 359 360 return failedUpdate(err); 361 } 362 }; 363 }; 364 365 /** 366 * Display a grading error, typically from a failed save. 367 * 368 * @param {HTMLElement} root The container for the grader 369 * @param {Object} user The user who was errored 370 * @param {Object} err The details of the error 371 */ 372 const 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); 382 addToast(errorString); 383 }; 384 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 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 399 */ 400 export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, { 401 initialUserId = null, 402 moduleName, 403 courseName, 404 courseUrl, 405 sendStudentNotifications, 406 focusOnClose = null, 407 } = {}) => { 408 409 // We need all of these functions to be executed in series, if one step runs before another the interface 410 // will not work. 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. 423 const [ 424 graderLayout, 425 {html, js}, 426 ] = await Promise.all([ 427 createFullScreenWindow({ 428 fullscreen: false, 429 showLoader: false, 430 focusOnClose, 431 }), 432 Templates.renderForPromise(templateNames.grader.app, { 433 moduleName, 434 courseName, 435 courseUrl, 436 drawer: {show: true}, 437 defaultsendnotifications: sendStudentNotifications, 438 }), 439 ]); 440 441 const graderContainer = graderLayout.getContainer(); 442 443 const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser); 444 445 Templates.replaceNodeContents(graderContainer, html, js); 446 const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser, saveGradeFunction); 447 448 const userIds = userList.map(user => user.id); 449 const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer); 450 // Fetch the userpicker for display. 451 const userPicker = await getUserPicker( 452 userList, 453 async(user) => { 454 const userGrade = await updateUserContent(user); 455 const renderContext = { 456 status: userGrade.hasgrade, 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(); 464 }, 465 saveGradeFunction, 466 { 467 initialUserId, 468 }, 469 ); 470 471 // Register all event listeners. 472 registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList); 473 474 // Display the newly created user picker. 475 displayUserPicker(graderContainer, userPicker.rootNode); 476 }; 477 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 483 * @param {String} moduleName the name of the module 484 * @param {object} param 485 * @param {null|HTMLElement} param.focusOnClose 486 */ 487 export const view = async(getGradeForUser, userid, moduleName, { 488 focusOnClose = null, 489 } = {}) => { 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(); 509 if (focusOnClose) { 510 try { 511 focusOnClose.focus(); 512 } catch (e) { 513 // eslint-disable-line 514 } 515 } 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 531 const renderGradeTemplate = async(userGrade) => { 532 const {html, js} = await Templates.renderForPromise(userGrade.templatename, userGrade.grade); 533 return [html, js]; 534 }; 535 export {getGradingPanelFunctions};
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated: Wed Jan 22 11:59:49 2025 | Cross-referenced by PHPXref 0.7.1 |