feat: AA-1020: Add Credit Information to Progress tab (#726)

This is to match what the old Progress tab would show so we
can enable this for all credit courses as well
This commit is contained in:
Dillon Dumesnil
2021-11-05 11:08:29 -07:00
committed by GitHub
parent f7428db3c3
commit 64eb268cb0
17 changed files with 279 additions and 27 deletions

View File

@@ -17,6 +17,7 @@ Factory.define('progressTabData')
percent: 1,
is_passing: true,
},
credit_course_requirements: null,
section_scores: [
{
display_name: 'First section',

View File

@@ -581,6 +581,7 @@ Object {
"visiblePercent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,

View File

@@ -487,6 +487,29 @@ describe('Progress Tab', () => {
// visible to them, which is non-passing
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
});
it('renders correct title when credit information is available', async () => {
setTabData({
credit_course_requirements: {
eligibility_status: 'eligible',
requirements: [
{
namespace: 'proctored_exam',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Proctored Mid Term Exam',
criteria: {},
reason: {},
status: 'satisfied',
status_date: '2015-06-26 11:07:42',
order: 1,
},
],
},
});
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
});
});
describe('Grade Summary', () => {
@@ -1189,6 +1212,54 @@ describe('Progress Tab', () => {
});
});
describe('Credit Information', () => {
it('renders credit information when provided', async () => {
setTabData({
credit_course_requirements: {
eligibility_status: 'eligible',
requirements: [
{
namespace: 'proctored_exam',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Proctored Mid Term Exam',
criteria: {},
reason: {},
status: null,
status_date: '2015-06-26 11:07:42',
order: 1,
},
{
namespace: 'grade',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Minimum Passing Grade',
criteria: { min_grade: 0.8 },
reason: { final_grade: 0.95 },
status: 'satisfied',
status_date: '2015-06-26 11:07:44',
order: 2,
},
],
},
});
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
expect(screen.getByText('Requirements for course credit')).toBeInTheDocument();
expect(screen.getByText('You have met the requirements for credit in this course.', { exact: false })).toBeInTheDocument();
expect(screen.getByText('Proctored Mid Term Exam:')).toBeInTheDocument();
// 80% comes from the criteria.minGrade being 0.8
expect(screen.getByText('Minimum grade for credit (80%):')).toBeInTheDocument();
// Completed because the grade requirement has been satisfied
expect(screen.getByText('Completed')).toBeInTheDocument();
});
it('does not render credit information when it is not provided', async () => {
await fetchAndRender();
expect(screen.queryByText('Grades & Credit')).not.toBeInTheDocument();
expect(screen.queryByText('Requirements for course credit.')).not.toBeInTheDocument();
});
});
describe('Access expiration masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled, WatchFilled } from '@edx/paragon/icons';
import { Hyperlink, Icon } from '@edx/paragon';
import { useModel } from '../../../generic/model-store';
import { DashboardLink } from '../../../shared/links';
import messages from './messages';
function CreditInformation({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
} = useModel('progress', courseId);
if (!creditCourseRequirements) { return null; }
let eligibilityStatus;
let requirementStatus;
const requirements = [];
const dashboardLink = <DashboardLink />;
const creditLink = (
<Hyperlink
variant="muted"
isInline
destination={getConfig().CREDIT_HELP_LINK_URL}
>{intl.formatMessage(messages.courseCredit)}
</Hyperlink>
);
switch (creditCourseRequirements.eligibilityStatus) {
case 'not_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditNotEligible"
defaultMessage="You are no longer eligible for credit in this course. Learn more about {creditLink}."
values={{ creditLink }}
/>
);
break;
case 'eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditEligible"
defaultMessage="
You have met the requirements for credit in this course. Go to your
{dashboardLink} to purchase course credit. Or learn more about {creditLink}."
values={{ dashboardLink, creditLink }}
/>
);
break;
case 'partial_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditPartialEligible"
defaultMessage="You have not yet met the requirements for credit. Learn more about {creditLink}."
values={{ creditLink }}
/>
);
break;
default:
break;
}
creditCourseRequirements.requirements.forEach(requirement => {
switch (requirement.status) {
case 'submitted':
requirementStatus = (<>{intl.formatMessage(messages.verificationSubmitted)} <Icon src={CheckCircle} className="text-success-500 d-inline-flex align-bottom" /></>);
break;
case 'failed':
case 'declined':
requirementStatus = (<>{intl.formatMessage(messages.verificationFailed)} <Icon src={WarningFilled} className="d-inline-flex align-bottom" /></>);
break;
case 'satisfied':
requirementStatus = (<>{intl.formatMessage(messages.completed)} <Icon src={CheckCircle} className="text-success-500 d-inline-flex align-bottom" /></>);
break;
default:
requirementStatus = (<>{intl.formatMessage(messages.upcoming)} <Icon src={WatchFilled} className="text-gray-500 d-inline-flex align-bottom" /></>);
}
requirements.push((
<div className="row w-100 m-0 small" key={`requirement-${requirement.order}`}>
<p className="font-weight-bold">
{requirement.namespace === 'grade'
? `${intl.formatMessage(messages.minimumGrade, { minGrade: Number(requirement.criteria.minGrade) * 100 })}:`
: `${requirement.displayName}:`}
</p>
<div className="ml-1">
{requirementStatus}
</div>
</div>
));
});
return (
<>
<h3 className="h4 col-12 p-0">{intl.formatMessage(messages.requirementsHeader)}</h3>
<p className="small">{eligibilityStatus}</p>
{requirements}
</>
);
}
CreditInformation.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CreditInformation);

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completed: {
id: 'progress.creditInformation.completed',
defaultMessage: 'Completed',
},
courseCredit: {
id: 'progress.creditInformation.courseCredit',
defaultMessage: 'course credit',
},
minimumGrade: {
id: 'progress.creditInformation.minimumGrade',
defaultMessage: 'Minimum grade for credit ({minGrade}%)',
},
requirementsHeader: {
id: 'progress.creditInformation.requirementsHeader',
defaultMessage: 'Requirements for course credit',
},
upcoming: {
id: 'progress.creditInformation.upcoming',
defaultMessage: 'Upcoming',
},
verificationFailed: {
id: 'progress.creditInformation.verificationFailed',
defaultMessage: 'Verification failed',
},
verificationSubmitted: {
id: 'progress.creditInformation.verificationSubmitted',
defaultMessage: 'Verification submitted',
},
});
export default messages;

View File

@@ -7,6 +7,7 @@ import { useModel } from '../../../../generic/model-store';
import CourseGradeFooter from './CourseGradeFooter';
import CourseGradeHeader from './CourseGradeHeader';
import GradeBar from './GradeBar';
import CreditInformation from '../../credit-information/CreditInformation';
import messages from '../messages';
@@ -16,6 +17,7 @@ function CourseGrade({ intl }) {
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
gradesFeatureIsFullyLocked,
gradesFeatureIsPartiallyLocked,
gradingPolicy: {
@@ -32,14 +34,20 @@ function CourseGrade({ intl }) {
{(gradesFeatureIsFullyLocked || gradesFeatureIsPartiallyLocked) && <CourseGradeHeader />}
<div className={applyLockedOverlay} aria-hidden={gradesFeatureIsFullyLocked}>
<div className="row w-100 m-0 p-4">
<div className="col-12 col-sm-6 p-0 pr-sm-2">
<h2>{intl.formatMessage(messages.grades)}</h2>
<div className="col-12 col-sm-6 p-0 pr-sm-5.5">
<h2>{creditCourseRequirements
? intl.formatMessage(messages.gradesAndCredit)
: intl.formatMessage(messages.grades)}
</h2>
<p className="small">
{intl.formatMessage(messages.courseGradeBody)}
</p>
</div>
<GradeBar passingGrade={passingGrade} />
</div>
<div className="row w-100 m-0 px-4">
<CreditInformation />
</div>
<CourseGradeFooter passingGrade={passingGrade} />
</div>
</section>

View File

@@ -27,7 +27,7 @@ function GradeBar({ intl, passingGrade }) {
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
return (
<div className="col-12 col-sm-6 align-self-center">
<div className="col-12 col-sm-6 align-self-center p-0">
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
<g style={{ transform: 'translateY(2.61em)' }}>

View File

@@ -39,7 +39,8 @@ function DetailedGrades({ intl }) {
const outlineLink = (
<Hyperlink
className="muted-link inline-link"
variant="muted"
isInline
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
onClick={logOutlineLinkClick}
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}

View File

@@ -87,7 +87,16 @@ function SubsectionTitleCell({ intl, subsection }) {
SubsectionTitleCell.propTypes = {
intl: intlShape.isRequired,
subsection: PropTypes.shape.isRequired,
subsection: PropTypes.shape({
blockKey: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
learnerHasAccess: PropTypes.bool.isRequired,
problemScores: PropTypes.arrayOf(PropTypes.shape({
earned: PropTypes.number.isRequired,
possible: PropTypes.number.isRequired,
})).isRequired,
url: PropTypes.string,
}).isRequired,
};
export default injectIntl(SubsectionTitleCell);

View File

@@ -64,7 +64,7 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
footnoteMarker = footnotes.length;
}
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type) ? 'greyed-out' : '';
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type);
return {
type: {

View File

@@ -89,6 +89,10 @@ const messages = defineMessages({
id: 'progress.courseGrade.grades',
defaultMessage: 'Grades',
},
gradesAndCredit: {
id: 'progress.courseGrade.gradesAndCredit',
defaultMessage: 'Grades & Credit',
},
gradeRangeTooltipAlt: {
id: 'progress.courseGrade.gradeRange.Tooltip',
defaultMessage: 'Grade range tooltip',

View File

@@ -97,6 +97,7 @@ initialize({
mergeConfig({
CONTACT_URL: process.env.CONTACT_URL || null,
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null,
CREDIT_HELP_LINK_URL: process.env.CREDIT_HELP_LINK_URL || null,
ENTERPRISE_LEARNER_PORTAL_HOSTNAME: process.env.ENTERPRISE_LEARNER_PORTAL_HOSTNAME || null,
ENABLE_JUMPNAV: process.env.ENABLE_JUMPNAV || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,

View File

@@ -9,8 +9,8 @@ import messages from '../courseware/course/course-exit/messages';
function IntlDashboardLink({ intl }) {
return (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
variant="muted"
isInline
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
>
{intl.formatMessage(messages.dashboardLink)}
@@ -28,8 +28,8 @@ function IntlIdVerificationSupportLink({ intl }) {
}
return (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
variant="muted"
isInline
destination={getConfig().SUPPORT_URL_ID_VERIFICATION}
>
{intl.formatMessage(messages.idVerificationSupportLink)}
@@ -46,8 +46,8 @@ function IntlProfileLink({ intl }) {
return (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
variant="muted"
isInline
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
>
{intl.formatMessage(messages.profileLink)}