first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
@@ -0,0 +1,92 @@
// 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/>.
import BulkActions from "core/bulkactions/bulk_actions";
import GradebookEditTreeBulkMove from "core_grades/bulkactions/edit/tree/move";
/**
* Class for defining the bulk actions area in the gradebook setup page.
*
* @module core_grades/bulkactions/edit/tree/bulk_actions
* @copyright 2023 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const Selectors = {
selectBulkItemCheckbox: 'input[type="checkbox"].itemselect'
};
export default class GradebookEditTreeBulkActions extends BulkActions {
/** @property {int|null} courseID The course ID. */
courseID = null;
/**
* Returns the instance of the class.
*
* @param {int} courseID
* @returns {GradebookEditTreeBulkActions}
*/
static init(courseID) {
return new this(courseID);
}
/**
* The class constructor.
*
* @param {int} courseID The course ID.
* @returns {void}
*/
constructor(courseID) {
super();
this.courseID = courseID;
}
/**
* Returns the array of the relevant bulk action objects for the gradebook setup page.
*
* @method getBulkActions
* @returns {Array}
*/
getBulkActions() {
return [
new GradebookEditTreeBulkMove(this.courseID)
];
}
/**
* Returns the array of selected items.
*
* @method getSelectedItems
* @returns {Array}
*/
getSelectedItems() {
return document.querySelectorAll(`${Selectors.selectBulkItemCheckbox}:checked`);
}
/**
* Adds the listener for the item select change event.
*
* @method registerItemSelectChangeEvent
* @param {function} eventHandler The event handler function.
* @returns {void}
*/
registerItemSelectChangeEvent(eventHandler) {
const itemSelectCheckboxes = document.querySelectorAll(Selectors.selectBulkItemCheckbox);
itemSelectCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', eventHandler.bind(this));
});
}
}
+177
View File
@@ -0,0 +1,177 @@
// 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/>.
/**
* Class that defines the bulk move action in the gradebook setup page.
*
* @module core_grades/bulkactions/edit/tree/move
* @copyright 2023 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import BulkAction from 'core/bulkactions/bulk_action';
import {get_string as getString} from 'core/str';
import ModalSaveCancel from 'core/modal_save_cancel';
import Templates from 'core/templates';
import Ajax from 'core/ajax';
import ModalEvents from 'core/modal_events';
import MoveOptionsTree from 'core_grades/bulkactions/edit/tree/move_options_tree';
/** @constant {Object} The object containing the relevant selectors. */
const Selectors = {
editTreeForm: '#gradetreeform',
bulkMoveInput: 'input[name="bulkmove"]',
bulkMoveAfterInput: 'input[name="moveafter"]'
};
export default class GradebookEditTreeBulkMove extends BulkAction {
/** @property {int|null} courseId The course ID. */
courseId = null;
/** @property {MoveOptionsTree|null} moveOptionsTree The move options tree object. */
moveOptionsTree = null;
/** @property {string|null} gradeTree The grade tree structure. */
gradeTree = null;
/**
* The class constructor.
*
* @param {int} courseId The course ID.
* @returns {void}
*/
constructor(courseId) {
super();
this.courseId = courseId;
}
/**
* Defines the selector of the element that triggers the bulk move action.
*
* @returns {string} The bulk move action trigger selector.
*/
getBulkActionTriggerSelector() {
return 'button[data-action="move"]';
}
/**
* Defines the behavior once the bulk move action is triggered.
*
* @method executeBulkAction
* @returns {void}
*/
async triggerBulkAction() {
const modal = await this.showModal();
this.registerCustomListenerEvents(modal);
}
/**
* Renders the bulk move action trigger element.
*
* @method renderBulkActionTrigger
* @returns {Promise} The bulk move action trigger promise
*/
async renderBulkActionTrigger() {
return Templates.render('core_grades/bulkactions/edit/tree/bulk_move_trigger', {});
}
/**
* Register custom event listeners.
*
* @method registerCustomClickListenerEvents
* @param {Object} modal The modal object.
* @returns {void}
*/
async registerCustomListenerEvents(modal) {
await modal.getBody();
// Initialize the move options tree once the modal is shown.
modal.getRoot().on(ModalEvents.shown, () => {
this.moveOptionsTree = new MoveOptionsTree(() => {
// Enable the 'Move' action button once something is selected.
modal.setButtonDisabled('save', false);
});
});
// Destroy the modal once it is hidden.
modal.getRoot().on(ModalEvents.hidden, () => {
modal.destroy();
});
// Define the move action event.
modal.getRoot().on(ModalEvents.save, () => {
// Make sure that a move option is selected.
if (this.moveOptionsTree && this.moveOptionsTree.selectedMoveOption) {
// Set the relevant form values.
document.querySelector(Selectors.bulkMoveInput).value = 1;
document.querySelector(Selectors.bulkMoveAfterInput).value = this.moveOptionsTree.selectedMoveOption.dataset.id;
// Submit the form.
document.querySelector(Selectors.editTreeForm).submit();
}
});
}
/**
* Fetch the grade tree structure for the current course.
*
* @method fetchGradeTree
* @returns {Promise} The grade tree promise
*/
fetchGradeTree() {
const request = {
methodname: 'core_grades_get_grade_tree',
args: {
courseid: this.courseId,
},
};
return Ajax.call([request])[0];
}
/**
* Renders the bulk move modal body.
*
* @method renderModalBody
* @returns {Promise} The modal body promise
*/
async renderModalBody() {
// We need to fetch the grade tree structure only once.
if (this.gradeTree === null) {
this.gradeTree = await this.fetchGradeTree();
}
return Templates.render('core_grades/bulkactions/edit/tree/bulk_move_grade_tree',
JSON.parse(this.gradeTree));
}
/**
* Show the bulk move modal.
*
* @method showModal
* @returns {Promise} The modal promise
*/
async showModal() {
const modal = await ModalSaveCancel.create({
title: await getString('movesitems', 'grades'),
body: await this.renderModalBody(),
buttons: {
save: await getString('move')
},
large: true,
});
// Disable the 'Move' action button until something is selected.
modal.setButtonDisabled('save', true);
modal.show();
return modal;
}
}
@@ -0,0 +1,109 @@
// 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/>.
/**
* Keyboard navigation and aria-tree compatibility for the grade move options.
*
* @module core_grades/bulkactions/edit/tree/move_options_tree
* @copyright 2023 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Tree from 'core/tree';
import {getList} from 'core/normalise';
/** @constant {Object} The object containing the relevant selectors. */
const Selectors = {
moveOptionsTree: '#destination-selector [role="tree"]',
moveOption: '#destination-selector [role="treeitem"]',
toggleGroupLink: '#destination-selector .collapse-list-link',
};
export default class MoveOptionsTree extends Tree {
/** @property {function|null} afterSelectMoveOptionCallback Callback function to run after selecting a move option. */
afterSelectMoveOptionCallback = null;
/** @property {HTMLElement|null} selectedMoveOption The selected move option. */
selectedMoveOption = null;
/**
* The class constructor.
*
* @param {function|null} afterSelectMoveOptionCallback Callback function used to define actions that should be run
* after selecting a move option.
* @returns {void}
*/
constructor(afterSelectMoveOptionCallback) {
super(Selectors.moveOptionsTree);
this.afterSelectMoveOptionCallback = afterSelectMoveOptionCallback;
}
/**
* Handle a key down event.
*
* @method handleKeyDown
* @param {Event} e The event.
*/
handleKeyDown(e) {
// If the user presses enter or space, select the item.
if (e.keyCode === this.keys.enter || e.keyCode === this.keys.space) {
this.selectMoveOption(e.target);
} else { // Otherwise, let the default behaviour happen.
super.handleKeyDown(e);
}
}
/**
* Handle an item click.
*
* @param {Event} event The click event.
* @param {jQuery} item The item clicked.
* @returns {void}
*/
handleItemClick(event, item) {
const isToggleGroupLink = event.target.closest(Selectors.toggleGroupLink);
// If the click is on the toggle group (chevron) link, let the default behaviour happen.
if (isToggleGroupLink) {
super.handleItemClick(event, item);
return;
}
// If the click is on the item itself, select it.
this.selectMoveOption(getList(item)[0]);
}
/**
* Select a move option.
*
* @method selectMoveOption
* @param {HTMLElement} moveOption The move option to select.
*/
selectMoveOption(moveOption) {
// Create the cache of the visible items.
this.refreshVisibleItemsCache();
// Deselect all the move options.
document.querySelectorAll(Selectors.moveOption).forEach(item => {
item.dataset.selected = "false";
});
// Select and set the focus on the specified move option.
moveOption.dataset.selected = "true";
this.selectedMoveOption = moveOption;
moveOption.focus();
// Call the callback function if it is defined.
if (typeof this.afterSelectMoveOptionCallback === 'function') {
this.afterSelectMoveOptionCallback();
}
}
}
+245
View File
@@ -0,0 +1,245 @@
// 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/>.
/**
* Allow the user to search for grades within the grade area.
*
* @module core_grades/comboboxsearch/grade
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import search_combobox from 'core/comboboxsearch/search_combobox';
import * as Repository from 'core_grades/searchwidget/repository';
import {renderForPromise, replaceNodeContents} from 'core/templates';
import {debounce} from 'core/utils';
export default class GradeItemSearch extends search_combobox {
courseID;
constructor() {
super();
// Define our standard lookups.
this.selectors = {
...this.selectors,
courseid: '[data-region="courseid"]',
placeholder: '.gradesearchdropdown [data-region="searchplaceholder"]',
};
const component = document.querySelector(this.componentSelector());
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;
this.instance = this.component.querySelector(this.selectors.instance).dataset.instance;
const searchValueElement = this.component.querySelector(`#${this.searchInput.dataset.inputElement}`);
searchValueElement.addEventListener('change', () => {
this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.
const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
if (valueElement.value !== searchValueElement.value) {
valueElement.value = searchValueElement.value;
valueElement.dispatchEvent(new Event('change', {bubbles: true}));
}
searchValueElement.value = '';
});
this.$component.on('hide.bs.dropdown', () => {
this.searchInput.removeAttribute('aria-activedescendant');
const listbox = document.querySelector(`#${this.searchInput.getAttribute('aria-controls')}[role="listbox"]`);
listbox.querySelectorAll('.active[role="option"]').forEach(option => {
option.classList.remove('active');
});
listbox.scrollTop = 0;
// Use setTimeout to make sure the following code is executed after the click event is handled.
setTimeout(() => {
if (this.searchInput.value !== '') {
this.searchInput.value = '';
this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));
}
});
});
this.renderDefault();
}
static init() {
return new GradeItemSearch();
}
/**
* The overall div that contains the searching widget.
*
* @returns {string}
*/
componentSelector() {
return '.grade-search';
}
/**
* The dropdown div that contains the searching widget result space.
*
* @returns {string}
*/
dropdownSelector() {
return '.gradesearchdropdown';
}
/**
* Build the content then replace the node.
*/
async renderDropdown() {
const {html, js} = await renderForPromise('core/local/comboboxsearch/resultset', {
instance: this.instance,
results: this.getMatchedResults(),
hasresults: this.getMatchedResults().length > 0,
searchterm: this.getSearchTerm(),
});
replaceNodeContents(this.selectors.placeholder, html, js);
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
/**
* Build the content then replace the node by default we want our form to exist.
*/
async renderDefault() {
this.setMatchedResults(await this.filterDataset(await this.getDataset()));
this.filterMatchDataset();
await this.renderDropdown();
this.updateNodes();
this.registerInputEvents();
}
/**
* Get the data we will be searching against in this component.
*
* @returns {Promise<*>}
*/
async fetchDataset() {
return await Repository.gradeitemFetch(this.courseID).then((r) => r.gradeitems);
}
/**
* Dictate to the search component how and what we want to match upon.
*
* @param {Array} filterableData
* @returns {Array} The users that match the given criteria.
*/
async filterDataset(filterableData) {
// Sometimes we just want to show everything.
if (this.getPreppedSearchTerm() === '') {
return filterableData;
}
return filterableData.filter((grade) => Object.keys(grade).some((key) => {
if (grade[key] === "") {
return false;
}
return grade[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());
}));
}
/**
* Given we have a subset of the dataset, set the field that we matched upon to inform the end user.
*/
filterMatchDataset() {
this.setMatchedResults(
this.getMatchedResults().map((grade) => {
return {
id: grade.id,
name: grade.name,
};
})
);
}
/**
* Handle any keyboard inputs.
*/
registerInputEvents() {
// Register & handle the text input.
this.searchInput.addEventListener('input', debounce(async() => {
this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
if (this.searchInput.value === '') {
// Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none');
} else {
// Display the "clear" search button in the search bar.
this.clearSearchButton.classList.remove('d-none');
}
// User has given something for us to filter against.
await this.filterrenderpipe();
}, 300));
}
/**
* The handler for when a user interacts with the component.
*
* @param {MouseEvent} e The triggering event that we are working with.
*/
async clickHandler(e) {
if (e.target.closest(this.selectors.clearSearch)) {
e.stopPropagation();
// Clear the entered search query in the search bar.
this.searchInput.value = '';
this.setSearchTerms(this.searchInput.value);
this.searchInput.focus();
this.clearSearchButton.classList.add('d-none');
// Display results.
await this.filterrenderpipe();
}
}
/**
* The handler for when a user changes the value of the component (selects an option from the dropdown).
*
* @param {Event} e The change event.
*/
changeHandler(e) {
window.location = this.selectOneLink(e.target.value);
}
/**
* Override the input event listener for the text input area.
*/
registerInputHandlers() {
// Register & handle the text input.
this.searchInput.addEventListener('input', debounce(() => {
this.setSearchTerms(this.searchInput.value);
// We can also require a set amount of input before search.
if (this.getSearchTerm() === '') {
// Hide the "clear" search button in the search bar.
this.clearSearchButton.classList.add('d-none');
} else {
// Display the "clear" search button in the search bar.
this.clearSearchButton.classList.remove('d-none');
}
}, 300));
}
/**
* Build up the view all link that is dedicated to a particular result.
* We will call this function when a user interacts with the combobox to redirect them to show their results in the page.
*
* @param {Number} gradeID The ID of the grade item selected.
*/
selectOneLink(gradeID) {
throw new Error(`selectOneLink(${gradeID}) must be implemented in ${this.constructor.name}`);
}
}
+268
View File
@@ -0,0 +1,268 @@
// 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/>.
/**
* Enhance the gradebook tree setup with various facilities.
*
* @module core_grades/edittree_index
* @copyright 2016 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import storage from 'core/localstorage';
import {addIconToContainer} from 'core/loadingicon';
import Notification from 'core/notification';
import Pending from 'core/pending';
const SELECTORS = {
CATEGORY_TOGGLE: '.toggle-category',
GRADEBOOK_SETUP_TABLE: '.setup-grades',
WEIGHT_OVERRIDE_CHECKBOX: '.weightoverride',
BULK_MOVE_SELECT: '#menumoveafter',
BULK_MOVE_INPUT: '#bulkmoveinput',
GRADEBOOK_SETUP_WRAPPER: '.gradetree-wrapper',
GRADEBOOK_SETUP_BOX: '.gradetreebox'
};
/**
* Register related event listeners.
*
* @method registerListenerEvents
* @param {int} courseId The ID of course.
* @param {int} userId The ID of the current logged user.
*/
const registerListenerEvents = (courseId, userId) => {
document.addEventListener('change', e => {
// Toggle the availability of the weight input field based on the changed state (checked/unchecked) of the
// related checkbox element.
if (e.target.matches(SELECTORS.WEIGHT_OVERRIDE_CHECKBOX)) {
toggleWeightInput(e.target);
}
// Submit the bulk move form when the selected option in the bulk move select element has been changed.
if (e.target.matches(SELECTORS.BULK_MOVE_SELECT)) {
submitBulkMoveForm(e.target);
}
});
const gradebookSetup = document.querySelector(SELECTORS.GRADEBOOK_SETUP_TABLE);
gradebookSetup.addEventListener('click', e => {
const toggle = e.target.closest(SELECTORS.CATEGORY_TOGGLE);
// Collapse or expand the grade category when the visibility toggle button is activated.
if (toggle) {
e.preventDefault();
toggleCategory(toggle, courseId, userId, true);
}
});
};
/**
* Toggle the weight input field based on its checkbox.
*
* @method toggleWeightInput
* @param {object} weightOverrideCheckbox The weight override checkbox element.
*/
const toggleWeightInput = (weightOverrideCheckbox) => {
const row = weightOverrideCheckbox.closest('tr');
const itemId = row.dataset.itemid;
const weightOverrideInput = row.querySelector(`input[name="weight_${itemId}"]`);
weightOverrideInput.disabled = !weightOverrideCheckbox.checked;
};
/**
* Submit the bulk move form.
*
* @method toggleWeightInput
* @param {object} bulkMoveSelect The bulk move select element.
*/
const submitBulkMoveForm = (bulkMoveSelect) => {
const form = bulkMoveSelect.closest('form');
const bulkMoveInput = form.querySelector(SELECTORS.BULK_MOVE_INPUT);
bulkMoveInput.value = 1;
form.submit();
};
/**
* Method that collapses all relevant grade categories based on the locally stored state of collapsed grade categories
* for a given user.
*
* @method collapseGradeCategories
* @param {int} courseId The ID of course.
* @param {int} userId The ID of the current logged user.
*/
const collapseGradeCategories = (courseId, userId) => {
const gradebookSetup = document.querySelector(SELECTORS.GRADEBOOK_SETUP_TABLE);
const storedCollapsedCategories = storage.get(`core_grade_collapsedgradecategories_${courseId}_${userId}`);
if (storedCollapsedCategories) {
// Fetch all grade categories that are locally stored as collapsed and re-apply the collapse action.
const collapsedCategories = JSON.parse(storedCollapsedCategories);
collapsedCategories.forEach((category) => {
const categoryToggleElement =
gradebookSetup.querySelector(`${SELECTORS.CATEGORY_TOGGLE}[data-category="${category}"`);
if (categoryToggleElement) {
toggleCategory(categoryToggleElement, courseId, userId, false);
}
});
}
};
/**
* Method that updates the locally stored state of collapsed grade categories based on a performed toggle action on a
* given grade category.
*
* @method updateCollapsedCategoriesStoredState
* @param {string} category The category to be added or removed from the collapsed grade categories local storage.
* @param {int} courseId The ID of course.
* @param {int} userId The ID of the current logged user.
* @param {boolean} isCollapsing Whether the category is being collapsed or not.
*/
const updateCollapsedCategoriesStoredState = (category, courseId, userId, isCollapsing) => {
const currentStoredCollapsedCategories = storage.get(`core_grade_collapsedgradecategories_${courseId}_${userId}`);
let collapsedCategories = currentStoredCollapsedCategories ?
JSON.parse(currentStoredCollapsedCategories) : [];
if (isCollapsing) {
collapsedCategories.push(category);
} else {
collapsedCategories = collapsedCategories.filter(cat => cat !== category);
}
storage.set(`core_grade_collapsedgradecategories_${courseId}_${userId}`, JSON.stringify(collapsedCategories));
};
/**
* Method that handles the grade category toggle action.
*
* @method toggleCategory
* @param {object} toggleElement The category toggle node that was clicked.
* @param {int} courseId The ID of course.
* @param {int} userId The ID of the current logged user.
* @param {boolean} storeCollapsedState Whether to store (local storage) the state of collapsed grade categories.
*/
const toggleCategory = (toggleElement, courseId, userId, storeCollapsedState) => {
const target = toggleElement.dataset.target;
const category = toggleElement.dataset.category;
// Whether the toggle action is collapsing the category or not.
const isCollapsing = toggleElement.getAttribute('aria-expanded') === "true";
const gradebookSetup = toggleElement.closest(SELECTORS.GRADEBOOK_SETUP_TABLE);
// Find all targeted 'children' rows of the toggled category.
const targetRows = gradebookSetup.querySelectorAll(target);
// Find the maximum grade cell in the grade category that is being collapsed/expanded.
const toggleElementRow = toggleElement.closest('tr');
const maxGradeCell = toggleElementRow.querySelector('.column-range');
if (isCollapsing) {
toggleElement.setAttribute('aria-expanded', 'false');
// Update the 'data-target' of the toggle category node to make sure that when we perform another toggle action
// to expand this category we only target rows which have been hidden by this category toggle action.
toggleElement.dataset.target = `[data-hidden-by='${category}']`;
if (maxGradeCell) {
const relatedCategoryAggregationRow = gradebookSetup.querySelector(`[data-aggregationforcategory='${category}']`);
maxGradeCell.innerHTML = relatedCategoryAggregationRow.querySelector('.column-range').innerHTML;
}
} else {
toggleElement.setAttribute('aria-expanded', 'true');
// Update the 'data-target' of the toggle category node to make sure that when we perform another toggle action
// to collapse this category we only target rows which are children of this category and are not currently hidden.
toggleElement.dataset.target = `.${category}[data-hidden='false']`;
if (maxGradeCell) {
maxGradeCell.innerHTML = '';
}
}
// If explicitly instructed, update accordingly the locally stored state of collapsed categories based on the
// toggle action performed on the given grade category.
if (storeCollapsedState) {
updateCollapsedCategoriesStoredState(category, courseId, userId, isCollapsing);
}
// Loop through all targeted child row elements and update the required data attributes to either hide or show
// them depending on the toggle action (collapsing or expanding).
targetRows.forEach((row) => {
if (isCollapsing) {
row.dataset.hidden = 'true';
row.dataset.hiddenBy = category;
} else {
row.dataset.hidden = 'false';
row.dataset.hiddenBy = '';
}
});
// Since the user report is presented in an HTML table, rowspans are used under each category to create a visual
// hierarchy between categories and grading items. When expanding or collapsing a category we need to also update
// (subtract or add) the rowspan values associated to each parent category row to preserve the correct visual
// hierarchy in the table.
updateParentCategoryRowspans(toggleElement, targetRows.length);
};
/**
* Method that updates the rowspan value of all 'parent' category rows of a given category node.
*
* @method updateParentCategoryRowspans
* @param {object} toggleElement The category toggle node that was clicked.
* @param {int} num The number we want to add or subtract from the rowspan value of the 'parent' category row elements.
*/
const updateParentCategoryRowspans = (toggleElement, num) => {
const gradebookSetup = toggleElement.closest(SELECTORS.GRADEBOOK_SETUP_TABLE);
// Get the row element which contains the category toggle node.
const rowElement = toggleElement.closest('tr');
// Loop through the class list of the toggle category row element.
// The list contains classes which identify all parent categories of the toggled category.
rowElement.classList.forEach((className) => {
// Find the toggle node of the 'parent' category that is identified by the given class name.
const parentCategoryToggleElement = gradebookSetup.querySelector(`[data-target=".${className}[data-hidden='false']"`);
if (parentCategoryToggleElement) {
// Get the row element which contains the parent category toggle node.
const categoryRowElement = parentCategoryToggleElement.closest('tr');
// Find the rowspan element associated to this parent category.
const categoryRowSpanElement = categoryRowElement.nextElementSibling.querySelector('[rowspan]');
// Depending on whether the toggle action has expanded or collapsed the category, either add or
// subtract from the 'parent' category rowspan.
if (toggleElement.getAttribute('aria-expanded') === "true") {
categoryRowSpanElement.rowSpan = categoryRowSpanElement.rowSpan + num;
} else { // The category has been collapsed.
categoryRowSpanElement.rowSpan = categoryRowSpanElement.rowSpan - num;
}
}
});
};
/**
* Initialize module.
*
* @method init
* @param {int} courseId The ID of course.
* @param {int} userId The ID of the current logged user.
*/
export const init = (courseId, userId) => {
const pendingPromise = new Pending();
const gradebookSetupBox = document.querySelector(SELECTORS.GRADEBOOK_SETUP_BOX);
// Display a loader while the relevant grade categories are being re-collapsed on page load (based on the locally
// stored state for the given user).
addIconToContainer(gradebookSetupBox).then((loader) => {
setTimeout(() => {
collapseGradeCategories(courseId, userId);
// Once the grade categories have been re-collapsed, remove the loader and display the Gradebook setup content.
loader.remove();
document.querySelector(SELECTORS.GRADEBOOK_SETUP_WRAPPER).classList.remove('d-none');
pendingPromise.resolve();
}, 150);
return;
}).fail(Notification.exception);
registerListenerEvents(courseId, userId);
};
+312
View File
@@ -0,0 +1,312 @@
// 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 provides functionality for managing weight calculations and adjustments for grade items.
*
* @module core_grades/edittree_weight
* @copyright 2023 Shamim Rezaie <shamim@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {getString} from 'core/str';
import {prefetchStrings} from 'core/prefetch';
/**
* Selectors.
*
* @type {Object}
*/
const selectors = {
weightOverrideCheckbox: 'input[type="checkbox"][name^="weightoverride_"]',
weightOverrideInput: 'input[type="text"][name^="weight_"]',
aggregationForCategory: category => `[data-aggregationforcategory='${category}']`,
childrenByCategory: category => `tr[data-parent-category="${category}"]`,
categoryByIdentifier: identifier => `tr.category[data-category="${identifier}"]`,
};
/**
* An object representing grading-related constants.
* The same as what's defined in lib/grade/constants.php.
*
* @type {Object}
* @property {Object} aggregation Aggregation settings.
* @property {number} aggregation.sum Aggregation method: sum.
* @property {Object} type Grade type settings.
* @property {number} type.none Grade type: none.
* @property {number} type.value Grade type: value.
* @property {number} type.scale Grade type: scale.
*/
const grade = {
aggregation: {
sum: 13,
},
};
/**
* The character used as the decimal separator for number formatting.
*
* @type {string}
*/
let decimalSeparator;
/**
* This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
* Even though the old algorithm has bugs in it, we need to preserve existing grades.
*
* @type {boolean}
*/
let oldExtraCreditCalculation;
/**
* Recalculates the natural weights for grade items within a given category.
*
* @param {HTMLElement} categoryElement The DOM element representing the category.
*/
// Suppress 'complexity' linting rule to keep this function as close to grade_category::auto_update_weights.
// eslint-disable-next-line complexity
const recalculateNaturalWeights = (categoryElement) => {
const childElements = document.querySelectorAll(selectors.childrenByCategory(categoryElement.dataset.category));
// Calculate the sum of the grademax's of all the items within this category.
let totalGradeMax = 0;
// Out of 100, how much weight has been manually overridden by a user?
let totalOverriddenWeight = 0;
let totalOverriddenGradeMax = 0;
// Has every assessment in this category been overridden?
let automaticGradeItemsPresent = false;
// Does the grade item require normalising?
let requiresNormalising = false;
// Is there an error in the weight calculations?
let erroneous = false;
// This array keeps track of the id and weight of every grade item that has been overridden.
const overrideArray = {};
for (const childElement of childElements) {
const weightInput = childElement.querySelector(selectors.weightOverrideInput);
const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);
// There are cases where a grade item should be excluded from calculations:
// - If the item's grade type is 'text' or 'none'.
// - If the grade item is an outcome item and the settings are set to not aggregate outcome items.
// - If the item's grade type is 'scale' and the settings are set to ignore scales in aggregations.
// All these cases are already taken care of in the backend, and no 'weight' input element is rendered on the page
// if a grade item should not have a weight.
if (!weightInput) {
continue;
}
const itemWeight = parseWeight(weightInput.value);
const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);
const itemGradeMax = parseFloat(childElement.dataset.grademax);
// Record the ID and the weight for this grade item.
overrideArray[childElement.dataset.itemid] = {
extraCredit: itemAggregationCoefficient,
weight: itemWeight,
weightOverride: weightCheckbox.checked,
};
// If this item has had its weight overridden then set the flag to true, but
// only if all previous items were also overridden. Note that extra credit items
// are counted as overridden grade items.
if (!weightCheckbox.checked && itemAggregationCoefficient === 0) {
automaticGradeItemsPresent = true;
}
if (itemAggregationCoefficient > 0) {
// An extra credit grade item doesn't contribute to totalOverriddenGradeMax.
continue;
} else if (weightCheckbox.checked && itemWeight <= 0) {
// An overridden item that defines a weight of 0 does not contribute to totalOverriddenGradeMax.
continue;
}
totalGradeMax += itemGradeMax;
if (weightCheckbox.checked) {
totalOverriddenWeight += itemWeight;
totalOverriddenGradeMax += itemGradeMax;
}
}
// Initialise this variable (used to keep track of the weight override total).
let normaliseTotal = 0;
// Keep a record of how much the override total is to see if it is above 100. If it is then we need to set the
// other weights to zero and normalise the others.
let overriddenTotal = 0;
// Total up all the weights.
for (const gradeItemDetail of Object.values(overrideArray)) {
// Exclude grade items with extra credit or negative weights (which will be set to zero later).
if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {
normaliseTotal += gradeItemDetail.weight;
}
// The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight.
if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {
// Add overridden weights up to see if they are greater than 1.
overriddenTotal += gradeItemDetail.weight;
}
}
if (overriddenTotal > 100) {
// Make sure that this category of weights gets normalised.
requiresNormalising = true;
// The normalised weights are only the overridden weights, so we just use the total of those.
normaliseTotal = overriddenTotal;
}
const totalNonOverriddenGradeMax = totalGradeMax - totalOverriddenGradeMax;
for (const childElement of childElements) {
const weightInput = childElement.querySelector(selectors.weightOverrideInput);
const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);
const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);
const itemGradeMax = parseFloat(childElement.dataset.grademax);
if (!weightInput) {
continue;
} else if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && weightCheckbox.checked) {
// For an item with extra credit ignore other weights and overrides but do not change anything at all
// if its weight was already overridden.
continue;
}
// Remove any error messages and classes.
weightInput.classList.remove('is-invalid');
const errorArea = weightInput.closest('td').querySelector('.invalid-feedback');
errorArea.textContent = '';
if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {
// For an item with extra credit ignore other weights and overrides.
weightInput.value = totalGradeMax ? formatFloat(itemGradeMax * 100 / totalGradeMax) : 0;
} else if (!weightCheckbox.checked) {
// Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.
if (totalOverriddenWeight >= 100 || totalNonOverriddenGradeMax === 0 || itemGradeMax === 0) {
// There is no more weight to distribute.
weightInput.value = formatFloat(0);
} else {
// Calculate this item's weight as a percentage of the non-overridden total grade maxes
// then convert it to a proportion of the available non-overridden weight.
weightInput.value = formatFloat((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));
}
} else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || requiresNormalising ||
overrideArray[childElement.dataset.itemid].weight < 0) {
if (overrideArray[childElement.dataset.itemid].weight < 0) {
weightInput.value = formatFloat(0);
}
// Zero is a special case. If the total is zero then we need to set the weight of the parent category to zero.
if (normaliseTotal !== 0) {
erroneous = true;
const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight';
// eslint-disable-next-line promise/always-return,promise/catch-or-return
getString(error, 'core_grades').then((errorString) => {
errorArea.textContent = errorString;
});
weightInput.classList.add('is-invalid');
}
}
}
if (!erroneous) {
const categoryGradeMax = parseFloat(categoryElement.dataset.grademax);
if (categoryGradeMax !== totalGradeMax) {
// The category grade max is not the same as the total grade max, so we need to update the category grade max.
categoryElement.dataset.grademax = totalGradeMax;
const relatedCategoryAggregationRow = document.querySelector(
selectors.aggregationForCategory(categoryElement.dataset.category)
);
relatedCategoryAggregationRow.querySelector('.column-range').innerHTML = formatFloat(totalGradeMax, 2, 2);
const parentCategory = document.querySelector(selectors.categoryByIdentifier(categoryElement.dataset.parentCategory));
if (parentCategory && (parseInt(parentCategory.dataset.aggregation) === grade.aggregation.sum)) {
recalculateNaturalWeights(parentCategory);
}
}
}
};
/**
* Formats a floating-point number as a string with the specified number of decimal places.
* Unnecessary trailing zeros are removed up to the specified minimum number of decimal places.
*
* @param {number} number The float value to be formatted.
* @param {number} [decimalPoints=3] The number of decimal places to use.
* @param {number} [minDecimals=1] The minimum number of decimal places to use.
* @returns {string} The formatted weight value with the specified decimal places.
*/
const formatFloat = (number, decimalPoints = 3, minDecimals = 1) => {
return number.toFixed(decimalPoints)
.replace(new RegExp(`0{0,${decimalPoints - minDecimals}}$`), '')
.replace('.', decimalSeparator);
};
/**
* Parses a weight string and returns a normalized float value.
*
* @param {string} weightString The weight as a string, possibly with localized formatting.
* @returns {number} The parsed weight as a float. If parsing fails, returns 0.
*/
const parseWeight = (weightString) => {
const normalizedWeightString = weightString.replace(decimalSeparator, '.');
return isNaN(Number(normalizedWeightString)) ? 0 : parseFloat(normalizedWeightString || 0);
};
/**
* Initializes the weight management module with optional configuration.
*
* @param {string} decSep The character used as the decimal separator for number formatting.
* @param {boolean} oldCalculation A flag indicating whether to use the old (pre MDL-49257) extra credit calculation.
*/
export const init = (decSep, oldCalculation) => {
decimalSeparator = decSep;
oldExtraCreditCalculation = oldCalculation;
prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']);
document.addEventListener('change', e => {
// Update the weights of all grade items in the category when the weight of any grade item in the category is changed.
if (e.target.matches(selectors.weightOverrideInput) || e.target.matches(selectors.weightOverrideCheckbox)) {
// The following is named gradeItemRow, but it may also be a row that's representing a grade category.
// It's ok because it serves as the categories associated grade item in our calculations.
const gradeItemRow = e.target.closest('tr');
const categoryElement = document.querySelector(selectors.categoryByIdentifier(gradeItemRow.dataset.parentCategory));
// This is only required if we are using natural weights.
if (parseInt(categoryElement.dataset.aggregation) === grade.aggregation.sum) {
const weightElement = gradeItemRow.querySelector(selectors.weightOverrideInput);
weightElement.value = formatFloat(Math.max(0, parseWeight(weightElement.value)));
recalculateNaturalWeights(categoryElement);
}
}
});
document.addEventListener('submit', e => {
// If the form is being submitted, then we need to ensure that the weight input fields are all set to
// a valid value.
if (e.target.matches('#gradetreeform')) {
const firstInvalidWeightInput = e.target.querySelector('input.is-invalid');
if (firstInvalidWeightInput) {
const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled');
if (firstFocusableInvalidWeightInput) {
firstFocusableInvalidWeightInput.focus();
} else {
firstInvalidWeightInput.scrollIntoView({block: 'center'});
}
e.preventDefault();
}
}
});
};
+128
View File
@@ -0,0 +1,128 @@
// 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/>.
/**
* Prints the add item gradebook form
*
* @module core_grades
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU Public License
*/
import ModalForm from 'core_form/modalform';
import {getString} from 'core/str';
import Notification from 'core/notification';
import * as FormChangeChecker from 'core_form/changechecker';
import PendingPromise from 'core/pending';
const Selectors = {
advancedFormLink: 'a.showadvancedform'
};
const getDetailsFromEvent = (event) => {
if (event.target.closest('[data-trigger="add-item-form"]')) {
const trigger = event.target.closest('[data-trigger="add-item-form"]');
return {
trigger,
formClass: 'core_grades\\form\\add_item',
titleKey: trigger.getAttribute('data-itemid') === '-1' ? 'newitem' : 'itemsedit',
args: {
itemid: trigger.getAttribute('data-itemid'),
},
};
} else if (event.target.closest('[data-trigger="add-category-form"]')) {
const trigger = event.target.closest('[data-trigger="add-category-form"]');
return {
trigger,
formClass: 'core_grades\\form\\add_category',
titleKey: trigger.getAttribute('data-category') === '-1' ? 'newcategory' : 'categoryedit',
args: {
category: trigger.getAttribute('data-category'),
},
};
} else if (event.target.closest('[data-trigger="add-outcome-form"]')) {
const trigger = event.target.closest('[data-trigger="add-outcome-form"]');
return {
trigger,
formClass: 'core_grades\\form\\add_outcome',
titleKey: trigger.getAttribute('data-itemid') === '-1' ? 'newoutcomeitem' : 'outcomeitemsedit',
args: {
itemid: trigger.getAttribute('data-itemid'),
},
};
}
return null;
};
/**
* Initialize module
*/
export const init = () => {
// Sometimes the trigger does not exist, so lets conditionally add it.
document.addEventListener('click', event => {
const triggerData = getDetailsFromEvent(event);
if (triggerData) {
event.preventDefault();
const pendingPromise = new PendingPromise(`core_grades:add_item:${triggerData.args.itemid}`);
const {trigger, formClass, titleKey, args} = triggerData;
args.courseid = trigger.getAttribute('data-courseid');
args.gpr_plugin = trigger.getAttribute('data-gprplugin');
const modalForm = new ModalForm({
modalConfig: {
title: getString(titleKey, 'core_grades'),
},
formClass: formClass,
args: args,
saveButtonText: getString('save', 'core'),
returnFocus: trigger,
});
// Show a toast notification when the form is submitted.
modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, event => {
if (event.detail.result) {
new PendingPromise('core_grades:form_submitted');
window.location.assign(event.detail.url);
} else {
Notification.addNotification({
type: 'error',
message: getString('saving_failed', 'core_grades')
});
}
});
modalForm.show();
pendingPromise.resolve();
}
const showAdvancedForm = event.target.closest(Selectors.advancedFormLink);
if (showAdvancedForm) {
// Navigate to the advanced form page and cary over any entered data.
event.preventDefault();
// Do not resolve this pendingPromise - it will be cleared when the page changes.
new PendingPromise('core_grades:show_advanced_form');
const form = event.target.closest('form');
form.action = showAdvancedForm.href;
// Disable the form change checker as we are going to carry over the data to the advanced form.
FormChangeChecker.disableAllChecks();
form.submit();
}
});
};
@@ -0,0 +1,79 @@
// 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/>.
/**
* Compare a given form's values and its previously set data attributes.
*
* @module core_grades/grades/grader/gradingpanel/comparison
* @copyright 2019 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export const fillInitialValues = (form) => {
Array.prototype.forEach.call(form.elements, (input) => {
if (input.type === 'submit' || input.type === 'button') {
return;
} else if (input.type === 'radio' || input.type === 'checkbox') {
input.dataset.initialValue = JSON.stringify(input.checked);
} else if (typeof input.value !== 'undefined') {
input.dataset.initialValue = JSON.stringify(input.value);
} else if (input.type === 'select-one') {
Array.prototype.forEach.call(input.options, (option) => {
option.dataset.initialValue = JSON.stringify(option.selected);
});
}
});
};
/**
* Compare the form data with the initial form data from when the form was set up.
*
* If values have changed, return a truthy value.
*
* @param {HTMLElement} form
* @return {Boolean}
*/
export const compareData = (form) => {
const result = Array.prototype.some.call(form.elements, (input) => {
if (input.type === 'submit' || input.type === 'button') {
return false;
} else if (input.type === 'radio' || input.type === 'checkbox') {
if (typeof input.dataset.initialValue !== 'undefined') {
return input.dataset.initialValue !== JSON.stringify(input.checked);
}
} else if (typeof input.value !== 'undefined') {
if (typeof input.dataset.initialValue !== 'undefined') {
return input.dataset.initialValue !== JSON.stringify(input.value);
}
} else if (input.type === 'select-one') {
return Array.prototype.some.call(input.options, (option) => {
if (typeof option.dataset.initialValue !== 'undefined') {
return option.dataset.initialValue !== JSON.stringify(option.selected);
}
return false;
});
}
// No value found to check. Assume that there were changes.
return true;
});
// Fill the initial values again as the form may not be reloaded.
fillInitialValues(form);
return result;
};
@@ -0,0 +1,66 @@
// 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/>.
/**
* Error handling and normalisation of provided data.
*
* @module core_grades/grades/grader/gradingpanel/normalise
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Normalise a resultset for consumption by the grader.
*
* @param {Object} result The result returned from a grading web service
* @return {Object}
*/
export const normaliseResult = result => {
return {
result,
failed: !!result.warnings.length,
success: !result.warnings.length,
error: null,
};
};
/**
* Return the resultset used to describe an invalid result.
*
* @return {Object}
*/
export const invalidResult = () => {
return {
success: false,
failed: false,
result: {},
error: null,
};
};
/**
* Return the resultset used to describe a failed update.
*
* @param {Object} error
* @return {Object}
*/
export const failedUpdate = error => {
return {
success: false,
failed: true,
result: {},
error,
};
};
@@ -0,0 +1,67 @@
// 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/>.
/**
* Grading panel for simple direct grading.
*
* @module core_grades/grades/grader/gradingpanel/point
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {saveGrade, fetchGrade} from './repository';
import {compareData} from 'core_grades/grades/grader/gradingpanel/comparison';
// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()
import jQuery from 'jquery';
import {invalidResult} from './normalise';
/**
* Fetch the current grade for a user.
*
* @param {object} args
* @param {String} args.component
* @param {Number} args.context
* @param {String} args.itemname
* @param {Number} args.userId
* @param {Element} args.rootNode
* @returns {Object}
*/
export const fetchCurrentGrade = (...args) => fetchGrade('point')(...args);
/**
* Store a new grade for a user.
*
* @param {String} component
* @param {Number} context
* @param {String} itemname
* @param {Number} userId
* @param {Boolean} notifyUser
* @param {Element} rootNode
* @returns {Object}
*/
export const storeCurrentGrade = async(component, context, itemname, userId, notifyUser, rootNode) => {
const form = rootNode.querySelector('form');
const grade = form.querySelector('input[name="grade"]');
if (!grade.checkValidity() || !grade.value.trim()) {
return invalidResult;
}
if (compareData(form) === true) {
return await saveGrade('point')(component, context, itemname, userId, notifyUser, jQuery(form).serialize());
} else {
return '';
}
};
@@ -0,0 +1,50 @@
// 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/>.
/**
* Repository for simple direct grading panel.
*
* @module core_grades/grades/grader/gradingpanel/repository
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {call as fetchMany} from 'core/ajax';
import {normaliseResult} from './normalise';
export const fetchGrade = type => (component, contextid, itemname, gradeduserid) => {
return fetchMany([{
methodname: `core_grades_grader_gradingpanel_${type}_fetch`,
args: {
component,
contextid,
itemname,
gradeduserid,
},
}])[0];
};
export const saveGrade = type => async(component, contextid, itemname, gradeduserid, notifyUser, formdata) => {
return normaliseResult(await fetchMany([{
methodname: `core_grades_grader_gradingpanel_${type}_store`,
args: {
component,
contextid,
itemname,
gradeduserid,
notifyuser: notifyUser,
formdata,
},
}])[0]);
};
@@ -0,0 +1,45 @@
// 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/>.
/**
* Grading panel for simple direct grading.
*
* @module core_grades/grades/grader/gradingpanel/scale
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {saveGrade, fetchGrade} from './repository';
import {compareData} from 'core_grades/grades/grader/gradingpanel/comparison';
// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()
import jQuery from 'jquery';
import {invalidResult} from './normalise';
export const fetchCurrentGrade = (...args) => fetchGrade('scale')(...args);
export const storeCurrentGrade = (component, context, itemname, userId, notifyUser, rootNode) => {
const form = rootNode.querySelector('form');
const grade = form.querySelector('select[name="grade"]');
if (!grade.checkValidity() || !grade.value.trim()) {
return invalidResult;
}
if (compareData(form) === true) {
return saveGrade('scale')(component, context, itemname, userId, notifyUser, jQuery(form).serialize());
} else {
return '';
}
};
+261
View File
@@ -0,0 +1,261 @@
// 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/>.
/**
* A widget to search users or grade items within the gradebook.
*
* @module core_grades/searchwidget/basewidget
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {debounce} from 'core/utils';
import * as Templates from 'core/templates';
import * as Selectors from 'core_grades/searchwidget/selectors';
import Notification from 'core/notification';
import Log from 'core/log';
/**
* Build the base searching widget.
*
* @method init
* @param {HTMLElement} widgetContentContainer The selector for the widget container element.
* @param {Promise} bodyPromise The promise from the callee of the contents to place in the widget container.
* @param {Array} data An array of all the data generated by the callee.
* @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
* @param {string|null} unsearchableContent The content rendered in a non-searchable area.
* @param {Function|null} afterSelect Callback executed after an item is selected.
*/
export const init = async(
widgetContentContainer,
bodyPromise,
data,
searchFunc,
unsearchableContent = null,
afterSelect = null,
) => {
Log.debug('The core_grades/searchwidget/basewidget component is deprecated. Please refer to core/search_combobox() instead.');
bodyPromise.then(async(bodyContent) => {
// Render the body content.
widgetContentContainer.innerHTML = bodyContent;
// Render the unsearchable content if defined.
if (unsearchableContent) {
const unsearchableContentContainer = widgetContentContainer.querySelector(Selectors.regions.unsearchableContent);
unsearchableContentContainer.innerHTML += unsearchableContent;
}
const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
// Display a loader until the search results are rendered.
await showLoader(searchResultsContainer);
// Render the search results.
await renderSearchResults(searchResultsContainer, data);
registerListenerEvents(widgetContentContainer, data, searchFunc, afterSelect);
}).catch(Notification.exception);
};
/**
* Register the event listeners for the search widget.
*
* @method registerListenerEvents
* @param {HTMLElement} widgetContentContainer The selector for the widget container element.
* @param {Array} data An array of all the data generated by the callee.
* @param {Function} searchFunc Partially applied function we need to manage search the passed dataset.
* @param {Function|null} afterSelect Callback executed after an item is selected.
*/
export const registerListenerEvents = (widgetContentContainer, data, searchFunc, afterSelect = null) => {
const searchResultsContainer = widgetContentContainer.querySelector(Selectors.regions.searchResults);
const searchInput = widgetContentContainer.querySelector(Selectors.actions.search);
if (!searchInput) {
// Too late. The widget is already closed and its content is empty.
return;
}
// We want to focus on the first known user interable element within the dropdown.
searchInput.focus();
const clearSearchButton = widgetContentContainer.querySelector(Selectors.actions.clearSearch);
// The search input is triggered.
searchInput.addEventListener('input', debounce(async() => {
// If search query is present display the 'clear search' button, otherwise hide it.
if (searchInput.value.length > 0) {
clearSearchButton.classList.remove('d-none');
} else {
clearSearchButton.classList.add('d-none');
}
// Remove aria-activedescendant when the available options change.
searchInput.removeAttribute('aria-activedescendant');
// Display the search results.
await renderSearchResults(
searchResultsContainer,
debounceCallee(
searchInput.value,
data,
searchFunc()
)
);
}, 300));
// Clear search is triggered.
clearSearchButton.addEventListener('click', async(e) => {
e.stopPropagation();
// Clear the entered search query in the search bar.
searchInput.value = "";
searchInput.focus();
clearSearchButton.classList.add('d-none');
// Remove aria-activedescendant when the available options change.
searchInput.removeAttribute('aria-activedescendant');
// Display all results.
await renderSearchResults(
searchResultsContainer,
debounceCallee(
searchInput.value,
data,
searchFunc()
)
);
});
const inputElement = document.getElementById(searchInput.dataset.inputElement);
if (inputElement && afterSelect) {
inputElement.addEventListener('change', e => {
const selectedOption = widgetContentContainer.querySelector(
Selectors.elements.getSearchWidgetSelectOption(searchInput),
);
if (selectedOption) {
afterSelect(e.target.value);
}
});
}
// Backward compatibility. Handle the click event for the following cases:
// - When we have <li> tags without an afterSelect callback function being provided (old js).
// - When we have <a> tags without href (old template).
widgetContentContainer.addEventListener('click', e => {
const deprecatedOption = e.target.closest(
'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
);
if (deprecatedOption) {
// We are in one of these situations:
// - We have <li> tags without an afterSelect callback function being provided.
// - We have <a> tags without href.
if (inputElement && afterSelect) {
afterSelect(deprecatedOption.dataset.value);
} else {
const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
location.href = url;
}
}
});
// Backward compatibility. Handle the keydown event for the following cases:
// - When we have <li> tags without an afterSelect callback function being provided (old js).
// - When we have <a> tags without href (old template).
widgetContentContainer.addEventListener('keydown', e => {
const deprecatedOption = e.target.closest(
'a.dropdown-item[role="menuitem"]:not([href]), .dropdown-item[role="option"]:not([href])'
);
if (deprecatedOption && (e.key === ' ' || e.key === 'Enter')) {
// We are in one of these situations:
// - We have <li> tags without an afterSelect callback function being provided.
// - We have <a> tags without href.
e.preventDefault();
if (inputElement && afterSelect) {
afterSelect(deprecatedOption.dataset.value);
} else {
const url = (data.find(object => object.id == deprecatedOption.dataset.value) || {url: ''}).url;
location.href = url;
}
}
});
};
/**
* Renders the loading placeholder for the search widget.
*
* @method showLoader
* @param {HTMLElement} container The DOM node where we'll render the loading placeholder.
*/
export const showLoader = async(container) => {
container.innerHTML = '';
const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/loading', {});
Templates.replaceNodeContents(container, html, js);
};
/**
* We have a small helper that'll call the curried search function allowing callers to filter
* the data set however we want rather than defining how data must be filtered.
*
* @method debounceCallee
* @param {String} searchValue The input from the user that we'll search against.
* @param {Array} data An array of all the data generated by the callee.
* @param {Function} searchFunction Partially applied function we need to manage search the passed dataset.
* @return {Array} The filtered subset of the provided data that we'll then render into the results.
*/
const debounceCallee = (searchValue, data, searchFunction) => {
if (searchValue.length > 0) { // Search query is present.
return searchFunction(data, searchValue);
}
return data;
};
/**
* Given the output of the callers' search function, render out the results into the search results container.
*
* @method renderSearchResults
* @param {HTMLElement} searchResultsContainer The DOM node of the widget where we'll render the provided results.
* @param {Array} searchResultsData The filtered subset of the provided data that we'll then render into the results.
*/
const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
const templateData = {
'searchresults': searchResultsData,
};
// Build up the html & js ready to place into the help section.
const {html, js} = await Templates.renderForPromise('core_grades/searchwidget/searchresults', templateData);
await Templates.replaceNodeContents(searchResultsContainer, html, js);
// Backward compatibility.
if (searchResultsContainer.getAttribute('role') !== 'listbox') {
const deprecatedOptions = searchResultsContainer.querySelectorAll(
'a.dropdown-item[role="menuitem"][href=""], .dropdown-item[role="option"]:not([href])'
);
for (const option of deprecatedOptions) {
option.tabIndex = 0;
option.removeAttribute('href');
}
}
};
/**
* We want to create the basic promises and hooks that the caller will implement, so we can build the search widget
* ahead of time and allow the caller to resolve their promises once complete.
*
* @method promisesAndResolvers
* @returns {{bodyPromise: Promise, bodyPromiseResolver}}
*/
export const promisesAndResolvers = () => {
// We want to show the widget instantly but loading whilst waiting for our data.
let bodyPromiseResolver;
const bodyPromise = new Promise(resolve => {
bodyPromiseResolver = resolve;
});
return {bodyPromiseResolver, bodyPromise};
};
+178
View File
@@ -0,0 +1,178 @@
// 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/>.
/**
* A small dropdown to filter users within the gradebook.
*
* @module core_grades/searchwidget/initials
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Pending from 'core/pending';
import * as Url from 'core/url';
import CustomEvents from "core/custom_interaction_events";
import $ from 'jquery';
/**
* Whether the event listener has already been registered for this module.
*
* @type {boolean}
*/
let registered = false;
// Contain our selectors within this file until they could be of use elsewhere.
const selectors = {
pageListItem: 'page-item',
pageClickableItem: '.page-link',
activeItem: 'active',
formDropdown: '.initialsdropdownform',
parentDomNode: '.initials-selector',
firstInitial: 'firstinitial',
lastInitial: 'lastinitial',
initialBars: '.initialbar', // Both first and last name use this class.
targetButton: 'initialswidget',
formItems: {
type: 'submit',
save: 'save',
cancel: 'cancel'
}
};
/**
* Our initial hook into the module which will eventually allow us to handle the dropdown initials bar form.
*
* @param {String} callingLink The link to redirect upon form submission.
* @param {Null|Number} gpr_userid The user id to filter by.
* @param {Null|String} gpr_search The search value to filter by.
*/
export const init = (callingLink, gpr_userid = null, gpr_search = null) => {
if (registered) {
return;
}
const pendingPromise = new Pending();
registerListenerEvents(callingLink, gpr_userid, gpr_search);
// BS events always bubble so, we need to listen for the event higher up the chain.
$(selectors.parentDomNode).on('shown.bs.dropdown', () => {
document.querySelector(selectors.pageClickableItem).focus({preventScroll: true});
});
pendingPromise.resolve();
registered = true;
};
/**
* Register event listeners.
*
* @param {String} callingLink The link to redirect upon form submission.
* @param {Null|Number} gpr_userid The user id to filter by.
* @param {Null|String} gpr_search The search value to filter by.
*/
const registerListenerEvents = (callingLink, gpr_userid = null, gpr_search = null) => {
const events = [
'click',
CustomEvents.events.activate,
CustomEvents.events.keyboardActivate
];
CustomEvents.define(document, events);
// Register events.
events.forEach((event) => {
document.addEventListener(event, (e) => {
// Always fetch the latest information when we click as state is a fickle thing.
let {firstActive, lastActive, sifirst, silast} = onClickVariables();
let itemToReset = '';
// Prevent the usual form behaviour.
if (e.target.closest(selectors.formDropdown)) {
e.preventDefault();
}
// Handle the state of active initials before form submission.
if (e.target.closest(`${selectors.formDropdown} .${selectors.pageListItem}`)) {
// Ensure the li items don't cause weird clicking emptying out the form.
if (e.target.classList.contains(selectors.pageListItem)) {
return;
}
const initialsBar = e.target.closest(selectors.initialBars); // Find out which initial bar we are in.
// We want to find the current active item in the menu area the user selected.
// We also want to fetch the raw item out of the array for instant manipulation.
if (initialsBar.classList.contains(selectors.firstInitial)) {
sifirst = e.target;
itemToReset = firstActive;
} else {
silast = e.target;
itemToReset = lastActive;
}
swapActiveItems(itemToReset, e);
}
// Handle form submissions.
if (e.target.closest(`${selectors.formDropdown}`) && e.target.type === selectors.formItems.type) {
if (e.target.dataset.action === selectors.formItems.save) {
// Ensure we strip out the value (All) as it messes with the PHP side of the initials bar.
// Then we will redirect the user back onto the page with new filters applied.
const params = {
'id': e.target.closest(selectors.formDropdown).dataset.courseid,
'gpr_search': gpr_search !== null ? gpr_search : '',
'sifirst': sifirst.parentElement.classList.contains('initialbarall') ? '' : sifirst.value,
'silast': silast.parentElement.classList.contains('initialbarall') ? '' : silast.value,
};
if (gpr_userid !== null) {
params.gpr_userid = gpr_userid;
}
window.location = Url.relativeUrl(callingLink, params);
}
if (e.target.dataset.action === selectors.formItems.cancel) {
$(`.${selectors.targetButton}`).dropdown('toggle');
}
}
});
});
};
/**
* A small abstracted helper function which allows us to ensure we have up-to-date lists of nodes.
*
* @returns {{firstActive: HTMLElement, lastActive: HTMLElement, sifirst: ?String, silast: ?String}}
*/
const onClickVariables = () => {
// Ensure we have an up-to-date initials bar.
const firstItems = [...document.querySelectorAll(`.${selectors.firstInitial} li`)];
const lastItems = [...document.querySelectorAll(`.${selectors.lastInitial} li`)];
const firstActive = firstItems.filter((item) => item.classList.contains(selectors.activeItem))[0];
const lastActive = lastItems.filter((item) => item.classList.contains(selectors.activeItem))[0];
// Ensure we retain both of the selections from a previous instance.
let sifirst = firstActive.querySelector(selectors.pageClickableItem);
let silast = lastActive.querySelector(selectors.pageClickableItem);
return {firstActive, lastActive, sifirst, silast};
};
/**
* Given we are provided the old li and current click event, swap around the active properties.
*
* @param {HTMLElement} itemToReset
* @param {Event} e
*/
const swapActiveItems = (itemToReset, e) => {
itemToReset.classList.remove(selectors.activeItem);
itemToReset.querySelector(selectors.pageClickableItem).ariaCurrent = false;
// Set the select item as the current item.
const itemToSetActive = e.target.parentElement;
itemToSetActive.classList.add(selectors.activeItem);
e.target.ariaCurrent = true;
};
+61
View File
@@ -0,0 +1,61 @@
// 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/>.
/**
* A repo for the search widget.
*
* @module core_grades/searchwidget/repository
* @copyright 2022 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import ajax from 'core/ajax';
/**
* Given a course ID, we want to fetch the gradable items, so we may fetch reports based on activity items.
* Note: This will be worked upon in the single view issue.
*
* @method gradeitemFetch
* @param {int} courseid ID of the course to fetch the users of.
* @return {object} jQuery promise
*/
export const gradeitemFetch = (courseid) => {
const request = {
methodname: 'gradereport_singleview_get_grade_items_for_search_widget',
args: {
courseid: courseid,
},
};
return ajax.call([request])[0];
};
/**
* Given a course ID, we want to fetch the enrolled learners, so we may fetch their reports.
*
* @method userFetch
* @param {int} courseid ID of the course to fetch the users of.
* @param {int} groupId ID of the group to fetch the users of.
* @return {object} jQuery promise
*/
export const userFetch = (courseid, groupId) => {
const request = {
methodname: 'core_grades_get_enrolled_users_for_selector',
args: {
courseid: courseid,
groupid: groupId,
},
};
return ajax.call([request])[0];
};
+39
View File
@@ -0,0 +1,39 @@
// 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/>.
/**
* Selectors for the search widget.
*
* @module core_grades/searchwidget/selectors
* @copyright 2022 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default {
regions: {
searchResults: '[data-region="search-results-container-widget"]',
unsearchableContent: '[data-region="unsearchable-content-container-widget"]',
},
actions: {
search: '[data-action="search"]',
clearSearch: '[data-action="clearsearch"]',
},
elements: {
getSearchWidgetSelector: searchtype => `.search-widget[data-searchtype="${searchtype}"]`,
getSearchWidgetDropdownSelector: searchtype => `.search-widget[data-searchtype="${searchtype}"] .dropdown-menu`,
getSearchWidgetSelectOption:
searchInput => `#${searchInput.getAttribute('aria-controls')} [role="option"][aria-selected="true"]`,
},
};