Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/course-home/data/__factories__/outlineTabData.factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ Factory.define('outlineTabData')
cert_status: null,
cert_web_view_url: null,
certificate_available_date: null,
certificate_blocked_due_to_proctoring: false,
certificate_block_reason: null,
certificate_blocking_statuses: [],
},
course_goals: {
goal_options: [],
Expand Down
3 changes: 3 additions & 0 deletions src/course-home/data/__snapshots__/redux.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
"certificateBlockReason": null,
"certificateBlockedDueToProctoring": false,
"certificateBlockingStatuses": [],
},
"courseBlocks": {
"courses": {
Expand Down
28 changes: 28 additions & 0 deletions src/course-home/outline-tab/OutlineTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,34 @@ describe('Outline Tab', () => {
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
});

it('shows proctoring block message without certificate action', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
certificate_blocked_due_to_proctoring: true,
certificate_block_reason: 'proctoring_review_pending',
certificate_blocking_statuses: ['submitted'],
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();

expect(screen.queryByText('Certificate temporarily unavailable')).toBeInTheDocument();
expect(screen.queryByText(/being reviewed/)).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View my certificate' })).not.toBeInTheDocument();
});
});

describe('Requesting Certificate Alert', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import certMessages from './messages';
import certStatusMessages from '../../../progress-tab/certificate-status/messages';
import certStatusMessages, { getProctoringBlockedMessage } from '../../../progress-tab/certificate-status/messages';
import { requestCert } from '../../../data/thunks';

export const CERT_STATUS_TYPE = {
Expand All @@ -36,6 +36,8 @@ const CertificateStatusAlert = ({ payload }) => {
userTimezone,
org,
notPassingCourseEnded,
certificateBlockedDueToProctoring,
certificateBlockReason,
tabs,
} = payload;

Expand All @@ -57,7 +59,13 @@ const CertificateStatusAlert = ({ payload }) => {
icon: faCheckCircle,
iconClassName: 'alert-icon text-success-500',
};
if (certStatus === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE && certificateBlockedDueToProctoring) {
alertProps.variant = 'warning';
alertProps.icon = faExclamationTriangle;
alertProps.iconClassName = 'alert-icon text-warning-500';
alertProps.header = intl.formatMessage(certStatusMessages.proctoringBlockedHeader);
alertProps.body = <p>{intl.formatMessage(getProctoringBlockedMessage(certificateBlockReason))}</p>;
} else if (certStatus === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const certificateAvailableDateFormatted = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
Expand Down Expand Up @@ -201,6 +209,8 @@ CertificateStatusAlert.propTypes = {
userTimezone: PropTypes.string,
org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool,
certificateBlockedDueToProctoring: PropTypes.bool,
certificateBlockReason: PropTypes.string,
tabs: PropTypes.arrayOf(PropTypes.shape({
tab_id: PropTypes.string,
title: PropTypes.string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function useCertificateStatusAlert(courseId) {
certStatus,
certWebViewUrl,
certificateAvailableDate,
certificateBlockedDueToProctoring,
certificateBlockReason,
} = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isVerifiedEnrollmentMode = (
Expand All @@ -77,6 +79,8 @@ function useCertificateStatusAlert(courseId) {
);
const payload = useMemo(() => ({
certificateAvailableDate,
certificateBlockedDueToProctoring,
certificateBlockReason,
certURL,
certStatus,
courseId,
Expand All @@ -85,7 +89,8 @@ function useCertificateStatusAlert(courseId) {
org,
notPassingCourseEnded,
tabs,
}), [certStatus, certURL, certificateAvailableDate, courseId,
}), [certStatus, certURL, certificateAvailableDate, certificateBlockedDueToProctoring,
certificateBlockReason, courseId,
endBlock, notPassingCourseEnded, org, tabs, userTimezone]);

useAlert(isVisible || notPassingCourseEnded, {
Expand Down
18 changes: 18 additions & 0 deletions src/course-home/progress-tab/ProgressTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,24 @@ describe('Progress Tab', () => {
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
});

it('Displays proctoring block message without certificate action', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
certificate_blocked_due_to_proctoring: true,
certificate_block_reason: 'proctoring_review_pending',
certificate_blocking_statuses: ['submitted'],
},
user_has_passing_grade: true,
});
await fetchAndRender();

expect(screen.getByText('Certificate temporarily unavailable')).toBeInTheDocument();
expect(screen.getByText(/being reviewed/)).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View my certificate' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'View my certificate' })).not.toBeInTheDocument();
});

it('sends events on view of progress tab and on click of view certificate link', async () => {
setTabData({
certificate_data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useModel } from '../../../generic/model-store';
import { COURSE_EXIT_MODES, getCourseExitMode } from '../../../courseware/course/course-exit/utils';
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
import { requestCert } from '../../data/thunks';
import messages from './messages';
import messages, { getProctoringBlockedMessage } from './messages';
import ProgressCertificateStatusSlot from '../../../plugin-slots/ProgressCertificateStatusSlot';

const CertificateStatus = () => {
Expand Down Expand Up @@ -43,6 +43,8 @@ const CertificateStatus = () => {
} = useModel('progress', courseId);
const {
certificateAvailableDate,
certificateBlockedDueToProctoring,
certificateBlockReason,
} = certificateData || {};

const entranceExamPassed = entranceExamData?.entranceExamPassed ?? null;
Expand Down Expand Up @@ -134,23 +136,29 @@ const CertificateStatus = () => {
break;

case 'downloadable':
// Certificate available, download/viewable
certCase = 'downloadable';
body = (
<FormattedMessage
id="progress.certificateStatus.downloadableBody"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
description="Recommending an action for learner when course certificate is available"
values={{ dashboardLink, profileLink }}
/>
);
if (certWebViewUrl) {
certEventName = 'earned_viewable';
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewableButton);
if (certificateBlockedDueToProctoring) {
certCase = 'proctoringBlocked';
certEventName = 'certificate_blocked_due_to_proctoring';
body = <p>{intl.formatMessage(getProctoringBlockedMessage(certificateBlockReason))}</p>;
} else {
// Certificate available, download/viewable
certCase = 'downloadable';
body = (
<FormattedMessage
id="progress.certificateStatus.downloadableBody"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
description="Recommending an action for learner when course certificate is available"
values={{ dashboardLink, profileLink }}
/>
);
if (certWebViewUrl) {
certEventName = 'earned_viewable';
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewableButton);
}
}
break;

Expand Down
30 changes: 30 additions & 0 deletions src/course-home/progress-tab/certificate-status/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ const messages = defineMessages({
defaultMessage: 'Your certificate is available!',
description: 'Header text when the certifcate is available',
},
proctoringBlockedHeader: {
id: 'progress.certificateStatus.proctoringBlockedHeader',
defaultMessage: 'Certificate temporarily unavailable',
description: 'Header text when certificate access is blocked by proctoring',
},
proctoringReviewPendingBody: {
id: 'progress.certificateStatus.proctoringReviewPendingBody',
defaultMessage: 'Your certificate is temporarily unavailable while your required proctored exam is being reviewed. Please check back after the review is complete.',
description: 'Body text when a required proctored exam is pending review',
},
proctoringIncompleteBody: {
id: 'progress.certificateStatus.proctoringIncompleteBody',
defaultMessage: 'Complete your required proctored exam before accessing your certificate.',
description: 'Body text when a required proctored exam is incomplete or not attempted',
},
proctoringUnavailableBody: {
id: 'progress.certificateStatus.proctoringUnavailableBody',
defaultMessage: 'Your certificate is temporarily unavailable because the proctoring result is still being confirmed. Please check back later.',
description: 'Body text when the proctoring status cannot be confirmed',
},
viewableButton: {
id: 'progress.certificateStatus.viewableButton',
defaultMessage: 'View my certificate',
Expand Down Expand Up @@ -103,4 +123,14 @@ const messages = defineMessages({
},
});

export const getProctoringBlockedMessage = (reason) => {
if (reason === 'proctoring_review_pending') {
return messages.proctoringReviewPendingBody;
}
if (reason === 'proctored_exam_not_attempted' || reason === 'proctored_exam_incomplete') {
return messages.proctoringIncompleteBody;
}
return messages.proctoringUnavailableBody;
};

export default messages;
90 changes: 55 additions & 35 deletions src/courseware/course/course-exit/CourseCelebration.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
Button,
useWindowSize,
} from '@openedx/paragon';
import { CheckCircle } from '@openedx/paragon/icons';
import { CheckCircle, WarningFilled } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';

Expand Down Expand Up @@ -59,6 +59,8 @@ const CourseCelebration = () => {
certStatus,
certWebViewUrl,
certificateAvailableDate,
certificateBlockedDueToProctoring,
certificateBlockReason,
} = certificateData || {};

const { administrator } = getAuthenticatedUser();
Expand All @@ -78,44 +80,62 @@ const CourseCelebration = () => {
let footnote;
let message;
let certHeader;
let certificateAlertVariant = 'success';
let certificateAlertIcon = CheckCircle;
let visitEvent = 'celebration_generic';

switch (certStatus) {
case 'downloadable':
certHeader = intl.formatMessage(messages.certificateHeaderDownloadable);
message = (
<p>
<FormattedMessage
id="courseCelebration.certificateBody.available"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
values={{ dashboardLink, profileLink }}
description="Recommending an action for learner when course certificate is available"
/>
</p>
);
if (certWebViewUrl) {
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewCertificateButton);
}
if (linkedinAddToProfileUrl) {
buttonPrefix = (
<Button
className="mr-3"
href={linkedinAddToProfileUrl}
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
>
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
</Button>
if (certificateBlockedDueToProctoring) {
certificateAlertVariant = 'warning';
certificateAlertIcon = WarningFilled;
certHeader = intl.formatMessage(messages.certificateHeaderProctoringBlocked);
if (certificateBlockReason === 'proctoring_review_pending') {
message = <p>{intl.formatMessage(messages.certificateProctoringReviewPendingBody)}</p>;
} else if (certificateBlockReason === 'proctored_exam_not_attempted'
|| certificateBlockReason === 'proctored_exam_incomplete') {
message = <p>{intl.formatMessage(messages.certificateProctoringIncompleteBody)}</p>;
} else {
message = <p>{intl.formatMessage(messages.certificateProctoringUnavailableBody)}</p>;
}
visitEvent = 'celebration_with_unavailable_cert';
footnote = <DashboardFootnote variant={visitEvent} />;
} else {
certHeader = intl.formatMessage(messages.certificateHeaderDownloadable);
message = (
<p>
<FormattedMessage
id="courseCelebration.certificateBody.available"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
values={{ dashboardLink, profileLink }}
description="Recommending an action for learner when course certificate is available"
/>
</p>
);
if (certWebViewUrl) {
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewCertificateButton);
}
if (linkedinAddToProfileUrl) {
buttonPrefix = (
<Button
className="mr-3"
href={linkedinAddToProfileUrl}
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
>
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
</Button>
);
}
buttonEvent = 'view_cert';
visitEvent = 'celebration_with_cert';
footnote = <DashboardFootnote variant={visitEvent} />;
}
buttonEvent = 'view_cert';
visitEvent = 'celebration_with_cert';
footnote = <DashboardFootnote variant={visitEvent} />;
break;
case 'earned_but_not_available': {
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
Expand Down Expand Up @@ -271,7 +291,7 @@ const CourseCelebration = () => {
/>
)}
{certHeader && (
<Alert variant="success" icon={CheckCircle}>
<Alert variant={certificateAlertVariant} icon={certificateAlertIcon}>
<div className="row w-100 m-0">
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
<div className="h4">{certHeader}</div>
Expand All @@ -291,7 +311,7 @@ const CourseCelebration = () => {
{buttonSuffix}
</div>
</div>
{certStatus !== 'unverified' && (
{certStatus !== 'unverified' && !certificateBlockedDueToProctoring && (
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
<img
src={certificateImage}
Expand Down
Loading
Loading