AA-196 course celebration cert (#197)

* AA-196 course celebration cert

* AA-196: Course Celebration for passing Verified Learners

Co-authored-by: Dillon Dumesnil <ddumesnil@edx.org>

Note: This PR is being merged in somewhat incomplete as we decided to split off the work into a couple of other tickets. For example, the UI styling is not complete and I plan to also take another look at the routing. These code paths are not in use yet as the `courseExitPageIsActive` will always be False.
This commit is contained in:
Nick
2020-10-02 10:27:59 -04:00
committed by GitHub
parent d52aa3246e
commit 2f738fdba4
13 changed files with 468 additions and 37 deletions

View File

@@ -0,0 +1,181 @@
import React from 'react';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
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 { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import celebration from './assets/celebration_456x328.gif';
import certificate from './assets/certificate.png';
import messages from './messages';
import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks';
function CourseCelebration({ intl }) {
const { courseId } = useParams();
const dispatch = useDispatch();
const {
certificateData,
end,
verifyIdentityUrl,
} = useModel('courses', courseId);
const {
certStatus,
certWebViewUrl,
downloadUrl,
} = certificateData || { downloadUrl: 'google.com', certWebViewUrl: 'duckduckgo.com' };
const { username } = getAuthenticatedUser();
const dashboardLink = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
>
{intl.formatMessage(messages.dashboardLink)}
</Hyperlink>
);
const idVerificationSupportLink = getConfig().SUPPORT_URL && (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/206503858-How-do-I-verify-my-identity`}
>
{intl.formatMessage(messages.idVerificationSupportLink)}
</Hyperlink>
);
const profileLink = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
>
{intl.formatMessage(messages.profileLink)}
</Hyperlink>
);
let buttonLocation;
let buttonText;
let message;
let title;
// These cases are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
switch (certStatus) {
case 'downloadable':
title = intl.formatMessage(messages.certificateHeaderDownloadable);
message = (
<FormattedMessage
id="courseCelebration.certificateBody.available"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
description="Body in certificate banner"
values={{ dashboardLink, profileLink }}
/>
);
if (certWebViewUrl) {
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewCertificateButton);
} else if (downloadUrl) {
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadButton);
}
break;
case 'earned_but_not_available': {
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
title = intl.formatMessage(messages.certificateHeaderNotAvailable);
message = (
<FormattedMessage
id="courseCelebration.certificateBody.notAvailable"
defaultMessage="
After this course officially ends on {endDate}, you will receive an
email notification with your certificate. Once you have your certificate,
be sure to showcase your accomplishment on LinkedIn or your resumé.
You will be able to access your certificate any time from your
{dashboardLink} and {profileLink}."
description="Body in certificate banner"
values={{ endDate, dashboardLink, profileLink }}
/>
);
break;
}
case 'requesting':
buttonText = intl.formatMessage(messages.requestCertificateButton);
title = intl.formatMessage(messages.certificateHeaderRequestable);
message = intl.formatMessage(messages.requestCertificateBodyText);
break;
case 'unverified':
buttonText = intl.formatMessage(messages.verifyIdentityButton);
buttonLocation = verifyIdentityUrl;
title = intl.formatMessage(messages.certificateHeaderUnverified);
message = (
<FormattedMessage
id="courseCelebration.certificateBody.unverified"
defaultMessage="In order to generate a certificate, you must complete ID verification.
{idVerificationSupportLink} now."
description="Body in certificate banner"
values={{ idVerificationSupportLink }}
/>
);
break;
default:
break;
}
return (
<>
<Helmet>
<title>{`Congratulations! | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<div className="align-items-center d-flex flex-column border border-light p-3 mb-3 mt-5">
<h2>{ intl.formatMessage(messages.congratulationsHeader) }</h2>
<div className="mb-3 h4 font-weight-normal">{ intl.formatMessage(messages.shareHeader) }</div>
<div className="mb-5"><img src={celebration} alt="" className="img-fluid" /></div>
<div className="d-flex flex-row p-4 mb-3 bg-primary-100">
<div className="d-flex flex-column justify-content-between">
<div className="h4 mb-0">{title}</div>
{message}
{/* The requesting status needs a different button because it does a POST instead of a GET */}
{certStatus === 'requesting' ? (
<div>
<Button variant="outline-primary" onClick={() => dispatch(requestCert(courseId))} style={{ backgroundColor: '#FFFFFF' }}>
{buttonText}
</Button>
</div>
) : (
<div>
<Button variant="outline-primary" href={buttonLocation} style={{ backgroundColor: '#FFFFFF' }}>
{buttonText}
</Button>
</div>
)}
</div>
{certStatus !== 'unverified' && (
<div className="col-3"><img src={certificate} alt="" className="img-fluid" /></div>
)}
</div>
<div className="mb-3 text-gray-500">
<FontAwesomeIcon icon={faCalendarAlt} className="mr-2" style={{ width: '20px' }} />
<FormattedMessage
id="courseCelebration.dashboardInfo"
defaultMessage="You can always access this course and its materials on your {dashboardLink}."
description="Text letting the user know they can view their dashboard"
values={{ dashboardLink }}
/>
</div>
</div>
</>
);
}
CourseCelebration.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseCelebration);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Redirect, useParams } from 'react-router-dom';
import CourseCelebration from './CourseCelebration';
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',
];
export default function CourseExit() {
const { courseId } = useParams();
const {
courseExitPageIsActive,
userHasPassingGrade,
certificateData,
} = useModel('courses', courseId);
// userHasPassingGrade can be removed once there is an experience for failing learners
if (!courseExitPageIsActive || !userHasPassingGrade) {
return (<Redirect to={`/course/${courseId}`} />);
}
const {
certStatus,
} = certificateData;
if (CELEBRATION_STATUSES.indexOf(certStatus) !== -1) {
return (<CourseCelebration />);
}
// Just to be safe
return (<Redirect to={`/course/${courseId}`} />);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,76 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
certificateHeaderDownloadable: {
id: 'courseCelebration.certificateHeader.downloadable',
defaultMessage: 'Your certificate is available!',
description: 'Text displayed when course certificate is ready to be downloaded',
},
certificateHeaderNotAvailable: {
id: 'courseCelebration.certificateHeader.notAvailable',
defaultMessage: 'Your certificate will be available soon!',
description: 'Text displayed when course certificate is not yet available to view',
},
certificateHeaderUnverified: {
id: 'courseCelebration.certificateHeader.unverified',
defaultMessage: 'You must complete verification to receive your certificate.',
description: 'Text displayed when a user has not verified their identity and cannot view their course certificate',
},
certificateHeaderRequestable: {
id: 'courseCelebration.certificateHeader.requestable',
defaultMessage: 'Congratulations, you qualified for a certificate!',
description: 'Text displayed when a user has completed the course and can request a certificate',
},
congratulationsHeader: {
id: 'courseCelebration.congratulationsHeader',
defaultMessage: 'Congratulations!',
description: 'Congratulations header on course completion page',
},
dashboardLink: {
id: 'courseExit.dashboardLink',
defaultMessage: 'Dashboard',
description: "Link to learner's dashboard",
},
downloadButton: {
id: 'courseCelebration.downloadButton',
defaultMessage: 'Download my certificate',
description: 'Text for download button in certificate banner',
},
idVerificationSupportLink: {
id: 'courseExit.idVerificationSupportLink',
defaultMessage: 'Learn more about ID verification',
description: 'Link to help article about ID verification',
},
profileLink: {
id: 'courseExit.profileLink',
defaultMessage: 'Profile',
description: 'Link to profile',
},
requestCertificateBodyText: {
id: 'courseCelebration.requestCertificateBodyText',
defaultMessage: 'In order to access your certificate, request it below.',
description: 'Body text for certificate banner',
},
requestCertificateButton: {
id: 'courseCelebration.requestCertificateButton',
defaultMessage: 'Request certificate',
description: 'Text for request certificate button in certificate banner',
},
shareHeader: {
id: 'courseCelebration.shareHeader',
defaultMessage: 'You have completed your course. Share your success on social media or email.',
description: 'Social media/email share header',
},
verifyIdentityButton: {
id: 'courseCelebration.verifyIdentityButton',
defaultMessage: 'Verify now',
description: 'Text for verify identity button in certificate banner',
},
viewCertificateButton: {
id: 'courseCelebration.viewCertificateButton',
defaultMessage: 'View now',
description: 'Text for view certificate button in certificate banner',
},
});
export default messages;

View File

@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import PageLoading from '../../../generic/PageLoading';
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
@@ -113,6 +114,9 @@ function Sequence({
}
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const goToCourseExitPage = () => {
history.push(`/course/${courseId}/course-exit`);
};
if (sequenceStatus === 'loaded') {
return (
@@ -134,6 +138,7 @@ function Sequence({
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
<div className="unit-container flex-grow-1">
<SequenceContent
@@ -155,6 +160,7 @@ function Sequence({
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
)}
</div>

View File

@@ -20,13 +20,21 @@ export default function SequenceNavigation({
onNavigate,
nextSequenceHandler,
previousSequenceHandler,
goToCourseExitPage,
}) {
const sequence = useModel('sequences', sequenceId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const {
courseId,
sequenceStatus,
} = useSelector(state => state.courseware);
const isLocked = sequenceStatus === LOADED ? (
sequence.gatedContent !== undefined && sequence.gatedContent.gated
) : undefined;
const {
courseExitPageIsActive,
userHasPassingGrade,
} = useModel('courses', courseId);
const renderUnitButtons = () => {
if (isLocked) {
@@ -49,6 +57,52 @@ export default 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 = (
<FormattedMessage
defaultMessage="Next"
id="learn.sequence.navigation.next.button"
description="The Next button in the sequence nav"
/>
);
if (isLastUnit && courseExitPageIsActive && userHasPassingGrade) {
buttonText = (
<FormattedMessage
defaultMessage="Complete the course"
id="learn.sequence.navigation.completeCourse.button"
description="The 'Complete the course' button in the sequence nav"
/>
);
}
// 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"
// />
// )
// }
return (
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled}>
{buttonText}
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
);
};
return sequenceStatus === LOADED && (
<nav className={classNames('sequence-navigation', className)}>
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
@@ -60,14 +114,7 @@ export default function SequenceNavigation({
/>
</Button>
{renderUnitButtons()}
<Button variant="link" className="next-btn" onClick={nextSequenceHandler} disabled={isLastUnit}>
<FormattedMessage
defaultMessage="Next"
id="learn.sequence.navigation.next.button"
description="The Next button in the sequence nav"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
{renderNextButton()}
</nav>
);
}
@@ -79,6 +126,7 @@ SequenceNavigation.propTypes = {
onNavigate: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
};
SequenceNavigation.defaultProps = {

View File

@@ -4,7 +4,9 @@ import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { useSequenceNavigationMetadata } from './hooks';
import { useModel } from '../../../../generic/model-store';
export default function UnitNavigation(props) {
const {
@@ -12,9 +14,77 @@ export default function UnitNavigation(props) {
unitId,
onClickPrevious,
onClickNext,
goToCourseExitPage,
} = props;
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 */}
{' '}
<FormattedMessage
id="learn.end.of.course"
description="Message shown to students in place of a 'Next' button when they're at the end of a course."
defaultMessage="You've reached the end of this course!"
/>
</div>
);
}
let buttonText = (
<FormattedMessage
id="learn.sequence.navigation.after.unit.next"
description="The button to go to the next unit"
defaultMessage="Next"
/>
);
if (isLastUnit && courseExitPageIsActive && userHasPassingGrade) {
buttonText = (
<FormattedMessage
defaultMessage="Complete the course"
id="learn.sequence.navigation.after.unit.completeCourse"
description="The 'Complete the course' button in the unit nav"
/>
);
}
// 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"
// />
// )
// }
return (
<Button variant="outline-primary" className="next-button" onClick={buttonOnClick} disabled={disabled}>
{buttonText}
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
);
};
return (
<div className="unit-navigation d-flex">
@@ -31,31 +101,7 @@ export default function UnitNavigation(props) {
defaultMessage="Previous"
/>
</Button>
{isLastUnit ? (
<div className="m-2">
<span role="img" aria-hidden="true">&#129303;</span> {/* This is a hugging face emoji */}
{' '}
<FormattedMessage
id="learn.end.of.course"
description="Message shown to students in place of a 'Next' button when they're at the end of a course."
defaultMessage="You've reached the end of this course!"
/>
</div>
) : (
<Button
variant="outline-primary"
className="next-button"
onClick={onClickNext}
disabled={isLastUnit}
>
<FormattedMessage
id="learn.sequence.navigation.after.unit.next"
description="The button to go to the next unit"
defaultMessage="Next"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
)}
{renderNextButton()}
</div>
);
}
@@ -65,6 +111,7 @@ UnitNavigation.propTypes = {
unitId: PropTypes.string,
onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
};
UnitNavigation.defaultProps = {

View File

@@ -135,6 +135,10 @@ function normalizeMetadata(metadata) {
notes: camelCaseObject(metadata.notes),
marketingUrl: metadata.marketing_url,
celebrations: camelCaseObject(metadata.celebrations),
userHasPassingGrade: metadata.user_has_passing_grade,
courseExitPageIsActive: metadata.course_exit_page_is_active,
certificateData: camelCaseObject(metadata.certificate_data),
verifyIdentityUrl: metadata.verify_identity_url,
};
}

View File

@@ -19,6 +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 CoursewareContainer from './courseware';
import CoursewareRedirectLandingPage from './courseware/CoursewareRedirectLandingPage';
import DatesTab from './course-home/dates-tab';
@@ -26,6 +27,7 @@ import ProgressTab from './course-home/progress-tab/ProgressTab';
import { TabContainer } from './tab-page';
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
import { fetchCourse } from './courseware/data';
import initializeStore from './store';
subscribe(APP_READY, () => {
@@ -49,6 +51,11 @@ subscribe(APP_READY, () => {
<ProgressTab />
</TabContainer>
</Route>
<Route path="/course/:courseId/course-exit">
<TabContainer tab="exit" fetch={fetchCourse} isExitPage>
<CourseExit />
</TabContainer>
</Route>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
@@ -36,6 +36,12 @@ function getStudioUrl(courseId, unitId) {
}
export default function InstructorToolbar(props) {
const [didMount, setDidMount] = useState(false);
useEffect(() => {
setDidMount(true);
return () => setDidMount(false);
}, []);
const {
courseId,
unitId,
@@ -51,6 +57,10 @@ export default function InstructorToolbar(props) {
});
const urlStudio = getStudioUrl(courseId, unitId);
const [masqueradeErrorMessage, showMasqueradeError] = useState(null);
if (!didMount) {
return null;
}
return (
<div>
<div className="bg-primary text-white">

View File

@@ -16,6 +16,8 @@ import {
import messages from './messages';
class MasqueradeWidget extends Component {
isMounted = false;
constructor(props) {
super(props);
this.courseId = props.courseId;
@@ -29,8 +31,9 @@ class MasqueradeWidget extends Component {
}
componentDidMount() {
this.isMounted = true;
getMasqueradeOptions(this.courseId).then((data) => {
if (data.success) {
if (data.success && this.isMounted) {
this.onSuccess(data);
} else {
// This was explicitly denied by the backend;
@@ -47,6 +50,10 @@ class MasqueradeWidget extends Component {
});
}
componentWillUnmount() {
this.isMounted = false;
}
onError(message) {
this.props.onError(message);
}

View File

@@ -10,6 +10,7 @@ export default function TabContainer(props) {
children,
fetch,
tab,
isExitPage,
} = props;
const { courseId: courseIdFromUrl } = useParams();
@@ -21,10 +22,11 @@ export default function TabContainer(props) {
// The courseId from the store is the course we HAVE loaded. If the URL changes,
// we don't want the application to adjust to it until it has actually loaded the new data.
const slice = isExitPage ? 'courseware' : 'courseHome';
const {
courseId,
courseStatus,
} = useSelector(state => state.courseHome);
} = useSelector(state => state[slice]);
return (
<TabPage
@@ -40,5 +42,10 @@ export default function TabContainer(props) {
TabContainer.propTypes = {
children: PropTypes.node.isRequired,
fetch: PropTypes.func.isRequired,
isExitPage: PropTypes.bool,
tab: PropTypes.string.isRequired,
};
TabContainer.defaultProps = {
isExitPage: false,
};