fix: [MICROBA-1769] Cert status before course end (#918)

* fix: [MICROBA-1769] Cert status before course end

Right now, learners who are nonpassing are able to view information
about thier certificates early at the course end screen and progress
pages. This is because we show messaging around the nonpassing state in
some cases before a course ends and certificates are available. This can
also lead to cases where grades are not finalized and students who may
be passing see a scary nonpassing message instead.

This change makes it so during the course exit, a student who finishes a
course before the course is over will see the celebration screen
regardless of passing status. Once the course is over (or if
certificates are available immediately), and they are
still not passing, they will see the nonpassing messaging. The same
change was made for the certificate status alert in the progress tab.
This commit is contained in:
Thomas Tracy
2022-04-13 10:14:00 -04:00
committed by GitHub
parent 6d42ee9c6f
commit aaa367780d
10 changed files with 192 additions and 5 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -31,6 +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);
@@ -1220,6 +1223,65 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows certificate_available_date if learner is passing', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: {
cert_status: 'earned_but_not_available',
certificate_available_date: overmorrow.toISOString(),
},
});
await fetchAndRender();
expect(screen.getByText('Certificate status'));
expect(screen.getByText(
overmorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
)).toBeInTheDocument();
});
});
describe('Credit Information', () => {

View File

@@ -22,6 +22,8 @@ function CertificateStatus({ intl }) {
const {
isEnrolled,
org,
canViewCertificate,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
@@ -45,6 +47,8 @@ function CertificateStatus({ intl }) {
hasScheduledContent,
isEnrolled,
userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
);
const eventProperties = {
@@ -58,6 +62,7 @@ function CertificateStatus({ intl }) {
let certStatus;
let certWebViewUrl;
let downloadUrl;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (certificateData) {
certStatus = certificateData.certStatus;
@@ -178,10 +183,22 @@ function CertificateStatus({ intl }) {
}
break;
// This code shouldn't be hit but coding defensively since switch expects a default statement
default:
certCase = null;
certEventName = 'no_certificate_status';
// if user completes a course before certificates are available, treat it as notAvailable
// regardless of passing or nonpassing status
if (!canViewCertificate) {
certCase = 'notAvailable';
endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
...timezoneFormatArgs,
});
body = intl.formatMessage(messages.notAvailableEndDateBody, { endDate });
} else {
certCase = null;
certEventName = 'no_certificate_status';
}
break;
}
}

View File

@@ -76,6 +76,11 @@ const messages = defineMessages({
defaultMessage: 'Certificate status',
description: 'Header text when the certifcate is not available',
},
notAvailableEndDateBody: {
id: 'progress.certificateBody.notAvailable.endDate',
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
description: 'Shown for learners who have finished a course before grades and certificates are available.',
},
upgradeHeader: {
id: 'progress.certificateStatus.upgradeHeader',
defaultMessage: 'Earn a certificate',

View File

@@ -55,6 +55,8 @@ function CourseCelebration({ intl }) {
const {
org,
verifiedMode,
canViewCertificate,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
@@ -69,6 +71,7 @@ function CourseCelebration({ intl }) {
const dashboardLink = <DashboardLink />;
const idVerificationSupportLink = <IdVerificationSupportLink />;
const profileLink = <ProfileLink />;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
let buttonPrefix = null;
let buttonLocation;
@@ -248,6 +251,29 @@ function CourseCelebration({ intl }) {
}
break;
default:
if (!canViewCertificate) {
// We reuse the cert event here. Since this default state is so
// Similar to the earned_not_available state, this event name should be fine
// to cover the same cases.
visitEvent = 'celebration_with_unavailable_cert';
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
const endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
...timezoneFormatArgs,
});
message = (
<>
<p>
{intl.formatMessage(messages.certificateNotAvailableEndDateBody, { endDate })}
</p>
<p>
{intl.formatMessage(messages.certificateNotAvailableBodyAccessCert)}
</p>
</>
);
}
break;
}

View File

@@ -27,7 +27,10 @@ function CourseExit({ intl }) {
userHasPassingGrade,
} = useModel('coursewareMeta', courseId);
const { isMasquerading } = useModel('courseHomeMeta', courseId);
const {
isMasquerading,
canViewCertificate,
} = useModel('courseHomeMeta', courseId);
const mode = getCourseExitMode(
certificateData,
@@ -35,6 +38,7 @@ function CourseExit({ intl }) {
isEnrolled,
userHasPassingGrade,
courseExitPageIsActive,
canViewCertificate,
);
// Audit users cannot fully complete a course, so we will

View File

@@ -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 };
@@ -363,6 +366,64 @@ describe('Course Exit Pages', () => {
expect(screen.queryByText('Same Course')).not.toBeInTheDocument();
});
});
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
setMetadata({
is_enrolled: true,
end: tomorrow.toISOString(),
user_has_passing_grade: false,
certificate_data: undefined,
}, {
can_view_certificate: false,
});
await fetchAndRender(<CourseCelebration />);
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
setMetadata({
is_enrolled: true,
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: undefined,
}, {
can_view_certificate: false,
});
await fetchAndRender(<CourseCelebration />);
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows certificate_available_date if learner is passing', async () => {
setMetadata({
is_enrolled: true,
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: {
cert_status: 'earned_but_not_available',
certificate_available_date: overmorrow.toISOString(),
},
}, {
can_view_certificate: false,
});
await fetchAndRender(<CourseCelebration />);
expect(screen.getByText('Your grade and certificate status will be available soon.'));
expect(screen.getByText(
overmorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
)).toBeInTheDocument();
});
});
describe('Course Non-passing Experience', () => {

View File

@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'If you have earned a passing grade, your certificate will be automatically issued.',
description: 'Text displayed when course certificate is not yet available to be viewed',
},
certificateNotAvailableEndDateBody: {
id: 'courseCelebration.certificateBody.notAvailable.endDate',
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
description: 'Shown for learners who have finished a course before grades and certificates are available.',
},
certificateHeaderUnverified: {
id: 'courseCelebration.certificateHeader.unverified',
defaultMessage: 'You must complete verification to receive your certificate.',

View File

@@ -31,6 +31,7 @@ function getCourseExitMode(
isEnrolled,
userHasPassingGrade,
courseExitPageIsActive = null,
canImmediatelyViewCertificate = false,
) {
const authenticatedUser = getAuthenticatedUser();
@@ -55,7 +56,7 @@ function getCourseExitMode(
if (hasScheduledContent && !userHasPassingGrade) {
return COURSE_EXIT_MODES.inProgress;
}
if (isEligibleForCertificate && !userHasPassingGrade) {
if (isEligibleForCertificate && !userHasPassingGrade && canImmediatelyViewCertificate) {
return COURSE_EXIT_MODES.nonPassing;
}
if (isCelebratoryStatus) {
@@ -73,12 +74,14 @@ function getCourseExitNavigation(courseId, intl) {
userHasPassingGrade,
courseExitPageIsActive,
} = useModel('coursewareMeta', courseId);
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
const exitMode = getCourseExitMode(
certificateData,
hasScheduledContent,
isEnrolled,
userHasPassingGrade,
courseExitPageIsActive,
canViewCertificate,
);
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;