feat: update grade summary to show floating point grades (#1558)

This PR resolves the bug that shows assignment's weighted grades that do not sum to the correct total grade. When a learner's weighted grades round down to the nearest whole number, but the summation of the weighted grades will round to a higher percent than the pre-rounded summation. To clarify this for users, the assignment's weighted grade will now show 2 decimal points, matching the legacy display found in the grade graph. To further clarify the difference, a tooltip was added to show the learner the raw weighted grade and the rounded weighted grade.
This commit is contained in:
Kristin Aoki
2025-01-06 15:12:40 -05:00
committed by GitHub
parent 346e15abd4
commit 1ffc93dc6d
4 changed files with 91 additions and 50 deletions

View File

@@ -1,49 +1,39 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon, IconButton, OverlayTrigger, Popover,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon';
import { Blocked, InfoOutline } from '@openedx/paragon/icons';
import messages from '../messages';
import { useModel } from '../../../../generic/model-store';
const GradeSummaryHeader = ({ intl, allOfSomeAssignmentTypeIsLocked }) => {
const GradeSummaryHeader = ({ allOfSomeAssignmentTypeIsLocked }) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked,
} = useModel('progress', courseId);
const [showTooltip, setShowTooltip] = useState(false);
return (
<div className="row w-100 m-0 align-items-center">
<h3 className="h4 mb-3 mr-1">{intl.formatMessage(messages.gradeSummary)}</h3>
<OverlayTrigger
trigger="click"
trigger="hover"
placement="top"
show={showTooltip}
overlay={(
<Popover>
<Popover.Content className="small text-dark-700">
{intl.formatMessage(messages.gradeSummaryTooltipBody)}
</Popover.Content>
</Popover>
<Tooltip>
{intl.formatMessage(messages.gradeSummaryTooltipBody)}
</Tooltip>
)}
>
<IconButton
onClick={() => { setShowTooltip(!showTooltip); }}
onBlur={() => { setShowTooltip(false); }}
<Icon
alt={intl.formatMessage(messages.gradeSummaryTooltipAlt)}
src={InfoOutline}
iconAs={Icon}
className="mb-3"
size="sm"
disabled={gradesFeatureIsFullyLocked}
/>
</OverlayTrigger>
{!gradesFeatureIsFullyLocked && allOfSomeAssignmentTypeIsLocked && (
@@ -57,8 +47,7 @@ const GradeSummaryHeader = ({ intl, allOfSomeAssignmentTypeIsLocked }) => {
};
GradeSummaryHeader.propTypes = {
intl: intlShape.isRequired,
allOfSomeAssignmentTypeIsLocked: PropTypes.bool.isRequired,
};
export default injectIntl(GradeSummaryHeader);
export default GradeSummaryHeader;

View File

@@ -1,10 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import { DataTable } from '@openedx/paragon';
import { useModel } from '../../../../generic/model-store';
@@ -14,7 +11,8 @@ import GradeSummaryTableFooter from './GradeSummaryTableFooter';
import messages from '../messages';
const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -34,6 +32,14 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
return footnoteId.replace(/[^A-Za-z0-9.-_]+/g, '-');
};
const getGradePercent = (grade) => {
if (Number.isInteger(grade * 100)) {
return (grade * 100).toFixed(0);
}
return (grade * 100).toFixed(2);
};
const hasNoAccessToAssignmentsOfType = (assignmentType) => {
const subsectionAssignmentsOfType = sectionScores.map((chapter) => chapter.subsections.filter((subsection) => (
subsection.assignmentType === assignmentType && subsection.hasGradedAssignment
@@ -52,31 +58,37 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
};
const gradeSummaryData = assignmentPolicies.map((assignment) => {
const {
averageGrade,
numDroppable,
type: assignmentType,
weight,
weightedGrade,
} = assignment;
let footnoteId = '';
let footnoteMarker;
if (assignment.numDroppable > 0) {
if (numDroppable > 0) {
footnoteId = getFootnoteId(assignment);
footnotes.push({
id: footnoteId,
numDroppable: assignment.numDroppable,
assignmentType: assignment.type,
numDroppable,
assignmentType,
});
footnoteMarker = footnotes.length;
}
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type);
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType);
const isLocaleRtl = isRtl(getLocale());
return {
type: {
footnoteId, footnoteMarker, type: assignment.type, locked,
footnoteId, footnoteMarker, type: assignmentType, locked,
},
weight: { weight: `${(assignment.weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
grade: { grade: `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
weightedGrade: { weightedGrade: `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
};
});
const getAssignmentTypeCell = (value) => (
@@ -137,8 +149,7 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
};
GradeSummaryTable.propTypes = {
intl: intlShape.isRequired,
setAllOfSomeAssignmentTypeIsLocked: PropTypes.func.isRequired,
};
export default injectIntl(GradeSummaryTable);
export default GradeSummaryTable;

View File

@@ -1,15 +1,34 @@
import React from 'react';
import { useContext } from 'react';
import { useSelector } from 'react-redux';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@openedx/paragon';
import { useModel } from '../../../../generic/model-store';
DataTable,
DataTableContext,
Icon,
OverlayTrigger,
Stack,
Tooltip,
} from '@openedx/paragon';
import { InfoOutline } from '@openedx/paragon/icons';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
const GradeSummaryTableFooter = ({ intl }) => {
const GradeSummaryTableFooter = () => {
const intl = useIntl();
const { data } = useContext(DataTableContext);
const rawGrade = data.reduce(
(grade, currentValue) => {
const { weightedGrade } = currentValue.weightedGrade;
const percent = weightedGrade.replace(/%/g, '').trim();
return grade + parseFloat(percent);
},
0,
).toFixed(2);
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -29,15 +48,33 @@ const GradeSummaryTableFooter = ({ intl }) => {
return (
<DataTable.TableFooter className={`border-top border-primary ${bgColor}`}>
<div className="row w-100 m-0">
<div id="weighted-grade-summary" className="col-8 p-0 small">{intl.formatMessage(messages.weightedGradeSummary)}</div>
<div id="weighted-grade-summary" className="col-8 p-0 small">
<Stack gap={2} direction="horizontal">
{intl.formatMessage(messages.weightedGradeSummary)}
<OverlayTrigger
trigger="hover"
placement="bottom"
overlay={(
<Tooltip>
{intl.formatMessage(
messages.weightedGradeSummaryTooltip,
{ roundedGrade: totalGrade, rawGrade },
)}
</Tooltip>
)}
>
<Icon
src={InfoOutline}
size="sm"
alt={intl.formatMessage(messages.gradeSummaryTooltipAlt)}
/>
</OverlayTrigger>
</Stack>
</div>
<div data-testid="gradeSummaryFooterTotalWeightedGrade" aria-labelledby="weighted-grade-summary" className="col-4 p-0 text-right font-weight-bold small">{totalGrade}{isLocaleRtl && '\u200f'}%</div>
</div>
</DataTable.TableFooter>
);
};
GradeSummaryTableFooter.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GradeSummaryTableFooter);
export default GradeSummaryTableFooter;

View File

@@ -203,7 +203,11 @@ const messages = defineMessages({
defaultMessage: 'Your current weighted grade summary',
description: 'It the text precede the sum of weighted grades of all the assignment',
},
weightedGradeSummaryTooltip: {
id: 'progress.weightedGradeSummary',
defaultMessage: 'Your raw weighted grade summary is {rawGrade} and rounds to {roundedGrade}.',
description: 'Tooltip content that explains the rounding of the summary versus individual assignments',
},
});
export default messages;