Skip to content

Commit 52692dc

Browse files
authored
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.
1 parent f91af21 commit 52692dc

File tree

10 files changed

+254
-235
lines changed

10 files changed

+254
-235
lines changed

src/course-home/data/__factories__/progressTabData.factory.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,21 @@ Factory.define('progressTabData')
1717
percent: 1,
1818
is_passing: true,
1919
},
20+
final_grades: 0.5,
2021
credit_course_requirements: null,
22+
assignment_type_grade_summary: [
23+
{
24+
type: 'Homework',
25+
short_label: 'HW',
26+
weight: 1,
27+
average_grade: 1,
28+
weighted_grade: 1,
29+
num_droppable: 1,
30+
num_total: 2,
31+
has_hidden_contribution: 'none',
32+
last_grade_publish_date: null,
33+
},
34+
],
2135
section_scores: [
2236
{
2337
display_name: 'First section',

src/course-home/data/api.js

Lines changed: 0 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
33
import { logInfo } from '@edx/frontend-platform/logging';
44
import { appendBrowserTimezoneToUrl } from '../../utils';
55

6-
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
7-
let dropCount = numDroppable;
8-
// Drop the lowest grades
9-
while (dropCount && points.length >= dropCount) {
10-
const lowestScore = Math.min(...points);
11-
const lowestScoreIndex = points.indexOf(lowestScore);
12-
points.splice(lowestScoreIndex, 1);
13-
dropCount--;
14-
}
15-
let averageGrade = 0;
16-
let weightedGrade = 0;
17-
if (points.length) {
18-
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
19-
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
20-
// exists in edx-platform.
21-
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
22-
weightedGrade = averageGrade * assignmentWeight;
23-
}
24-
return { averageGrade, weightedGrade };
25-
};
26-
27-
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
28-
const gradeByAssignmentType = {};
29-
assignmentPolicies.forEach(assignment => {
30-
// Create an array with the number of total assignments and set the scores to 0
31-
// as placeholders for assignments that have not yet been released
32-
gradeByAssignmentType[assignment.type] = {
33-
grades: Array(assignment.numTotal).fill(0),
34-
numAssignmentsCreated: 0,
35-
numTotalExpectedAssignments: assignment.numTotal,
36-
};
37-
});
38-
39-
sectionScores.forEach((chapter) => {
40-
chapter.subsections.forEach((subsection) => {
41-
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
42-
return;
43-
}
44-
const {
45-
assignmentType,
46-
numPointsEarned,
47-
numPointsPossible,
48-
} = subsection;
49-
50-
// If a subsection's assignment type does not match an assignment policy in Studio,
51-
// we won't be able to include it in this accumulation of grades by assignment type.
52-
// This may happen if a course author has removed/renamed an assignment policy in Studio and
53-
// neglected to update the subsection's of that assignment type
54-
if (!gradeByAssignmentType[assignmentType]) {
55-
return;
56-
}
57-
58-
let {
59-
numAssignmentsCreated,
60-
} = gradeByAssignmentType[assignmentType];
61-
62-
numAssignmentsCreated++;
63-
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
64-
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
65-
// of expected assignments
66-
gradeByAssignmentType[assignmentType].grades.shift();
67-
}
68-
// Add the graded assignment to the list
69-
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
70-
// Record the created assignment
71-
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
72-
});
73-
});
74-
75-
return assignmentPolicies.map((assignment) => {
76-
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
77-
gradeByAssignmentType[assignment.type].grades,
78-
assignment.weight,
79-
assignment.numDroppable,
80-
);
81-
82-
return {
83-
averageGrade,
84-
numDroppable: assignment.numDroppable,
85-
shortLabel: assignment.shortLabel,
86-
type: assignment.type,
87-
weight: assignment.weight,
88-
weightedGrade,
89-
};
90-
});
91-
}
92-
936
/**
947
* Tweak the metadata for consistency
958
* @param metadata the data to normalize
@@ -236,11 +149,6 @@ export async function getProgressTabData(courseId, targetUserId) {
236149
const { data } = await getAuthenticatedHttpClient().get(url);
237150
const camelCasedData = camelCaseObject(data);
238151

239-
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
240-
camelCasedData.gradingPolicy.assignmentPolicies,
241-
camelCasedData.sectionScores,
242-
);
243-
244152
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
245153
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
246154
// in order to preserve a course team's desired grade formatting.

src/course-home/progress-tab/ProgressTab.test.jsx

Lines changed: 71 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -661,49 +661,49 @@ describe('Progress Tab', () => {
661661
expect(screen.getByText('Grade summary')).toBeInTheDocument();
662662
});
663663

664-
it('does not render Grade Summary when assignment policies are not populated', async () => {
664+
it('does not render Grade Summary when assignment type grade summary is not populated', async () => {
665665
setTabData({
666-
grading_policy: {
667-
assignment_policies: [],
668-
grade_range: {
669-
pass: 0.75,
670-
},
671-
},
672-
section_scores: [],
666+
assignment_type_grade_summary: [],
673667
});
674668
await fetchAndRender();
675669
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
676670
});
677671

678-
it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => {
672+
it('shows lock icon when all subsections of assignment type are hidden', async () => {
679673
setTabData({
680674
grading_policy: {
681675
assignment_policies: [
682676
{
683-
num_droppable: 2,
684-
num_total: 2,
685-
short_label: 'HW',
686-
type: 'Homework',
677+
num_droppable: 0,
678+
num_total: 1,
679+
short_label: 'Final',
680+
type: 'Final Exam',
687681
weight: 1,
688682
},
689683
],
690684
grade_range: {
691685
pass: 0.75,
692686
},
693687
},
688+
assignment_type_grade_summary: [
689+
{
690+
type: 'Final Exam',
691+
weight: 0.4,
692+
average_grade: 0.0,
693+
weighted_grade: 0.0,
694+
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
695+
has_hidden_contribution: 'all',
696+
short_label: 'Final',
697+
num_droppable: 0,
698+
},
699+
],
694700
});
695701
await fetchAndRender();
696-
expect(screen.getByText('Grade summary')).toBeInTheDocument();
697-
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
698-
expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
699-
});
700-
it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
701-
await fetchAndRender();
702-
expect(screen.getByText('Grade summary')).toBeInTheDocument();
703-
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
704-
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
702+
// Should show lock icon for grade and weighted grade
703+
expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
705704
});
706-
it('calculates grades correctly when number of droppable assignments is zero', async () => {
705+
706+
it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
707707
setTabData({
708708
grading_policy: {
709709
assignment_policies: [
@@ -719,41 +719,36 @@ describe('Progress Tab', () => {
719719
pass: 0.75,
720720
},
721721
},
722-
});
723-
await fetchAndRender();
724-
expect(screen.getByText('Grade summary')).toBeInTheDocument();
725-
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
726-
expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
727-
});
728-
it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
729-
setTabData({
730-
grading_policy: {
731-
assignment_policies: [
732-
{
733-
num_droppable: 1,
734-
num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
735-
short_label: 'HW',
736-
type: 'Homework',
737-
weight: 1,
738-
},
739-
],
740-
grade_range: {
741-
pass: 0.75,
722+
assignment_type_grade_summary: [
723+
{
724+
type: 'Homework',
725+
weight: 1,
726+
average_grade: 0.25,
727+
weighted_grade: 0.25,
728+
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
729+
has_hidden_contribution: 'some',
730+
short_label: 'HW',
731+
num_droppable: 0,
742732
},
743-
},
733+
],
744734
});
745735
await fetchAndRender();
746-
expect(screen.getByText('Grade summary')).toBeInTheDocument();
747-
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
748-
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
736+
// Should show percent + hidden scores for grade and weighted grade
737+
const hiddenScoresCells = screen.getAllByText(/% \+ Hidden Scores/);
738+
expect(hiddenScoresCells).toHaveLength(2);
739+
// Only correct visible scores should be shown (from subsection2)
740+
// The correct visible score is 1/4 = 0.25 -> 25%
741+
expect(hiddenScoresCells[0]).toHaveTextContent('25% + Hidden Scores');
742+
expect(hiddenScoresCells[1]).toHaveTextContent('25% + Hidden Scores');
749743
});
750-
it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
744+
745+
it('displays a warning message with the latest due date when not all assignment scores are included in the total grade', async () => {
751746
setTabData({
752747
grading_policy: {
753748
assignment_policies: [
754749
{
755750
num_droppable: 0,
756-
num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
751+
num_total: 2,
757752
short_label: 'HW',
758753
type: 'Homework',
759754
weight: 1,
@@ -763,41 +758,36 @@ describe('Progress Tab', () => {
763758
pass: 0.75,
764759
},
765760
},
766-
});
767-
await fetchAndRender();
768-
expect(screen.getByText('Grade summary')).toBeInTheDocument();
769-
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
770-
expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
771-
});
772-
it('calculates weighted grades correctly', async () => {
773-
setTabData({
774-
grading_policy: {
775-
assignment_policies: [
776-
{
777-
num_droppable: 1,
778-
num_total: 2,
779-
short_label: 'HW',
780-
type: 'Homework',
781-
weight: 0.5,
782-
},
783-
{
784-
num_droppable: 0,
785-
num_total: 1,
786-
short_label: 'Ex',
787-
type: 'Exam',
788-
weight: 0.5,
789-
},
790-
],
791-
grade_range: {
792-
pass: 0.75,
761+
assignment_type_grade_summary: [
762+
{
763+
type: 'Homework',
764+
weight: 1,
765+
average_grade: 1,
766+
weighted_grade: 1,
767+
last_grade_publish_date: tomorrow.toISOString(),
768+
has_hidden_contribution: 'none',
769+
short_label: 'HW',
770+
num_droppable: 0,
793771
},
794-
},
772+
],
795773
});
774+
796775
await fetchAndRender();
797-
expect(screen.getByText('Grade summary')).toBeInTheDocument();
798-
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
799-
expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument();
800-
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
776+
777+
const formattedDateTime = new Intl.DateTimeFormat('en', {
778+
year: 'numeric',
779+
month: 'long',
780+
day: 'numeric',
781+
hour: 'numeric',
782+
minute: 'numeric',
783+
timeZoneName: 'short',
784+
}).format(tomorrow);
785+
786+
expect(
787+
screen.getByText(
788+
`Some assignment scores are not yet included in your total grade. These grades will be released by ${formattedDateTime}.`,
789+
),
790+
).toBeInTheDocument();
801791
});
802792

803793
it('renders override notice', async () => {

0 commit comments

Comments
 (0)