forked from openedx/edx-platform
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
AA-720: Progress Tab Course Completion chart (openedx#407)
- Loading branch information
Carla Duarte
authored
Apr 13, 2021
1 parent
d13bb04
commit aca45fb
Showing
15 changed files
with
572 additions
and
31 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
import './courseHomeMetadata.factory'; | ||
import './datesTabData.factory'; | ||
import './outlineTabData.factory'; | ||
import './progressTabData.factory'; |
71 changes: 71 additions & 0 deletions
71
src/course-home/data/__factories__/progressTabData.factory.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(<ProgressTab />, { 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(); | ||
}); | ||
}); | ||
}); |
77 changes: 77 additions & 0 deletions
77
src/course-home/progress-tab/course-completion/CompleteDonutSegment.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<g | ||
className="donut-segment-group" | ||
onBlur={() => setShowCompletePopover(false)} | ||
onFocus={() => setShowCompletePopover(true)} | ||
tabIndex="-1" | ||
> | ||
<circle | ||
className="donut-segment complete-stroke" | ||
cx="21" | ||
cy="21" | ||
r="15.91549430918954" | ||
strokeDasharray={`${completePercentage} ${100 - completePercentage}`} | ||
strokeDashoffset={lockedSegmentOffset + completePercentage} | ||
/> | ||
|
||
{/* Tooltip */} | ||
<OverlayTrigger | ||
show={showCompletePopover} | ||
placement="top" | ||
overlay={( | ||
<Popover aria-hidden="true"> | ||
<Popover.Content> | ||
{intl.formatMessage(messages.completeContentTooltip)} | ||
</Popover.Content> | ||
</Popover> | ||
)} | ||
> | ||
{/* Used to anchor the tooltip within the complete segment's stroke */} | ||
<rect x="19" y="3" style={{ transform: `rotate(${completeTooltipDegree}deg)` }} /> | ||
</OverlayTrigger> | ||
|
||
{/* Segment dividers */} | ||
{lockedPercentage > 0 && lockedPercentage < 100 && ( | ||
<circle | ||
className="donut-segment divider-stroke" | ||
strokeDasharray="0.3 99.7" | ||
strokeDashoffset={0.15 + lockedSegmentOffset} | ||
/> | ||
)} | ||
{completePercentage < 100 && lockedPercentage < 100 && lockedPercentage + completePercentage === 100 && ( | ||
<circle | ||
className="donut-segment divider-stroke" | ||
strokeDasharray="0.3 99.7" | ||
strokeDashoffset="25.15" | ||
/> | ||
)} | ||
</g> | ||
); | ||
} | ||
|
||
CompleteDonutSegment.propTypes = { | ||
completePercentage: PropTypes.number.isRequired, | ||
intl: intlShape.isRequired, | ||
lockedPercentage: PropTypes.number.isRequired, | ||
}; | ||
|
||
export default injectIntl(CompleteDonutSegment); |
65 changes: 65 additions & 0 deletions
65
src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<> | ||
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true"> | ||
{/* The radius (or "r" attribute) is based off of a circumference of 100 in order to simplify percentage | ||
calculations. The subsequent stroke-dasharray values found in each segment should add up to equal 100 | ||
in order to wrap around the circle once. */} | ||
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" /> | ||
<g className="donut-chart-text"> | ||
<text x="50%" y="50%" className="donut-chart-number"> | ||
{completePercentage}% | ||
</text> | ||
<text x="50%" y="50%" className="donut-chart-label"> | ||
{intl.formatMessage(messages.donutLabel)} | ||
</text> | ||
</g> | ||
<IncompleteDonutSegment incompletePercentage={incompletePercentage} /> | ||
<LockedDonutSegment lockedPercentage={lockedPercentage} /> | ||
<CompleteDonutSegment completePercentage={completePercentage} lockedPercentage={lockedPercentage} /> | ||
</svg> | ||
<div className="sr-only"> | ||
{intl.formatMessage(messages.percentComplete, { percent: completePercentage })} | ||
{intl.formatMessage(messages.percentIncomplete, { percent: incompletePercentage })} | ||
{lockedPercentage > 0 && ( | ||
<> | ||
{intl.formatMessage(messages.percentLocked, { percent: lockedPercentage })} | ||
</> | ||
)} | ||
</div> | ||
</> | ||
); | ||
} | ||
|
||
CompletionDonutChart.propTypes = { | ||
intl: intlShape.isRequired, | ||
}; | ||
|
||
export default injectIntl(CompletionDonutChart); |
74 changes: 74 additions & 0 deletions
74
src/course-home/progress-tab/course-completion/CompletionDonutChart.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
Oops, something went wrong.