Skip to content

Commit

Permalink
[BD-26][EDUCATOR-5808] Add additional pages for onboarding / practice…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
viktorrusakov authored Jun 11, 2021
1 parent ef7110b commit e44e38e
Show file tree
Hide file tree
Showing 29 changed files with 820 additions and 236 deletions.
8 changes: 8 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const ExamAction = Object.freeze({
PING: 'ping',
SUBMIT: 'submit',
ERROR: 'error',
RESET: 'reset_attempt',
CLICK_DOWNLOAD_SOFTWARE: 'click_download_software',
});

Expand All @@ -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',
});
4 changes: 4 additions & 0 deletions src/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions src/data/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export {
getExamAttemptsData,
getProctoringSettings,
startExam,
startProctoringExam,
startTimedExam,
startProctoredExam,
skipProctoringExam,
stopExam,
continueExam,
Expand All @@ -12,6 +12,7 @@ export {
getVerificationData,
getExamReviewPolicy,
pingAttempt,
resetExam,
} from './thunks';

export { default as store } from './store';
Expand Down
81 changes: 60 additions & 21 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
softwareDownloadAttempt,
fetchVerificationStatus,
fetchExamReviewPolicy,
resetAttempt,
} from './api';
import { isEmpty } from '../helpers';
import {
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
};
}

Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions src/exam/ExamWrapper.test.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => ({
Expand All @@ -24,6 +24,7 @@ store.getState = () => ({
},
exam: {
time_limit_mins: 30,
type: 'timed',
attempt: {},
},
},
Expand Down
44 changes: 44 additions & 0 deletions src/instructions/EntranceInstructions.jsx
Original file line number Diff line number Diff line change
@@ -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 <EntranceProctoredExamInstructions skipProctoredExam={skipProctoredExam} />;
case ExamType.ONBOARDING:
return <EntranceOnboardingExamInstructions />;
case ExamType.PRACTICE:
return <EntrancePracticeExamInstructions />;
case ExamType.TIMED:
return <StartTimedExamInstructions />;
default:
return null;
}
};

return (
<div>
<Container className="border py-5 mb-4">
{renderInstructions()}
</Container>
{examType === ExamType.TIMED
? <TimedExamFooter />
: <Footer />}
</div>
);
};

EntranceExamInstructions.propTypes = {
examType: PropTypes.string.isRequired,
skipProctoredExam: PropTypes.func.isRequired,
};

export default EntranceExamInstructions;
Loading

0 comments on commit e44e38e

Please sign in to comment.