diff --git a/src/course-home/data/__factories__/progressTabData.factory.js b/src/course-home/data/__factories__/progressTabData.factory.js
index 1ff83241ce..3a3508f99e 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 88d684c83e..8254d4ef1e 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 a08bf8a40a..be99cab11d 100644
--- a/src/course-home/progress-tab/ProgressTab.test.jsx
+++ b/src/course-home/progress-tab/ProgressTab.test.jsx
@@ -661,29 +661,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 () => {
+ 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',
+ num_droppable: 0,
+ num_total: 1,
+ short_label: 'Final',
+ type: 'Final Exam',
weight: 1,
},
],
@@ -691,19 +685,25 @@ describe('Progress Tab', () => {
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 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();
+ // Should show lock icon for grade and weighted grade
+ expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
});
- it('calculates grades correctly when number of droppable assignments is zero', async () => {
+
+ it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
setTabData({
grading_policy: {
assignment_policies: [
@@ -719,41 +719,36 @@ describe('Progress Tab', () => {
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,
+ 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();
- 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();
+ // 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('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
+
+ 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: 5, // two assignments created in the factory, but 5 are expected per Studio settings
+ num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
@@ -763,41 +758,36 @@ describe('Progress Tab', () => {
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,
+ 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();
- 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();
+
+ 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 e075411f25..6cada4cbb4 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 && (
+
+
+