AutorÃa | Ultima modificación | Ver Log |
// This file is part of Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>./*** This module will tie together all of the different calls the gradable module will make.** @module mod_forum/local/grades/grader* @copyright 2019 Mathew May <mathew.solutions>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/import Templates from 'core/templates';import Selectors from './local/grader/selectors';import getUserPicker from './local/grader/user_picker';import {createLayout as createFullScreenWindow} from 'mod_forum/local/layout/fullscreen';import getGradingPanelFunctions from './local/grader/gradingpanel';import {add as addToast} from 'core/toast';import {addNotification} from 'core/notification';import {getString} from 'core/str';import {failedUpdate} from 'core_grades/grades/grader/gradingpanel/normalise';import {addIconToContainerWithPromise} from 'core/loadingicon';import {debounce} from 'core/utils';import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison';import Modal from 'core/modal_cancel';import {subscribe} from 'core/pubsub';import DrawerEvents from 'core/drawer_events';const templateNames = {grader: {app: 'mod_forum/local/grades/grader',gradingPanel: {error: 'mod_forum/local/grades/local/grader/gradingpanel/error',},searchResults: 'mod_forum/local/grades/local/grader/user_picker/user_search',status: 'mod_forum/local/grades/local/grader/status',},};/*** Helper function that replaces the user picker placeholder with what we get back from the user picker class.** @param {HTMLElement} root* @param {String} html*/const displayUserPicker = (root, html) => {const pickerRegion = root.querySelector(Selectors.regions.pickerRegion);Templates.replaceNodeContents(pickerRegion, html, '');};/*** To be removed, this is now done as a part of Templates.renderForPromise()** @param {String} html* @param {String} js* @returns {array} An array containing the HTML, and JS.*/const fetchContentFromRender = (html, js) => {return [html, js];};/*** Here we build the function that is passed to the user picker that'll handle updating the user content area* of the grading interface.** @param {HTMLElement} root* @param {Function} getContentForUser* @param {Function} getGradeForUser* @param {Function} saveGradeForUser* @return {Function}*/const getUpdateUserContentFunction = (root, getContentForUser, getGradeForUser, saveGradeForUser) => {let firstLoad = true;return async(user) => {const spinner = firstLoad ? null : addIconToContainerWithPromise(root);const [[html, js],userGrade,] = await Promise.all([getContentForUser(user.id).then(fetchContentFromRender),getGradeForUser(user.id),]);Templates.replaceNodeContents(root.querySelector(Selectors.regions.moduleReplace), html, js);const [gradingPanelHtml,gradingPanelJS] = await Templates.render(userGrade.templatename, userGrade.grade).then(fetchContentFromRender);const panelContainer = root.querySelector(Selectors.regions.gradingPanelContainer);const panel = panelContainer.querySelector(Selectors.regions.gradingPanel);Templates.replaceNodeContents(panel, gradingPanelHtml, gradingPanelJS);const form = panel.querySelector('form');fillInitialValues(form);form.addEventListener('submit', event => {saveGradeForUser(user);event.preventDefault();});panelContainer.scrollTop = 0;firstLoad = false;if (spinner) {spinner.resolve();}return userGrade;};};/*** Show the search results container and hide the user picker and body content.** @param {HTMLElement} bodyContainer The container element for the body content* @param {HTMLElement} userPickerContainer The container element for the user picker* @param {HTMLElement} searchResultsContainer The container element for the search results*/const showSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {bodyContainer.classList.add('hidden');userPickerContainer.classList.add('hidden');searchResultsContainer.classList.remove('hidden');};/*** Hide the search results container and show the user picker and body content.** @param {HTMLElement} bodyContainer The container element for the body content* @param {HTMLElement} userPickerContainer The container element for the user picker* @param {HTMLElement} searchResultsContainer The container element for the search results*/const hideSearchResultContainer = (bodyContainer, userPickerContainer, searchResultsContainer) => {bodyContainer.classList.remove('hidden');userPickerContainer.classList.remove('hidden');searchResultsContainer.classList.add('hidden');};/*** Toggles the visibility of the user search.** @param {HTMLElement} toggleSearchButton The button that toggles the search* @param {HTMLElement} searchContainer The container element for the user search* @param {HTMLElement} searchInput The input element for searching*/const showUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {searchContainer.classList.remove('collapsed');toggleSearchButton.setAttribute('aria-expanded', 'true');toggleSearchButton.classList.add('expand');toggleSearchButton.classList.remove('collapse');// Hide the grading info container from screen reader.const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);gradingInfoContainer.setAttribute('aria-hidden', 'true');// Hide the collapse grading drawer button from screen reader.const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);collapseGradingDrawer.setAttribute('aria-hidden', 'true');collapseGradingDrawer.setAttribute('tabindex', '-1');searchInput.focus();};/*** Toggles the visibility of the user search.** @param {HTMLElement} toggleSearchButton The button that toggles the search* @param {HTMLElement} searchContainer The container element for the user search* @param {HTMLElement} searchInput The input element for searching*/const hideUserSearchInput = (toggleSearchButton, searchContainer, searchInput) => {searchContainer.classList.add('collapsed');toggleSearchButton.setAttribute('aria-expanded', 'false');toggleSearchButton.classList.add('collapse');toggleSearchButton.classList.remove('expand');toggleSearchButton.focus();// Show the grading info container to screen reader.const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);gradingInfoContainer.removeAttribute('aria-hidden');// Show the collapse grading drawer button from screen reader.const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);collapseGradingDrawer.removeAttribute('aria-hidden');collapseGradingDrawer.setAttribute('tabindex', '0');searchInput.value = '';};/*** Find the list of users who's names include the given search term.** @param {Array} userList List of users for the grader* @param {String} searchTerm The search term to match* @return {Array}*/const searchForUsers = (userList, searchTerm) => {if (searchTerm === '') {return userList;}searchTerm = searchTerm.toLowerCase();return userList.filter((user) => {return user.fullname.toLowerCase().includes(searchTerm);});};/*** Render the list of users in the search results area.** @param {HTMLElement} searchResultsContainer The container element for search results* @param {Array} users The list of users to display*/const renderSearchResults = async(searchResultsContainer, users) => {const {html, js} = await Templates.renderForPromise(templateNames.grader.searchResults, {users});Templates.replaceNodeContents(searchResultsContainer, html, js);};/*** Add click handlers to the buttons in the header of the grading interface.** @param {HTMLElement} graderLayout* @param {Object} userPicker* @param {Function} saveGradeFunction* @param {Array} userList List of users for the grader.*/const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, userList) => {const graderContainer = graderLayout.getContainer();const toggleSearchButton = graderContainer.querySelector(Selectors.buttons.toggleSearch);const searchInputContainer = graderContainer.querySelector(Selectors.regions.userSearchContainer);const searchInput = searchInputContainer.querySelector(Selectors.regions.userSearchInput);const bodyContainer = graderContainer.querySelector(Selectors.regions.bodyContainer);const userPickerContainer = graderContainer.querySelector(Selectors.regions.pickerRegion);const searchResultsContainer = graderContainer.querySelector(Selectors.regions.searchResultsContainer);graderContainer.addEventListener('click', (e) => {if (e.target.closest(Selectors.buttons.toggleFullscreen)) {e.stopImmediatePropagation();e.preventDefault();graderLayout.toggleFullscreen();return;}if (e.target.closest(Selectors.buttons.closeGrader)) {e.stopImmediatePropagation();e.preventDefault();graderLayout.close();return;}if (e.target.closest(Selectors.buttons.saveGrade)) {saveGradeFunction(userPicker.currentUser);}if (e.target.closest(Selectors.buttons.toggleSearch)) {if (toggleSearchButton.getAttribute('aria-expanded') === 'true') {// Search is open so let's close it.hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);searchResultsContainer.innerHTML = '';} else {// Search is closed so let's open it.showUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);showSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);renderSearchResults(searchResultsContainer, userList);}return;}const selectUserButton = e.target.closest(Selectors.buttons.selectUser);if (selectUserButton) {const userId = selectUserButton.getAttribute('data-userid');const user = userList.find(user => user.id == userId);userPicker.setUserId(userId);userPicker.showUser(user);hideUserSearchInput(toggleSearchButton, searchInputContainer, searchInput);hideSearchResultContainer(bodyContainer, userPickerContainer, searchResultsContainer);searchResultsContainer.innerHTML = '';}});// Debounce the search input so that it only executes 300 milliseconds after the user has finished typing.searchInput.addEventListener('input', debounce(() => {const users = searchForUsers(userList, searchInput.value);renderSearchResults(searchResultsContainer, users);}, 300));// Remove the right margin of the content container when the grading panel is hidden so that it expands to full-width.subscribe(DrawerEvents.DRAWER_HIDDEN, (drawerRoot) => {const gradingPanel = drawerRoot[0];if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {setContentContainerMargin(graderContainer, 0);}});// Bring back the right margin of the content container when the grading panel is shown to give space for the grading panel.subscribe(DrawerEvents.DRAWER_SHOWN, (drawerRoot) => {const gradingPanel = drawerRoot[0];if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {setContentContainerMargin(graderContainer, gradingPanel.offsetWidth);}});};/*** Adjusts the right margin of the content container.** @param {HTMLElement} graderContainer The container for the grader app.* @param {Number} rightMargin The right margin value.*/const setContentContainerMargin = (graderContainer, rightMargin) => {const contentContainer = graderContainer.querySelector(Selectors.regions.moduleContainer);if (contentContainer) {contentContainer.style.marginRight = `${rightMargin}px`;}};/*** Get the function used to save a user grade.** @param {HTMLElement} root The container for the grader* @param {Function} setGradeForUser The function that will be called.* @return {Function}*/const getSaveUserGradeFunction = (root, setGradeForUser) => {return async(user) => {try {root.querySelector(Selectors.regions.gradingPanelErrors).innerHTML = '';const result = await setGradeForUser(user.id,root.querySelector(Selectors.values.sendStudentNotifications).value,root.querySelector(Selectors.regions.gradingPanel));if (result.success) {addToast(await getString('grades:gradesavedfor', 'mod_forum', user));}if (result.failed) {displayGradingError(root, user, result.error);}return result;} catch (err) {displayGradingError(root, user, err);return failedUpdate(err);}};};/*** Display a grading error, typically from a failed save.** @param {HTMLElement} root The container for the grader* @param {Object} user The user who was errored* @param {Object} err The details of the error*/const displayGradingError = async(root, user, err) => {const [{html, js},errorString] = await Promise.all([Templates.renderForPromise(templateNames.grader.gradingPanel.error, {error: err}),await getString('grades:gradesavefailed', 'mod_forum', {error: err.message, ...user}),]);Templates.replaceNodeContents(root.querySelector(Selectors.regions.gradingPanelErrors), html, js);addToast(errorString, {type: 'warning'});};/*** Launch the grader interface with the specified parameters.** @param {Function} getListOfUsers A function to get the list of users* @param {Function} getContentForUser A function to get the content for a specific user* @param {Function} getGradeForUser A function get the grade details for a specific user* @param {Function} setGradeForUser A function to set the grade for a specific user* @param {Object} preferences Preferences for the launch function* @param {Number} preferences.initialUserId* @param {string} preferences.moduleName* @param {string} preferences.courseName* @param {string} preferences.courseUrl* @param {boolean} preferences.sendStudentNotifications* @param {null|HTMLElement} preferences.focusOnClose*/export const launch = async(getListOfUsers, getContentForUser, getGradeForUser, setGradeForUser, {initialUserId = null,moduleName,courseName,courseUrl,sendStudentNotifications,focusOnClose = null,} = {}) => {// We need all of these functions to be executed in series, if one step runs before another the interface// will not work.// We need this promise to resolve separately so that we can avoid loading the whole interface if there are no users.const userList = await getListOfUsers();if (!userList.length) {addNotification({message: await getString('nouserstograde', 'core_grades'),type: "error",});return;}// Now that we have confirmed there are at least some users let's boot up the grader interface.const [graderLayout,{html, js},] = await Promise.all([createFullScreenWindow({fullscreen: false,showLoader: false,focusOnClose,}),Templates.renderForPromise(templateNames.grader.app, {moduleName,courseName,courseUrl,drawer: {show: true},defaultsendnotifications: sendStudentNotifications,}),]);const graderContainer = graderLayout.getContainer();const saveGradeFunction = getSaveUserGradeFunction(graderContainer, setGradeForUser);Templates.replaceNodeContents(graderContainer, html, js);const updateUserContent = getUpdateUserContentFunction(graderContainer, getContentForUser, getGradeForUser, saveGradeFunction);const userIds = userList.map(user => user.id);const statusContainer = graderContainer.querySelector(Selectors.regions.statusContainer);// Fetch the userpicker for display.const userPicker = await getUserPicker(userList,async(user) => {const userGrade = await updateUserContent(user);const renderContext = {status: userGrade.hasgrade,index: userIds.indexOf(user.id) + 1,total: userList.length};Templates.render(templateNames.grader.status, renderContext).then(html => {statusContainer.innerHTML = html;return html;}).catch();},saveGradeFunction,{initialUserId,},);// Register all event listeners.registerEventListeners(graderLayout, userPicker, saveGradeFunction, userList);// Display the newly created user picker.displayUserPicker(graderContainer, userPicker.rootNode);};/*** Show the grade for a specific user.** @param {Function} getGradeForUser A function get the grade details for a specific user* @param {Number} userid The ID of a specific user* @param {String} moduleName the name of the module* @param {object} param* @param {null|HTMLElement} param.focusOnClose*/export const view = async(getGradeForUser, userid, moduleName, {focusOnClose = null,} = {}) => {const userGrade = await getGradeForUser(userid);const [modal,gradeTemplateData] = await Promise.all([Modal.create({title: moduleName,large: true,removeOnClose: true,returnElement: focusOnClose,show: true,body: Templates.render('mod_forum/local/grades/view_grade', userGrade),}),renderGradeTemplate(userGrade)]);const bodyPromise = await modal.getBodyPromise();const gradeReplace = bodyPromise[0].querySelector('[data-region="grade-template"]');Templates.replaceNodeContents(gradeReplace, gradeTemplateData.html, gradeTemplateData.js);};const renderGradeTemplate = (userGrade) => Templates.renderForPromise(userGrade.templatename, userGrade.grade);export {getGradingPanelFunctions};