AA-377: Add non-passing course exit screen (#246)

- Adds a non-passing cert learner course exit screen
- Moves all the logic about what course-exit mode we're in into
  a utility method in the course-exit folder
- Moves all the logic about how the 'Next' button should read into
  a utility method in the course-exit folder
This commit is contained in:
Michael Terry
2020-10-19 11:00:44 -04:00
committed by GitHub
parent f83a6e574c
commit c5821faee8
14 changed files with 255 additions and 128 deletions

View File

@@ -7,9 +7,7 @@ import { layoutGenerator } from 'react-break';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import { Button, Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { Alert, Button, Hyperlink } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
@@ -19,6 +17,7 @@ import certificate from './assets/edx_certificate.png';
import messages from './messages';
import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks';
import DashboardFootnote from './DashboardFootnote';
function CourseCelebration({ intl }) {
const layout = layoutGenerator({
@@ -154,7 +153,7 @@ function CourseCelebration({ intl }) {
<Helmet>
<title>{`${intl.formatMessage(messages.congratulationsHeader)} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<div className="row w-100 mx-0 my-4 px-5 py-3 border border-light">
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light">
<div className="col-12 p-0 h2 text-center">
{intl.formatMessage(messages.congratulationsHeader)}
</div>
@@ -179,7 +178,7 @@ function CourseCelebration({ intl }) {
</OnAtLeastTablet>
</div>
<div className="col-12 px-0 px-md-5">
<div className="row w-100 m-0 p-4 bg-primary-100">
<Alert variant="primary" className="row w-100 m-0">
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
<div className="h4">{title}</div>
<p>{message}</p>
@@ -213,17 +212,8 @@ function CourseCelebration({ intl }) {
/>
</div>
)}
</div>
<div className="row w-100 mx-0 my-3 justify-content-center">
<p className="text-gray-700">
<FontAwesomeIcon icon={faCalendarAlt} style={{ width: '20px' }} />&nbsp;
<FormattedMessage
id="courseCelebration.dashboardInfo"
defaultMessage="You can access this course and its materials on your {dashboardLink}."
values={{ dashboardLink }}
/>
</p>
</div>
</Alert>
<DashboardFootnote />
</div>
</div>
</>

View File

@@ -6,51 +6,36 @@ import { Button } from '@edx/paragon';
import { Redirect, useParams } from 'react-router-dom';
import CourseCelebration from './CourseCelebration';
import CourseNonPassing from './CourseNonPassing';
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
import messages from './messages';
import { useModel } from '../../../generic/model-store';
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
const CELEBRATION_STATUSES = [
'downloadable',
'earned_but_not_available',
'requesting',
'unverified',
];
function CourseExit({ intl }) {
const { courseId } = useParams();
const {
courseExitPageIsActive,
userHasPassingGrade,
certificateData,
} = useModel('courses', courseId);
const mode = getCourseExitMode(courseId);
// userHasPassingGrade can be removed once there is an experience for failing learners
if (!courseExitPageIsActive || !userHasPassingGrade) {
let body = null;
if (mode === COURSE_EXIT_MODES.nonPassing) {
body = (<CourseNonPassing />);
} else if (mode === COURSE_EXIT_MODES.celebration) {
body = (<CourseCelebration />);
} else {
return (<Redirect to={`/course/${courseId}`} />);
}
const {
certStatus,
} = certificateData;
if (CELEBRATION_STATUSES.indexOf(certStatus) !== -1) {
return (
<>
<div className="row w-100 m-0 justify-content-end">
<Button
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
>
{intl.formatMessage(messages.viewCoursesButton)}
</Button>
</div>
<CourseCelebration />
</>
);
}
// Just to be safe
return (<Redirect to={`/course/${courseId}`} />);
return (
<>
<div className="row w-100 mt-2 mb-4 justify-content-end">
<Button
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
>
{intl.formatMessage(messages.viewCoursesButton)}
</Button>
</div>
{body}
</>
);
}
CourseExit.propTypes = {

View File

@@ -0,0 +1,51 @@
import React from 'react';
import {
injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router-dom';
import { Alert, Button } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../generic/model-store';
import DashboardFootnote from './DashboardFootnote';
import messages from './messages';
function CourseNonPassing({ intl }) {
const { courseId } = useParams();
const { tabs } = useModel('courses', courseId);
// Get progress tab link for 'view grades' button
const progressTab = tabs.find(tab => tab.slug === 'progress');
const progressLink = progressTab && progressTab.url;
return (
<>
<Helmet>
<title>{`${intl.formatMessage(messages.endOfCourseTitle)} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light justify-content-center">
<div className="col-12 p-0 h2 text-center">
{ intl.formatMessage(messages.endOfCourseHeader) }
</div>
<Alert variant="primary" className="col col-lg-10 mt-4 d-flex align-items-start">
<div className="flex-grow-1 mr-5">{ intl.formatMessage(messages.endOfCourseDescription) }</div>
{progressLink && (
<Button variant="primary" className="flex-shrink-0" href={progressLink}>
{intl.formatMessage(messages.viewGradesButton)}
</Button>
)}
</Alert>
<DashboardFootnote />
</div>
</>
);
}
CourseNonPassing.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseNonPassing);

View File

@@ -0,0 +1,42 @@
import React from 'react';
import {
FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import Footnote from './Footnote';
import messages from './messages';
function DashboardFootnote({ intl }) {
const dashboardLink = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
className="text-reset"
>
{intl.formatMessage(messages.dashboardLink)}
</Hyperlink>
);
return (
<Footnote
icon={faCalendarAlt}
text={(
<FormattedMessage
id="courseCelebration.dashboardInfo" // for historical reasons
defaultMessage="You can access this course and its materials on your {dashboardLink}."
values={{ dashboardLink }}
/>
)}
/>
);
}
DashboardFootnote.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DashboardFootnote);

View File

@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
function Footnote({ icon, text }) {
return (
<div className="row w-100 mx-0 my-4 justify-content-center">
<p className="text-gray-700">
<FontAwesomeIcon icon={icon} style={{ width: '20px' }} />&nbsp;
{text}
</p>
</div>
);
}
Footnote.propTypes = {
icon: PropTypes.shape({}).isRequired,
text: PropTypes.node.isRequired,
};
export default Footnote;

View File

@@ -0,0 +1,4 @@
import CourseExit from './CourseExit';
import { getCourseExitText } from './utils';
export { CourseExit, getCourseExitText };

View File

@@ -45,11 +45,31 @@ const messages = defineMessages({
defaultMessage: 'Download my certificate',
description: 'Button to download the course certificate',
},
endOfCourseDescription: {
id: 'courseExit.endOfCourseDescription',
defaultMessage: 'Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.',
},
endOfCourseHeader: {
id: 'courseExit.endOfCourseHeader',
defaultMessage: 'Youve reached the end of the course!',
},
endOfCourseTitle: {
id: 'courseExit.endOfCourseTitle',
defaultMessage: 'End of Course',
},
idVerificationSupportLink: {
id: 'courseExit.idVerificationSupportLink',
defaultMessage: 'Learn more about ID verification',
description: 'Link to an article about identity verification',
},
nextButtonComplete: {
id: 'learn.sequence.navigation.complete.button', // for historical reasons
defaultMessage: 'Complete the course',
},
nextButtonEnd: {
id: 'courseExit.nextButton.endOfCourse',
defaultMessage: 'Next (end of course)',
},
profileLink: {
id: 'courseExit.profileLink',
defaultMessage: 'Profile',
@@ -83,6 +103,10 @@ const messages = defineMessages({
defaultMessage: 'View my courses',
description: 'Button to redirect user to their course dashboard',
},
viewGradesButton: {
id: 'courseExit.viewGradesButton',
defaultMessage: 'View grades',
},
});
export default messages;

View File

@@ -0,0 +1,60 @@
import { useModel } from '../../../generic/model-store';
import messages from './messages';
const COURSE_EXIT_MODES = {
disabled: 0,
celebration: 1,
nonPassing: 2,
};
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
const CELEBRATION_STATUSES = [
'downloadable',
'earned_but_not_available',
'requesting',
'unverified',
];
const NON_CERTIFICATE_STATUSES = [ // no certificate will be given, though a valid certificateData block is provided
'audit_passing',
'honor_passing', // honor can be configured to not give a certificate
];
function getCourseExitMode(courseId) {
const {
certificateData,
courseExitPageIsActive,
userHasPassingGrade,
} = useModel('courses', courseId);
if (!courseExitPageIsActive || !certificateData) {
return COURSE_EXIT_MODES.disabled;
}
const {
certStatus,
} = certificateData;
const isEligibleForCertificate = NON_CERTIFICATE_STATUSES.indexOf(certStatus) === -1;
if (isEligibleForCertificate && !userHasPassingGrade) {
return COURSE_EXIT_MODES.nonPassing;
}
if (CELEBRATION_STATUSES.indexOf(certStatus) !== -1) {
return COURSE_EXIT_MODES.celebration;
}
return COURSE_EXIT_MODES.disabled;
}
// Returns null if course exit is either not active or not handling the current case
function getCourseExitText(courseId, intl) {
switch (getCourseExitMode(courseId)) {
case COURSE_EXIT_MODES.celebration:
return intl.formatMessage(messages.nextButtonComplete);
case COURSE_EXIT_MODES.nonPassing:
return intl.formatMessage(messages.nextButtonEnd);
default:
return null;
}
}
export { COURSE_EXIT_MODES, getCourseExitMode, getCourseExitText };

View File

@@ -7,6 +7,7 @@ import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { getCourseExitText } from '../../course-exit';
import UnitButton from './UnitButton';
import SequenceNavigationTabs from './SequenceNavigationTabs';
import { useSequenceNavigationMetadata } from './hooks';
@@ -34,10 +35,6 @@ function SequenceNavigation({
const isLocked = sequenceStatus === LOADED ? (
sequence.gatedContent !== undefined && sequence.gatedContent.gated
) : undefined;
const {
courseExitPageIsActive,
userHasPassingGrade,
} = useModel('courses', courseId);
const renderUnitButtons = () => {
if (isLocked) {
@@ -61,31 +58,10 @@ function SequenceNavigation({
};
const renderNextButton = () => {
// AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
const buttonOnClick = (isLastUnit && courseExitPageIsActive && userHasPassingGrade
? goToCourseExitPage : nextSequenceHandler);
// AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
const disabled = isLastUnit && (!courseExitPageIsActive || !userHasPassingGrade);
let buttonText = (intl.formatMessage(messages.nextButton));
if (isLastUnit && courseExitPageIsActive && userHasPassingGrade) {
buttonText = (intl.formatMessage(messages.completeCourseButton));
}
// AA-198: Uncomment once there is a view for learners with failing grades
// else if (isLastUnit && courseExitPageIsActive && !userHasPassingGrade) {
// buttonText = (
// <FormattedMessage
// defaultMessage="Next"
// id="learn.sequence.navigation.endOfCourse.button.next"
// description="The 'next' text in the end of course button in the sequence nav"
// />
// <FormattedMessage
// defaultMessage="(end of course)"
// id="learn.sequence.navigation.endOfCourse.button.endOfCourse"
// description="The '(end of course)' text in the end of course button in the sequence nav"
// />
// )
// }
const exitText = getCourseExitText(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
const buttonText = isLastUnit && exitText ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitText;
return (
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled}>
{buttonText}

View File

@@ -5,9 +5,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { useSequenceNavigationMetadata } from './hooks';
import { useModel } from '../../../../generic/model-store';
import { getCourseExitText } from '../../course-exit';
import { useSequenceNavigationMetadata } from './hooks';
import messages from './messages';
function UnitNavigation({
@@ -20,38 +21,12 @@ function UnitNavigation({
}) {
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const { courseId } = useSelector(state => state.courseware);
const {
courseExitPageIsActive,
userHasPassingGrade,
} = useModel('courses', courseId);
const renderNextButton = () => {
// AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
const buttonOnClick = (isLastUnit && courseExitPageIsActive && userHasPassingGrade
? goToCourseExitPage : onClickNext);
// AA-198: The userHasPassingGrade condition can be removed once we have a view for learners with failing grades
const disabled = isLastUnit && (!courseExitPageIsActive || !userHasPassingGrade);
// This is just to support what used to show while we are getting courseExitPageIsActive turned on.
// This should be good to remove once disabled goes away.
if (disabled) {
return (
<div className="m-2">
<span role="img" aria-hidden="true">&#129303;</span> {/* This is a hugging face emoji */}
{' '}
{intl.formatMessage(messages.endOfCourse)}
</div>
);
}
let buttonText = (intl.formatMessage(messages.nextButton));
if (isLastUnit && courseExitPageIsActive && userHasPassingGrade) {
buttonText = (intl.formatMessage(messages.completeCourseButton));
}
// AA-198: Uncomment once there is a view for learners with failing grades
// else if (isLastUnit && courseExitPageIsActive && !userHasPassingGrade) {
// buttonText = (`${intl.formatMessage(messages.nextButton)} (${intl.formatMessage(messages.endOfCourse)})`);
// }
const exitText = getCourseExitText(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
const buttonText = isLastUnit && exitText ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitText;
return (
<Button variant="outline-primary" className="next-button" onClick={buttonOnClick} disabled={disabled}>
{buttonText}

View File

@@ -7,7 +7,11 @@ import UnitNavigation from './UnitNavigation';
describe('Unit Navigation', () => {
let mockData;
const courseMetadata = Factory.build('courseMetadata');
const courseMetadata = Factory.build('courseMetadata', {
certificate_data: {
cert_status: 'notpassing', // some interesting status that will trigger the last unit button to be active
},
});
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
'block',
{ type: 'vertical' },
@@ -73,11 +77,10 @@ describe('Unit Navigation', () => {
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
});
it('displays "learn.end.of.course" message instead of the "Next" button for the last unit in the sequence', () => {
it('displays end of course message instead of the "Next" button as needed', () => {
render(<UnitNavigation {...mockData} unitId={unitBlocks[unitBlocks.length - 1].id} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.queryByRole('button', { name: /next/i })).not.toBeInTheDocument();
expect(screen.getByText("You've reached the end of this course!")).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
});
});

View File

@@ -1,15 +1,6 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completeCourseButton: {
id: 'learn.sequence.navigation.complete.button',
defaultMessage: 'Complete the course',
description: 'Button to advance to the course completion page',
},
endOfCourse: {
id: 'learn.end.of.course',
defaultMessage: "You've reached the end of this course!",
},
nextButton: {
id: 'learn.sequence.navigation.next.button',
defaultMessage: 'Next',

View File

@@ -51,6 +51,11 @@ Factory.define('courseMetadata')
marketing_url: null,
celebrations: null,
enroll_alert: null,
course_exit_page_is_active: true,
user_has_passing_grade: false,
certificate_data: {
cert_status: 'audit_passing',
},
}).attr(
'tabs', ['tabs', 'id'], (passedTabs, id) => {
if (passedTabs) {

View File

@@ -19,7 +19,7 @@ import { UserMessagesProvider } from './generic/user-messages';
import './index.scss';
import './assets/favicon.ico';
import OutlineTab from './course-home/outline-tab';
import CourseExit from './courseware/course/course-exit/CourseExit';
import { CourseExit } from './courseware/course/course-exit';
import CoursewareContainer from './courseware';
import CoursewareRedirectLandingPage from './courseware/CoursewareRedirectLandingPage';
import DatesTab from './course-home/dates-tab';