diff --git a/src/constants.js b/src/constants.js index b2804867..2b40b02e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -23,6 +23,7 @@ export const ExamAction = Object.freeze({ PING: 'ping', SUBMIT: 'submit', ERROR: 'error', + RESET: 'reset_attempt', CLICK_DOWNLOAD_SOFTWARE: 'click_download_software', }); @@ -33,3 +34,10 @@ export const VerificationStatus = Object.freeze({ EXPIRED: 'expired', NONE: 'none', }); + +export const ExamType = Object.freeze({ + ONBOARDING: 'onboarding', + PRACTICE: 'practice', + PROCTORED: 'proctored', + TIMED: 'timed', +}); diff --git a/src/data/api.js b/src/data/api.js index 3eee4c10..5cd6f610 100644 --- a/src/data/api.js +++ b/src/data/api.js @@ -50,6 +50,10 @@ export async function submitAttempt(attemptId) { return updateAttemptStatus(attemptId, ExamAction.SUBMIT); } +export async function resetAttempt(attemptId) { + return updateAttemptStatus(attemptId, ExamAction.RESET); +} + export async function endExamWithFailure(attemptId, error) { return updateAttemptStatus(attemptId, ExamAction.ERROR, error); } diff --git a/src/data/index.js b/src/data/index.js index 1f4d4379..5e2400f2 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -1,8 +1,8 @@ export { getExamAttemptsData, getProctoringSettings, - startExam, - startProctoringExam, + startTimedExam, + startProctoredExam, skipProctoringExam, stopExam, continueExam, @@ -12,6 +12,7 @@ export { getVerificationData, getExamReviewPolicy, pingAttempt, + resetExam, } from './thunks'; export { default as store } from './store'; diff --git a/src/data/thunks.js b/src/data/thunks.js index 856e1e7c..960f99c8 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -10,6 +10,7 @@ import { softwareDownloadAttempt, fetchVerificationStatus, fetchExamReviewPolicy, + resetAttempt, } from './api'; import { isEmpty } from '../helpers'; import { @@ -88,9 +89,12 @@ export function getProctoringSettings() { }; } -export function startExam() { +/** + * Start a timed exam + */ +export function startTimedExam() { return async (dispatch, getState) => { - const { exam, activeAttempt } = getState().examState; + const { exam } = getState().examState; if (!exam.id) { logError('Failed to start exam. No exam id.'); handleAPIError( @@ -99,36 +103,53 @@ export function startExam() { ); return; } - - const useWorker = window.Worker && activeAttempt && activeAttempt.desktop_application_js_url; - - if (useWorker) { - workerPromiseForEventNames(actionToMessageTypesMap.ping, activeAttempt.desktop_application_js_url)() - .then(() => updateAttemptAfter(exam.course_id, exam.content_id, createExamAttempt(exam.id))(dispatch)) - .catch(() => handleAPIError( - { message: 'Something has gone wrong starting your exam. Please double-check that the application is running.' }, - dispatch, - )); - } else { - await updateAttemptAfter( - exam.course_id, exam.content_id, createExamAttempt(exam.id), - )(dispatch); - } + await updateAttemptAfter( + exam.course_id, exam.content_id, createExamAttempt(exam.id), + )(dispatch); }; } -export function startProctoringExam() { +export function createProctoredExamAttempt() { return async (dispatch, getState) => { const { exam } = getState().examState; if (!exam.id) { - logError('Failed to start exam. No exam id.'); + logError('Failed to create exam attempt. No exam id.'); return; } await updateAttemptAfter( exam.course_id, exam.content_id, createExamAttempt(exam.id, false, true), )(dispatch); - const proctoringSettings = await fetchProctoringSettings(exam.id); - dispatch(setProctoringSettings({ proctoringSettings })); + }; +} + +/** + * Start a proctored exam (including onboarding and practice exams) + */ +export function startProctoredExam() { + return async (dispatch, getState) => { + const { exam } = getState().examState; + const { attempt } = exam || {}; + if (!exam.id) { + logError('Failed to start proctored exam. No exam id.'); + return; + } + const { desktop_application_js_url: workerUrl } = attempt || {}; + const useWorker = window.Worker && workerUrl; + + if (useWorker) { + workerPromiseForEventNames(actionToMessageTypesMap.start, exam.attempt.desktop_application_js_url)() + .then(() => updateAttemptAfter( + exam.course_id, exam.content_id, continueAttempt(attempt.attempt_id), + )(dispatch)) + .catch(() => handleAPIError( + { message: 'Something has gone wrong starting your exam. Please double-check that the application is running.' }, + dispatch, + )); + } else { + await updateAttemptAfter( + exam.course_id, exam.content_id, continueAttempt(attempt.attempt_id), + )(dispatch); + } }; } @@ -215,6 +236,24 @@ export function continueExam(noLoading = true) { }; } +export function resetExam() { + return async (dispatch, getState) => { + const { exam } = getState().examState; + const attemptId = exam.attempt.attempt_id; + if (!attemptId) { + logError('Failed to reset exam attempt. No attempt id.'); + handleAPIError( + { message: 'Failed to reset exam attempt. No attempt id was found.' }, + dispatch, + ); + return; + } + await updateAttemptAfter( + exam.course_id, exam.content_id, resetAttempt(attemptId), + )(dispatch); + }; +} + export function submitExam() { return async (dispatch, getState) => { const { exam, activeAttempt } = getState().examState; diff --git a/src/exam/ExamWrapper.test.jsx b/src/exam/ExamWrapper.test.jsx index acc5b5f4..ca284b70 100644 --- a/src/exam/ExamWrapper.test.jsx +++ b/src/exam/ExamWrapper.test.jsx @@ -1,17 +1,17 @@ import '@testing-library/jest-dom'; import React from 'react'; import SequenceExamWrapper from './ExamWrapper'; -import { store, getExamAttemptsData, startExam } from '../data'; +import { store, getExamAttemptsData, startTimedExam } from '../data'; import { render } from '../setupTest'; import { ExamStateProvider } from '../index'; jest.mock('../data', () => ({ store: {}, getExamAttemptsData: jest.fn(), - startExam: jest.fn(), + startTimedExam: jest.fn(), })); getExamAttemptsData.mockReturnValue(jest.fn()); -startExam.mockReturnValue(jest.fn()); +startTimedExam.mockReturnValue(jest.fn()); store.subscribe = jest.fn(); store.dispatch = jest.fn(); store.getState = () => ({ @@ -24,6 +24,7 @@ store.getState = () => ({ }, exam: { time_limit_mins: 30, + type: 'timed', attempt: {}, }, }, diff --git a/src/instructions/EntranceInstructions.jsx b/src/instructions/EntranceInstructions.jsx new file mode 100644 index 00000000..4bbe635a --- /dev/null +++ b/src/instructions/EntranceInstructions.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Container } from '@edx/paragon'; +import { ExamType } from '../constants'; +import { EntranceProctoredExamInstructions } from './proctored_exam'; +import { EntranceOnboardingExamInstructions } from './onboarding_exam'; +import EntrancePracticeExamInstructions from './practice_exam'; +import { StartTimedExamInstructions, TimedExamFooter } from './timed_exam'; +import Footer from './proctored_exam/Footer'; + +const EntranceExamInstructions = ({ examType, skipProctoredExam }) => { + const renderInstructions = () => { + switch (examType) { + case ExamType.PROCTORED: + return ; + case ExamType.ONBOARDING: + return ; + case ExamType.PRACTICE: + return ; + case ExamType.TIMED: + return ; + default: + return null; + } + }; + + return ( +
+ + {renderInstructions()} + + {examType === ExamType.TIMED + ? + :
} +
+ ); +}; + +EntranceExamInstructions.propTypes = { + examType: PropTypes.string.isRequired, + skipProctoredExam: PropTypes.func.isRequired, +}; + +export default EntranceExamInstructions; diff --git a/src/instructions/Instructions.test.jsx b/src/instructions/Instructions.test.jsx index 94384892..14c2b1d4 100644 --- a/src/instructions/Instructions.test.jsx +++ b/src/instructions/Instructions.test.jsx @@ -2,17 +2,18 @@ import '@testing-library/jest-dom'; import React from 'react'; import { fireEvent } from '@testing-library/dom'; import Instructions from './index'; -import { store, getExamAttemptsData, startExam } from '../data'; +import { store, getExamAttemptsData, startTimedExam } from '../data'; import { render, screen } from '../setupTest'; import { ExamStateProvider } from '../index'; +import { ExamStatus, ExamType } from '../constants'; jest.mock('../data', () => ({ store: {}, getExamAttemptsData: jest.fn(), - startExam: jest.fn(), + startTimedExam: jest.fn(), })); getExamAttemptsData.mockReturnValue(jest.fn()); -startExam.mockReturnValue(jest.fn()); +startTimedExam.mockReturnValue(jest.fn()); store.subscribe = jest.fn(); store.dispatch = jest.fn(); @@ -22,6 +23,7 @@ describe('SequenceExamWrapper', () => { examState: { isLoading: false, activeAttempt: null, + proctoringSettings: {}, verification: { status: 'none', can_verify: true, @@ -29,6 +31,7 @@ describe('SequenceExamWrapper', () => { exam: { time_limit_mins: 30, attempt: {}, + type: ExamType.TIMED, }, }, }); @@ -57,6 +60,7 @@ describe('SequenceExamWrapper', () => { }, exam: { time_limit_mins: 30, + type: ExamType.PROCTORED, attempt: { attempt_status: 'started', }, @@ -75,6 +79,85 @@ describe('SequenceExamWrapper', () => { expect(getByTestId('sequence-content')).toHaveTextContent('Sequence'); }); + it.each([ + ['', ''], + ['integration@email.com', ''], + ['', 'learner_notification@example.com'], + ['integration@email.com', 'learner_notification@example.com'], + ])('Shows onboarding exam entrance instructions when receives onboarding exam with integration email: "%s", learner email: "%s"', (integrationEmail, learnerEmail) => { + store.getState = () => ({ + examState: { + isLoading: false, + verification: { + status: 'approved', + }, + proctoringSettings: { + learner_notification_from_email: learnerEmail, + integration_specific_email: integrationEmail, + }, + activeAttempt: {}, + exam: { + time_limit_mins: 30, + type: ExamType.ONBOARDING, + attempt: {}, + }, + }, + }); + + const { queryByTestId } = render( + + +
Sequence
+
+
, + { store }, + ); + + expect(queryByTestId('exam-instructions-title')).toHaveTextContent('Proctoring onboarding exam'); + const integrationEmailComponent = queryByTestId('integration-email-contact'); + const learnerNotificationEmailComponent = queryByTestId('learner-notification-email-contact'); + if (learnerEmail) { + expect(learnerNotificationEmailComponent).toBeInTheDocument(); + expect(learnerNotificationEmailComponent).toHaveTextContent(learnerEmail); + } else { + expect(learnerNotificationEmailComponent).not.toBeInTheDocument(); + } + if (integrationEmail) { + expect(integrationEmailComponent).toBeInTheDocument(); + expect(integrationEmailComponent).toHaveTextContent(integrationEmail); + } else { + expect(integrationEmailComponent).not.toBeInTheDocument(); + } + }); + + it('Shows practice exam entrance instructions when receives practice exam', () => { + store.getState = () => ({ + examState: { + isLoading: false, + verification: { + status: 'approved', + }, + activeAttempt: {}, + proctoringSettings: {}, + exam: { + time_limit_mins: 30, + type: ExamType.PRACTICE, + attempt: {}, + }, + }, + }); + + const { getByTestId } = render( + + +
Sequence
+
+
, + { store }, + ); + expect(getByTestId('exam-instructions-title')).toHaveTextContent('Try a proctored exam'); + }); + it('Shows failed prerequisites page if user has failed prerequisites for the exam', () => { store.getState = () => ({ examState: { @@ -89,7 +172,9 @@ describe('SequenceExamWrapper', () => { is_proctored: true, time_limit_mins: 30, attempt: {}, + type: ExamType.PROCTORED, prerequisite_status: { + are_prerequisites_satisfied: false, failed_prerequisites: [ { test: 'failed', @@ -131,9 +216,11 @@ describe('SequenceExamWrapper', () => { exam: { allow_proctoring_opt_out: false, is_proctored: true, + type: ExamType.PROCTORED, time_limit_mins: 30, attempt: {}, prerequisite_status: { + are_prerequisites_satisfied: false, pending_prerequisites: [ { test: 'failed', @@ -164,7 +251,11 @@ describe('SequenceExamWrapper', () => { examState: { isLoading: false, proctoringSettings: { - link_urls: '', + link_urls: [ + { + contact_us: 'http://localhost:2000', + }, + ], }, verification: { status: 'none', @@ -209,6 +300,7 @@ describe('SequenceExamWrapper', () => { attempt_status: 'ready_to_resume', }, exam: { + type: 'proctored', time_limit_mins: 30, attempt: { attempt_status: 'ready_to_resume', @@ -238,10 +330,12 @@ describe('SequenceExamWrapper', () => { status: 'none', can_verify: true, }, + proctoringSettings: {}, activeAttempt: { attempt_status: 'ready_to_submit', }, exam: { + type: 'timed', time_limit_mins: 30, attempt: { attempt_status: 'ready_to_submit', @@ -270,6 +364,7 @@ describe('SequenceExamWrapper', () => { status: 'none', can_verify: true, }, + proctoringSettings: {}, activeAttempt: { attempt_status: 'submitted', }, @@ -302,6 +397,7 @@ describe('SequenceExamWrapper', () => { status: 'none', can_verify: true, }, + proctoringSettings: {}, activeAttempt: { attempt_status: 'submitted', }, @@ -324,4 +420,80 @@ describe('SequenceExamWrapper', () => { ); expect(getByTestId('exam.submittedExamInstructions.title')).toHaveTextContent('The time allotted for this exam has expired.'); }); + + it.each(['integration@example.com', ''])('Shows correct rejected onboarding exam instructions when attempt is rejected and integration email is "%s"', (integrationEmail) => { + store.getState = () => ({ + examState: { + isLoading: false, + timeIsOver: false, + proctoringSettings: { + platform_name: 'Your Platform', + integration_specific_email: integrationEmail, + }, + activeAttempt: {}, + exam: { + is_proctored: true, + type: ExamType.ONBOARDING, + time_limit_mins: 30, + attempt: { + attempt_status: ExamStatus.REJECTED, + }, + prerequisite_status: {}, + }, + verification: {}, + }, + }); + + const { queryByTestId } = render( + + +
Sequence
+
+
, + { store }, + ); + + expect(queryByTestId('rejected-onboarding-title')).toBeInTheDocument(); + const contactComponent = queryByTestId('integration-email-contact'); + if (integrationEmail) { + expect(contactComponent).toBeInTheDocument(); + expect(contactComponent).toHaveTextContent(integrationEmail); + } else { + expect(contactComponent).not.toBeInTheDocument(); + } + }); + + it('Shows submit onboarding exam instructions if exam is onboarding and attempt status is ready_to_submit', () => { + store.getState = () => ({ + examState: { + isLoading: false, + timeIsOver: false, + proctoringSettings: { + platform_name: 'Your Platform', + }, + activeAttempt: {}, + exam: { + is_proctored: true, + type: ExamType.ONBOARDING, + time_limit_mins: 30, + attempt: { + attempt_status: ExamStatus.READY_TO_SUBMIT, + }, + prerequisite_status: {}, + }, + verification: {}, + }, + }); + + const { getByTestId } = render( + + +
Sequence
+
+
, + { store }, + ); + + expect(getByTestId('submit-onboarding-exam')).toBeInTheDocument(); + }); }); diff --git a/src/instructions/RejectedInstructions.jsx b/src/instructions/RejectedInstructions.jsx new file mode 100644 index 00000000..9b9ec1ba --- /dev/null +++ b/src/instructions/RejectedInstructions.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Container } from '@edx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { ExamType } from '../constants'; +import { RejectedOnboardingExamInstructions } from './onboarding_exam'; +import { RejectedProctoredExamInstructions } from './proctored_exam'; +import Footer from './proctored_exam/Footer'; + +const RejectedExamInstructions = ({ examType }) => { + const renderInstructions = () => { + switch (examType) { + case ExamType.PROCTORED: + return ; + case ExamType.ONBOARDING: + return ; + default: + return null; + } + }; + + return ( +
+ + {renderInstructions()} + + {examType === ExamType.PROCTORED && ( +
+

+ +

+
+ )} +
+
+ ); +}; + +RejectedExamInstructions.propTypes = { + examType: PropTypes.string.isRequired, +}; + +export default RejectedExamInstructions; diff --git a/src/instructions/StartExamInstructions.jsx b/src/instructions/StartExamInstructions.jsx deleted file mode 100644 index ae900fab..00000000 --- a/src/instructions/StartExamInstructions.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useContext } from 'react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Button, Container } from '@edx/paragon'; -import ExamStateContext from '../context'; - -const StartExamInstructions = () => { - const state = useContext(ExamStateContext); - const { exam, startExam } = state; - const examDuration = exam.time_limit_mins; - - return ( -
- -
- -
-

- - - - - -

- -
- -
-
- -
-

- -

-
-
- ); -}; - -export default StartExamInstructions; diff --git a/src/instructions/SubmitInstructions.jsx b/src/instructions/SubmitInstructions.jsx new file mode 100644 index 00000000..01b0b544 --- /dev/null +++ b/src/instructions/SubmitInstructions.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Container } from '@edx/paragon'; +import { ExamType } from '../constants'; +import { SubmitProctoredExamInstructions } from './proctored_exam'; +import { SubmitTimedExamInstructions } from './timed_exam'; +import Footer from './proctored_exam/Footer'; + +const SubmitExamInstructions = ({ examType }) => ( +
+ + {examType === ExamType.TIMED + ? + : } + + {examType !== ExamType.TIMED &&
} +
+); + +SubmitExamInstructions.propTypes = { + examType: PropTypes.string.isRequired, +}; + +export default SubmitExamInstructions; diff --git a/src/instructions/index.jsx b/src/instructions/index.jsx index e5f4c2d1..7b42979d 100644 --- a/src/instructions/index.jsx +++ b/src/instructions/index.jsx @@ -1,29 +1,27 @@ import React, { useContext, useState } from 'react'; import PropTypes from 'prop-types'; -import StartExamInstructions from './StartExamInstructions'; -import SubmitExamInstructions from './SubmitExamInstructions'; import SubmittedExamInstructions from './SubmittedExamInstructions'; import { ErrorProctoredExamInstructions, - EntranceProctoredExamInstructions, VerificationProctoredExamInstructions, - SubmitProctoredExamInstructions, SubmittedProctoredExamInstructions, VerifiedProctoredExamInstructions, - RejectedProctoredExamInstructions, DownloadSoftwareProctoredExamInstructions, ReadyToStartProctoredExamInstructions, PrerequisitesProctoredExamInstructions, SkipProctoredExamInstruction, } from './proctored_exam'; import { isEmpty } from '../helpers'; -import { ExamStatus, VerificationStatus } from '../constants'; +import { ExamStatus, VerificationStatus, ExamType } from '../constants'; import ExamStateContext from '../context'; +import EntranceExamInstructions from './EntranceInstructions'; +import SubmitExamInstructions from './SubmitInstructions'; +import RejectedInstructions from './RejectedInstructions'; const Instructions = ({ children }) => { const state = useContext(ExamStateContext); const { exam, verification } = state; - const { attempt, is_proctored: isProctored, prerequisite_status: prerequisitesData } = exam || {}; + const { attempt, type: examType, prerequisite_status: prerequisitesData } = exam || {}; const prerequisitesPassed = prerequisitesData ? prerequisitesData.are_prerequisites_satisifed : true; let verificationStatus = verification.status || ''; const { verification_url: verificationUrl } = attempt || {}; @@ -40,14 +38,14 @@ const Instructions = ({ children }) => { switch (true) { case isEmpty(attempt): // eslint-disable-next-line no-nested-ternary - return isProctored + return examType === ExamType.PROCTORED // eslint-disable-next-line no-nested-ternary ? skipProctoring ? : prerequisitesPassed - ? + ? : - : ; + : ; case attempt.attempt_status === ExamStatus.CREATED: return verificationStatus === VerificationStatus.APPROVED ? @@ -57,21 +55,19 @@ const Instructions = ({ children }) => { case attempt.attempt_status === ExamStatus.READY_TO_START: return ; case attempt.attempt_status === ExamStatus.READY_TO_SUBMIT: - return isProctored - ? - : ; + return ; case attempt.attempt_status === ExamStatus.SUBMITTED: - return isProctored + return examType === ExamType.PROCTORED ? : ; case attempt.attempt_status === ExamStatus.VERIFIED: return ; case attempt.attempt_status === ExamStatus.REJECTED: - return ; + return ; case attempt.attempt_status === ExamStatus.ERROR: return ; case attempt.attempt_status === ExamStatus.READY_TO_RESUME: - return ; + return ; default: return children; } diff --git a/src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx b/src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx new file mode 100644 index 00000000..d3cbc8bf --- /dev/null +++ b/src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx @@ -0,0 +1,110 @@ +import React, { useContext } from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Button, MailtoLink } from '@edx/paragon'; +import ExamStateContext from '../../context'; + +const EntranceOnboardingExamInstructions = () => { + const state = useContext(ExamStateContext); + const { createProctoredExamAttempt, proctoringSettings } = state; + const { + provider_name: providerName, + learner_notification_from_email: learnerNotificationFromEmail, + integration_specific_email: integrationSpecificEmail, + } = proctoringSettings || {}; + + return ( + <> +
+ +
+

+ +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+

+ +

+ {learnerNotificationFromEmail && ( +

+ + + {learnerNotificationFromEmail} + + +

+ )} + {integrationSpecificEmail && ( +

+ + + {integrationSpecificEmail} + + +

+ )} +

+ +

+

+ +

+ + ); +}; + +export default EntranceOnboardingExamInstructions; diff --git a/src/instructions/onboarding_exam/RejectedOnboardingExamInstructions.jsx b/src/instructions/onboarding_exam/RejectedOnboardingExamInstructions.jsx new file mode 100644 index 00000000..3d0b565c --- /dev/null +++ b/src/instructions/onboarding_exam/RejectedOnboardingExamInstructions.jsx @@ -0,0 +1,48 @@ +import React, { useContext } from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Button, MailtoLink } from '@edx/paragon'; +import ExamStateContext from '../../context'; + +const RejectedOnboardingExamInstructions = () => { + const state = useContext(ExamStateContext); + const { proctoringSettings, resetExam } = state; + const { integration_specific_email: integrationSpecificEmail } = proctoringSettings || {}; + + return ( + <> +

+ +

+ {integrationSpecificEmail && ( +

+ + + {integrationSpecificEmail} + + +

+ )} + + + ); +}; + +export default RejectedOnboardingExamInstructions; diff --git a/src/instructions/onboarding_exam/index.js b/src/instructions/onboarding_exam/index.js new file mode 100644 index 00000000..47865c94 --- /dev/null +++ b/src/instructions/onboarding_exam/index.js @@ -0,0 +1,2 @@ +export { default as EntranceOnboardingExamInstructions } from './EntranceOnboardingExamInstructions'; +export { default as RejectedOnboardingExamInstructions } from './RejectedOnboardingExamInstructions'; diff --git a/src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx b/src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx new file mode 100644 index 00000000..844db80e --- /dev/null +++ b/src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx @@ -0,0 +1,47 @@ +import React, { useContext } from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; +import ExamStateContext from '../../context'; + +const EntrancePracticeExamInstructions = () => { + const state = useContext(ExamStateContext); + const { createProctoredExamAttempt } = state; + + return ( + <> +
+ +
+

+ +

+

+ +

+

+ +

+ + ); +}; + +export default EntrancePracticeExamInstructions; diff --git a/src/instructions/practice_exam/index.js b/src/instructions/practice_exam/index.js new file mode 100644 index 00000000..51b782d7 --- /dev/null +++ b/src/instructions/practice_exam/index.js @@ -0,0 +1 @@ +export { default } from './EntrancePracticeExamInstructions'; diff --git a/src/instructions/proctored_exam/EntranceProctoredExamInstructions.jsx b/src/instructions/proctored_exam/EntranceProctoredExamInstructions.jsx index 6e8bdc8b..1ad69ac2 100644 --- a/src/instructions/proctored_exam/EntranceProctoredExamInstructions.jsx +++ b/src/instructions/proctored_exam/EntranceProctoredExamInstructions.jsx @@ -1,74 +1,70 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Button, Container } from '@edx/paragon'; +import { Button } from '@edx/paragon'; import { ExamStatus } from '../../constants'; import ExamStateContext from '../../context'; -import Footer from './Footer'; import SkipProctoredExamButton from './SkipProctoredExamButton'; const EntranceProctoredExamInstructions = ({ skipProctoredExam }) => { const state = useContext(ExamStateContext); - const { exam, startProctoringExam } = state; + const { exam, createProctoredExamAttempt } = state; const { attempt, allow_proctoring_opt_out: allowProctoringOptOut } = exam || {}; const { total_time: totalTime = 0 } = attempt; return ( -
- - { exam.attempt.attempt_status === ExamStatus.READY_TO_RESUME ? ( -
-
- -
-

- -

-
- ) : ( + <> + { attempt.attempt_status === ExamStatus.READY_TO_RESUME ? ( +
- )} -

+

+ +

+
+ ) : ( +
-

-

+

+ )} +

+ +

+

+ +

+

+ -

- {allowProctoringOptOut && } -
-
-
+ +

+ {allowProctoringOptOut && } + ); }; diff --git a/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx b/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx index 640971f8..5dbeb890 100644 --- a/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx +++ b/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx @@ -2,18 +2,16 @@ import '@testing-library/jest-dom'; import React from 'react'; import { fireEvent } from '@testing-library/dom'; import Instructions from '../index'; -import { store, getExamAttemptsData, startExam } from '../../data'; +import { store, getExamAttemptsData } from '../../data'; import { render } from '../../setupTest'; import { ExamStateProvider } from '../../index'; -import { ExamStatus, VerificationStatus } from '../../constants'; +import { ExamType, ExamStatus, VerificationStatus } from '../../constants'; jest.mock('../../data', () => ({ store: {}, getExamAttemptsData: jest.fn(), - startExam: jest.fn(), })); getExamAttemptsData.mockReturnValue(jest.fn()); -startExam.mockReturnValue(jest.fn()); store.subscribe = jest.fn(); store.dispatch = jest.fn(); @@ -31,6 +29,7 @@ describe('SequenceExamWrapper', () => { can_verify: true, }, exam: { + type: ExamType.PROCTORED, allow_proctoring_opt_out: true, is_proctored: true, time_limit_mins: 30, @@ -67,7 +66,9 @@ describe('SequenceExamWrapper', () => { activeAttempt: { attempt_status: 'started', }, + proctoringSettings: {}, exam: { + type: ExamType.PROCTORED, is_proctored: true, time_limit_mins: 30, attempt: { @@ -103,6 +104,7 @@ describe('SequenceExamWrapper', () => { attempt_status: 'ready_to_start', }, exam: { + type: ExamType.PROCTORED, is_proctored: true, time_limit_mins: 30, attempt: { @@ -139,6 +141,7 @@ describe('SequenceExamWrapper', () => { attempt_status: 'submitted', }, exam: { + type: ExamType.PROCTORED, is_proctored: true, time_limit_mins: 30, attempt: { @@ -170,7 +173,9 @@ describe('SequenceExamWrapper', () => { activeAttempt: { attempt_status: 'ready_to_submit', }, + proctoringSettings: {}, exam: { + type: ExamType.PROCTORED, is_proctored: true, time_limit_mins: 30, attempt: { @@ -206,6 +211,7 @@ describe('SequenceExamWrapper', () => { attempt_status: 'verified', }, exam: { + type: ExamType.PROCTORED, is_proctored: true, time_limit_mins: 30, attempt: { @@ -237,7 +243,9 @@ describe('SequenceExamWrapper', () => { activeAttempt: { attempt_status: 'rejected', }, + proctoringSettings: {}, exam: { + type: ExamType.PROCTORED, is_proctored: true, time_limit_mins: 30, attempt: { @@ -269,6 +277,7 @@ describe('SequenceExamWrapper', () => { }, activeAttempt: {}, exam: { + type: ExamType.PROCTORED, is_proctored: true, time_limit_mins: 30, attempt: { diff --git a/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx b/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx index 5153505f..47357682 100644 --- a/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx +++ b/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx @@ -10,7 +10,7 @@ const ReadyToStartProctoredExamInstructions = () => { exam, proctoringSettings, getExamReviewPolicy, - continueExam, + startProctoredExam, } = state; const { time_limit_mins: examDuration, reviewPolicy } = exam; const { link_urls: linkUrls, platform_name: platformName } = proctoringSettings; @@ -112,7 +112,7 @@ const ReadyToStartProctoredExamInstructions = () => { - - -); + + ); +}; export default RejectedProctoredExamInstructions; diff --git a/src/instructions/proctored_exam/SubmitProctoredExamInstructions.jsx b/src/instructions/proctored_exam/SubmitProctoredExamInstructions.jsx index 8a260c30..94b144a9 100644 --- a/src/instructions/proctored_exam/SubmitProctoredExamInstructions.jsx +++ b/src/instructions/proctored_exam/SubmitProctoredExamInstructions.jsx @@ -1,32 +1,54 @@ import React, { useContext } from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Button, Container } from '@edx/paragon'; +import { Button } from '@edx/paragon'; import ExamStateContext from '../../context'; +import { ExamType } from '../../constants'; const SubmitProctoredExamInstructions = () => { const state = useContext(ExamStateContext); - const { submitExam, continueExam } = state; + const { + submitExam, + continueExam, + exam, + activeAttempt, + } = state; + const { type: examType } = exam || {}; + const { exam_display_name: examName } = activeAttempt; return ( - + <>

-

- -

-

- -

+
    +
  • + +
  • +
  • + +
  • +
+ {examType === ExamType.ONBOARDING && ( +

+ +

+ )} -
+ ); }; diff --git a/src/instructions/proctored_exam/download-instructions/ProviderInstructions.jsx b/src/instructions/proctored_exam/download-instructions/ProviderInstructions.jsx index e2d4fa5f..d5eb76b5 100644 --- a/src/instructions/proctored_exam/download-instructions/ProviderInstructions.jsx +++ b/src/instructions/proctored_exam/download-instructions/ProviderInstructions.jsx @@ -2,7 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -const ProviderProctoredExamInstructions = ({ platformName, contactInfo, instructions }) => ( +const ProviderProctoredExamInstructions = ({ + providerName, supportEmail, supportPhone, instructions, +}) => ( <>

))} - {platformName && contactInfo && ( + {supportEmail && supportPhone && (

@@ -37,9 +40,16 @@ const ProviderProctoredExamInstructions = ({ platformName, contactInfo, instruct ); ProviderProctoredExamInstructions.propTypes = { - platformName: PropTypes.string.isRequired, - contactInfo: PropTypes.string.isRequired, + providerName: PropTypes.string, + supportEmail: PropTypes.string, + supportPhone: PropTypes.string, instructions: PropTypes.arrayOf(PropTypes.string).isRequired, }; +ProviderProctoredExamInstructions.defaultProps = { + providerName: '', + supportEmail: '', + supportPhone: '', +}; + export default ProviderProctoredExamInstructions; diff --git a/src/instructions/proctored_exam/download-instructions/index.jsx b/src/instructions/proctored_exam/download-instructions/index.jsx index e901de7c..e3a982e9 100644 --- a/src/instructions/proctored_exam/download-instructions/index.jsx +++ b/src/instructions/proctored_exam/download-instructions/index.jsx @@ -30,8 +30,9 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl }) => { software_download_url: downloadUrl, } = attempt; const { - platform_name: platformName, - contact_us: contactInfo, + provider_name: providerName, + provider_tech_support_email: supportEmail, + provider_tech_support_phone: supportPhone, exam_proctoring_backend: proctoringBackend, } = proctoringSettings; const { instructions } = proctoringBackend || {}; @@ -85,7 +86,14 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl }) => { /> {withProviderInstructions - ? + ? ( + + ) : } { + const state = useContext(ExamStateContext); + const { exam, startTimedExam } = state; + const examDuration = exam.time_limit_mins; + + return ( + <> +
+ +
+

+ + + + + +

+ + + ); +}; + +export default StartTimedExamInstructions; diff --git a/src/instructions/SubmitExamInstructions.jsx b/src/instructions/timed_exam/SubmitTimedExamInstructions.jsx similarity index 85% rename from src/instructions/SubmitExamInstructions.jsx rename to src/instructions/timed_exam/SubmitTimedExamInstructions.jsx index a6a36ef0..7108d196 100644 --- a/src/instructions/SubmitExamInstructions.jsx +++ b/src/instructions/timed_exam/SubmitTimedExamInstructions.jsx @@ -1,14 +1,14 @@ import React, { useContext } from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Button, Container } from '@edx/paragon'; -import ExamStateContext from '../context'; +import { Button } from '@edx/paragon'; +import ExamStateContext from '../../context'; -const SubmitExamInstructions = () => { +const SubmitTimedExamInstructions = () => { const state = useContext(ExamStateContext); const { submitExam, continueExam } = state; return ( - + <>

{ defaultMessage="No, I want to continue working." /> - + ); }; -export default SubmitExamInstructions; +export default SubmitTimedExamInstructions; diff --git a/src/instructions/timed_exam/TimedExamFooter.jsx b/src/instructions/timed_exam/TimedExamFooter.jsx new file mode 100644 index 00000000..26765a36 --- /dev/null +++ b/src/instructions/timed_exam/TimedExamFooter.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const TimedExamFooter = () => ( +
+
+ +
+

+ +

+
+); + +export default TimedExamFooter; diff --git a/src/instructions/timed_exam/index.jsx b/src/instructions/timed_exam/index.jsx new file mode 100644 index 00000000..b3113b03 --- /dev/null +++ b/src/instructions/timed_exam/index.jsx @@ -0,0 +1,3 @@ +export { default as StartTimedExamInstructions } from './StartTimedExamInstructions'; +export { default as SubmitTimedExamInstructions } from './SubmitTimedExamInstructions'; +export { default as TimedExamFooter } from './TimedExamFooter'; diff --git a/src/timer/CountDownTimer.test.jsx b/src/timer/CountDownTimer.test.jsx index fc5cb997..145ab671 100644 --- a/src/timer/CountDownTimer.test.jsx +++ b/src/timer/CountDownTimer.test.jsx @@ -31,6 +31,7 @@ describe('ExamTimerBlock', () => { critically_low_threshold_sec: 5, exam_started_poll_url: '', taking_as_proctored: false, + exam_type: 'a timed exam', }, proctoringSettings: {}, exam: {}, @@ -108,6 +109,7 @@ describe('ExamTimerBlock', () => { critically_low_threshold_sec: 5, exam_started_poll_url: '', taking_as_proctored: false, + exam_type: 'a timed exam', }, proctoringSettings: {}, exam: {}, diff --git a/src/timer/ExamTimerBlock.jsx b/src/timer/ExamTimerBlock.jsx index 9bf43c9d..0b32bd0b 100644 --- a/src/timer/ExamTimerBlock.jsx +++ b/src/timer/ExamTimerBlock.jsx @@ -54,8 +54,8 @@ const ExamTimerBlock = injectIntl(({ { isShowMore