diff --git a/src/course-home/data/__factories__/index.js b/src/course-home/data/__factories__/index.js
index a2680575c5ea..a620ad5db537 100644
--- a/src/course-home/data/__factories__/index.js
+++ b/src/course-home/data/__factories__/index.js
@@ -1,3 +1,4 @@
import './courseHomeMetadata.factory';
import './datesTabData.factory';
import './outlineTabData.factory';
+import './progressTabData.factory';
diff --git a/src/course-home/data/__factories__/progressTabData.factory.js b/src/course-home/data/__factories__/progressTabData.factory.js
new file mode 100644
index 000000000000..5a508d78bc95
--- /dev/null
+++ b/src/course-home/data/__factories__/progressTabData.factory.js
@@ -0,0 +1,71 @@
+import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
+
+// Sample data helpful when developing & testing, to see a variety of configurations.
+// This set of data may not be realistic, but it is intended to demonstrate many UI results.
+Factory.define('progressTabData')
+ .attrs({
+ certificate_data: null,
+ completion_summary: {
+ complete_count: 1,
+ incomplete_count: 1,
+ locked_count: 0,
+ },
+ course_grade: {
+ percent: 0,
+ is_passing: false,
+ },
+ section_scores: [
+ {
+ display_name: 'First section',
+ subsections: [
+ {
+ assignment_type: 'Homework',
+ display_name: 'First subsection',
+ has_graded_assignment: true,
+ num_points_earned: 0,
+ num_points_possible: 1,
+ percent_graded: 0.0,
+ show_correctness: 'always',
+ show_grades: true,
+ url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
+ },
+ ],
+ },
+ {
+ display_name: 'Second section',
+ subsections: [
+ {
+ assignment_type: 'Homework',
+ display_name: 'Second subsection',
+ has_graded_assignment: true,
+ num_points_earned: 1,
+ num_points_possible: 1,
+ percent_graded: 1.0,
+ show_correctness: 'always',
+ show_grades: true,
+ url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
+ },
+ ],
+ },
+ ],
+ enrollment_mode: 'audit',
+ grading_policy: {
+ assignment_policies: [
+ {
+ num_droppable: 1,
+ short_label: 'HW',
+ type: 'Homework',
+ weight: 1,
+ },
+ ],
+ grade_range: {
+ pass: 0.75,
+ },
+ },
+ studio_url: 'http://studio.edx.org/settings/grading/course-v1:edX+Test+run',
+ verification_data: {
+ link: null,
+ status: 'none',
+ status_date: null,
+ },
+ });
diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx
new file mode 100644
index 000000000000..416423cb9cf8
--- /dev/null
+++ b/src/course-home/progress-tab/ProgressTab.test.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { Factory } from 'rosie';
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import MockAdapter from 'axios-mock-adapter';
+
+import {
+ initializeMockApp, logUnhandledRequests, render, screen, act,
+} from '../../setupTest';
+import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
+import * as thunks from '../data/thunks';
+import initializeStore from '../../store';
+import ProgressTab from './ProgressTab';
+
+initializeMockApp();
+jest.mock('@edx/frontend-platform/analytics');
+
+describe('Progress Tab', () => {
+ let axiosMock;
+
+ const courseId = 'course-v1:edX+Test+run';
+ let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
+ courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
+ const progressUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
+
+ const store = initializeStore();
+ const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
+ const defaultTabData = Factory.build('progressTabData');
+
+ function setTabData(attributes, options) {
+ const progressTabData = Factory.build('progressTabData', attributes, options);
+ axiosMock.onGet(progressUrl).reply(200, progressTabData);
+ }
+
+ async function fetchAndRender() {
+ await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
+ await act(async () => render(, { store }));
+ }
+
+ beforeEach(async () => {
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+
+ // Set defaults for network requests
+ axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
+ axiosMock.onGet(progressUrl).reply(200, defaultTabData);
+
+ logUnhandledRequests(axiosMock);
+ });
+
+ describe('Grade Summary', () => {
+ it('renders Grade Summary table when assignment policies are populated', async () => {
+ await fetchAndRender();
+ expect(screen.getByText('Grade summary')).toBeInTheDocument();
+ });
+
+ it('does not render Grade Summary when assignment policies are not populated', async () => {
+ setTabData({
+ grading_policy: {
+ assignment_policies: [],
+ },
+ });
+ await fetchAndRender();
+ expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Detailed Grades', () => {
+ it('renders Detailed Grades table when section scores are populated', async () => {
+ await fetchAndRender();
+ expect(screen.getByText('Detailed grades')).toBeInTheDocument();
+
+ expect(screen.getByRole('link', { name: 'First subsection' }));
+ expect(screen.getByRole('link', { name: 'Second subsection' }));
+ });
+
+ it('render message when section scores are not populated', async () => {
+ setTabData({
+ section_scores: [],
+ });
+ await fetchAndRender();
+ expect(screen.getByText('Detailed grades')).toBeInTheDocument();
+ expect(screen.getByText('You currently have no graded problem scores.')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/course-home/progress-tab/course-completion/CompleteDonutSegment.jsx b/src/course-home/progress-tab/course-completion/CompleteDonutSegment.jsx
new file mode 100644
index 000000000000..03d17e4790b6
--- /dev/null
+++ b/src/course-home/progress-tab/course-completion/CompleteDonutSegment.jsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { OverlayTrigger, Popover } from '@edx/paragon';
+
+import messages from './messages';
+
+function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
+ const [showCompletePopover, setShowCompletePopover] = useState(false);
+
+ const completeSegmentOffset = (3.6 * completePercentage) / 8;
+ let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0;
+
+ const lockedSegmentOffset = lockedPercentage - 75;
+ if (lockedPercentage > 0) {
+ completeTooltipDegree = (lockedSegmentOffset + completePercentage) * -3.6 + 90 + completeSegmentOffset;
+ }
+
+ return (
+ setShowCompletePopover(false)}
+ onFocus={() => setShowCompletePopover(true)}
+ tabIndex="-1"
+ >
+
+
+ {/* Tooltip */}
+
+
+ {intl.formatMessage(messages.completeContentTooltip)}
+
+
+ )}
+ >
+ {/* Used to anchor the tooltip within the complete segment's stroke */}
+
+
+
+ {/* Segment dividers */}
+ {lockedPercentage > 0 && lockedPercentage < 100 && (
+
+ )}
+ {completePercentage < 100 && lockedPercentage < 100 && lockedPercentage + completePercentage === 100 && (
+
+ )}
+
+ );
+}
+
+CompleteDonutSegment.propTypes = {
+ completePercentage: PropTypes.number.isRequired,
+ intl: intlShape.isRequired,
+ lockedPercentage: PropTypes.number.isRequired,
+};
+
+export default injectIntl(CompleteDonutSegment);
diff --git a/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx b/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx
new file mode 100644
index 000000000000..ddeb67e93d7d
--- /dev/null
+++ b/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { useModel } from '../../../generic/model-store';
+
+import CompleteDonutSegment from './CompleteDonutSegment';
+import IncompleteDonutSegment from './IncompleteDonutSegment';
+import LockedDonutSegment from './LockedDonutSegment';
+import messages from './messages';
+
+function CompletionDonutChart({ intl }) {
+ const {
+ courseId,
+ } = useSelector(state => state.courseHome);
+
+ const {
+ completionSummary: {
+ completeCount,
+ incompleteCount,
+ lockedCount,
+ },
+ } = useModel('progress', courseId);
+
+ const numTotalUnits = completeCount + incompleteCount + lockedCount;
+ const completePercentage = Number(((completeCount / numTotalUnits) * 100).toFixed(0));
+ const lockedPercentage = Number(((lockedCount / numTotalUnits) * 100).toFixed(0));
+ const incompletePercentage = 100 - completePercentage - lockedPercentage;
+
+ return (
+ <>
+
+
+ {intl.formatMessage(messages.percentComplete, { percent: completePercentage })}
+ {intl.formatMessage(messages.percentIncomplete, { percent: incompletePercentage })}
+ {lockedPercentage > 0 && (
+ <>
+ {intl.formatMessage(messages.percentLocked, { percent: lockedPercentage })}
+ >
+ )}
+
+ >
+ );
+}
+
+CompletionDonutChart.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(CompletionDonutChart);
diff --git a/src/course-home/progress-tab/course-completion/CompletionDonutChart.scss b/src/course-home/progress-tab/course-completion/CompletionDonutChart.scss
new file mode 100644
index 000000000000..e8abfc09efa0
--- /dev/null
+++ b/src/course-home/progress-tab/course-completion/CompletionDonutChart.scss
@@ -0,0 +1,74 @@
+.donut rect {
+ fill: transparent;
+ width: 4px;
+ height: 4px;
+ transform-origin: center;
+}
+
+.donut-chart-label {
+ font: {
+ family: $font-family-sans-serif;
+ size: .2rem;
+ weight: $font-weight-normal;
+ }
+ text-anchor: middle;
+}
+
+.donut-chart-number {
+ font: {
+ family: $font-family-monospace;
+ size: .5rem;
+ weight: $font-weight-bold;
+ }
+ line-height: 1rem;
+ text-anchor: middle;
+ -moz-transform: translateY(-0.6em);
+ -ms-transform: translateY(-0.6em);
+ -webkit-transform: translateY(-0.6em);
+ transform: translateY(-0.6em);
+}
+
+.donut-chart-text {
+ fill: $primary-500;
+ -moz-transform: translateY(0.25em);
+ -ms-transform: translateY(0.25em);
+ -webkit-transform: translateY(0.25em);
+ transform: translateY(0.25em);
+}
+
+.donut-ring, .donut-segment {
+ stroke-width: 6px;
+ fill: transparent;
+}
+
+.donut-segment-group {
+ cursor: pointer;
+ pointer-events: visibleStroke;
+
+ &:focus {
+ outline: none;
+
+ circle {
+ stroke-width: 7px;
+ }
+ }
+}
+
+.donut-ring, .donut-segment, .donut-hole {
+ &.complete-stroke {
+ stroke: $info-500;
+ }
+
+ &.divider-stroke {
+ stroke-width: 7px;
+ stroke: white;
+ }
+
+ &.incomplete-stroke {
+ stroke: $light-300;
+ }
+
+ &.locked-stroke {
+ stroke: $primary-500;
+ }
+}
diff --git a/src/course-home/progress-tab/course-completion/CourseCompletion.jsx b/src/course-home/progress-tab/course-completion/CourseCompletion.jsx
index 2e19a78ba3d8..c48143509044 100644
--- a/src/course-home/progress-tab/course-completion/CourseCompletion.jsx
+++ b/src/course-home/progress-tab/course-completion/CourseCompletion.jsx
@@ -1,35 +1,29 @@
import React from 'react';
-import { useSelector } from 'react-redux';
-import { useModel } from '../../../generic/model-store';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-function CourseCompletion() {
- // TODO: AA-720
- const {
- courseId,
- } = useSelector(state => state.courseHome);
-
- const {
- completionSummary: {
- completeCount,
- incompleteCount,
- lockedCount,
- },
- } = useModel('progress', courseId);
-
- const total = completeCount + incompleteCount + lockedCount;
- const completePercentage = ((completeCount / total) * 100).toFixed(0);
- const incompletePercentage = ((incompleteCount / total) * 100).toFixed(0);
- const lockedPercentage = ((lockedCount / total) * 100).toFixed(0);
+import CompletionDonutChart from './CompletionDonutChart';
+import messages from './messages';
+function CourseCompletion({ intl }) {
return (
- Course completion
- This represents how much course content you have completed.
- Complete: {completePercentage}%
- Incomplete: {incompletePercentage}%
- Locked: {lockedPercentage}%
+
+
+
{intl.formatMessage(messages.courseCompletion)}
+
+ {intl.formatMessage(messages.completionBody)}
+
+
+
+
+
+
);
}
-export default CourseCompletion;
+CourseCompletion.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(CourseCompletion);
diff --git a/src/course-home/progress-tab/course-completion/IncompleteDonutSegment.jsx b/src/course-home/progress-tab/course-completion/IncompleteDonutSegment.jsx
new file mode 100644
index 000000000000..33ebec905acd
--- /dev/null
+++ b/src/course-home/progress-tab/course-completion/IncompleteDonutSegment.jsx
@@ -0,0 +1,55 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { OverlayTrigger, Popover } from '@edx/paragon';
+
+import messages from './messages';
+
+function IncompleteDonutSegment({ incompletePercentage, intl }) {
+ const [showIncompletePopover, setShowIncompletePopover] = useState(false);
+
+ const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;
+ const incompleteTooltipDegree = incompletePercentage < 100 ? incompleteSegmentOffset : 0;
+
+ return (
+ setShowIncompletePopover(false)}
+ onFocus={() => setShowIncompletePopover(true)}
+ tabIndex="-1"
+ >
+
+
+ {/* Tooltip */}
+
+
+ {intl.formatMessage(messages.incompleteContentTooltip)}
+
+
+ )}
+ >
+ {/* Used to anchor the tooltip within the incomplete segment's stroke */}
+
+
+
+ );
+}
+
+IncompleteDonutSegment.propTypes = {
+ incompletePercentage: PropTypes.number.isRequired,
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(IncompleteDonutSegment);
diff --git a/src/course-home/progress-tab/course-completion/LockedDonutSegment.jsx b/src/course-home/progress-tab/course-completion/LockedDonutSegment.jsx
new file mode 100644
index 000000000000..80f03db4cb45
--- /dev/null
+++ b/src/course-home/progress-tab/course-completion/LockedDonutSegment.jsx
@@ -0,0 +1,72 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { OverlayTrigger, Popover } from '@edx/paragon';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+
+function LockedDonutSegment({ intl, lockedPercentage }) {
+ const [showLockedPopover, setShowLockedPopover] = useState(false);
+
+ if (!lockedPercentage > 0) {
+ return null;
+ }
+
+ const iconDegree = lockedPercentage > 8 ? (3.6 * lockedPercentage) / 8 : ((3.6 * lockedPercentage) / 5) * 2;
+
+ return (
+ setShowLockedPopover(false)}
+ onFocus={() => setShowLockedPopover(true)}
+ tabIndex="-1"
+ >
+
+
+ {/* Tooltip */}
+
+
+ {intl.formatMessage(messages.lockedContentTooltip)}
+
+
+ )}
+ >
+
+ {/* Locked icon */}
+ 5 ? 'white' : 'transparent'}
+ style={{ transform: `scale(0.18) translate(5.8em, .7em) rotate(${iconDegree}deg)` }}
+ />
+
+
+
+ );
+}
+
+LockedDonutSegment.propTypes = {
+ intl: intlShape.isRequired,
+ lockedPercentage: PropTypes.number.isRequired,
+};
+
+export default injectIntl(LockedDonutSegment);
diff --git a/src/course-home/progress-tab/course-completion/messages.js b/src/course-home/progress-tab/course-completion/messages.js
new file mode 100644
index 000000000000..8d66f4f7d1ef
--- /dev/null
+++ b/src/course-home/progress-tab/course-completion/messages.js
@@ -0,0 +1,42 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ donutLabel: {
+ id: 'progress.completion.donut.label',
+ defaultMessage: 'completed',
+ },
+ completionBody: {
+ id: 'progress.completion.body',
+ defaultMessage: 'This represents how much of the course content you have completed. Note that some content may not yet be released.',
+ },
+ completeContentTooltip: {
+ id: 'progress.completion.tooltip.locked',
+ defaultMessage: 'Content that you have completed.',
+ },
+ courseCompletion: {
+ id: 'progress.completion.header',
+ defaultMessage: 'Course completion',
+ },
+ incompleteContentTooltip: {
+ id: 'progress.completion.tooltip',
+ defaultMessage: 'Content that you have access to and have not completed.',
+ },
+ lockedContentTooltip: {
+ id: 'progress.completion.tooltip.complete',
+ defaultMessage: 'Content that is locked and available only to those who upgrade.',
+ },
+ percentComplete: {
+ id: 'progress.completion.donut.percentComplete',
+ defaultMessage: 'You have completed {percent}% of content in this course.',
+ },
+ percentIncomplete: {
+ id: 'progress.completion.donut.percentIncomplete',
+ defaultMessage: 'You have not completed {percent}% of content in this course that you have access to.',
+ },
+ percentLocked: {
+ id: 'progress.completion.donut.percentLocked',
+ defaultMessage: '{percent}% of content in this course is locked and available only for those who upgrade.',
+ },
+});
+
+export default messages;
diff --git a/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx b/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx
index 8ed819bc42ba..621ac48deb11 100644
--- a/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx
+++ b/src/course-home/progress-tab/grades/detailed-grades/DetailedGrades.jsx
@@ -31,15 +31,15 @@ function DetailedGrades({ intl }) {
);
return (
-
+
{intl.formatMessage(messages.detailedGrades)}
{hasSectionScores && (
)}
{!hasSectionScores && (
- You currently have no graded problem scores.
+ {intl.formatMessage(messages.detailedGradesEmpty)}
)}
-
+
{intl.formatMessage(messages.gradeSummary)}
diff --git a/src/course-home/progress-tab/grades/messages.js b/src/course-home/progress-tab/grades/messages.js
index 9881ccafb070..ddaca03c78c4 100644
--- a/src/course-home/progress-tab/grades/messages.js
+++ b/src/course-home/progress-tab/grades/messages.js
@@ -17,6 +17,10 @@ const messages = defineMessages({
id: 'progress.detailedGrades',
defaultMessage: 'Detailed grades',
},
+ detailedGradesEmpty: {
+ id: 'progress.detailedGrades.emptyTable',
+ defaultMessage: 'You currently have no graded problem scores.',
+ },
footnotesTitle: {
id: 'progress.footnotes.title',
defaultMessage: 'Grade summary footnotes',
diff --git a/src/index.scss b/src/index.scss
index b330fffc1c66..8e8f03fac65e 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -368,6 +368,7 @@
@import 'course-home/dates-tab/Day.scss';
@import 'course-home/outline-tab/widgets/UpgradeCard.scss';
@import 'course-home/outline-tab/widgets/ProctoringInfoPanel.scss';
+@import 'course-home/progress-tab/course-completion/CompletionDonutChart.scss';
@import 'courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp';
/** [MM-P2P] Experiment */