[ Index ]

PHP Cross Reference of Moodle 310

title

Body

[close]

/mod/forum/amd/src/local/grades/ -> grader.js (source)

   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};


Generated: Wed Jan 22 11:59:49 2025 Cross-referenced by PHPXref 0.7.1