Compare commits
3 Commits
dependabot
...
ttracy/MIC
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31c116cf58 | ||
|
|
b3f56f8f18 | ||
|
|
e74de71fb7 |
@@ -10,6 +10,7 @@ Factory.define('courseHomeMetadata')
|
||||
is_enrolled: false,
|
||||
is_staff: false,
|
||||
can_load_courseware: true,
|
||||
can_view_certificate: true,
|
||||
celebrations: null,
|
||||
course_access: {
|
||||
additional_context_user_message: null,
|
||||
|
||||
@@ -22,6 +22,7 @@ Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canLoadCourseware": true,
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
@@ -340,6 +341,7 @@ Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canLoadCourseware": true,
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
@@ -538,6 +540,7 @@ Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canLoadCourseware": true,
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
|
||||
@@ -31,7 +31,9 @@ describe('Progress Tab', () => {
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
|
||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
@@ -806,11 +808,54 @@ describe('Progress Tab', () => {
|
||||
sendTrackEvent.mockClear();
|
||||
});
|
||||
|
||||
it('Displays text for nonPassing case when learner does not have a passing grade', async () => {
|
||||
it('Displays text for nonPassing case when learner does not have a passing grade and certificates are viewable', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('In order to qualify for a certificate, you must have a passing grade.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays text for notAvailableNonPassing case when a learner does not have a passing grade and certificates are not viewable', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText(`This course ends on ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}, final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Handles certificate available dates for notAvailableNotPassing', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
certificate_data: {
|
||||
certificate_available_date: overmorrow.toISOString(),
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText(`This course ends on ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}, final grades and any earned certificates are scheduled to be available after ${overmorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends event when visiting progress tab when learner is not passing', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ function CertificateStatus({ intl }) {
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
isSelfPaced,
|
||||
canViewCertificate,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
@@ -90,9 +92,25 @@ function CertificateStatus({ intl }) {
|
||||
if (mode === COURSE_EXIT_MODES.disabled) {
|
||||
certEventName = 'certificate_status_disabled';
|
||||
} else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) {
|
||||
certCase = 'notPassing';
|
||||
certEventName = 'not_passing';
|
||||
body = intl.formatMessage(messages[`${certCase}Body`]);
|
||||
// If the learner is not supposed to be able to view a certificate because
|
||||
// of the certificate display behavior, we also don't want to show them a
|
||||
// "not passing" message early.
|
||||
certCase = canViewCertificate && !isSelfPaced ? 'notPassing' : 'notAvailableNotPassing';
|
||||
if (!canViewCertificate) {
|
||||
endDate = intl.formatDate(end, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
certAvailabilityDate = intl.formatDate(certificateAvailableDate || end, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
// These values just pass through if they are not needed by the message
|
||||
body = intl.formatMessage(messages[`${certCase}Body`], { endDate, certAvailabilityDate });
|
||||
} else if (mode === COURSE_EXIT_MODES.inProgress && !certIsDownloadable) {
|
||||
certCase = 'inProgress';
|
||||
certEventName = 'has_scheduled_content';
|
||||
|
||||
@@ -106,6 +106,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.',
|
||||
description: 'Body text when the learner needs to do verification to earn a certifcate',
|
||||
},
|
||||
notAvailableNotPassingHeader: {
|
||||
id: 'progress.certificateStatus.notAvailableNotPassingHeader',
|
||||
defaultMessage: 'Certificate status',
|
||||
description: 'Header text when the certifcate is not available',
|
||||
},
|
||||
notAvailableNotPassingBody: {
|
||||
id: 'progress.certificateStatus.notAvailableNotPassingBody',
|
||||
defaultMessage: 'This course ends on {endDate}, final grades and any earned certificates are scheduled to be available after {certAvailabilityDate}.',
|
||||
description: 'message that appears at the end of a course for a user who has finished a course early and has not passed or grades are not final',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -55,6 +55,7 @@ function CourseCelebration({ intl }) {
|
||||
const {
|
||||
org,
|
||||
verifiedMode,
|
||||
canViewCertificate,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
@@ -248,6 +249,29 @@ function CourseCelebration({ intl }) {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// To properly handle messaging in the case where a course has not ended yet
|
||||
// and certificateData is null, we want to make sure we serve a generic message
|
||||
// whether they are in a nonpassing or earned state.
|
||||
if (!canViewCertificate) {
|
||||
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||||
const certAvailableDate = <FormattedDate value={certificateAvailableDate || end} day="numeric" month="long" year="numeric" />;
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
|
||||
message = (
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.v2"
|
||||
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certAvailableDate}."
|
||||
values={{ endDate, certAvailableDate }}
|
||||
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
visitEvent = 'celebration_with_unavailable_cert';
|
||||
footnote = <DashboardFootnote variant={visitEvent} />;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ describe('Course Exit Pages', () => {
|
||||
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
|
||||
const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
|
||||
|
||||
function setMetadata(coursewareAttributes, courseHomeAttributes = {}) {
|
||||
const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes };
|
||||
@@ -367,11 +370,54 @@ describe('Course Exit Pages', () => {
|
||||
|
||||
describe('Course Non-passing Experience', () => {
|
||||
it('Displays link to progress tab', async () => {
|
||||
setMetadata({ user_has_passing_grade: false });
|
||||
setMetadata({
|
||||
user_has_passing_grade: false,
|
||||
});
|
||||
await fetchAndRender(<CourseNonPassing />);
|
||||
expect(screen.getByText('You’ve reached the end of the course!')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'View grades' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays text for notAvailableNonPassing case when a learner does not have a passing grade and certificates are not viewable', async () => {
|
||||
setMetadata({
|
||||
user_has_passing_grade: false,
|
||||
end: tomorrow.toISOString(),
|
||||
}, {
|
||||
can_view_certificate: false,
|
||||
});
|
||||
await fetchAndRender(<CourseNonPassing />);
|
||||
expect(screen.getByText(`This course ends on ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}, final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Handles certificate available dates for notAvailableNotPassing', async () => {
|
||||
setMetadata({
|
||||
user_has_passing_grade: false,
|
||||
end: tomorrow.toISOString(),
|
||||
certificate_data: {
|
||||
certificate_available_date: overmorrow.toISOString(),
|
||||
},
|
||||
}, {
|
||||
can_view_certificate: false,
|
||||
});
|
||||
await fetchAndRender(<CourseNonPassing />);
|
||||
expect(screen.getByText(`This course ends on ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}, final grades and any earned certificates are scheduled to be available after ${overmorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course in progress experience', () => {
|
||||
|
||||
@@ -4,11 +4,17 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import {
|
||||
Alert, breakpoints, Button, useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { CheckCircle } from '@edx/paragon/icons';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import CelebrationMobile from './assets/celebration_456x328.gif';
|
||||
import CelebrationDesktop from './assets/celebration_750x540.gif';
|
||||
import certificate from '../../../generic/assets/edX_certificate.png';
|
||||
import CatalogSuggestion from './CatalogSuggestion';
|
||||
import DashboardFootnote from './DashboardFootnote';
|
||||
import messages from './messages';
|
||||
@@ -20,15 +26,27 @@ function CourseNonPassing({ intl }) {
|
||||
org,
|
||||
tabs,
|
||||
title,
|
||||
isSelfPaced,
|
||||
canViewCertificate,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const { end, certificateData } = useModel('coursewareMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
let certificateAvailableDate = certificateData?.certificateAvailableDate || end;
|
||||
certificateAvailableDate = intl.formatDate(certificateAvailableDate, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
const courseEndDate = intl.formatDate(end, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
|
||||
// Get progress tab link for 'view grades' button
|
||||
const progressTab = tabs.find(tab => tab.slug === 'progress');
|
||||
const progressLink = progressTab && progressTab.url;
|
||||
|
||||
useEffect(() => logVisit(org, courseId, administrator, 'nonpassing'), [org, courseId, administrator]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -38,21 +56,79 @@ function CourseNonPassing({ intl }) {
|
||||
<div className="col-12 p-0 h2 text-center">
|
||||
{ intl.formatMessage(messages.endOfCourseHeader) }
|
||||
</div>
|
||||
<Alert variant="primary" className="col col-lg-10 mt-4">
|
||||
<div className="row w-100 m-0 align-items-start">
|
||||
<div className="flex-grow-1 col-sm p-0">{ intl.formatMessage(messages.endOfCourseDescription) }</div>
|
||||
{progressLink && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-shrink-0 mt-3 mt-sm-0 mb-1 mb-sm-0 ml-sm-5"
|
||||
href={progressLink}
|
||||
onClick={() => logClick(org, courseId, administrator, 'view_grades')}
|
||||
>
|
||||
{intl.formatMessage(messages.viewGradesButton)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
{!canViewCertificate && !isSelfPaced && (
|
||||
<>
|
||||
{!wideScreen && (
|
||||
<div className="col-12 mt-3 mb-4 px-0 px-md-5 text-center">
|
||||
<img
|
||||
src={CelebrationMobile}
|
||||
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
|
||||
className="img-fluid"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{wideScreen && (
|
||||
<div className="col-12 mt-3 mb-4 px-0 px-md-5 text-center">
|
||||
<img
|
||||
src={CelebrationDesktop}
|
||||
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
|
||||
className="img-fluid"
|
||||
style={{ width: '36rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{ // We don't want to show a learner an nonPassing message
|
||||
// before the certificates are viewable for the course.
|
||||
canViewCertificate || isSelfPaced
|
||||
? (
|
||||
<Alert variant="primary" className="col col-lg-10 mt-4">
|
||||
<div className="row w-100 m-0 align-items-start">
|
||||
<div className="flex-grow-1 col-sm p-0">
|
||||
{intl.formatMessage(messages.endOfCourseDescription)}
|
||||
</div>
|
||||
{progressLink && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-shrink-0 mt-3 mt-sm-0 mb-1 mb-sm-0 ml-sm-5"
|
||||
href={progressLink}
|
||||
onClick={() => logClick(org, courseId, administrator, 'view_grades')}
|
||||
>
|
||||
{intl.formatMessage(messages.viewGradesButton)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="col-12 px-0 px-md-5">
|
||||
<Alert Alert variant="success" icon={CheckCircle}>
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<h4 className="h4">{intl.formatMessage(messages.courseFinishedEarlyHeading)}</h4>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages.courseFinishedEarlyBody,
|
||||
{
|
||||
courseEndDate,
|
||||
certificateAvailableDate,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||
<img
|
||||
src={certificate}
|
||||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||||
className="w-100"
|
||||
style={{ maxWidth: '13rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<DashboardFootnote variant="nonpassing" />
|
||||
<CatalogSuggestion variant="nonpassing" />
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'More content is coming soon!',
|
||||
description: 'Header when the status of the course not all of (contents or assignments) available yet',
|
||||
},
|
||||
courseFinishedEarlyHeading: {
|
||||
id: 'courseExit.courseFinishedEarlyHeading',
|
||||
defaultMessage: 'Your grade and certificate status will be available soon.',
|
||||
description: 'Heading to a message that appears at the end of a course for a user who has finished a course early and has not passed or grades are not final',
|
||||
},
|
||||
courseFinishedEarlyBody: {
|
||||
id: 'courseExit.courseFinishedEarlyBody',
|
||||
defaultMessage: 'This course ends on {courseEndDate}, final grades and any earned certificates are scheduled to be available after {certificateAvailableDate}.',
|
||||
description: 'message that appears at the end of a course for a user who has finished a course early and has not passed or grades are not final',
|
||||
},
|
||||
dashboardLink: {
|
||||
id: 'courseExit.dashboardLink',
|
||||
defaultMessage: 'Dashboard',
|
||||
|
||||
@@ -124,6 +124,7 @@ function normalizeMetadata(metadata) {
|
||||
relatedPrograms: camelCaseObject(data.related_programs),
|
||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||
canAccessProctoredExams: data.can_access_proctored_exams,
|
||||
canViewCertificate: data.can_view_certificate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user