MDL-78217 grade: Improve front-end grade item weight calculations

This change enhances the user experience in the grade report grader by
moving grade item weight calculations to the front-end. Previously,
these calculations were done on form submission, resulting in slower
performance and less interactivity.

With this improvement, grade item weights are now calculated dynamically
in the browser as users make changes, providing instant feedback and a
more responsive interface. This change improves the usability and
efficiency of the grade report grader.

This is only applicable to natural aggregation type.
This commit is contained in:
Shamim Rezaie
2023-09-19 21:00:15 +10:00
parent ecddfa6ccd
commit 22a97437d1
6 changed files with 285 additions and 3 deletions
+11
View File
@@ -0,0 +1,11 @@
define("core_grades/edittree_weights",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0;
/**
* 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
*/
const selectors_weightOverrideCheckbox='input[type="checkbox"][name^="weightoverride_"]',selectors_weightOverrideInput='input[type="text"][name^="weight_"]',selectors_childrenByCategory=category=>'tr[data-parent-category="'.concat(category,'"]'),selectors_categoryByIdentifier=identifier=>'tr.category[data-category="'.concat(identifier,'"]'),grade_aggregation={sum:13};let decimalSeparator,oldExtraCreditCalculation;const formatWeight=weight=>weight.toFixed(3).replace(/0{0,2}$/,"").replace(".",decimalSeparator),parseWeight=weightString=>{const normalizedWeightString=weightString.replace(decimalSeparator,".");return isNaN(Number(normalizedWeightString))?0:parseFloat(normalizedWeightString||0)};_exports.init=(decSep,oldCalculation)=>{decimalSeparator=decSep,oldExtraCreditCalculation=oldCalculation,document.addEventListener("change",(e=>{if(e.target.matches(selectors_weightOverrideInput)||e.target.matches(selectors_weightOverrideCheckbox)){const gradeItemRow=e.target.closest("tr"),categoryElement=document.querySelector(selectors_categoryByIdentifier(gradeItemRow.dataset.parentCategory));if(parseInt(categoryElement.dataset.aggregation)===grade_aggregation.sum){const weightElement=gradeItemRow.querySelector(selectors_weightOverrideInput);weightElement.value=formatWeight(parseWeight(weightElement.value)),(categoryElement=>{const childElements=document.querySelectorAll(selectors_childrenByCategory(categoryElement.dataset.category));let totalGradeMax=0,totalOverriddenWeight=0,totalOverriddenGradeMax=0,automaticGradeItemsPresent=!1,requiresNormalising=!1;const overrideArray={};for(const childElement of childElements){const weightInput=childElement.querySelector(selectors_weightOverrideInput),weightCheckbox=childElement.querySelector(selectors_weightOverrideCheckbox);if(!weightInput)continue;const itemWeight=parseWeight(weightInput.value),itemAggregationCoefficient=parseInt(childElement.dataset.aggregationcoef),itemGradeMax=parseFloat(childElement.dataset.grademax);overrideArray[childElement.dataset.itemid]={extraCredit:itemAggregationCoefficient,weight:itemWeight,weightOverride:weightCheckbox.checked},weightCheckbox.checked||0!==itemAggregationCoefficient||(automaticGradeItemsPresent=!0),itemAggregationCoefficient>0||weightCheckbox.checked&&itemWeight<=0||(totalGradeMax+=itemGradeMax,weightCheckbox.checked&&(totalOverriddenWeight+=itemWeight,totalOverriddenGradeMax+=itemGradeMax))}let normaliseTotal=0,overriddenTotal=0;for(const gradeItemDetail of Object.values(overrideArray))gradeItemDetail.extraCredit||(normaliseTotal+=gradeItemDetail.weight),gradeItemDetail.weightOverride&&!gradeItemDetail.extraCredit&&gradeItemDetail.weight>0&&(overriddenTotal+=gradeItemDetail.weight);overriddenTotal>100&&(requiresNormalising=!0,normaliseTotal=overriddenTotal);const totalNonOverriddenGradeMax=totalGradeMax-totalOverriddenGradeMax;for(const childElement of childElements){const weightInput=childElement.querySelector(selectors_weightOverrideInput),weightCheckbox=childElement.querySelector(selectors_weightOverrideCheckbox),itemAggregationCoefficient=parseInt(childElement.dataset.aggregationcoef),itemGradeMax=parseFloat(childElement.dataset.grademax);weightInput&&(!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&weightCheckbox.checked||(!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&!weightCheckbox.checked?weightInput.value=totalGradeMax?formatWeight(100*itemGradeMax/totalGradeMax):0:weightCheckbox.checked?(!automaticGradeItemsPresent&&100!==normaliseTotal||requiresNormalising||overrideArray[childElement.dataset.itemid].weight<0)&&(0===normaliseTotal||overrideArray[childElement.dataset.itemid].weight<0?weightInput.value=formatWeight(0):weightInput.value=formatWeight(100*overrideArray[childElement.dataset.itemid].weight/normaliseTotal)):weightInput.value=formatWeight(totalOverriddenWeight>=100||0===totalNonOverriddenGradeMax||0===itemGradeMax?0:itemGradeMax/totalNonOverriddenGradeMax*(100-totalOverriddenWeight))))}})(categoryElement)}}}))}}));
//# sourceMappingURL=edittree_weights.min.js.map
File diff suppressed because one or more lines are too long
+256
View File
@@ -0,0 +1,256 @@
// 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 <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Selectors.
*
* @type {Object}
*/
const selectors = {
weightOverrideCheckbox: 'input[type="checkbox"][name^="weightoverride_"]',
weightOverrideInput: 'input[type="text"][name^="weight_"]',
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;
// 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)) {
// If the grade item has extra credit, then don't add it to normaliseTotal.
if (!gradeItemDetail.extraCredit) {
normaliseTotal += gradeItemDetail.weight;
}
// The overridden total comprises items that are set as overridden, are not extra credit, and have a value
// greater than zero.
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;
}
if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {
// For an item with extra credit ignore other weights and overrides.
weightInput.value = totalGradeMax ? formatWeight(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 = formatWeight(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 = formatWeight((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));
}
} else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || (requiresNormalising) ||
overrideArray[childElement.dataset.itemid].weight < 0) {
// Just divide the overridden weight for this item against the total weight override of all
// items in this category.
if (normaliseTotal === 0 || overrideArray[childElement.dataset.itemid].weight < 0) {
// If the normalised total equals zero, or the weight value is less than zero,
// set the weight for the grade item to zero.
weightInput.value = formatWeight(0);
} else {
weightInput.value = formatWeight(100 * overrideArray[childElement.dataset.itemid].weight / normaliseTotal);
}
}
}
};
/**
* Formats a weight value as a string with up to 3 decimal places.
*
* @param {number} weight The weight value to be formatted.
* @returns {string} The formatted weight value with the specified decimal places.
*/
const formatWeight = (weight) => {
return weight.toFixed(3).replace(/0{0,2}$/, '').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;
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 = formatWeight(parseWeight(weightElement.value));
recalculateNaturalWeights(categoryElement);
}
}
});
};
+6
View File
@@ -50,6 +50,12 @@ require_capability('moodle/grade:manage', $context);
$PAGE->requires->js_call_amd('core_grades/edittree_index', 'init', [$courseid, $USER->id]);
$PAGE->requires->js_call_amd('core_grades/gradebooksetup_forms', 'init');
$decsep = get_string('decsep', 'langconfig');
// This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
$gradebookcalculationfreeze = (int) get_config('core', 'gradebook_calculations_freeze_' . $courseid);
$oldextracreditcalculation = $gradebookcalculationfreeze && ($gradebookcalculationfreeze <= 20150619);
$PAGE->requires->js_call_amd('core_grades/edittree_weights', 'init', [$decsep, $oldextracreditcalculation]);
/// return tracking object
$gpr = new grade_plugin_return(array('type'=>'edit', 'plugin'=>'tree', 'courseid'=>$courseid));
$returnurl = $gpr->get_return_url(null);
+10
View File
@@ -271,6 +271,12 @@ class grade_edit_tree {
$categoryrow->id = 'grade-item-' . $eid;
$categoryrow->attributes['class'] = $courseclass . ' category ';
$categoryrow->attributes['data-category'] = $eid;
if (!empty($parent_eid)) {
$categoryrow->attributes['data-parent-category'] = $parent_eid;
}
$categoryrow->attributes['data-aggregation'] = $category->aggregation;
$categoryrow->attributes['data-grademax'] = $category->grade_item->grademax;
$categoryrow->attributes['data-aggregationcoef'] = $category->grade_item->aggregationcoef;
$categoryrow->attributes['data-itemid'] = $category->grade_item->id;
$categoryrow->attributes['data-hidden'] = 'false';
foreach ($rowclasses as $class) {
@@ -341,6 +347,10 @@ class grade_edit_tree {
// collapsed and the aggregated max grade is not visible.
if (!empty($categoryitemclass)) {
$gradeitemrow->attributes['data-aggregationforcategory'] = $parent_eid;
} else {
$gradeitemrow->attributes['data-parent-category'] = $parent_eid;
$gradeitemrow->attributes['data-grademax'] = $object->grademax;
$gradeitemrow->attributes['data-aggregationcoef'] = $object->aggregationcoef;
}
foreach ($rowclasses as $class) {
$gradeitemrow->attributes['class'] .= ' ' . $class;
+1 -3
View File
@@ -1650,7 +1650,7 @@ class grade_category extends grade_object {
// An extra credit grade item doesn't contribute to $totaloverriddengrademax.
continue;
} else if ($gradeitem->weightoverride > 0 && $gradeitem->aggregationcoef2 <= 0) {
// An overriden item that defines a weight of 0 does not contribute to $totaloverriddengrademax.
// An overridden item that defines a weight of 0 does not contribute to $totaloverriddengrademax.
continue;
}
@@ -1666,8 +1666,6 @@ class grade_category extends grade_object {
// Keep a record of how much the override total is to see if it is above 100. It it is then we need to set the
// other weights to zero and normalise the others.
$overriddentotal = 0;
// If the overridden weight total is higher than 1 then set the other untouched weights to zero.
$setotherweightstozero = false;
// Total up all of the weights.
foreach ($overridearray as $gradeitemdetail) {
// If the grade item has extra credit, then don't add it to the normalisetotal.