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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user