From cd8ac6c781a2372c7a08263797c68b2cd545af80 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Mon, 29 Sep 2025 21:35:08 +0500 Subject: [PATCH 1/6] feat: add subsection visibility option to hide scores but include in final grade --- src/course-home/data/api.js | 37 +++- .../progress-tab/ProgressTab.test.jsx | 181 ++++++++++++++++++ .../grades/course-grade/CourseGradeFooter.jsx | 126 ++++++++---- .../course-grade/CurrentGradeTooltip.jsx | 19 ++ .../detailed-grades/DetailedGradesTable.jsx | 1 + .../grade-summary/GradeSummaryTable.jsx | 27 ++- .../grade-summary/GradeSummaryTableFooter.jsx | 25 +-- .../progress-tab/grades/messages.ts | 20 ++ src/course-home/progress-tab/utils.ts | 37 ++++ 9 files changed, 414 insertions(+), 59 deletions(-) diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 88d684c83e..867714f701 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -3,25 +3,40 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { logInfo } from '@edx/frontend-platform/logging'; import { appendBrowserTimezoneToUrl } from '../../utils'; -const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => { +const calculateAssignmentTypeGrades = (points, visibilities, assignmentWeight, numDroppable) => { let dropCount = numDroppable; // Drop the lowest grades while (dropCount && points.length >= dropCount) { const lowestScore = Math.min(...points); const lowestScoreIndex = points.indexOf(lowestScore); points.splice(lowestScoreIndex, 1); + visibilities.splice(lowestScoreIndex, 1); dropCount--; } let averageGrade = 0; let weightedGrade = 0; + let totalWeightedGrade = 0; + if (points.length) { - // Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately - // reflect what a learner's grade would be, however, we must have parity with the current grading behavior that - // exists in edx-platform. - averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4); - weightedGrade = averageGrade * assignmentWeight; + // Scores for visible grades (exclude never_but_include_grade) + const visibleScores = points.filter( + (_, idx) => visibilities[idx] !== 'never_but_include_grade', + ); + + // Average all scores (for totalWeightedGrade) + const overallAverage = parseFloat( + (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4), + ); + totalWeightedGrade = overallAverage * assignmentWeight; + if (visibleScores.length) { + const visibleAverage = parseFloat( + (visibleScores.reduce((a, b) => a + b, 0) / points.length).toFixed(4), + ); + averageGrade = visibleAverage; + weightedGrade = averageGrade * assignmentWeight; + } } - return { averageGrade, weightedGrade }; + return { averageGrade, weightedGrade, totalWeightedGrade }; }; function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) { @@ -33,6 +48,7 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) { grades: Array(assignment.numTotal).fill(0), numAssignmentsCreated: 0, numTotalExpectedAssignments: assignment.numTotal, + visibility: Array(assignment.numTotal), }; }); @@ -45,6 +61,7 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) { assignmentType, numPointsEarned, numPointsPossible, + showCorrectness, } = subsection; // If a subsection's assignment type does not match an assignment policy in Studio, @@ -64,17 +81,20 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) { // Remove a placeholder grade so long as the number of recorded created assignments is less than the number // of expected assignments gradeByAssignmentType[assignmentType].grades.shift(); + gradeByAssignmentType[assignmentType].visibility.shift(); } // Add the graded assignment to the list gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0); // Record the created assignment gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated; + gradeByAssignmentType[assignmentType].visibility.push(showCorrectness); }); }); return assignmentPolicies.map((assignment) => { - const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades( + const { averageGrade, weightedGrade, totalWeightedGrade } = calculateAssignmentTypeGrades( gradeByAssignmentType[assignment.type].grades, + gradeByAssignmentType[assignment.type].visibility, assignment.weight, assignment.numDroppable, ); @@ -86,6 +106,7 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) { type: assignment.type, weight: assignment.weight, weightedGrade, + totalWeightedGrade, }; }); } diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index a08bf8a40a..0e485b7ebf 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -697,6 +697,187 @@ describe('Progress Tab', () => { // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}" expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument(); }); + + it('shows lock icon when all subsections of assignment type are never_but_include_grade', async () => { + setTabData({ + grading_policy: { + assignment_policies: [ + { + num_droppable: 0, + num_total: 2, + short_label: 'HW', + type: 'Homework', + weight: 1, + }, + ], + grade_range: { + pass: 0.75, + }, + }, + section_scores: [ + { + display_name: 'Section 1', + subsections: [ + { + assignment_type: 'Homework', + display_name: 'Subsection 1', + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'never_but_include_grade', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection1', + }, + { + assignment_type: 'Homework', + display_name: 'Subsection 2', + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'never_but_include_grade', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection2', + }, + ], + }, + ], + }); + await fetchAndRender(); + // Should show lock icon for grade and weighted grade + expect(screen.getAllByTestId('lock-icon')).toHaveLength(2); + }); + + it.only('shows percent plus hidden grades when some subsections of assignment type are never_but_include_grade', async () => { + setTabData({ + grading_policy: { + assignment_policies: [ + { + num_droppable: 0, + num_total: 2, + short_label: 'HW', + type: 'Homework', + weight: 1, + }, + ], + grade_range: { + pass: 0.75, + }, + }, + section_scores: [ + { + display_name: 'Section 1', + subsections: [ + { + assignment_type: 'Homework', + display_name: 'Subsection 1', + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'never_but_include_grade', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection1', + }, + { + assignment_type: 'Homework', + display_name: 'Subsection 2', + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'always', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection2', + }, + ], + }, + ], + }); + await fetchAndRender(); + // Should show percent + hidden scores for grade and weighted grade + const hiddenScoresCells = screen.getAllByText(/% \+ Hidden Scores/); + expect(hiddenScoresCells).toHaveLength(2); + // Only correct visible scores should be shown (from subsection2) + // The correct visible score is 1/4 = 0.25 -> 25% + expect(hiddenScoresCells[0]).toHaveTextContent('25% + Hidden Scores'); + expect(hiddenScoresCells[1]).toHaveTextContent('25% + Hidden Scores'); + }); + + it('displays a warning message with the latest due date when not all assignment scores are included in the total grade', async () => { + setTabData({ + grading_policy: { + assignment_policies: [ + { + num_droppable: 0, + num_total: 2, + short_label: 'HW', + type: 'Homework', + weight: 1, + }, + ], + grade_range: { + pass: 0.75, + }, + }, + section_scores: [ + { + display_name: 'Section 1', + subsections: [ + { + assignment_type: 'Homework', + display_name: 'Subsection 1', + due: tomorrow.toISOString(), + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'never_but_include_grade', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection1', + }, + { + assignment_type: 'Homework', + display_name: 'Subsection 2', + due: null, + learner_has_access: true, + has_graded_assignment: true, + num_points_earned: 1, + num_points_possible: 2, + percent_graded: 1.0, + show_correctness: 'always', + show_grades: true, + url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection2', + }, + ], + }, + ], + }); + + await fetchAndRender(); + + const formattedDateTime = new Intl.DateTimeFormat('en', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + }).format(tomorrow); + + expect( + screen.getByText( + `Some assignment scores are not yet included in your total grade. These grades will be released by ${formattedDateTime}.`, + ), + ).toBeInTheDocument(); + }); + it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => { await fetchAndRender(); expect(screen.getByText('Grade summary')).toBeInTheDocument(); diff --git a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx index e075411f25..e36d9d5ffe 100644 --- a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx @@ -8,26 +8,57 @@ import { useModel } from '../../../../generic/model-store'; import GradeRangeTooltip from './GradeRangeTooltip'; import messages from '../messages'; +import { getLatestDueDateInFuture } from '../../utils'; + +const ResponsiveText = ({ + wideScreen, children, hasLetterGrades, passingGrade, +}) => { + const className = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom'; + const iconSize = wideScreen ? 'h3' : 'h4'; + + return ( + + {children} + {hasLetterGrades && ( + +   + + + )} + + ); +}; + +const NoticeRow = ({ + wideScreen, icon, bgClass, message, +}) => { + const textClass = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom'; + return ( +
+
{icon}
+
+ {message} +
+
+ ); +}; const CourseGradeFooter = ({ passingGrade }) => { const intl = useIntl(); const courseId = useContextId(); const { - courseGrade: { - isPassing, - letterGrade, - }, - gradingPolicy: { - gradeRange, - }, + courseGrade: { isPassing, letterGrade }, + gradingPolicy: { gradeRange }, + sectionScores, } = useModel('progress', courseId); + const latestDueDate = getLatestDueDateInFuture(sectionScores); const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; + const hasLetterGrades = Object.keys(gradeRange).length > 1; - const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key + // build footer text let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade }); - if (isPassing) { if (hasLetterGrades) { const minGradeRangeCutoff = gradeRange[letterGrade] * 100; @@ -47,42 +78,63 @@ const CourseGradeFooter = ({ passingGrade }) => { } } - const icon = isPassing ? - : ; + const passingIcon = isPassing ? ( + + ) : ( + + ); return ( -
-
- {icon} -
-
- {!wideScreen && ( - - {footerText} - {hasLetterGrades && ( - -   - - - )} - - )} - {wideScreen && ( - +
+ {footerText} - {hasLetterGrades && ( - -   - - - )} - + )} -
+ /> + {latestDueDate && ( + } + bgClass="bg-warning-100" + message={intl.formatMessage(messages.courseGradeFooterDueDateNotice, { + dueDate: intl.formatDate(latestDueDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + }), + })} + /> + )}
); }; +ResponsiveText.propTypes = { + wideScreen: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + hasLetterGrades: PropTypes.bool.isRequired, + passingGrade: PropTypes.number.isRequired, +}; + +NoticeRow.propTypes = { + wideScreen: PropTypes.bool.isRequired, + icon: PropTypes.element.isRequired, + bgClass: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, +}; + CourseGradeFooter.propTypes = { passingGrade: PropTypes.number.isRequired, }; diff --git a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx index 36ba44e926..2bf2002bc6 100644 --- a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx @@ -5,6 +5,7 @@ import { OverlayTrigger, Popover } from '@openedx/paragon'; import { useContextId } from '../../../../data/hooks'; import { useModel } from '../../../../generic/model-store'; +import { areAllGradesHiddenForType, areSomeGradesHiddenForType } from '../../utils'; import messages from '../messages'; @@ -13,10 +14,14 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { const courseId = useContextId(); const { + gradingPolicy: { + assignmentPolicies, + }, courseGrade: { isPassing, percent, }, + sectionScores, } = useModel('progress', courseId); const currentGrade = Number((percent * 100).toFixed(0)); @@ -25,6 +30,11 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { const isLocaleRtl = isRtl(getLocale()); + const hasHiddenGrades = assignmentPolicies.some( + (assignment) => areSomeGradesHiddenForType(assignment.type, sectionScores) + || areAllGradesHiddenForType(assignment.type, sectionScores), + ); + if (isLocaleRtl) { currentGradeDirection = currentGrade < 50 ? '-' : ''; } @@ -56,6 +66,15 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { > {intl.formatMessage(messages.currentGradeLabel)} + + {hasHiddenGrades ? ` + ${intl.formatMessage(messages.hiddenScoreLabel)}` : ''} + ); }; diff --git a/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx b/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx index 723aeae49f..81a9628fbb 100644 --- a/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx +++ b/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx @@ -22,6 +22,7 @@ const DetailedGradesTable = () => { (subsection) => !!( (showUngradedAssignments() || subsection.hasGradedAssignment) && subsection.showGrades + && subsection.showCorrectness !== 'never_but_include_grade' && (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0) ), ); diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx index b6e5ceafbb..c13754254f 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n'; import { DataTable } from '@openedx/paragon'; +import { Lock } from '@openedx/paragon/icons'; import { useContextId } from '../../../../data/hooks'; import { useModel } from '../../../../generic/model-store'; @@ -10,6 +11,7 @@ import DroppableAssignmentFootnote from './DroppableAssignmentFootnote'; import GradeSummaryTableFooter from './GradeSummaryTableFooter'; import messages from '../messages'; +import { areAllGradesHiddenForType, areSomeGradesHiddenForType } from '../../utils'; const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { const intl = useIntl(); @@ -80,13 +82,24 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType); const isLocaleRtl = isRtl(getLocale()); + let weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`; + let gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`; + + if (areAllGradesHiddenForType(assignmentType, sectionScores)) { + gradeDisplay = ; + weightedGradeDisplay = ; + } else if (areSomeGradesHiddenForType(assignmentType, sectionScores)) { + gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`; + weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`; + } + return { type: { footnoteId, footnoteMarker, type: assignmentType, locked, }, weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, - grade: { grade: `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, - weightedGrade: { weightedGrade: `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, + grade: { grade: gradeDisplay, locked }, + weightedGrade: { weightedGrade: weightedGradeDisplay, locked }, }; }); const getAssignmentTypeCell = (value) => ( @@ -102,6 +115,16 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { return ( <> +
    +
  • + {intl.formatMessage(messages.hiddenScoreLabel)}: + {intl.formatMessage(messages.hiddenScoreInfoText)} +
  • +
  • + : + {` ${intl.formatMessage(messages.hiddenScoreLockInfoText)}`} +
  • +
{ const intl = useIntl(); + const courseId = useContextId(); + + const { + gradingPolicy: { assignmentPolicies }, + } = useModel('progress', courseId); - const { data } = useContext(DataTableContext); + const getGradePercent = (grade) => { + const percent = grade * 100; + return Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(2); + }; - const rawGrade = data.reduce( - (grade, currentValue) => { - const { weightedGrade } = currentValue.weightedGrade; - const percent = weightedGrade.replace(/%/g, '').trim(); - return grade + parseFloat(percent); - }, + let rawGrade = assignmentPolicies.reduce( + (sum, { totalWeightedGrade }) => sum + totalWeightedGrade, 0, - ).toFixed(2); + ); - const courseId = useContextId(); + rawGrade = getGradePercent(rawGrade); const { courseGrade: { diff --git a/src/course-home/progress-tab/grades/messages.ts b/src/course-home/progress-tab/grades/messages.ts index a052096c4f..486d81d352 100644 --- a/src/course-home/progress-tab/grades/messages.ts +++ b/src/course-home/progress-tab/grades/messages.ts @@ -21,6 +21,11 @@ const messages = defineMessages({ defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.', description: 'Alt text for the grade chart bar', }, + courseGradeFooterDueDateNotice: { + id: 'progress.courseGrade.footer.dueDateNotice', + defaultMessage: 'Some assignment scores are not yet included in your total grade. These grades will be released by {dueDate}.', + description: 'This shown when there are pending assignments with due date in the future', + }, courseGradeFooterGenericPassing: { id: 'progress.courseGrade.footer.generic.passing', defaultMessage: 'You’re currently passing this course', @@ -148,6 +153,21 @@ const messages = defineMessages({ + "Your weighted grade is what's used to determine if you pass the course.", description: 'The content of (tip box) for the grade summary section', }, + hiddenScoreLabel: { + id: 'progress.hiddenScoreLabel', + defaultMessage: 'Hidden Scores', + description: 'Text to indicate that some scores are hidden', + }, + hiddenScoreInfoText: { + id: 'progress.hiddenScoreInfoText', + defaultMessage: 'Scores from assignments that count toward your final grade but some are not shown here.', + description: 'Information text about hidden score label', + }, + hiddenScoreLockInfoText: { + id: 'progress.hiddenScoreLockInfoText', + defaultMessage: 'Shown when scores for an assignment type are hidden yet still counted toward the course grade.', + description: 'Information text about hidden score label when learner have limited access to grades feature', + }, noAccessToAssignmentType: { id: 'progress.noAcessToAssignmentType', defaultMessage: 'You do not have access to assignments of type {assignmentType}', diff --git a/src/course-home/progress-tab/utils.ts b/src/course-home/progress-tab/utils.ts index 29dd42de85..ed1b144cf6 100644 --- a/src/course-home/progress-tab/utils.ts +++ b/src/course-home/progress-tab/utils.ts @@ -5,3 +5,40 @@ export const showUngradedAssignments = () => ( getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true' || getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true ); + +// Returns the subsections for an assignment type +const getSubsectionsOfType = (assignmentType, sectionScores) => (sectionScores || []).reduce((acc, chapter) => { + const subs = (chapter.subsections || []).filter( + (s) => s.assignmentType === assignmentType, + ); + return acc.concat(subs); +}, []); + +// Returns True if this subsection is "hidden" +const isSubsectionHidden = (sub) => sub.showGrades && sub.showCorrectness === 'never_but_include_grade'; + +// Returns True if all grades are hidden for this assignment type +export const areAllGradesHiddenForType = (assignmentType, sectionScores) => { + const subs = getSubsectionsOfType(assignmentType, sectionScores); + if (subs.length === 0) { return false; } // no subsections -> treat as not hidden + return subs.every(isSubsectionHidden); +}; + +// Returns True if some grades are hidden for this assignment type +export const areSomeGradesHiddenForType = (assignmentType, sectionScores) => { + const subs = getSubsectionsOfType(assignmentType, sectionScores); + return subs.some(isSubsectionHidden) && !areAllGradesHiddenForType(assignmentType, sectionScores); +}; + +export const getLatestDueDateInFuture = (sectionScores) => { + let latest = null; + sectionScores.forEach((chapter) => { + chapter.subsections.forEach((subsection) => { + if (subsection.due && (!latest || new Date(subsection.due) > new Date(latest)) + && new Date(subsection.due) > new Date()) { + latest = subsection.due; + } + }); + }); + return latest; +}; From f83ad4de72eb5e2542ec00e359a66cbd29e2677c Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Tue, 30 Sep 2025 12:38:33 +0500 Subject: [PATCH 2/6] fix: remove it.only from test --- src/course-home/progress-tab/ProgressTab.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index 0e485b7ebf..ac3b9caabf 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -751,7 +751,7 @@ describe('Progress Tab', () => { expect(screen.getAllByTestId('lock-icon')).toHaveLength(2); }); - it.only('shows percent plus hidden grades when some subsections of assignment type are never_but_include_grade', async () => { + it('shows percent plus hidden grades when some subsections of assignment type are never_but_include_grade', async () => { setTabData({ grading_policy: { assignment_policies: [ From eacc04de81be1da1007d85b83bf6f40175247696 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Wed, 8 Oct 2025 16:15:10 +0500 Subject: [PATCH 3/6] fix: issues --- src/course-home/progress-tab/grades/messages.ts | 6 +++--- src/course-home/progress-tab/utils.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/course-home/progress-tab/grades/messages.ts b/src/course-home/progress-tab/grades/messages.ts index 486d81d352..2754374461 100644 --- a/src/course-home/progress-tab/grades/messages.ts +++ b/src/course-home/progress-tab/grades/messages.ts @@ -24,7 +24,7 @@ const messages = defineMessages({ courseGradeFooterDueDateNotice: { id: 'progress.courseGrade.footer.dueDateNotice', defaultMessage: 'Some assignment scores are not yet included in your total grade. These grades will be released by {dueDate}.', - description: 'This shown when there are pending assignments with due date in the future', + description: 'This is shown when there are pending assignments with a due date in the future', }, courseGradeFooterGenericPassing: { id: 'progress.courseGrade.footer.generic.passing', @@ -165,8 +165,8 @@ const messages = defineMessages({ }, hiddenScoreLockInfoText: { id: 'progress.hiddenScoreLockInfoText', - defaultMessage: 'Shown when scores for an assignment type are hidden yet still counted toward the course grade.', - description: 'Information text about hidden score label when learner have limited access to grades feature', + defaultMessage: 'Scores for an assignment type are hidden but still counted toward the course grade.', + description: 'Information text about hidden score label when learners have limited access to grades feature', }, noAccessToAssignmentType: { id: 'progress.noAcessToAssignmentType', diff --git a/src/course-home/progress-tab/utils.ts b/src/course-home/progress-tab/utils.ts index ed1b144cf6..f2907262ba 100644 --- a/src/course-home/progress-tab/utils.ts +++ b/src/course-home/progress-tab/utils.ts @@ -15,7 +15,7 @@ const getSubsectionsOfType = (assignmentType, sectionScores) => (sectionScores | }, []); // Returns True if this subsection is "hidden" -const isSubsectionHidden = (sub) => sub.showGrades && sub.showCorrectness === 'never_but_include_grade'; +const isSubsectionHidden = (sub) => sub.showCorrectness === 'never_but_include_grade'; // Returns True if all grades are hidden for this assignment type export const areAllGradesHiddenForType = (assignmentType, sectionScores) => { From db9a11e86e6f7d73a168fad4607ad6bb09a99b01 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Wed, 15 Oct 2025 19:16:31 +0500 Subject: [PATCH 4/6] refactor: remove progress page calculation logic and use data from backend --- src/course-home/data/api.js | 114 +----------------- .../grades/course-grade/CourseGradeFooter.jsx | 5 +- .../course-grade/CurrentGradeTooltip.jsx | 11 +- .../detailed-grades/DetailedGradesTable.jsx | 1 - .../grades/grade-summary/GradeSummary.jsx | 6 +- .../grade-summary/GradeSummaryTable.jsx | 11 +- .../grade-summary/GradeSummaryTableFooter.jsx | 22 ++-- src/course-home/progress-tab/utils.ts | 38 ++---- 8 files changed, 26 insertions(+), 182 deletions(-) diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 867714f701..267cfab10f 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -3,114 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { logInfo } from '@edx/frontend-platform/logging'; import { appendBrowserTimezoneToUrl } from '../../utils'; -const calculateAssignmentTypeGrades = (points, visibilities, assignmentWeight, numDroppable) => { - let dropCount = numDroppable; - // Drop the lowest grades - while (dropCount && points.length >= dropCount) { - const lowestScore = Math.min(...points); - const lowestScoreIndex = points.indexOf(lowestScore); - points.splice(lowestScoreIndex, 1); - visibilities.splice(lowestScoreIndex, 1); - dropCount--; - } - let averageGrade = 0; - let weightedGrade = 0; - let totalWeightedGrade = 0; - - if (points.length) { - // Scores for visible grades (exclude never_but_include_grade) - const visibleScores = points.filter( - (_, idx) => visibilities[idx] !== 'never_but_include_grade', - ); - - // Average all scores (for totalWeightedGrade) - const overallAverage = parseFloat( - (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4), - ); - totalWeightedGrade = overallAverage * assignmentWeight; - if (visibleScores.length) { - const visibleAverage = parseFloat( - (visibleScores.reduce((a, b) => a + b, 0) / points.length).toFixed(4), - ); - averageGrade = visibleAverage; - weightedGrade = averageGrade * assignmentWeight; - } - } - return { averageGrade, weightedGrade, totalWeightedGrade }; -}; - -function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) { - const gradeByAssignmentType = {}; - assignmentPolicies.forEach(assignment => { - // Create an array with the number of total assignments and set the scores to 0 - // as placeholders for assignments that have not yet been released - gradeByAssignmentType[assignment.type] = { - grades: Array(assignment.numTotal).fill(0), - numAssignmentsCreated: 0, - numTotalExpectedAssignments: assignment.numTotal, - visibility: Array(assignment.numTotal), - }; - }); - - sectionScores.forEach((chapter) => { - chapter.subsections.forEach((subsection) => { - if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) { - return; - } - const { - assignmentType, - numPointsEarned, - numPointsPossible, - showCorrectness, - } = subsection; - - // If a subsection's assignment type does not match an assignment policy in Studio, - // we won't be able to include it in this accumulation of grades by assignment type. - // This may happen if a course author has removed/renamed an assignment policy in Studio and - // neglected to update the subsection's of that assignment type - if (!gradeByAssignmentType[assignmentType]) { - return; - } - - let { - numAssignmentsCreated, - } = gradeByAssignmentType[assignmentType]; - - numAssignmentsCreated++; - if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) { - // Remove a placeholder grade so long as the number of recorded created assignments is less than the number - // of expected assignments - gradeByAssignmentType[assignmentType].grades.shift(); - gradeByAssignmentType[assignmentType].visibility.shift(); - } - // Add the graded assignment to the list - gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0); - // Record the created assignment - gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated; - gradeByAssignmentType[assignmentType].visibility.push(showCorrectness); - }); - }); - - return assignmentPolicies.map((assignment) => { - const { averageGrade, weightedGrade, totalWeightedGrade } = calculateAssignmentTypeGrades( - gradeByAssignmentType[assignment.type].grades, - gradeByAssignmentType[assignment.type].visibility, - assignment.weight, - assignment.numDroppable, - ); - - return { - averageGrade, - numDroppable: assignment.numDroppable, - shortLabel: assignment.shortLabel, - type: assignment.type, - weight: assignment.weight, - weightedGrade, - totalWeightedGrade, - }; - }); -} - /** * Tweak the metadata for consistency * @param metadata the data to normalize @@ -256,11 +148,7 @@ export async function getProgressTabData(courseId, targetUserId) { try { const { data } = await getAuthenticatedHttpClient().get(url); const camelCasedData = camelCaseObject(data); - - camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies( - camelCasedData.gradingPolicy.assignmentPolicies, - camelCasedData.sectionScores, - ); + camelCasedData.gradingPolicy.assignmentPolicies = camelCasedData.assignmentTypeGradeSummary; // We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade. // For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a") diff --git a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx index e36d9d5ffe..7b457a5c93 100644 --- a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx @@ -9,6 +9,7 @@ import { useModel } from '../../../../generic/model-store'; import GradeRangeTooltip from './GradeRangeTooltip'; import messages from '../messages'; import { getLatestDueDateInFuture } from '../../utils'; +import { assert } from 'joi'; const ResponsiveText = ({ wideScreen, children, hasLetterGrades, passingGrade, @@ -48,12 +49,12 @@ const CourseGradeFooter = ({ passingGrade }) => { const courseId = useContextId(); const { + assignmentTypeGradeSummary, courseGrade: { isPassing, letterGrade }, gradingPolicy: { gradeRange }, - sectionScores, } = useModel('progress', courseId); - const latestDueDate = getLatestDueDateInFuture(sectionScores); + const latestDueDate = getLatestDueDateInFuture(assignmentTypeGradeSummary); const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; const hasLetterGrades = Object.keys(gradeRange).length > 1; diff --git a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx index 2bf2002bc6..8c7ae12e0d 100644 --- a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx @@ -5,7 +5,6 @@ import { OverlayTrigger, Popover } from '@openedx/paragon'; import { useContextId } from '../../../../data/hooks'; import { useModel } from '../../../../generic/model-store'; -import { areAllGradesHiddenForType, areSomeGradesHiddenForType } from '../../utils'; import messages from '../messages'; @@ -14,14 +13,11 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { const courseId = useContextId(); const { - gradingPolicy: { - assignmentPolicies, - }, + assignmentTypeGradeSummary, courseGrade: { isPassing, percent, }, - sectionScores, } = useModel('progress', courseId); const currentGrade = Number((percent * 100).toFixed(0)); @@ -30,10 +26,7 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { const isLocaleRtl = isRtl(getLocale()); - const hasHiddenGrades = assignmentPolicies.some( - (assignment) => areSomeGradesHiddenForType(assignment.type, sectionScores) - || areAllGradesHiddenForType(assignment.type, sectionScores), - ); + const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== "none"); if (isLocaleRtl) { currentGradeDirection = currentGrade < 50 ? '-' : ''; diff --git a/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx b/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx index 81a9628fbb..723aeae49f 100644 --- a/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx +++ b/src/course-home/progress-tab/grades/detailed-grades/DetailedGradesTable.jsx @@ -22,7 +22,6 @@ const DetailedGradesTable = () => { (subsection) => !!( (showUngradedAssignments() || subsection.hasGradedAssignment) && subsection.showGrades - && subsection.showCorrectness !== 'never_but_include_grade' && (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0) ), ); diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx index ffc5e2c890..6066997a9f 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx @@ -10,14 +10,12 @@ const GradeSummary = () => { const courseId = useContextId(); const { - gradingPolicy: { - assignmentPolicies, - }, + assignmentTypeGradeSummary, } = useModel('progress', courseId); const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false); - if (assignmentPolicies.length === 0) { + if (assignmentTypeGradeSummary.length === 0) { return null; } diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx index c13754254f..44129521e1 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx @@ -11,16 +11,13 @@ import DroppableAssignmentFootnote from './DroppableAssignmentFootnote'; import GradeSummaryTableFooter from './GradeSummaryTableFooter'; import messages from '../messages'; -import { areAllGradesHiddenForType, areSomeGradesHiddenForType } from '../../utils'; const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { const intl = useIntl(); const courseId = useContextId(); const { - gradingPolicy: { - assignmentPolicies, - }, + assignmentTypeGradeSummary, gradesFeatureIsFullyLocked, sectionScores, } = useModel('progress', courseId); @@ -57,7 +54,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { return false; }; - const gradeSummaryData = assignmentPolicies.map((assignment) => { + const gradeSummaryData = assignmentTypeGradeSummary.map((assignment) => { const { averageGrade, numDroppable, @@ -85,10 +82,10 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { let weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`; let gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`; - if (areAllGradesHiddenForType(assignmentType, sectionScores)) { + if (assignment.hasHiddenContribution === 'all') { gradeDisplay = ; weightedGradeDisplay = ; - } else if (areSomeGradesHiddenForType(assignmentType, sectionScores)) { + } else if (assignment.hasHiddenContribution === 'some') { gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`; weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`; } diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx index ad34632490..06d84dc913 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx @@ -17,7 +17,11 @@ const GradeSummaryTableFooter = () => { const courseId = useContextId(); const { - gradingPolicy: { assignmentPolicies }, + courseGrade: { + isPassing, + percent, + }, + finalGrades, } = useModel('progress', courseId); const getGradePercent = (grade) => { @@ -25,19 +29,7 @@ const GradeSummaryTableFooter = () => { return Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(2); }; - let rawGrade = assignmentPolicies.reduce( - (sum, { totalWeightedGrade }) => sum + totalWeightedGrade, - 0, - ); - - rawGrade = getGradePercent(rawGrade); - - const { - courseGrade: { - isPassing, - percent, - }, - } = useModel('progress', courseId); + const rawGrade = getGradePercent(finalGrades); const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100'; const totalGrade = (percent * 100).toFixed(0); @@ -57,7 +49,7 @@ const GradeSummaryTableFooter = () => { {intl.formatMessage( messages.weightedGradeSummaryTooltip, - { roundedGrade: totalGrade, rawGrade }, + { roundedGrade: totalGrade, rawGrade: rawGrade }, )} )} diff --git a/src/course-home/progress-tab/utils.ts b/src/course-home/progress-tab/utils.ts index f2907262ba..43384a260a 100644 --- a/src/course-home/progress-tab/utils.ts +++ b/src/course-home/progress-tab/utils.ts @@ -6,39 +6,15 @@ export const showUngradedAssignments = () => ( || getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true ); -// Returns the subsections for an assignment type -const getSubsectionsOfType = (assignmentType, sectionScores) => (sectionScores || []).reduce((acc, chapter) => { - const subs = (chapter.subsections || []).filter( - (s) => s.assignmentType === assignmentType, - ); - return acc.concat(subs); -}, []); -// Returns True if this subsection is "hidden" -const isSubsectionHidden = (sub) => sub.showCorrectness === 'never_but_include_grade'; - -// Returns True if all grades are hidden for this assignment type -export const areAllGradesHiddenForType = (assignmentType, sectionScores) => { - const subs = getSubsectionsOfType(assignmentType, sectionScores); - if (subs.length === 0) { return false; } // no subsections -> treat as not hidden - return subs.every(isSubsectionHidden); -}; - -// Returns True if some grades are hidden for this assignment type -export const areSomeGradesHiddenForType = (assignmentType, sectionScores) => { - const subs = getSubsectionsOfType(assignmentType, sectionScores); - return subs.some(isSubsectionHidden) && !areAllGradesHiddenForType(assignmentType, sectionScores); -}; - -export const getLatestDueDateInFuture = (sectionScores) => { +export const getLatestDueDateInFuture = (assignmentTypeGradeSummary) => { let latest = null; - sectionScores.forEach((chapter) => { - chapter.subsections.forEach((subsection) => { - if (subsection.due && (!latest || new Date(subsection.due) > new Date(latest)) - && new Date(subsection.due) > new Date()) { - latest = subsection.due; - } - }); + assignmentTypeGradeSummary.forEach((assignment) => { + let assignmentLastGradePublishDate = assignment.lastGradePublishDate; + if (assignmentLastGradePublishDate && (!latest || new Date(assignmentLastGradePublishDate) > new Date(latest)) + && new Date(assignmentLastGradePublishDate) > new Date()) { + latest = assignmentLastGradePublishDate; + } }); return latest; }; From 480acb71ddf8125897984dbae623b2953c4914d7 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Thu, 16 Oct 2025 12:08:38 +0500 Subject: [PATCH 5/6] fix: tests --- .../__factories__/progressTabData.factory.js | 14 + src/course-home/data/api.js | 1 - .../progress-tab/ProgressTab.test.jsx | 351 +++++++----------- .../grades/course-grade/CourseGradeFooter.jsx | 1 - .../course-grade/CurrentGradeTooltip.jsx | 2 +- .../grade-summary/GradeSummaryTableFooter.jsx | 6 +- src/course-home/progress-tab/utils.ts | 3 +- 7 files changed, 145 insertions(+), 233 deletions(-) diff --git a/src/course-home/data/__factories__/progressTabData.factory.js b/src/course-home/data/__factories__/progressTabData.factory.js index 1ff83241ce..8c642e1b0b 100644 --- a/src/course-home/data/__factories__/progressTabData.factory.js +++ b/src/course-home/data/__factories__/progressTabData.factory.js @@ -16,8 +16,22 @@ Factory.define('progressTabData') letter_grade: 'pass', percent: 1, is_passing: true, + total_weighted_grade: 1, }, credit_course_requirements: null, + assignment_type_grade_summary: [ + { + type: 'Homework', + short_label: 'HW', + weight: 1, + average_grade: 1, + weighted_grade: 1, + num_droppable: 1, + num_total: 2, + has_hidden_contribution: 'none', + last_grade_publish_date: null, + }, + ], section_scores: [ { display_name: 'First section', diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 267cfab10f..8254d4ef1e 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -148,7 +148,6 @@ export async function getProgressTabData(courseId, targetUserId) { try { const { data } = await getAuthenticatedHttpClient().get(url); const camelCasedData = camelCaseObject(data); - camelCasedData.gradingPolicy.assignmentPolicies = camelCasedData.assignmentTypeGradeSummary; // We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade. // For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a") diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index ac3b9caabf..da8caf18c3 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -37,6 +37,98 @@ const mockSearchParams = ((props = coursewareSearch) => { mockCoursewareSearchParams.mockReturnValue(props); }); +// const clone = data => JSON.parse(JSON.stringify(data)); + +// const calculateLastGradePublishDate = (subsections) => subsections.reduce((latest, subsection) => { +// if (!subsection.last_grade_publish_date) { +// return latest; +// } + +// if (!latest) { +// return subsection.last_grade_publish_date; +// } + +// return new Date(subsection.last_grade_publish_date) > new Date(latest) +// ? subsection.last_grade_publish_date +// : latest; +// }, null); + +// const clampToRange = value => Math.min(1, Math.max(0, value)); + +// const buildAssignmentTypeGradeSummary = (progressTabData) => { +// const sectionScores = progressTabData.section_scores || []; +// const assignmentPolicies = progressTabData.grading_policy?.assignment_policies || []; + +// return assignmentPolicies.map((policy) => { +// const subsections = sectionScores.reduce((list, chapter) => ( +// [...list, ...((chapter.subsections || []).filter(subsection => ( +// subsection.assignment_type === policy.type +// && subsection.has_graded_assignment +// && (subsection.num_points_possible > 0 || subsection.num_points_earned > 0) +// )))] +// ), []); + +// const grades = subsections.map((subsection) => { +// const possible = subsection.num_points_possible ?? 0; +// const earned = subsection.num_points_earned ?? 0; + +// if (possible > 0) { +// return clampToRange(earned / possible); +// } + +// if (possible === 0 && earned > 0) { +// return 1; +// } + +// return 0; +// }); + +// const numTotal = policy.num_total ?? grades.length; +// const numDroppable = Math.min(policy.num_droppable ?? 0, numTotal); +// const sortedGrades = [...grades].sort((a, b) => b - a); +// const assignmentsToConsider = sortedGrades.slice(0, Math.min(sortedGrades.length, numTotal)); +// const adjustedDroppable = Math.min(numDroppable, assignmentsToConsider.length); +// const countedGrades = assignmentsToConsider.slice(0, assignmentsToConsider.length - adjustedDroppable); +// const sumGrades = countedGrades.reduce((sum, grade) => sum + grade, 0); +// const denominator = assignmentsToConsider.length === 0 ? 1 : Math.max(1, numTotal - numDroppable); +// const averageGrade = assignmentsToConsider.length === 0 ? 0 : clampToRange(sumGrades / denominator); +// const weightedGrade = clampToRange(averageGrade * (policy.weight ?? 0)); +// const lastGradePublishDate = calculateLastGradePublishDate(subsections); + +// return { +// type: policy.type, +// short_label: policy.short_label, +// weight: policy.weight ?? 0, +// average_grade: averageGrade, +// weighted_grade: weightedGrade, +// num_droppable: policy.num_droppable ?? 0, +// num_total: policy.num_total ?? (subsections.length || grades.length), +// has_hidden_contribution: 'none', +// last_grade_publish_date: lastGradePublishDate, +// }; +// }); +// }; + +// const mapProgressTabData = (data) => { +// const progressTabData = clone(data); + +// if (!progressTabData.assignment_type_grade_summary) { +// progressTabData.assignment_type_grade_summary = buildAssignmentTypeGradeSummary(progressTabData); +// } + +// if (Array.isArray(progressTabData.assignment_type_grade_summary)) { +// const totalWeightedGrade = progressTabData.assignment_type_grade_summary +// .reduce((sum, assignment) => sum + (assignment.weighted_grade ?? 0), 0); + +// progressTabData.course_grade = { +// ...progressTabData.course_grade, +// total_weighted_grade: progressTabData.course_grade?.total_weighted_grade ?? totalWeightedGrade, +// }; +// } + +// return progressTabData; +// }; + describe('Progress Tab', () => { let axiosMock; @@ -661,52 +753,23 @@ describe('Progress Tab', () => { expect(screen.getByText('Grade summary')).toBeInTheDocument(); }); - it('does not render Grade Summary when assignment policies are not populated', async () => { + it('does not render Grade Summary when assignment type grade summary is not populated', async () => { setTabData({ - grading_policy: { - assignment_policies: [], - grade_range: { - pass: 0.75, - }, - }, - section_scores: [], + assignment_type_grade_summary: [], }); await fetchAndRender(); expect(screen.queryByText('Grade summary')).not.toBeInTheDocument(); }); - it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => { - setTabData({ - grading_policy: { - assignment_policies: [ - { - num_droppable: 2, - num_total: 2, - short_label: 'HW', - type: 'Homework', - weight: 1, - }, - ], - grade_range: { - pass: 0.75, - }, - }, - }); - await fetchAndRender(); - expect(screen.getByText('Grade summary')).toBeInTheDocument(); - // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}" - expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument(); - }); - - it('shows lock icon when all subsections of assignment type are never_but_include_grade', async () => { + it('shows lock icon when all subsections of assignment type are hidden', async () => { setTabData({ grading_policy: { assignment_policies: [ { num_droppable: 0, - num_total: 2, - short_label: 'HW', - type: 'Homework', + num_total: 1, + short_label: 'Final', + type: 'Final Exam', weight: 1, }, ], @@ -714,35 +777,16 @@ describe('Progress Tab', () => { pass: 0.75, }, }, - section_scores: [ + assignment_type_grade_summary: [ { - display_name: 'Section 1', - subsections: [ - { - assignment_type: 'Homework', - display_name: 'Subsection 1', - learner_has_access: true, - has_graded_assignment: true, - num_points_earned: 1, - num_points_possible: 2, - percent_graded: 1.0, - show_correctness: 'never_but_include_grade', - show_grades: true, - url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection1', - }, - { - assignment_type: 'Homework', - display_name: 'Subsection 2', - learner_has_access: true, - has_graded_assignment: true, - num_points_earned: 1, - num_points_possible: 2, - percent_graded: 1.0, - show_correctness: 'never_but_include_grade', - show_grades: true, - url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection2', - }, - ], + type: 'Final Exam', + weight: 0.4, + average_grade: 0.0, + weighted_grade: 0.0, + last_grade_publish_date: '2025-10-15T14:17:04.368903Z', + has_hidden_contribution: 'all', + short_label: 'Final', + num_droppable: 0, }, ], }); @@ -751,7 +795,7 @@ describe('Progress Tab', () => { expect(screen.getAllByTestId('lock-icon')).toHaveLength(2); }); - it('shows percent plus hidden grades when some subsections of assignment type are never_but_include_grade', async () => { + it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => { setTabData({ grading_policy: { assignment_policies: [ @@ -767,35 +811,16 @@ describe('Progress Tab', () => { pass: 0.75, }, }, - section_scores: [ + assignment_type_grade_summary: [ { - display_name: 'Section 1', - subsections: [ - { - assignment_type: 'Homework', - display_name: 'Subsection 1', - learner_has_access: true, - has_graded_assignment: true, - num_points_earned: 1, - num_points_possible: 2, - percent_graded: 1.0, - show_correctness: 'never_but_include_grade', - show_grades: true, - url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection1', - }, - { - assignment_type: 'Homework', - display_name: 'Subsection 2', - learner_has_access: true, - has_graded_assignment: true, - num_points_earned: 1, - num_points_possible: 2, - percent_graded: 1.0, - show_correctness: 'always', - show_grades: true, - url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection2', - }, - ], + type: 'Homework', + weight: 1, + average_grade: 0.25, + weighted_grade: 0.25, + last_grade_publish_date: '2025-10-15T14:17:04.368903Z', + has_hidden_contribution: 'some', + short_label: 'HW', + num_droppable: 0, }, ], }); @@ -825,37 +850,16 @@ describe('Progress Tab', () => { pass: 0.75, }, }, - section_scores: [ + assignment_type_grade_summary: [ { - display_name: 'Section 1', - subsections: [ - { - assignment_type: 'Homework', - display_name: 'Subsection 1', - due: tomorrow.toISOString(), - learner_has_access: true, - has_graded_assignment: true, - num_points_earned: 1, - num_points_possible: 2, - percent_graded: 1.0, - show_correctness: 'never_but_include_grade', - show_grades: true, - url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection1', - }, - { - assignment_type: 'Homework', - display_name: 'Subsection 2', - due: null, - learner_has_access: true, - has_graded_assignment: true, - num_points_earned: 1, - num_points_possible: 2, - percent_graded: 1.0, - show_correctness: 'always', - show_grades: true, - url: 'http://learning.edx.org/course/course-v1:edX+Test+run/subsection2', - }, - ], + type: 'Homework', + weight: 1, + average_grade: 1, + weighted_grade: 1, + last_grade_publish_date: tomorrow.toISOString(), + has_hidden_contribution: 'none', + short_label: 'HW', + num_droppable: 0, }, ], }); @@ -878,109 +882,6 @@ describe('Progress Tab', () => { ).toBeInTheDocument(); }); - it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => { - await fetchAndRender(); - expect(screen.getByText('Grade summary')).toBeInTheDocument(); - // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}" - expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument(); - }); - it('calculates grades correctly when number of droppable assignments is zero', async () => { - setTabData({ - grading_policy: { - assignment_policies: [ - { - num_droppable: 0, - num_total: 2, - short_label: 'HW', - type: 'Homework', - weight: 1, - }, - ], - grade_range: { - pass: 0.75, - }, - }, - }); - await fetchAndRender(); - expect(screen.getByText('Grade summary')).toBeInTheDocument(); - // The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}" - expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument(); - }); - it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => { - setTabData({ - grading_policy: { - assignment_policies: [ - { - num_droppable: 1, - num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings - short_label: 'HW', - type: 'Homework', - weight: 1, - }, - ], - grade_range: { - pass: 0.75, - }, - }, - }); - await fetchAndRender(); - expect(screen.getByText('Grade summary')).toBeInTheDocument(); - // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}" - expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument(); - }); - it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => { - setTabData({ - grading_policy: { - assignment_policies: [ - { - num_droppable: 0, - num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings - short_label: 'HW', - type: 'Homework', - weight: 1, - }, - ], - grade_range: { - pass: 0.75, - }, - }, - }); - await fetchAndRender(); - expect(screen.getByText('Grade summary')).toBeInTheDocument(); - // The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}" - expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument(); - }); - it('calculates weighted grades correctly', async () => { - setTabData({ - grading_policy: { - assignment_policies: [ - { - num_droppable: 1, - num_total: 2, - short_label: 'HW', - type: 'Homework', - weight: 0.5, - }, - { - num_droppable: 0, - num_total: 1, - short_label: 'Ex', - type: 'Exam', - weight: 0.5, - }, - ], - grade_range: { - pass: 0.75, - }, - }, - }); - await fetchAndRender(); - expect(screen.getByText('Grade summary')).toBeInTheDocument(); - // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}" - expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument(); - expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument(); - }); - it('renders override notice', async () => { setTabData({ section_scores: [ diff --git a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx index 7b457a5c93..6cada4cbb4 100644 --- a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx @@ -9,7 +9,6 @@ import { useModel } from '../../../../generic/model-store'; import GradeRangeTooltip from './GradeRangeTooltip'; import messages from '../messages'; import { getLatestDueDateInFuture } from '../../utils'; -import { assert } from 'joi'; const ResponsiveText = ({ wideScreen, children, hasLetterGrades, passingGrade, diff --git a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx index 8c7ae12e0d..8e1c6b2985 100644 --- a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx @@ -26,7 +26,7 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { const isLocaleRtl = isRtl(getLocale()); - const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== "none"); + const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== 'none'); if (isLocaleRtl) { currentGradeDirection = currentGrade < 50 ? '-' : ''; diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx index 06d84dc913..e8655e6eb7 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTableFooter.jsx @@ -25,8 +25,8 @@ const GradeSummaryTableFooter = () => { } = useModel('progress', courseId); const getGradePercent = (grade) => { - const percent = grade * 100; - return Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(2); + const percentage = grade * 100; + return Number.isInteger(percentage) ? percentage.toFixed(0) : percentage.toFixed(2); }; const rawGrade = getGradePercent(finalGrades); @@ -49,7 +49,7 @@ const GradeSummaryTableFooter = () => { {intl.formatMessage( messages.weightedGradeSummaryTooltip, - { roundedGrade: totalGrade, rawGrade: rawGrade }, + { roundedGrade: totalGrade, rawGrade }, )} )} diff --git a/src/course-home/progress-tab/utils.ts b/src/course-home/progress-tab/utils.ts index 43384a260a..aeb40f5099 100644 --- a/src/course-home/progress-tab/utils.ts +++ b/src/course-home/progress-tab/utils.ts @@ -6,11 +6,10 @@ export const showUngradedAssignments = () => ( || getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true ); - export const getLatestDueDateInFuture = (assignmentTypeGradeSummary) => { let latest = null; assignmentTypeGradeSummary.forEach((assignment) => { - let assignmentLastGradePublishDate = assignment.lastGradePublishDate; + const assignmentLastGradePublishDate = assignment.lastGradePublishDate; if (assignmentLastGradePublishDate && (!latest || new Date(assignmentLastGradePublishDate) > new Date(latest)) && new Date(assignmentLastGradePublishDate) > new Date()) { latest = assignmentLastGradePublishDate; From ba63dec2c20b941903492f6a3c73e8ddc6fc7736 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Thu, 16 Oct 2025 12:22:05 +0500 Subject: [PATCH 6/6] fix: issues --- .../__factories__/progressTabData.factory.js | 2 +- .../progress-tab/ProgressTab.test.jsx | 92 ------------------- 2 files changed, 1 insertion(+), 93 deletions(-) diff --git a/src/course-home/data/__factories__/progressTabData.factory.js b/src/course-home/data/__factories__/progressTabData.factory.js index 8c642e1b0b..3a3508f99e 100644 --- a/src/course-home/data/__factories__/progressTabData.factory.js +++ b/src/course-home/data/__factories__/progressTabData.factory.js @@ -16,8 +16,8 @@ Factory.define('progressTabData') letter_grade: 'pass', percent: 1, is_passing: true, - total_weighted_grade: 1, }, + final_grades: 0.5, credit_course_requirements: null, assignment_type_grade_summary: [ { diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index da8caf18c3..be99cab11d 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -37,98 +37,6 @@ const mockSearchParams = ((props = coursewareSearch) => { mockCoursewareSearchParams.mockReturnValue(props); }); -// const clone = data => JSON.parse(JSON.stringify(data)); - -// const calculateLastGradePublishDate = (subsections) => subsections.reduce((latest, subsection) => { -// if (!subsection.last_grade_publish_date) { -// return latest; -// } - -// if (!latest) { -// return subsection.last_grade_publish_date; -// } - -// return new Date(subsection.last_grade_publish_date) > new Date(latest) -// ? subsection.last_grade_publish_date -// : latest; -// }, null); - -// const clampToRange = value => Math.min(1, Math.max(0, value)); - -// const buildAssignmentTypeGradeSummary = (progressTabData) => { -// const sectionScores = progressTabData.section_scores || []; -// const assignmentPolicies = progressTabData.grading_policy?.assignment_policies || []; - -// return assignmentPolicies.map((policy) => { -// const subsections = sectionScores.reduce((list, chapter) => ( -// [...list, ...((chapter.subsections || []).filter(subsection => ( -// subsection.assignment_type === policy.type -// && subsection.has_graded_assignment -// && (subsection.num_points_possible > 0 || subsection.num_points_earned > 0) -// )))] -// ), []); - -// const grades = subsections.map((subsection) => { -// const possible = subsection.num_points_possible ?? 0; -// const earned = subsection.num_points_earned ?? 0; - -// if (possible > 0) { -// return clampToRange(earned / possible); -// } - -// if (possible === 0 && earned > 0) { -// return 1; -// } - -// return 0; -// }); - -// const numTotal = policy.num_total ?? grades.length; -// const numDroppable = Math.min(policy.num_droppable ?? 0, numTotal); -// const sortedGrades = [...grades].sort((a, b) => b - a); -// const assignmentsToConsider = sortedGrades.slice(0, Math.min(sortedGrades.length, numTotal)); -// const adjustedDroppable = Math.min(numDroppable, assignmentsToConsider.length); -// const countedGrades = assignmentsToConsider.slice(0, assignmentsToConsider.length - adjustedDroppable); -// const sumGrades = countedGrades.reduce((sum, grade) => sum + grade, 0); -// const denominator = assignmentsToConsider.length === 0 ? 1 : Math.max(1, numTotal - numDroppable); -// const averageGrade = assignmentsToConsider.length === 0 ? 0 : clampToRange(sumGrades / denominator); -// const weightedGrade = clampToRange(averageGrade * (policy.weight ?? 0)); -// const lastGradePublishDate = calculateLastGradePublishDate(subsections); - -// return { -// type: policy.type, -// short_label: policy.short_label, -// weight: policy.weight ?? 0, -// average_grade: averageGrade, -// weighted_grade: weightedGrade, -// num_droppable: policy.num_droppable ?? 0, -// num_total: policy.num_total ?? (subsections.length || grades.length), -// has_hidden_contribution: 'none', -// last_grade_publish_date: lastGradePublishDate, -// }; -// }); -// }; - -// const mapProgressTabData = (data) => { -// const progressTabData = clone(data); - -// if (!progressTabData.assignment_type_grade_summary) { -// progressTabData.assignment_type_grade_summary = buildAssignmentTypeGradeSummary(progressTabData); -// } - -// if (Array.isArray(progressTabData.assignment_type_grade_summary)) { -// const totalWeightedGrade = progressTabData.assignment_type_grade_summary -// .reduce((sum, assignment) => sum + (assignment.weighted_grade ?? 0), 0); - -// progressTabData.course_grade = { -// ...progressTabData.course_grade, -// total_weighted_grade: progressTabData.course_grade?.total_weighted_grade ?? totalWeightedGrade, -// }; -// } - -// return progressTabData; -// }; - describe('Progress Tab', () => { let axiosMock;