From aa30501b80bd0bde952116a4b1d9f28850abca93 Mon Sep 17 00:00:00 2001 From: Shamim Rezaie Date: Thu, 21 Sep 2023 03:39:37 +1000 Subject: [PATCH] MDL-78217 grade: Recalculate Parent Weights on Zero Child Weights The maximum grade for that category is considered as zero when all child weights within a category are zero. --- grade/amd/build/edittree_weights.min.js | 2 +- grade/amd/build/edittree_weights.min.js.map | 2 +- grade/amd/src/edittree_weights.js | 21 ++++++++++++++++++- .../behat/grade_natural_normalisation.feature | 3 +++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/grade/amd/build/edittree_weights.min.js b/grade/amd/build/edittree_weights.min.js index 2a5d7396628..b06ea77ec61 100644 --- a/grade/amd/build/edittree_weights.min.js +++ b/grade/amd/build/edittree_weights.min.js @@ -6,6 +6,6 @@ define("core_grades/edittree_weights",["exports","core/str","core/prefetch"],(fu * @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,(0,_prefetch.prefetchStrings)("core_grades",["erroroverweight","errorunderweight"]),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&&gradeItemDetail.weight>0&&(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);if(!weightInput)continue;if(!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&weightCheckbox.checked)continue;weightInput.classList.remove("is-invalid");const errorArea=weightInput.closest("td").querySelector(".invalid-feedback");if(errorArea.textContent="",!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&!weightCheckbox.checked)weightInput.value=totalGradeMax?formatWeight(100*itemGradeMax/totalGradeMax):0;else if(weightCheckbox.checked){if(!automaticGradeItemsPresent&&100!==normaliseTotal||requiresNormalising||overrideArray[childElement.dataset.itemid].weight<0)if(overrideArray[childElement.dataset.itemid].weight<0)weightInput.value=formatWeight(0);else{const error=normaliseTotal>100?"erroroverweight":"errorunderweight";(0,_str.getString)(error,"core_grades").then((errorString=>{errorArea.textContent=errorString})),weightInput.classList.add("is-invalid")}}else weightInput.value=formatWeight(totalOverriddenWeight>=100||0===totalNonOverriddenGradeMax||0===itemGradeMax?0:itemGradeMax/totalNonOverriddenGradeMax*(100-totalOverriddenWeight))}})(categoryElement)}}})),document.addEventListener("submit",(e=>{if(e.target.matches("#gradetreeform")){const firstInvalidWeightInput=e.target.querySelector("input.is-invalid");if(firstInvalidWeightInput){const firstFocusableInvalidWeightInput=e.target.querySelector("input.is-invalid:enabled");firstFocusableInvalidWeightInput?firstFocusableInvalidWeightInput.focus():firstInvalidWeightInput.scrollIntoView({block:"center"}),e.preventDefault()}}}))}})); +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 recalculateNaturalWeights=categoryElement=>{const childElements=document.querySelectorAll(selectors_childrenByCategory(categoryElement.dataset.category));let totalGradeMax=0,totalOverriddenWeight=0,totalOverriddenGradeMax=0,automaticGradeItemsPresent=!1,requiresNormalising=!1,erroneous=!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&&gradeItemDetail.weight>0&&(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);if(!weightInput)continue;if(!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&weightCheckbox.checked)continue;weightInput.classList.remove("is-invalid");const errorArea=weightInput.closest("td").querySelector(".invalid-feedback");if(errorArea.textContent="",!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&!weightCheckbox.checked)weightInput.value=totalGradeMax?formatWeight(100*itemGradeMax/totalGradeMax):0;else if(weightCheckbox.checked){if((!automaticGradeItemsPresent&&100!==normaliseTotal||requiresNormalising||overrideArray[childElement.dataset.itemid].weight<0)&&(overrideArray[childElement.dataset.itemid].weight<0&&(weightInput.value=formatWeight(0)),0!==normaliseTotal)){erroneous=!0;const error=normaliseTotal>100?"erroroverweight":"errorunderweight";(0,_str.getString)(error,"core_grades").then((errorString=>{errorArea.textContent=errorString})),weightInput.classList.add("is-invalid")}}else weightInput.value=formatWeight(totalOverriddenWeight>=100||0===totalNonOverriddenGradeMax||0===itemGradeMax?0:itemGradeMax/totalNonOverriddenGradeMax*(100-totalOverriddenWeight))}if(!erroneous){if(parseFloat(categoryElement.dataset.grademax)!==totalGradeMax){categoryElement.dataset.grademax=totalGradeMax;const parentCategory=document.querySelector(selectors_categoryByIdentifier(categoryElement.dataset.parentCategory));parentCategory&&parseInt(parentCategory.dataset.aggregation)===grade_aggregation.sum&&recalculateNaturalWeights(parentCategory)}}},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,(0,_prefetch.prefetchStrings)("core_grades",["erroroverweight","errorunderweight"]),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)),recalculateNaturalWeights(categoryElement)}}})),document.addEventListener("submit",(e=>{if(e.target.matches("#gradetreeform")){const firstInvalidWeightInput=e.target.querySelector("input.is-invalid");if(firstInvalidWeightInput){const firstFocusableInvalidWeightInput=e.target.querySelector("input.is-invalid:enabled");firstFocusableInvalidWeightInput?firstFocusableInvalidWeightInput.focus():firstInvalidWeightInput.scrollIntoView({block:"center"}),e.preventDefault()}}}))}})); //# 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 index 949a3328ff5..f0a361aad9c 100644 --- a/grade/amd/build/edittree_weights.min.js.map +++ b/grade/amd/build/edittree_weights.min.js.map @@ -1 +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\nimport {getString} from 'core/str';\nimport {prefetchStrings} from 'core/prefetch';\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 // Exclude grade items with extra credit or negative weights (which will be set to zero later).\n if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {\n normaliseTotal += gradeItemDetail.weight;\n }\n // The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight.\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 // Remove any error messages and classes.\n weightInput.classList.remove('is-invalid');\n const errorArea = weightInput.closest('td').querySelector('.invalid-feedback');\n errorArea.textContent = '';\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 if (overrideArray[childElement.dataset.itemid].weight < 0) {\n weightInput.value = formatWeight(0);\n } else {\n const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight';\n // eslint-disable-next-line promise/always-return,promise/catch-or-return\n getString(error, 'core_grades').then((errorString) => {\n errorArea.textContent = errorString;\n });\n weightInput.classList.add('is-invalid');\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 prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']);\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 document.addEventListener('submit', e => {\n // If the form is being submitted, then we need to ensure that the weight input fields are all set to\n // a valid value.\n if (e.target.matches('#gradetreeform')) {\n const firstInvalidWeightInput = e.target.querySelector('input.is-invalid');\n if (firstInvalidWeightInput) {\n const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled');\n if (firstFocusableInvalidWeightInput) {\n firstFocusableInvalidWeightInput.focus();\n } else {\n firstInvalidWeightInput.scrollIntoView({block: 'center'});\n }\n e.preventDefault();\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","classList","remove","errorArea","textContent","error","then","errorString","add","recalculateNaturalWeights","firstInvalidWeightInput","firstFocusableInvalidWeightInput","focus","scrollIntoView","block","preventDefault"],"mappings":"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"} \ No newline at end of file +{"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\nimport {getString} from 'core/str';\nimport {prefetchStrings} from 'core/prefetch';\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 // Is there an error in the weight calculations?\n let erroneous = 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 // Exclude grade items with extra credit or negative weights (which will be set to zero later).\n if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {\n normaliseTotal += gradeItemDetail.weight;\n }\n // The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight.\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 // Remove any error messages and classes.\n weightInput.classList.remove('is-invalid');\n const errorArea = weightInput.closest('td').querySelector('.invalid-feedback');\n errorArea.textContent = '';\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 if (overrideArray[childElement.dataset.itemid].weight < 0) {\n weightInput.value = formatWeight(0);\n }\n\n // Zero is a special case. If the total is zero then we need to set the weight of the parent category to zero.\n if (normaliseTotal !== 0) {\n erroneous = true;\n const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight';\n // eslint-disable-next-line promise/always-return,promise/catch-or-return\n getString(error, 'core_grades').then((errorString) => {\n errorArea.textContent = errorString;\n });\n weightInput.classList.add('is-invalid');\n }\n }\n }\n\n if (!erroneous) {\n const categoryGradeMax = parseFloat(categoryElement.dataset.grademax);\n if (categoryGradeMax !== totalGradeMax) {\n // The category grade max is not the same as the total grade max, so we need to update the category grade max.\n categoryElement.dataset.grademax = totalGradeMax;\n const parentCategory = document.querySelector(selectors.categoryByIdentifier(categoryElement.dataset.parentCategory));\n if (parentCategory && (parseInt(parentCategory.dataset.aggregation) === grade.aggregation.sum)) {\n recalculateNaturalWeights(parentCategory);\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 prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']);\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 document.addEventListener('submit', e => {\n // If the form is being submitted, then we need to ensure that the weight input fields are all set to\n // a valid value.\n if (e.target.matches('#gradetreeform')) {\n const firstInvalidWeightInput = e.target.querySelector('input.is-invalid');\n if (firstInvalidWeightInput) {\n const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled');\n if (firstFocusableInvalidWeightInput) {\n firstFocusableInvalidWeightInput.focus();\n } else {\n firstInvalidWeightInput.scrollIntoView({block: 'center'});\n }\n e.preventDefault();\n }\n }\n });\n};\n"],"names":["selectors","category","identifier","grade","sum","decimalSeparator","oldExtraCreditCalculation","recalculateNaturalWeights","categoryElement","childElements","document","querySelectorAll","dataset","totalGradeMax","totalOverriddenWeight","totalOverriddenGradeMax","automaticGradeItemsPresent","requiresNormalising","erroneous","overrideArray","childElement","weightInput","querySelector","weightCheckbox","itemWeight","parseWeight","value","itemAggregationCoefficient","parseInt","aggregationcoef","itemGradeMax","parseFloat","grademax","itemid","extraCredit","weight","weightOverride","checked","normaliseTotal","overriddenTotal","gradeItemDetail","Object","values","totalNonOverriddenGradeMax","classList","remove","errorArea","closest","textContent","formatWeight","error","then","errorString","add","parentCategory","aggregation","toFixed","replace","weightString","normalizedWeightString","isNaN","Number","decSep","oldCalculation","addEventListener","e","target","matches","gradeItemRow","weightElement","firstInvalidWeightInput","firstFocusableInvalidWeightInput","focus","scrollIntoView","block","preventDefault"],"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 index f12c718e029..1dddb5a67c8 100644 --- a/grade/amd/src/edittree_weights.js +++ b/grade/amd/src/edittree_weights.js @@ -91,6 +91,9 @@ const recalculateNaturalWeights = (categoryElement) => { // 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 = {}; @@ -202,7 +205,11 @@ const recalculateNaturalWeights = (categoryElement) => { overrideArray[childElement.dataset.itemid].weight < 0) { if (overrideArray[childElement.dataset.itemid].weight < 0) { weightInput.value = formatWeight(0); - } else { + } + + // 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) => { @@ -212,6 +219,18 @@ const recalculateNaturalWeights = (categoryElement) => { } } } + + 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 parentCategory = document.querySelector(selectors.categoryByIdentifier(categoryElement.dataset.parentCategory)); + if (parentCategory && (parseInt(parentCategory.dataset.aggregation) === grade.aggregation.sum)) { + recalculateNaturalWeights(parentCategory); + } + } + } }; /** diff --git a/grade/tests/behat/grade_natural_normalisation.feature b/grade/tests/behat/grade_natural_normalisation.feature index 7ed0f9e0b64..2cc587ba5d7 100644 --- a/grade/tests/behat/grade_natural_normalisation.feature +++ b/grade/tests/behat/grade_natural_normalisation.feature @@ -234,6 +234,9 @@ Feature: We can use natural aggregation and weights will be normalised to a tota Scenario: Overriding a grade item with a negative value results in the value being changed to zero. When I set the field "Override weight of Test assignment five" to "1" And I set the field "Weight of Test assignment five" to "-15" + Then the field "Weight of Test assignment five" matches value "0.0" + And the field "Weight of Test assignment six" matches value "40.0" + And the field "Weight of Test assignment seven" matches value "60.0" And I press "Save changes" Then the field "Weight of Test assignment five" matches value "0.0" And the field "Weight of Test assignment six" matches value "40.0"