From e44e38e68dbc863f7531bf9479134596ae194b2b Mon Sep 17 00:00:00 2001
From: Viktor Rusakov <52399399+ViktorRusakov@users.noreply.github.com>
Date: Fri, 11 Jun 2021 17:28:52 +0300
Subject: [PATCH] [BD-26][EDUCATOR-5808] Add additional pages for onboarding /
practice exams and fix a bug with JS worker (#15)
* fix: improve not emitting onStartAttempt message to proctoring backend provider when starting proctored exam (#26)
* feat: add entrance pages for onboarding and practice exams + reafactoring
* fix: timer text
* feat: add rejected and submit exam onboarding instructions pages + more refactoring
* refactor: instructions page
* fix: updated broken tests
* fix: remove unnecessary attributes in tests
* fix: revert timer changes
* refactor: split startProctoredExam function into two to handle create attempt and start exam actions separately
* tests: add new test cases for entrance / rejected onboarding instructions
* refactor: rename startExam function to startTimedExam since it's only used for timed exams, add docstring
---
src/constants.js | 8 +
src/data/api.js | 4 +
src/data/index.js | 5 +-
src/data/thunks.js | 81 ++++++--
src/exam/ExamWrapper.test.jsx | 7 +-
src/instructions/EntranceInstructions.jsx | 44 +++++
src/instructions/Instructions.test.jsx | 180 +++++++++++++++++-
src/instructions/RejectedInstructions.jsx | 46 +++++
src/instructions/StartExamInstructions.jsx | 71 -------
src/instructions/SubmitInstructions.jsx | 24 +++
src/instructions/index.jsx | 28 ++-
.../EntranceOnboardingExamInstructions.jsx | 110 +++++++++++
.../RejectedOnboardingExamInstructions.jsx | 48 +++++
src/instructions/onboarding_exam/index.js | 2 +
.../EntrancePracticeExamInstructions.jsx | 47 +++++
src/instructions/practice_exam/index.js | 1 +
.../EntranceProctoredExamInstructions.jsx | 96 +++++-----
.../ProctoredExamInstructions.test.jsx | 17 +-
.../ReadyToStartProctoredExamInstructions.jsx | 4 +-
.../RejectedProctoredExamInstructions.jsx | 43 ++---
.../SubmitProctoredExamInstructions.jsx | 54 ++++--
.../ProviderInstructions.jsx | 26 ++-
.../download-instructions/index.jsx | 14 +-
.../timed_exam/StartTimedExamInstructions.jsx | 52 +++++
.../SubmitTimedExamInstructions.jsx} | 12 +-
.../timed_exam/TimedExamFooter.jsx | 23 +++
src/instructions/timed_exam/index.jsx | 3 +
src/timer/CountDownTimer.test.jsx | 2 +
src/timer/ExamTimerBlock.jsx | 4 +-
29 files changed, 820 insertions(+), 236 deletions(-)
create mode 100644 src/instructions/EntranceInstructions.jsx
create mode 100644 src/instructions/RejectedInstructions.jsx
delete mode 100644 src/instructions/StartExamInstructions.jsx
create mode 100644 src/instructions/SubmitInstructions.jsx
create mode 100644 src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx
create mode 100644 src/instructions/onboarding_exam/RejectedOnboardingExamInstructions.jsx
create mode 100644 src/instructions/onboarding_exam/index.js
create mode 100644 src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx
create mode 100644 src/instructions/practice_exam/index.js
create mode 100644 src/instructions/timed_exam/StartTimedExamInstructions.jsx
rename src/instructions/{SubmitExamInstructions.jsx => timed_exam/SubmitTimedExamInstructions.jsx} (85%)
create mode 100644 src/instructions/timed_exam/TimedExamFooter.jsx
create mode 100644 src/instructions/timed_exam/index.jsx
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 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