From e74de71fb793920ede260c46f4c85871bb6ed525 Mon Sep 17 00:00:00 2001 From: Thomas Tracy Date: Mon, 4 Apr 2022 17:19:41 -0400 Subject: [PATCH] fix: [MICROBA-1769] Fix nonPassing states When a learner is in the non-passing state for a certificate, we don't want to give them messaging about certificate status IF the certificate is not released yet for the course. Before this change, learners who were were in the nonpassing state would be informed before finishing the course or before the certificates were released. After this change, learners who are nonpassing will be in a "not passing not available" state (similar to "earned but not available" state) until the course issues its certificate. --- .../courseHomeMetadata.factory.js | 1 + .../data/__snapshots__/redux.test.js.snap | 3 + .../progress-tab/ProgressTab.test.jsx | 49 +++++++- .../certificate-status/CertificateStatus.jsx | 22 +++- .../certificate-status/messages.js | 10 ++ .../course/course-exit/CourseCelebration.jsx | 24 ++++ .../course/course-exit/CourseExit.test.jsx | 51 +++++++- .../course/course-exit/CourseNonPassing.jsx | 114 +++++++++++++++--- src/courseware/course/course-exit/messages.js | 10 ++ src/courseware/data/api.js | 1 + 10 files changed, 261 insertions(+), 24 deletions(-) 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, }; }