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 */} + + + {/* 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 */} + + + ); +} + +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 */} + + + ); +} + +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 */