grade/amd/src/edittree_weights.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * This module provides functionality for managing weight calculations and adjustments for grade items.
 *
 * @module     core_grades/edittree_weight
 * @copyright  2023 Shamim Rezaie <shamim@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import {getString} from 'core/str';
import {prefetchStrings} from 'core/prefetch';

/**
 * Selectors.
 *
 * @type {Object}
 */
const selectors = {
    weightOverrideCheckbox: 'input[type="checkbox"][name^="weightoverride_"]',
    weightOverrideInput: 'input[type="text"][name^="weight_"]',
    aggregationForCategory: category => `[data-aggregationforcategory='${category}']`,
    childrenByCategory: category => `tr[data-parent-category="${category}"]`,
    categoryByIdentifier: identifier => `tr.category[data-category="${identifier}"]`,
};

/**
 * An object representing grading-related constants.
 * The same as what's defined in lib/grade/constants.php.
 *
 * @type {Object}
 * @property {Object} aggregation Aggregation settings.
 * @property {number} aggregation.sum Aggregation method: sum.
 * @property {Object} type Grade type settings.
 * @property {number} type.none Grade type: none.
 * @property {number} type.value Grade type: value.
 * @property {number} type.scale Grade type: scale.
 */
const grade = {
    aggregation: {
        sum: 13,
    },
};

/**
 * The character used as the decimal separator for number formatting.
 *
 * @type {string}
 */
let decimalSeparator;

/**
 * This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
 * Even though the old algorithm has bugs in it, we need to preserve existing grades.
 *
 * @type {boolean}
 */
let oldExtraCreditCalculation;

/**
 * Recalculates the natural weights for grade items within a given category.
 *
 * @param {HTMLElement} categoryElement The DOM element representing the category.
 */
// Suppress 'complexity' linting rule to keep this function as close to grade_category::auto_update_weights.
// eslint-disable-next-line complexity
const recalculateNaturalWeights = (categoryElement) => {
    const childElements = document.querySelectorAll(selectors.childrenByCategory(categoryElement.dataset.category));

    // Calculate the sum of the grademax's of all the items within this category.
    let totalGradeMax = 0;

    // Out of 100, how much weight has been manually overridden by a user?
    let totalOverriddenWeight = 0;
    let totalOverriddenGradeMax = 0;

    // Has every assessment in this category been overridden?
    let automaticGradeItemsPresent = false;
    // Does the grade item require normalising?
    let requiresNormalising = false;

    // Is there an error in the weight calculations?
    let erroneous = false;

    // This array keeps track of the id and weight of every grade item that has been overridden.
    const overrideArray = {};

    for (const childElement of childElements) {
        const weightInput = childElement.querySelector(selectors.weightOverrideInput);
        const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);

        // There are cases where a grade item should be excluded from calculations:
        // - If the item's grade type is 'text' or 'none'.
        // - If the grade item is an outcome item and the settings are set to not aggregate outcome items.
        // - If the item's grade type is 'scale' and the settings are set to ignore scales in aggregations.
        // All these cases are already taken care of in the backend, and no 'weight' input element is rendered on the page
        // if a grade item should not have a weight.
        if (!weightInput) {
            continue;
        }

        const itemWeight = parseWeight(weightInput.value);
        const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);
        const itemGradeMax = parseFloat(childElement.dataset.grademax);

        // Record the ID and the weight for this grade item.
        overrideArray[childElement.dataset.itemid] = {
            extraCredit: itemAggregationCoefficient,
            weight: itemWeight,
            weightOverride: weightCheckbox.checked,
        };
        // If this item has had its weight overridden then set the flag to true, but
        // only if all previous items were also overridden. Note that extra credit items
        // are counted as overridden grade items.
        if (!weightCheckbox.checked && itemAggregationCoefficient === 0) {
            automaticGradeItemsPresent = true;
        }

        if (itemAggregationCoefficient > 0) {
            // An extra credit grade item doesn't contribute to totalOverriddenGradeMax.
            continue;
        } else if (weightCheckbox.checked && itemWeight <= 0) {
            // An overridden item that defines a weight of 0 does not contribute to totalOverriddenGradeMax.
            continue;
        }

        totalGradeMax += itemGradeMax;
        if (weightCheckbox.checked) {
            totalOverriddenWeight += itemWeight;
            totalOverriddenGradeMax += itemGradeMax;
        }
    }

    // Initialise this variable (used to keep track of the weight override total).
    let normaliseTotal = 0;
    // Keep a record of how much the override total is to see if it is above 100. If it is then we need to set the
    // other weights to zero and normalise the others.
    let overriddenTotal = 0;
    // Total up all the weights.
    for (const gradeItemDetail of Object.values(overrideArray)) {
        // Exclude grade items with extra credit or negative weights (which will be set to zero later).
        if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {
            normaliseTotal += gradeItemDetail.weight;
        }
        // The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight.
        if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {
            // Add overridden weights up to see if they are greater than 1.
            overriddenTotal += gradeItemDetail.weight;
        }
    }
    if (overriddenTotal > 100) {
        // Make sure that this category of weights gets normalised.
        requiresNormalising = true;
        // The normalised weights are only the overridden weights, so we just use the total of those.
        normaliseTotal = overriddenTotal;
    }

    const totalNonOverriddenGradeMax = totalGradeMax - totalOverriddenGradeMax;

    for (const childElement of childElements) {
        const weightInput = childElement.querySelector(selectors.weightOverrideInput);
        const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);
        const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);
        const itemGradeMax = parseFloat(childElement.dataset.grademax);

        if (!weightInput) {
            continue;
        } else if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && weightCheckbox.checked) {
            // For an item with extra credit ignore other weights and overrides but do not change anything at all
            // if its weight was already overridden.
            continue;
        }

        // Remove any error messages and classes.
        weightInput.classList.remove('is-invalid');
        const errorArea = weightInput.closest('td').querySelector('.invalid-feedback');
        errorArea.textContent = '';

        if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {
            // For an item with extra credit ignore other weights and overrides.
            weightInput.value = totalGradeMax ? formatFloat(itemGradeMax * 100 / totalGradeMax) : 0;
        } else if (!weightCheckbox.checked) {
            // Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.
            if (totalOverriddenWeight >= 100 || totalNonOverriddenGradeMax === 0 || itemGradeMax === 0) {
                // There is no more weight to distribute.
                weightInput.value = formatFloat(0);
            } else {
                // Calculate this item's weight as a percentage of the non-overridden total grade maxes
                // then convert it to a proportion of the available non-overridden weight.
                weightInput.value = formatFloat((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));
            }
        } else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || requiresNormalising ||
                overrideArray[childElement.dataset.itemid].weight < 0) {
            if (overrideArray[childElement.dataset.itemid].weight < 0) {
                weightInput.value = formatFloat(0);
            }

            // Zero is a special case. If the total is zero then we need to set the weight of the parent category to zero.
            if (normaliseTotal !== 0) {
                erroneous = true;
                const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight';
                // eslint-disable-next-line promise/always-return,promise/catch-or-return
                getString(error, 'core_grades').then((errorString) => {
                    errorArea.textContent = errorString;
                });
                weightInput.classList.add('is-invalid');
            }
        }
    }

    if (!erroneous) {
        const categoryGradeMax = parseFloat(categoryElement.dataset.grademax);
        if (categoryGradeMax !== totalGradeMax) {
            // The category grade max is not the same as the total grade max, so we need to update the category grade max.
            categoryElement.dataset.grademax = totalGradeMax;
            const relatedCategoryAggregationRow = document.querySelector(
                selectors.aggregationForCategory(categoryElement.dataset.category)
            );
            relatedCategoryAggregationRow.querySelector('.column-range').innerHTML = formatFloat(totalGradeMax, 2, 2);

            const parentCategory = document.querySelector(selectors.categoryByIdentifier(categoryElement.dataset.parentCategory));
            if (parentCategory && (parseInt(parentCategory.dataset.aggregation) === grade.aggregation.sum)) {
                recalculateNaturalWeights(parentCategory);
            }
        }
    }
};

/**
 * Formats a floating-point number as a string with the specified number of decimal places.
 * Unnecessary trailing zeros are removed up to the specified minimum number of decimal places.
 *
 * @param {number} number The float value to be formatted.
 * @param {number} [decimalPoints=3] The number of decimal places to use.
 * @param {number} [minDecimals=1] The minimum number of decimal places to use.
 * @returns {string} The formatted weight value with the specified decimal places.
 */
const formatFloat = (number, decimalPoints = 3, minDecimals = 1) => {
    return number.toFixed(decimalPoints)
        .replace(new RegExp(`0{0,${decimalPoints - minDecimals}}$`), '')
        .replace('.', decimalSeparator);
};

/**
 * Parses a weight string and returns a normalized float value.
 *
 * @param {string} weightString The weight as a string, possibly with localized formatting.
 * @returns {number} The parsed weight as a float. If parsing fails, returns 0.
 */
const parseWeight = (weightString) => {
    const normalizedWeightString = weightString.replace(decimalSeparator, '.');
    return isNaN(Number(normalizedWeightString)) ? 0 : parseFloat(normalizedWeightString || 0);
};

/**
 * Initializes the weight management module with optional configuration.
 *
 * @param {string} decSep The character used as the decimal separator for number formatting.
 * @param {boolean} oldCalculation A flag indicating whether to use the old (pre MDL-49257) extra credit calculation.
 */
export const init = (decSep, oldCalculation) => {
    decimalSeparator = decSep;
    oldExtraCreditCalculation = oldCalculation;
    prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']);

    document.addEventListener('change', e => {
        // Update the weights of all grade items in the category when the weight of any grade item in the category is changed.
        if (e.target.matches(selectors.weightOverrideInput) || e.target.matches(selectors.weightOverrideCheckbox)) {
            // The following is named gradeItemRow, but it may also be a row that's representing a grade category.
            // It's ok because it serves as the categories associated grade item in our calculations.
            const gradeItemRow = e.target.closest('tr');
            const categoryElement = document.querySelector(selectors.categoryByIdentifier(gradeItemRow.dataset.parentCategory));

            // This is only required if we are using natural weights.
            if (parseInt(categoryElement.dataset.aggregation) === grade.aggregation.sum) {
                const weightElement = gradeItemRow.querySelector(selectors.weightOverrideInput);
                weightElement.value = formatFloat(Math.max(0, parseWeight(weightElement.value)));
                recalculateNaturalWeights(categoryElement);
            }
        }
    });

    document.addEventListener('submit', e => {
        // If the form is being submitted, then we need to ensure that the weight input fields are all set to
        // a valid value.
        if (e.target.matches('#gradetreeform')) {
            const firstInvalidWeightInput = e.target.querySelector('input.is-invalid');
            if (firstInvalidWeightInput) {
                const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled');
                if (firstFocusableInvalidWeightInput) {
                    firstFocusableInvalidWeightInput.focus();
                } else {
                    firstInvalidWeightInput.scrollIntoView({block: 'center'});
                }
                e.preventDefault();
            }
        }
    });
};