diff --git a/src/course-home/data/__factories__/courseHomeMetadata.factory.js b/src/course-home/data/__factories__/courseHomeMetadata.factory.js index fb4781b3..7bdf3b50 100644 --- a/src/course-home/data/__factories__/courseHomeMetadata.factory.js +++ b/src/course-home/data/__factories__/courseHomeMetadata.factory.js @@ -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, diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap index 8d927da1..d3872878 100644 --- a/src/course-home/data/__snapshots__/redux.test.js.snap +++ b/src/course-home/data/__snapshots__/redux.test.js.snap @@ -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, diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index aa9c1ffd..7de2b563 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -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(); diff --git a/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx b/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx index e32b3532..9777216b 100644 --- a/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx +++ b/src/course-home/progress-tab/certificate-status/CertificateStatus.jsx @@ -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'; diff --git a/src/course-home/progress-tab/certificate-status/messages.js b/src/course-home/progress-tab/certificate-status/messages.js index 332fea7b..3948a265 100644 --- a/src/course-home/progress-tab/certificate-status/messages.js +++ b/src/course-home/progress-tab/certificate-status/messages.js @@ -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; diff --git a/src/courseware/course/course-exit/CourseCelebration.jsx b/src/courseware/course/course-exit/CourseCelebration.jsx index 69ee7171..0b709a3a 100644 --- a/src/courseware/course/course-exit/CourseCelebration.jsx +++ b/src/courseware/course/course-exit/CourseCelebration.jsx @@ -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 = ; + const certAvailableDate = ; + certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable); + message = ( + <> +

+ +

+ + ); + visitEvent = 'celebration_with_unavailable_cert'; + footnote = ; + } break; } diff --git a/src/courseware/course/course-exit/CourseExit.test.jsx b/src/courseware/course/course-exit/CourseExit.test.jsx index 5c8d0398..4b8ee80c 100644 --- a/src/courseware/course/course-exit/CourseExit.test.jsx +++ b/src/courseware/course/course-exit/CourseExit.test.jsx @@ -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,57 @@ 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, + can_view_certificate: true, + }, { + can_view_certificate: false, + }); await fetchAndRender(); 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(); + 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(); + 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', () => { diff --git a/src/courseware/course/course-exit/CourseNonPassing.jsx b/src/courseware/course/course-exit/CourseNonPassing.jsx index a39d35bb..67d9e954 100644 --- a/src/courseware/course/course-exit/CourseNonPassing.jsx +++ b/src/courseware/course/course-exit/CourseNonPassing.jsx @@ -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 ( <> @@ -38,21 +56,79 @@ function CourseNonPassing({ intl }) {
{ intl.formatMessage(messages.endOfCourseHeader) }
- -
-
{ intl.formatMessage(messages.endOfCourseDescription) }
- {progressLink && ( - - )} -
-
+ {!canViewCertificate && !isSelfPaced && ( + <> + {!wideScreen && ( +
+ {`${intl.formatMessage(messages.congratulationsImage)}`} +
+ )} + {wideScreen && ( +
+ {`${intl.formatMessage(messages.congratulationsImage)}`} +
+ )} + + )} + { // We don't want to show a learner an nonPassing message + // before the certificates are viewable for the course. + canViewCertificate || isSelfPaced + ? ( + +
+
+ {intl.formatMessage(messages.endOfCourseDescription)} +
+ {progressLink && ( + + )} +
+
+ ) : ( +
+ +
+
+

{intl.formatMessage(messages.courseFinishedEarlyHeading)}

+

+ {intl.formatMessage( + messages.courseFinishedEarlyBody, + { + courseEndDate, + certificateAvailableDate, + }, + )} +

+
+
+ {`${intl.formatMessage(messages.certificateImage)}`} +
+
+
+
+ ) + } diff --git a/src/courseware/course/course-exit/messages.js b/src/courseware/course/course-exit/messages.js index 63bff39a..a7cc6c55 100644 --- a/src/courseware/course/course-exit/messages.js +++ b/src/courseware/course/course-exit/messages.js @@ -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', diff --git a/src/courseware/data/api.js b/src/courseware/data/api.js index ccbc3d61..b79f5723 100644 --- a/src/courseware/data/api.js +++ b/src/courseware/data/api.js @@ -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, }; }