Skip to content

Commit

Permalink
AA-720: Progress Tab Course Completion chart (openedx#407)
Browse files Browse the repository at this point in the history
  • Loading branch information
Carla Duarte authored Apr 13, 2021
1 parent d13bb04 commit aca45fb
Show file tree
Hide file tree
Showing 15 changed files with 572 additions and 31 deletions.
1 change: 1 addition & 0 deletions src/course-home/data/__factories__/index.js
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 src/course-home/data/__factories__/progressTabData.factory.js
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,
},
});
85 changes: 85 additions & 0 deletions src/course-home/progress-tab/ProgressTab.test.jsx
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();
});
});
});
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);
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);
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;
}
}
Loading

0 comments on commit aca45fb

Please sign in to comment.