From 52692dc6620bde88761a0d4e5d746f27b9c4c9c7 Mon Sep 17 00:00:00 2001
From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com>
Date: Fri, 24 Oct 2025 22:55:12 +0500
Subject: [PATCH] refactor: shift grade summary calculation to backend and
display "hidden grades" label in the grade table (#1797)
Refactors the grade summary logic to delegate all calculation responsibilities to the backend.
Previously, the frontend was performing grade summary computations using data fetched from the API. Now, the API itself provides the fully computed grade summary, simplifying the frontend and ensuring consistent results across clients.
Additionally, a "Hidden Grades" label has been added in the grade summary table to clearly indicate sections where grades are not visible to learners.
Finally, for visibility settings that depend on the due date, this PR adds a banner on the Progress page indicating that grades are not yet released, along with the relevant due date information.
---
.../__factories__/progressTabData.factory.js | 14 ++
src/course-home/data/api.js | 92 -------
.../progress-tab/ProgressTab.test.jsx | 224 +++++++++---------
.../grades/course-grade/CourseGradeFooter.jsx | 126 +++++++---
.../course-grade/CurrentGradeTooltip.jsx | 12 +
.../grades/grade-summary/GradeSummary.jsx | 6 +-
.../grade-summary/GradeSummaryTable.jsx | 32 ++-
.../grade-summary/GradeSummaryTableFooter.jsx | 23 +-
.../progress-tab/grades/messages.ts | 20 ++
src/course-home/progress-tab/utils.ts | 12 +
10 files changed, 290 insertions(+), 271 deletions(-)
diff --git a/src/course-home/data/__factories__/progressTabData.factory.js b/src/course-home/data/__factories__/progressTabData.factory.js
index 1ff83241..3a3508f9 100644
--- a/src/course-home/data/__factories__/progressTabData.factory.js
+++ b/src/course-home/data/__factories__/progressTabData.factory.js
@@ -17,7 +17,21 @@ Factory.define('progressTabData')
percent: 1,
is_passing: true,
},
+ final_grades: 0.5,
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 88d684c8..8254d4ef 100644
--- a/src/course-home/data/api.js
+++ b/src/course-home/data/api.js
@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
import { appendBrowserTimezoneToUrl } from '../../utils';
-const calculateAssignmentTypeGrades = (points, 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);
- dropCount--;
- }
- let averageGrade = 0;
- let weightedGrade = 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;
- }
- return { averageGrade, weightedGrade };
-};
-
-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,
- };
- });
-
- sectionScores.forEach((chapter) => {
- chapter.subsections.forEach((subsection) => {
- if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
- return;
- }
- const {
- assignmentType,
- numPointsEarned,
- numPointsPossible,
- } = 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();
- }
- // Add the graded assignment to the list
- gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
- // Record the created assignment
- gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
- });
- });
-
- return assignmentPolicies.map((assignment) => {
- const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
- gradeByAssignmentType[assignment.type].grades,
- assignment.weight,
- assignment.numDroppable,
- );
-
- return {
- averageGrade,
- numDroppable: assignment.numDroppable,
- shortLabel: assignment.shortLabel,
- type: assignment.type,
- weight: assignment.weight,
- weightedGrade,
- };
- });
-}
-
/**
* Tweak the metadata for consistency
* @param metadata the data to normalize
@@ -236,11 +149,6 @@ export async function getProgressTabData(courseId, targetUserId) {
const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data);
- camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
- camelCasedData.gradingPolicy.assignmentPolicies,
- camelCasedData.sectionScores,
- );
-
// 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")
// in order to preserve a course team's desired grade formatting.
diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx
index a08bf8a4..be99cab1 100644
--- a/src/course-home/progress-tab/ProgressTab.test.jsx
+++ b/src/course-home/progress-tab/ProgressTab.test.jsx
@@ -661,143 +661,133 @@ 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 () => {
+ it('shows lock icon when all subsections of assignment type are hidden', 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('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,
+ short_label: 'Final',
+ type: 'Final Exam',
+ weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
+ assignment_type_grade_summary: [
+ {
+ 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,
+ },
+ ],
});
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();
+ // Should show lock icon for grade and weighted grade
+ expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
+ });
+
+ it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
+ setTabData({
+ grading_policy: {
+ assignment_policies: [
+ {
+ num_droppable: 0,
+ num_total: 2,
+ short_label: 'HW',
+ type: 'Homework',
+ weight: 1,
+ },
+ ],
+ grade_range: {
+ pass: 0.75,
+ },
+ },
+ assignment_type_grade_summary: [
+ {
+ 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,
+ },
+ ],
+ });
+ 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,
+ },
+ },
+ assignment_type_grade_summary: [
+ {
+ 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,
+ },
+ ],
+ });
+
+ 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('renders override notice', async () => {
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 e075411f..6cada4cb 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 && (
+
+
+