diff --git a/grade/amd/build/edittree_weights.min.js b/grade/amd/build/edittree_weights.min.js new file mode 100644 index 00000000000..92d9ba3c1ff --- /dev/null +++ b/grade/amd/build/edittree_weights.min.js @@ -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 + * @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 \ No newline at end of file diff --git a/grade/amd/build/edittree_weights.min.js.map b/grade/amd/build/edittree_weights.min.js.map new file mode 100644 index 00000000000..68f27b7d90f --- /dev/null +++ b/grade/amd/build/edittree_weights.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"edittree_weights.min.js","sources":["../src/edittree_weights.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * This module provides functionality for managing weight calculations and adjustments for grade items.\n *\n * @module core_grades/edittree_weight\n * @copyright 2023 Shamim Rezaie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * Selectors.\n *\n * @type {Object}\n */\nconst selectors = {\n weightOverrideCheckbox: 'input[type=\"checkbox\"][name^=\"weightoverride_\"]',\n weightOverrideInput: 'input[type=\"text\"][name^=\"weight_\"]',\n childrenByCategory: category => `tr[data-parent-category=\"${category}\"]`,\n categoryByIdentifier: identifier => `tr.category[data-category=\"${identifier}\"]`,\n};\n\n/**\n * An object representing grading-related constants.\n * The same as what's defined in lib/grade/constants.php.\n *\n * @type {Object}\n * @property {Object} aggregation Aggregation settings.\n * @property {number} aggregation.sum Aggregation method: sum.\n * @property {Object} type Grade type settings.\n * @property {number} type.none Grade type: none.\n * @property {number} type.value Grade type: value.\n * @property {number} type.scale Grade type: scale.\n */\nconst grade = {\n aggregation: {\n sum: 13,\n },\n};\n\n/**\n * The character used as the decimal separator for number formatting.\n *\n * @type {string}\n */\nlet decimalSeparator;\n\n/**\n * This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.\n * Even though the old algorithm has bugs in it, we need to preserve existing grades.\n *\n * @type {boolean}\n */\nlet oldExtraCreditCalculation;\n\n/**\n * Recalculates the natural weights for grade items within a given category.\n *\n * @param {HTMLElement} categoryElement The DOM element representing the category.\n */\n// Suppress 'complexity' linting rule to keep this function as close to grade_category::auto_update_weights.\n// eslint-disable-next-line complexity\nconst recalculateNaturalWeights = (categoryElement) => {\n const childElements = document.querySelectorAll(selectors.childrenByCategory(categoryElement.dataset.category));\n\n // Calculate the sum of the grademax's of all the items within this category.\n let totalGradeMax = 0;\n\n // Out of 100, how much weight has been manually overridden by a user?\n let totalOverriddenWeight = 0;\n let totalOverriddenGradeMax = 0;\n\n // Has every assessment in this category been overridden?\n let automaticGradeItemsPresent = false;\n // Does the grade item require normalising?\n let requiresNormalising = false;\n\n // This array keeps track of the id and weight of every grade item that has been overridden.\n const overrideArray = {};\n\n for (const childElement of childElements) {\n const weightInput = childElement.querySelector(selectors.weightOverrideInput);\n const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);\n\n // There are cases where a grade item should be excluded from calculations:\n // - If the item's grade type is 'text' or 'none'.\n // - If the grade item is an outcome item and the settings are set to not aggregate outcome items.\n // - If the item's grade type is 'scale' and the settings are set to ignore scales in aggregations.\n // All these cases are already taken care of in the backend, and no 'weight' input element is rendered on the page\n // if a grade item should not have a weight.\n if (!weightInput) {\n continue;\n }\n\n const itemWeight = parseWeight(weightInput.value);\n const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);\n const itemGradeMax = parseFloat(childElement.dataset.grademax);\n\n // Record the ID and the weight for this grade item.\n overrideArray[childElement.dataset.itemid] = {\n extraCredit: itemAggregationCoefficient,\n weight: itemWeight,\n weightOverride: weightCheckbox.checked,\n };\n // If this item has had its weight overridden then set the flag to true, but\n // only if all previous items were also overridden. Note that extra credit items\n // are counted as overridden grade items.\n if (!weightCheckbox.checked && itemAggregationCoefficient === 0) {\n automaticGradeItemsPresent = true;\n }\n\n if (itemAggregationCoefficient > 0) {\n // An extra credit grade item doesn't contribute to totalOverriddenGradeMax.\n continue;\n } else if (weightCheckbox.checked && itemWeight <= 0) {\n // An overridden item that defines a weight of 0 does not contribute to totalOverriddenGradeMax.\n continue;\n }\n\n totalGradeMax += itemGradeMax;\n if (weightCheckbox.checked) {\n totalOverriddenWeight += itemWeight;\n totalOverriddenGradeMax += itemGradeMax;\n }\n }\n\n // Initialise this variable (used to keep track of the weight override total).\n let normaliseTotal = 0;\n // 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\n // other weights to zero and normalise the others.\n let overriddenTotal = 0;\n // Total up all the weights.\n for (const gradeItemDetail of Object.values(overrideArray)) {\n // If the grade item has extra credit, then don't add it to normaliseTotal.\n if (!gradeItemDetail.extraCredit) {\n normaliseTotal += gradeItemDetail.weight;\n }\n // The overridden total comprises items that are set as overridden, are not extra credit, and have a value\n // greater than zero.\n if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {\n // Add overridden weights up to see if they are greater than 1.\n overriddenTotal += gradeItemDetail.weight;\n }\n }\n if (overriddenTotal > 100) {\n // Make sure that this category of weights gets normalised.\n requiresNormalising = true;\n // The normalised weights are only the overridden weights, so we just use the total of those.\n normaliseTotal = overriddenTotal;\n }\n\n const totalNonOverriddenGradeMax = totalGradeMax - totalOverriddenGradeMax;\n\n for (const childElement of childElements) {\n const weightInput = childElement.querySelector(selectors.weightOverrideInput);\n const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);\n const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);\n const itemGradeMax = parseFloat(childElement.dataset.grademax);\n\n if (!weightInput) {\n continue;\n } else if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && weightCheckbox.checked) {\n // For an item with extra credit ignore other weights and overrides but do not change anything at all\n // if its weight was already overridden.\n continue;\n }\n\n if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {\n // For an item with extra credit ignore other weights and overrides.\n weightInput.value = totalGradeMax ? formatWeight(itemGradeMax * 100 / totalGradeMax) : 0;\n } else if (!weightCheckbox.checked) {\n // Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.\n if (totalOverriddenWeight >= 100 || totalNonOverriddenGradeMax === 0 || itemGradeMax === 0) {\n // There is no more weight to distribute.\n weightInput.value = formatWeight(0);\n } else {\n // Calculate this item's weight as a percentage of the non-overridden total grade maxes\n // then convert it to a proportion of the available non-overridden weight.\n weightInput.value = formatWeight((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));\n }\n } else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || (requiresNormalising) ||\n overrideArray[childElement.dataset.itemid].weight < 0) {\n // Just divide the overridden weight for this item against the total weight override of all\n // items in this category.\n if (normaliseTotal === 0 || overrideArray[childElement.dataset.itemid].weight < 0) {\n // If the normalised total equals zero, or the weight value is less than zero,\n // set the weight for the grade item to zero.\n weightInput.value = formatWeight(0);\n } else {\n weightInput.value = formatWeight(100 * overrideArray[childElement.dataset.itemid].weight / normaliseTotal);\n }\n }\n }\n};\n\n/**\n * Formats a weight value as a string with up to 3 decimal places.\n *\n * @param {number} weight The weight value to be formatted.\n * @returns {string} The formatted weight value with the specified decimal places.\n */\nconst formatWeight = (weight) => {\n return weight.toFixed(3).replace(/0{0,2}$/, '').replace('.', decimalSeparator);\n};\n\n/**\n * Parses a weight string and returns a normalized float value.\n *\n * @param {string} weightString The weight as a string, possibly with localized formatting.\n * @returns {number} The parsed weight as a float. If parsing fails, returns 0.\n */\nconst parseWeight = (weightString) => {\n const normalizedWeightString = weightString.replace(decimalSeparator, '.');\n return isNaN(Number(normalizedWeightString)) ? 0 : parseFloat(normalizedWeightString || 0);\n};\n\n/**\n * Initializes the weight management module with optional configuration.\n *\n * @param {string} decSep The character used as the decimal separator for number formatting.\n * @param {boolean} oldCalculation A flag indicating whether to use the old (pre MDL-49257) extra credit calculation.\n */\nexport const init = (decSep, oldCalculation) => {\n decimalSeparator = decSep;\n oldExtraCreditCalculation = oldCalculation;\n\n document.addEventListener('change', e => {\n // Update the weights of all grade items in the category when the weight of any grade item in the category is changed.\n if (e.target.matches(selectors.weightOverrideInput) || e.target.matches(selectors.weightOverrideCheckbox)) {\n // The following is named gradeItemRow, but it may also be a row that's representing a grade category.\n // It's ok because it serves as the categories associated grade item in our calculations.\n const gradeItemRow = e.target.closest('tr');\n const categoryElement = document.querySelector(selectors.categoryByIdentifier(gradeItemRow.dataset.parentCategory));\n\n // This is only required if we are using natural weights.\n if (parseInt(categoryElement.dataset.aggregation) === grade.aggregation.sum) {\n const weightElement = gradeItemRow.querySelector(selectors.weightOverrideInput);\n weightElement.value = formatWeight(parseWeight(weightElement.value));\n recalculateNaturalWeights(categoryElement);\n }\n }\n });\n};\n"],"names":["selectors","category","identifier","grade","sum","decimalSeparator","oldExtraCreditCalculation","formatWeight","weight","toFixed","replace","parseWeight","weightString","normalizedWeightString","isNaN","Number","parseFloat","decSep","oldCalculation","document","addEventListener","e","target","matches","gradeItemRow","closest","categoryElement","querySelector","dataset","parentCategory","parseInt","aggregation","weightElement","value","childElements","querySelectorAll","totalGradeMax","totalOverriddenWeight","totalOverriddenGradeMax","automaticGradeItemsPresent","requiresNormalising","overrideArray","childElement","weightInput","weightCheckbox","itemWeight","itemAggregationCoefficient","aggregationcoef","itemGradeMax","grademax","itemid","extraCredit","weightOverride","checked","normaliseTotal","overriddenTotal","gradeItemDetail","Object","values","totalNonOverriddenGradeMax","recalculateNaturalWeights"],"mappings":"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"} \ No newline at end of file diff --git a/grade/amd/src/edittree_weights.js b/grade/amd/src/edittree_weights.js new file mode 100644 index 00000000000..e1f2889edcc --- /dev/null +++ b/grade/amd/src/edittree_weights.js @@ -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 . + +/** + * This module provides functionality for managing weight calculations and adjustments for grade items. + * + * @module core_grades/edittree_weight + * @copyright 2023 Shamim Rezaie + * @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); + } + } + }); +}; diff --git a/grade/edit/tree/index.php b/grade/edit/tree/index.php index 447c3b492fb..447e12634f7 100644 --- a/grade/edit/tree/index.php +++ b/grade/edit/tree/index.php @@ -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); diff --git a/grade/edit/tree/lib.php b/grade/edit/tree/lib.php index e9db04b3920..70a92ba1a42 100644 --- a/grade/edit/tree/lib.php +++ b/grade/edit/tree/lib.php @@ -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; diff --git a/lib/grade/grade_category.php b/lib/grade/grade_category.php index fe2ee8d9d3f..88b4028f2a7 100644 --- a/lib/grade/grade_category.php +++ b/lib/grade/grade_category.php @@ -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.