Compare commits

...

3 Commits

Author SHA1 Message Date
Thomas Tracy
31c116cf58 fix text id 2022-04-07 13:44:25 -04:00
Thomas Tracy
b3f56f8f18 fix test 2022-04-07 13:40:59 -04:00
Thomas Tracy
e74de71fb7 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.
2022-04-07 13:33:34 -04:00
10 changed files with 258 additions and 24 deletions

View File

@@ -10,6 +10,7 @@ Factory.define('courseHomeMetadata')
is_enrolled: false, is_enrolled: false,
is_staff: false, is_staff: false,
can_load_courseware: true, can_load_courseware: true,
can_view_certificate: true,
celebrations: null, celebrations: null,
course_access: { course_access: {
additional_context_user_message: null, additional_context_user_message: null,

View File

@@ -22,6 +22,7 @@ Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
@@ -340,6 +341,7 @@ Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
@@ -538,6 +540,7 @@ Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,

View File

@@ -31,7 +31,9 @@ describe('Progress Tab', () => {
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`); const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`; 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) { function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options); const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
@@ -806,11 +808,54 @@ describe('Progress Tab', () => {
sendTrackEvent.mockClear(); 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(); await fetchAndRender();
expect(screen.getByText('In order to qualify for a certificate, you must have a passing grade.')).toBeInTheDocument(); 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 () => { it('sends event when visiting progress tab when learner is not passing', async () => {
await fetchAndRender(); await fetchAndRender();

View File

@@ -22,6 +22,8 @@ function CertificateStatus({ intl }) {
const { const {
isEnrolled, isEnrolled,
org, org,
isSelfPaced,
canViewCertificate,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
@@ -90,9 +92,25 @@ function CertificateStatus({ intl }) {
if (mode === COURSE_EXIT_MODES.disabled) { if (mode === COURSE_EXIT_MODES.disabled) {
certEventName = 'certificate_status_disabled'; certEventName = 'certificate_status_disabled';
} else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) { } else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) {
certCase = 'notPassing';
certEventName = 'not_passing'; 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) { } else if (mode === COURSE_EXIT_MODES.inProgress && !certIsDownloadable) {
certCase = 'inProgress'; certCase = 'inProgress';
certEventName = 'has_scheduled_content'; certEventName = 'has_scheduled_content';

View File

@@ -106,6 +106,16 @@ const messages = defineMessages({
defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.', 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', 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; export default messages;

View File

@@ -55,6 +55,7 @@ function CourseCelebration({ intl }) {
const { const {
org, org,
verifiedMode, verifiedMode,
canViewCertificate,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
@@ -248,6 +249,29 @@ function CourseCelebration({ intl }) {
} }
break; break;
default: 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; break;
} }

View File

@@ -38,6 +38,9 @@ describe('Course Exit Pages', () => {
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`); 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 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 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 = {}) { function setMetadata(coursewareAttributes, courseHomeAttributes = {}) {
const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes }; const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes };
@@ -367,11 +370,54 @@ describe('Course Exit Pages', () => {
describe('Course Non-passing Experience', () => { describe('Course Non-passing Experience', () => {
it('Displays link to progress tab', async () => { it('Displays link to progress tab', async () => {
setMetadata({ user_has_passing_grade: false }); setMetadata({
user_has_passing_grade: false,
});
await fetchAndRender(<CourseNonPassing />); await fetchAndRender(<CourseNonPassing />);
expect(screen.getByText('Youve reached the end of the course!')).toBeInTheDocument(); expect(screen.getByText('Youve reached the end of the course!')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'View grades' })).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', () => { describe('Course in progress experience', () => {

View File

@@ -4,11 +4,17 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useSelector } from 'react-redux'; 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 { getConfig } from '@edx/frontend-platform';
import { CheckCircle } from '@edx/paragon/icons';
import { useModel } from '../../../generic/model-store'; 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 CatalogSuggestion from './CatalogSuggestion';
import DashboardFootnote from './DashboardFootnote'; import DashboardFootnote from './DashboardFootnote';
import messages from './messages'; import messages from './messages';
@@ -20,15 +26,27 @@ function CourseNonPassing({ intl }) {
org, org,
tabs, tabs,
title, title,
isSelfPaced,
canViewCertificate,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { end, certificateData } = useModel('coursewareMeta', courseId);
const { administrator } = getAuthenticatedUser(); 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 // Get progress tab link for 'view grades' button
const progressTab = tabs.find(tab => tab.slug === 'progress'); const progressTab = tabs.find(tab => tab.slug === 'progress');
const progressLink = progressTab && progressTab.url; const progressLink = progressTab && progressTab.url;
useEffect(() => logVisit(org, courseId, administrator, 'nonpassing'), [org, courseId, administrator]); useEffect(() => logVisit(org, courseId, administrator, 'nonpassing'), [org, courseId, administrator]);
return ( return (
<> <>
<Helmet> <Helmet>
@@ -38,21 +56,79 @@ function CourseNonPassing({ intl }) {
<div className="col-12 p-0 h2 text-center"> <div className="col-12 p-0 h2 text-center">
{ intl.formatMessage(messages.endOfCourseHeader) } { intl.formatMessage(messages.endOfCourseHeader) }
</div> </div>
<Alert variant="primary" className="col col-lg-10 mt-4"> {!canViewCertificate && !isSelfPaced && (
<div className="row w-100 m-0 align-items-start"> <>
<div className="flex-grow-1 col-sm p-0">{ intl.formatMessage(messages.endOfCourseDescription) }</div> {!wideScreen && (
{progressLink && ( <div className="col-12 mt-3 mb-4 px-0 px-md-5 text-center">
<Button <img
variant="primary" src={CelebrationMobile}
className="flex-shrink-0 mt-3 mt-sm-0 mb-1 mb-sm-0 ml-sm-5" alt={`${intl.formatMessage(messages.congratulationsImage)}`}
href={progressLink} className="img-fluid"
onClick={() => logClick(org, courseId, administrator, 'view_grades')} />
> </div>
{intl.formatMessage(messages.viewGradesButton)} )}
</Button> {wideScreen && (
)} <div className="col-12 mt-3 mb-4 px-0 px-md-5 text-center">
</div> <img
</Alert> 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" /> <DashboardFootnote variant="nonpassing" />
<CatalogSuggestion variant="nonpassing" /> <CatalogSuggestion variant="nonpassing" />
</div> </div>

View File

@@ -66,6 +66,16 @@ const messages = defineMessages({
defaultMessage: 'More content is coming soon!', defaultMessage: 'More content is coming soon!',
description: 'Header when the status of the course not all of (contents or assignments) available yet', 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: { dashboardLink: {
id: 'courseExit.dashboardLink', id: 'courseExit.dashboardLink',
defaultMessage: 'Dashboard', defaultMessage: 'Dashboard',

View File

@@ -124,6 +124,7 @@ function normalizeMetadata(metadata) {
relatedPrograms: camelCaseObject(data.related_programs), relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature, userNeedsIntegritySignature: data.user_needs_integrity_signature,
canAccessProctoredExams: data.can_access_proctored_exams, canAccessProctoredExams: data.can_access_proctored_exams,
canViewCertificate: data.can_view_certificate,
}; };
} }