diff --git a/src/api.js b/src/api.js
index 68ec4e50..fe4d37b1 100644
--- a/src/api.js
+++ b/src/api.js
@@ -1,23 +1,28 @@
-import { examRequiresAccessToken, store } from './data';
+import { useDispatch, useSelector } from 'react-redux';
+import { examRequiresAccessToken } from './data';
+
+export const useIsExam = () => {
+ const { exam } = useSelector(state => state.specialExams);
-export function isExam() {
- const { exam } = store.getState().examState;
return !!exam?.id;
-}
+};
+
+export const useExamAccessToken = () => {
+ const { exam, examAccessToken } = useSelector(state => state.specialExams);
-export function getExamAccess() {
- const { exam, examAccessToken } = store.getState().examState;
if (!exam) {
return '';
}
+
return examAccessToken.exam_access_token;
-}
+};
+
+export const useFetchExamAccessToken = () => {
+ const { exam } = useSelector(state => state.specialExams);
+ const dispatch = useDispatch();
-export async function fetchExamAccess() {
- const { exam } = store.getState().examState;
- const { dispatch } = store;
if (!exam) {
return Promise.resolve();
}
- return dispatch(examRequiresAccessToken());
-}
+ return () => dispatch(examRequiresAccessToken());
+};
diff --git a/src/api.test.jsx b/src/api.test.jsx
index 1cabd935..511e47ef 100644
--- a/src/api.test.jsx
+++ b/src/api.test.jsx
@@ -1,16 +1,34 @@
import { Factory } from 'rosie';
-import { isExam, getExamAccess, fetchExamAccess } from './api';
-import { store } from './data';
+import { useExamAccessToken, useFetchExamAccessToken, useIsExam } from './api';
+import { initializeTestStore, render } from './setupTest';
+
+/**
+ * Hooks must be run in the scope of a component. To run the hook, wrap it in a test component whose sole
+ * responsibility it is to run the hook and assign it to a return value that is returned by the function.
+ * @param {*} hook: the hook function to run
+ * @param {*} store: an initial store, passed to the call to render
+ * @returns: the return value of the hook
+ */
+const getHookReturnValue = (hook, store) => {
+ let returnVal;
+ const TestComponent = () => {
+ returnVal = hook();
+ return null;
+ };
+ render( , { store });
+ return returnVal;
+};
describe('External API integration tests', () => {
- describe('Test isExam with exam', () => {
+ describe('Test useIsExam with exam', () => {
+ let store;
+
beforeAll(() => {
- jest.mock('./data');
const mockExam = Factory.build('exam', { attempt: Factory.build('attempt') });
const mockToken = Factory.build('examAccessToken');
- const mockState = { examState: { exam: mockExam, examAccessToken: mockToken } };
- store.getState = jest.fn().mockReturnValue(mockState);
+ const mockState = { specialExams: { exam: mockExam, examAccessToken: mockToken } };
+ store = initializeTestStore(mockState);
});
afterAll(() => {
@@ -18,25 +36,28 @@ describe('External API integration tests', () => {
jest.resetAllMocks();
});
- it('isExam should return true if exam is set', () => {
- expect(isExam()).toBe(true);
+ it('useIsExam should return true if exam is set', () => {
+ expect(getHookReturnValue(useIsExam, store)).toBe(true);
});
- it('getExamAccess should return exam access token if access token', () => {
- expect(getExamAccess()).toBeTruthy();
+ it('useExamAccessToken should return exam access token if access token', () => {
+ expect(getHookReturnValue(useExamAccessToken, store)).toBeTruthy();
});
- it('fetchExamAccess should dispatch get exam access token', () => {
- const dispatchReturn = fetchExamAccess();
- expect(dispatchReturn).toBeInstanceOf(Promise);
+ it('useFetchExamAccessToken should dispatch get exam access token', () => {
+ // The useFetchExamAccessToken hook returns a function that calls dispatch, so we must call the returned
+ // value to invoke dispatch.
+ expect(getHookReturnValue(useFetchExamAccessToken, store)()).toBeInstanceOf(Promise);
});
});
- describe('Test isExam without exam', () => {
+ describe('Test useIsExam without exam', () => {
+ let store;
+
beforeAll(() => {
jest.mock('./data');
- const mockState = { examState: { exam: null, examAccessToken: null } };
- store.getState = jest.fn().mockReturnValue(mockState);
+ const mockState = { specialExams: { exam: null, examAccessToken: null } };
+ store = initializeTestStore(mockState);
});
afterAll(() => {
@@ -44,17 +65,16 @@ describe('External API integration tests', () => {
jest.resetAllMocks();
});
- it('isExam should return false if exam is not set', () => {
- expect(isExam()).toBe(false);
+ it('useIsExam should return false if exam is not set', () => {
+ expect(getHookReturnValue(useIsExam, store)).toBe(false);
});
- it('getExamAccess should return empty string if exam access token not set', () => {
- expect(getExamAccess()).toBeFalsy();
+ it('useExamAccessToken should return empty string if exam access token not set', () => {
+ expect(getHookReturnValue(useExamAccessToken, store)).toBeFalsy();
});
- it('fetchExamAccess should not dispatch get exam access token', () => {
- const dispatchReturn = fetchExamAccess();
- expect(dispatchReturn).toBeInstanceOf(Promise);
+ it('useFetchExamAccessToken should not dispatch get exam access token', () => {
+ expect(getHookReturnValue(useFetchExamAccessToken, store)).toBeInstanceOf(Promise);
});
});
});
diff --git a/src/context.jsx b/src/context.jsx
deleted file mode 100644
index 3005f5a7..00000000
--- a/src/context.jsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import React from 'react';
-
-const ExamStateContext = React.createContext({});
-export default ExamStateContext;
diff --git a/src/core/ExamStateProvider.jsx b/src/core/ExamStateProvider.jsx
deleted file mode 100644
index ced1d07c..00000000
--- a/src/core/ExamStateProvider.jsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import React, { useMemo } from 'react';
-import { withExamStore } from '../hocs';
-import * as dispatchActions from '../data/thunks';
-import ExamStateContext from '../context';
-import { IS_STARTED_STATUS } from '../constants';
-
-/**
- * Make exam state available as a context for all library components.
- * @param children - sequence content
- * @param state - exam state params and actions
- * @returns {JSX.Element}
- */
-
-// eslint-disable-next-line react/prop-types
-const StateProvider = ({ children, ...state }) => {
- const contextValue = useMemo(() => ({
- ...state,
- showTimer: !!(state.activeAttempt && IS_STARTED_STATUS(state.activeAttempt.attempt_status)),
- }), [state]);
- return (
-
- {children}
-
- );
-};
-
-const mapStateToProps = (state) => ({ ...state.examState });
-
-const ExamStateProvider = withExamStore(
- StateProvider,
- mapStateToProps,
- dispatchActions,
-);
-
-export default ExamStateProvider;
diff --git a/src/core/OuterExamTimer.jsx b/src/core/OuterExamTimer.jsx
index f5ef7fbf..2ae0f571 100644
--- a/src/core/OuterExamTimer.jsx
+++ b/src/core/OuterExamTimer.jsx
@@ -1,22 +1,23 @@
import React, { useEffect, useContext } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
-import ExamStateContext from '../context';
import { ExamTimerBlock } from '../timer';
import ExamAPIError from '../exam/ExamAPIError';
-import ExamStateProvider from './ExamStateProvider';
+import { getLatestAttemptData } from '../data';
+import { IS_STARTED_STATUS } from '../constants';
const ExamTimer = ({ courseId }) => {
- const state = useContext(ExamStateContext);
+ const { activeAttempt } = useSelector(state => state.specialExams);
const { authenticatedUser } = useContext(AppContext);
- const {
- activeAttempt, showTimer, stopExam, submitExam,
- expireExam, pollAttempt, apiErrorMsg, pingAttempt,
- getLatestAttemptData,
- } = state;
+ const showTimer = !!(activeAttempt && IS_STARTED_STATUS(activeAttempt.attempt_status));
+
+ const { apiErrorMsg } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
useEffect(() => {
- getLatestAttemptData(courseId);
+ dispatch(getLatestAttemptData(courseId));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
@@ -29,14 +30,7 @@ const ExamTimer = ({ courseId }) => {
return (
{showTimer && (
-
+
)}
{apiErrorMsg && }
@@ -53,9 +47,7 @@ ExamTimer.propTypes = {
* will be shown.
*/
const OuterExamTimer = ({ courseId }) => (
-
-
-
+
);
OuterExamTimer.propTypes = {
diff --git a/src/core/OuterExamTimer.test.jsx b/src/core/OuterExamTimer.test.jsx
index fb6984c2..2afa9f32 100644
--- a/src/core/OuterExamTimer.test.jsx
+++ b/src/core/OuterExamTimer.test.jsx
@@ -2,12 +2,11 @@ import '@testing-library/jest-dom';
import { Factory } from 'rosie';
import React from 'react';
import OuterExamTimer from './OuterExamTimer';
-import { store, getLatestAttemptData } from '../data';
-import { render } from '../setupTest';
+import { getLatestAttemptData } from '../data';
+import { initializeTestStore, render } from '../setupTest';
import { ExamStatus } from '../constants';
jest.mock('../data', () => ({
- store: {},
getLatestAttemptData: jest.fn(),
Emitter: {
on: () => jest.fn(),
@@ -17,18 +16,22 @@ jest.mock('../data', () => ({
},
}));
getLatestAttemptData.mockReturnValue(jest.fn());
-store.subscribe = jest.fn();
-store.dispatch = jest.fn();
describe('OuterExamTimer', () => {
const courseId = 'course-v1:test+test+test';
+ let store;
+
+ beforeEach(() => {
+ store = initializeTestStore();
+ });
+
it('is successfully rendered and shows timer if there is an exam in progress', () => {
const attempt = Factory.build('attempt', {
attempt_status: ExamStatus.STARTED,
});
store.getState = () => ({
- examState: {
+ specialExams: {
activeAttempt: attempt,
exam: {
time_limit_mins: 60,
@@ -45,7 +48,7 @@ describe('OuterExamTimer', () => {
it('does not render timer if there is no exam in progress', () => {
store.getState = () => ({
- examState: {
+ specialExams: {
activeAttempt: {},
exam: {},
},
diff --git a/src/core/SequenceExamWrapper.jsx b/src/core/SequenceExamWrapper.jsx
index dea56e2b..4480ac7f 100644
--- a/src/core/SequenceExamWrapper.jsx
+++ b/src/core/SequenceExamWrapper.jsx
@@ -1,6 +1,5 @@
import React from 'react';
import ExamWrapper from '../exam/ExamWrapper';
-import ExamStateProvider from './ExamStateProvider';
/**
* SequenceExamWrapper is the component responsible for handling special exams.
@@ -14,9 +13,7 @@ import ExamStateProvider from './ExamStateProvider';
*
*/
const SequenceExamWrapper = (props) => (
-
-
-
+
);
export default SequenceExamWrapper;
diff --git a/src/data/__factories__/examState.factory.js b/src/data/__factories__/examState.factory.js
index af775148..ccb6e411 100644
--- a/src/data/__factories__/examState.factory.js
+++ b/src/data/__factories__/examState.factory.js
@@ -4,7 +4,7 @@ import './exam.factory';
import './proctoringSettings.factory';
import './examAccessToken.factory';
-Factory.define('examState')
+Factory.define('specialExams')
.attr('proctoringSettings', Factory.build('proctoringSettings'))
.attr('exam', Factory.build('exam'))
.attr('examAccessToken', Factory.build('examAccessToken'))
diff --git a/src/data/__snapshots__/redux.test.jsx.snap b/src/data/__snapshots__/redux.test.jsx.snap
index 69475b13..24344fd5 100644
--- a/src/data/__snapshots__/redux.test.jsx.snap
+++ b/src/data/__snapshots__/redux.test.jsx.snap
@@ -9,7 +9,7 @@ Object {
exports[`Data layer integration tests Test getExamAttemptsData Should get, and save exam and attempt 1`] = `
Object {
- "examState": Object {
+ "specialExams": Object {
"activeAttempt": Object {
"attempt_code": "",
"attempt_id": 1,
@@ -93,7 +93,7 @@ Object {
exports[`Data layer integration tests Test getLatestAttemptData with edx-proctoring as a backend (no EXAMS_BASE_URL) Should get, and save latest attempt 1`] = `
Object {
- "examState": Object {
+ "specialExams": Object {
"activeAttempt": Object {
"attempt_code": "",
"attempt_id": 1,
@@ -245,7 +245,7 @@ Object {
exports[`Data layer integration tests Test resetExam Should reset exam attempt 1`] = `
Object {
- "examState": Object {
+ "specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
@@ -314,7 +314,7 @@ Object {
exports[`Data layer integration tests Test resetExam with edx-proctoring as backend (no EXAMS_BASE_URL) Should reset exam attempt 1`] = `
Object {
- "examState": Object {
+ "specialExams": Object {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
diff --git a/src/data/index.js b/src/data/index.js
index 9c6cb1a3..fa1db443 100644
--- a/src/data/index.js
+++ b/src/data/index.js
@@ -1,4 +1,5 @@
export {
+ createProctoredExamAttempt,
getExamAttemptsData,
getLatestAttemptData,
getProctoringSettings,
@@ -15,7 +16,9 @@ export {
resetExam,
getAllowProctoringOptOut,
examRequiresAccessToken,
+ checkExamEntry,
} from './thunks';
-export { default as store } from './store';
+export { default as reducer } from './slice';
+
export { default as Emitter } from './emitter';
diff --git a/src/data/redux.test.jsx b/src/data/redux.test.jsx
index b56e0ee9..9293f7c2 100644
--- a/src/data/redux.test.jsx
+++ b/src/data/redux.test.jsx
@@ -53,13 +53,13 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
};
- beforeEach(async () => {
+ beforeEach(() => {
initializeTestConfig();
windowSpy = jest.spyOn(window, 'window', 'get');
axiosMock.reset();
loggingService.logError.mockReset();
loggingService.logInfo.mockReset();
- store = await initializeTestStore();
+ store = initializeTestStore();
});
afterEach(() => {
@@ -70,7 +70,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getAllowProctoringOptOut(true), store.dispatch);
const state = store.getState();
- expect(state.examState.allowProctoringOptOut).toEqual(true);
+ expect(state.specialExams.allowProctoringOptOut).toEqual(true);
});
describe('Test getExamAttemptsData', () => {
@@ -94,7 +94,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
const state = store.getState();
- expect(state.examState.exam.total_time).toBe('30 minutes');
+ expect(state.specialExams.exam.total_time).toBe('30 minutes');
});
it('Should fail to fetch if error occurs', async () => {
@@ -103,7 +103,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
const state = store.getState();
- expect(state.examState.apiErrorMsg).toBe('Network Error');
+ expect(state.specialExams.apiErrorMsg).toBe('Network Error');
});
});
@@ -123,7 +123,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getProctoringSettings(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.proctoringSettings).toMatchSnapshot();
+ expect(state.specialExams.proctoringSettings).toMatchSnapshot();
});
});
@@ -134,7 +134,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getProctoringSettings(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.proctoringSettings).toMatchSnapshot();
+ expect(state.specialExams.proctoringSettings).toMatchSnapshot();
});
it('Should fail to fetch if error occurs', async () => {
@@ -144,7 +144,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.proctoringSettings).toMatchSnapshot();
+ expect(state.specialExams.proctoringSettings).toMatchSnapshot();
});
it('Should fail to fetch if error occurs', async () => {
@@ -154,7 +154,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getProctoringSettings(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.apiErrorMsg).toBe('Network Error');
+ expect(state.specialExams.apiErrorMsg).toBe('Network Error');
});
});
@@ -174,7 +174,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamReviewPolicy(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.exam.reviewPolicy).toEqual(reviewPolicy);
+ expect(state.specialExams.exam.reviewPolicy).toEqual(reviewPolicy);
});
it('Should fail to fetch if error occurs', async () => {
@@ -185,7 +185,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamReviewPolicy(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.apiErrorMsg).toBe('Network Error');
+ expect(state.specialExams.apiErrorMsg).toBe('Network Error');
});
it('Should fail to fetch if no exam id', async () => {
@@ -195,7 +195,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.exam.reviewPolicy).toBeUndefined();
+ expect(state.specialExams.exam.reviewPolicy).toBeUndefined();
});
});
@@ -212,11 +212,11 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.activeAttempt).toBeNull();
+ expect(state.specialExams.activeAttempt).toBeNull();
await executeThunk(thunks.startTimedExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.activeAttempt).toMatchSnapshot();
+ expect(state.specialExams.activeAttempt).toMatchSnapshot();
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({
exam_id: exam.id,
start_clock: 'true',
@@ -233,7 +233,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.startTimedExam(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.activeAttempt).toMatchSnapshot();
+ expect(state.specialExams.activeAttempt).toMatchSnapshot();
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({
exam_id: exam.id,
start_clock: 'true',
@@ -258,7 +258,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.apiErrorMsg).toBe('Failed to start exam. No exam id was found.');
+ expect(state.specialExams.apiErrorMsg).toBe('Failed to start exam. No exam id was found.');
});
});
@@ -278,11 +278,11 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
await executeThunk(thunks.stopExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
expect(axiosMock.history.put[0].url).toEqual(updateAttemptStatusLegacyUrl);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'stop' }));
});
@@ -299,7 +299,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
const state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
await executeThunk(thunks.stopExam(), store.dispatch, store.getState);
expect(axiosMock.history.put[0].url).toEqual(updateAttemptStatusLegacyUrl);
@@ -312,7 +312,7 @@ describe('Data layer integration tests', () => {
it('Should stop exam, and update attempt', async () => {
await initWithExamAttempt();
let state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
axiosMock.onPut(`${createUpdateAttemptURL}/${readyToSubmitAttempt.attempt_id}`).reply(200, { exam_attempt_id: readyToSubmitAttempt.attempt_id });
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: readyToSubmitExam });
@@ -320,7 +320,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.stopExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
expect(axiosMock.history.put[0].url).toEqual(`${createUpdateAttemptURL}/${readyToSubmitAttempt.attempt_id}`);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'stop' }));
});
@@ -334,7 +334,7 @@ describe('Data layer integration tests', () => {
await initWithExamAttempt({}, attempt);
const state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
axiosMock.onPut(`${createUpdateAttemptURL}/${readyToSubmitAttempt.attempt_id}`).reply(200, { exam_attempt_id: readyToSubmitAttempt.attempt_id });
@@ -358,13 +358,13 @@ describe('Data layer integration tests', () => {
it('Should fail to fetch if error occurs', async () => {
await initWithExamAttempt();
let state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
axiosMock.onPut(`${createUpdateAttemptURL}/${attempt.attempt_id}`).networkError();
await executeThunk(thunks.stopExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.apiErrorMsg).toBe('Network Error');
+ expect(state.specialExams.apiErrorMsg).toBe('Network Error');
});
it('Should fail to fetch if no active attempt', async () => {
@@ -374,7 +374,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.apiErrorMsg).toBe('Failed to stop exam. No active attempt was found.');
+ expect(state.specialExams.apiErrorMsg).toBe('Failed to stop exam. No active attempt was found.');
});
});
@@ -394,11 +394,11 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
await executeThunk(thunks.continueExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
expect(axiosMock.history.put[0].url).toEqual(updateAttemptStatusLegacyUrl);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'start' }));
});
@@ -407,7 +407,7 @@ describe('Data layer integration tests', () => {
it('Should return to exam, and update attempt', async () => {
await initWithExamAttempt(readyToSubmitExam, {});
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam });
axiosMock.onGet(latestAttemptURL).reply(200, { attempt });
@@ -415,7 +415,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.continueExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
expect(axiosMock.history.put[0].url).toEqual(`${createUpdateAttemptURL}/${attempt.attempt_id}`);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'start' }));
});
@@ -428,7 +428,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.apiErrorMsg).toBe('Failed to continue exam. No attempt id was found.');
+ expect(state.specialExams.apiErrorMsg).toBe('Failed to continue exam. No attempt id was found.');
});
});
@@ -456,12 +456,12 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
await executeThunk(thunks.resetExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
expect(state).toMatchSnapshot();
expect(axiosMock.history.put[0].url).toEqual(updateAttemptStatusLegacyUrl);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'reset_attempt' }));
@@ -471,7 +471,7 @@ describe('Data layer integration tests', () => {
it('Should reset exam attempt', async () => {
await initWithExamAttempt();
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: examWithCreatedAttempt });
axiosMock.onGet(latestAttemptURL).reply(200, {});
@@ -480,7 +480,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.resetExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
expect(state).toMatchSnapshot();
expect(axiosMock.history.put[0].url).toEqual(`${createUpdateAttemptURL}/${attempt.attempt_id}`);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'reset_attempt' }));
@@ -494,7 +494,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.apiErrorMsg).toBe('Failed to reset exam attempt. No attempt id was found.');
+ expect(state.specialExams.apiErrorMsg).toBe('Failed to reset exam attempt. No attempt id was found.');
});
});
@@ -514,25 +514,25 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
await executeThunk(thunks.submitExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
});
});
it('Should submit exam, and update attempt and exam', async () => {
await initWithExamAttempt();
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: submittedExam });
axiosMock.onPut(`${createUpdateAttemptURL}/${attempt.attempt_id}`).reply(200, { exam_attempt_id: submittedAttempt.attempt_id });
await executeThunk(thunks.submitExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
expect(axiosMock.history.put[0].url).toEqual(`${createUpdateAttemptURL}/${attempt.attempt_id}`);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'submit' }));
});
@@ -547,7 +547,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.apiErrorMsg).toBe('Failed to submit exam. No active attempt was found.');
+ expect(state.specialExams.apiErrorMsg).toBe('Failed to submit exam. No active attempt was found.');
});
it('Should submit exam and redirect to sequence if no exam attempt', async () => {
@@ -562,7 +562,7 @@ describe('Data layer integration tests', () => {
await initWithExamAttempt({}, attempt);
const state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
axiosMock.onPut(`${createUpdateAttemptURL}/${attempt.attempt_id}`).reply(200, { exam_attempt_id: submittedAttempt.attempt_id });
@@ -600,19 +600,19 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
await executeThunk(thunks.expireExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
- expect(state.examState.timeIsOver).toBe(true);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
+ expect(state.specialExams.timeIsOver).toBe(true);
});
});
it('Should submit expired exam, and update attempt', async () => {
await initWithExamAttempt();
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: submittedExam });
axiosMock.onGet(latestAttemptURL).reply(200, submittedAttempt);
@@ -620,8 +620,8 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.expireExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
- expect(state.examState.timeIsOver).toBe(true);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.SUBMITTED);
+ expect(state.specialExams.timeIsOver).toBe(true);
expect(axiosMock.history.put[0].url).toEqual(`${createUpdateAttemptURL}/${attempt.attempt_id}`);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'submit' }));
});
@@ -632,7 +632,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
- expect(state.examState.apiErrorMsg).toBe('Failed to expire exam. No attempt id was found.');
+ expect(state.specialExams.apiErrorMsg).toBe('Failed to expire exam. No attempt id was found.');
});
});
@@ -656,11 +656,11 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.STARTED);
await executeThunk(thunks.startProctoringSoftwareDownload(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.DOWNLOAD_SOFTWARE_CLICKED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.DOWNLOAD_SOFTWARE_CLICKED);
});
});
@@ -672,7 +672,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.startProctoringSoftwareDownload(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.DOWNLOAD_SOFTWARE_CLICKED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.DOWNLOAD_SOFTWARE_CLICKED);
expect(axiosMock.history.put[0].url).toEqual(`${createUpdateAttemptURL}/${softwareDownloadedAttempt.attempt_id}`);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'click_download_software' }));
});
@@ -694,11 +694,11 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt).toEqual({});
+ expect(state.specialExams.exam.attempt).toEqual({});
await executeThunk(thunks.createProctoredExamAttempt(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
});
});
@@ -711,7 +711,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.createProctoredExamAttempt(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.CREATED);
expect(axiosMock.history.post.length).toBe(1);
});
@@ -754,18 +754,18 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.CREATED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.CREATED);
await executeThunk(thunks.startProctoredExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.activeAttempt).toMatchSnapshot();
+ expect(state.specialExams.activeAttempt).toMatchSnapshot();
});
});
it('Should start exam, and update attempt and exam', async () => {
await initWithExamAttempt(createdExam, createdAttempt);
let state = store.getState();
- expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.CREATED);
+ expect(state.specialExams.activeAttempt.attempt_status).toBe(ExamStatus.CREATED);
axiosMock.onPost(createUpdateAttemptURL).reply(200, { exam_attempt_id: startedAttempt.attempt_id });
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: startedExam });
@@ -773,7 +773,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.startProctoredExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.activeAttempt).toMatchSnapshot();
+ expect(state.specialExams.activeAttempt).toMatchSnapshot();
});
it('Should fail to fetch if no exam id', async () => {
@@ -850,11 +850,11 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt).toEqual({});
+ expect(state.specialExams.exam.attempt).toEqual({});
await executeThunk(thunks.skipProctoringExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
});
it('Should change existing attempt status to declined, and update attempt and exam', async () => {
@@ -864,11 +864,11 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toEqual(ExamStatus.CREATED);
+ expect(state.specialExams.exam.attempt.attempt_status).toEqual(ExamStatus.CREATED);
await executeThunk(thunks.skipProctoringExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
});
});
@@ -879,21 +879,21 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.skipProctoringExam(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
expect(axiosMock.history.post.length).toBe(1);
});
it('Should change existing attempt status to declined, and update attempt and exam', async () => {
await initWithExamAttempt(createdExam, {});
let state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toEqual(ExamStatus.CREATED);
+ expect(state.specialExams.exam.attempt.attempt_status).toEqual(ExamStatus.CREATED);
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: declinedExam, active_attempt: {} });
axiosMock.onPut(`${createUpdateAttemptURL}/${declinedAttempt.attempt_id}`).reply(200, { exam_attempt_id: declinedAttempt.attempt_id });
await executeThunk(thunks.skipProctoringExam(), store.dispatch, store.getState);
state = store.getState();
- expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
+ expect(state.specialExams.exam.attempt.attempt_status).toBe(ExamStatus.DECLINED);
expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ action: 'decline' }));
});
@@ -921,12 +921,12 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
- expect(state.examState.exam.attempt).toMatchSnapshot();
+ expect(state.specialExams.exam.attempt).toMatchSnapshot();
await executeThunk(thunks.pollAttempt(attempt.exam_started_poll_url), store.dispatch, store.getState);
state = store.getState();
const expectedPollUrl = `${getConfig().LMS_BASE_URL}${attempt.exam_started_poll_url}`;
- expect(state.examState.exam.attempt).toMatchSnapshot();
+ expect(state.specialExams.exam.attempt).toMatchSnapshot();
expect(axiosMock.history.get[1].url).toEqual(expectedPollUrl);
});
});
@@ -942,7 +942,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.pollAttempt(attempt.exam_started_poll_url), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.activeAttempt).toMatchSnapshot();
+ expect(state.specialExams.activeAttempt).toMatchSnapshot();
});
describe('pollAttempt api called directly', () => {
@@ -1028,7 +1028,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.getLatestAttemptData(courseId), store.dispatch);
const state = store.getState();
- expect(state.examState.activeAttempt.attempt_id).toEqual(1234);
+ expect(state.specialExams.activeAttempt.attempt_id).toEqual(1234);
});
});
@@ -1046,8 +1046,8 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.exam.id).toBe(exam.id);
- expect(state.examState.examAccessToken.exam_access_token).toBe('');
+ expect(state.specialExams.exam.id).toBe(exam.id);
+ expect(state.specialExams.examAccessToken.exam_access_token).toBe('');
});
});
@@ -1059,7 +1059,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.examAccessToken).toMatchSnapshot();
+ expect(state.specialExams.examAccessToken).toMatchSnapshot();
});
it('Should fail to fetch if no exam id', async () => {
@@ -1067,7 +1067,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.examAccessToken.exam_access_token).toBe('');
+ expect(state.specialExams.examAccessToken.exam_access_token).toBe('');
});
it('Should fail to fetch if API error occurs', async () => {
@@ -1077,7 +1077,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState);
const state = store.getState();
- expect(state.examState.examAccessToken.exam_access_token).toBe('');
+ expect(state.specialExams.examAccessToken.exam_access_token).toBe('');
});
});
diff --git a/src/data/store.js b/src/data/store.js
deleted file mode 100644
index 916de85c..00000000
--- a/src/data/store.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { configureStore } from '@reduxjs/toolkit';
-import examReducer from './slice';
-
-export default configureStore({
- reducer: {
- examState: examReducer,
- },
-});
diff --git a/src/data/thunks.js b/src/data/thunks.js
index d1d06cf6..da9268b8 100644
--- a/src/data/thunks.js
+++ b/src/data/thunks.js
@@ -101,7 +101,7 @@ export function getLatestAttemptData(courseId) {
export function getProctoringSettings() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
if (!exam.id) {
logError('Failed to get exam settings. No exam id.');
handleAPIError(
@@ -124,7 +124,7 @@ export function examRequiresAccessToken() {
if (!getConfig().EXAMS_BASE_URL) {
return;
}
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
if (!exam.id) {
logError('Failed to get exam access token. No exam id.');
return;
@@ -143,7 +143,7 @@ export function examRequiresAccessToken() {
*/
export function startTimedExam() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
if (!exam.id) {
logError('Failed to start exam. No exam id.');
handleAPIError(
@@ -162,7 +162,7 @@ export function startTimedExam() {
export function createProctoredExamAttempt() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
if (!exam.id) {
logError('Failed to create exam attempt. No exam id.');
return;
@@ -180,7 +180,7 @@ export function createProctoredExamAttempt() {
*/
export function startProctoredExam() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
const { attempt } = exam || {};
if (!exam.id) {
logError('Failed to start proctored exam. No exam id.');
@@ -231,7 +231,7 @@ export function startProctoredExam() {
export function skipProctoringExam() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
if (!exam.id) {
logError('Failed to skip proctored exam. No exam id.');
return;
@@ -260,7 +260,7 @@ export function skipProctoringExam() {
*/
export function pollAttempt(url) {
return async (dispatch, getState) => {
- const currentAttempt = getState().examState.activeAttempt;
+ const currentAttempt = getState().specialExams.activeAttempt;
// If the learner is in a state where they've finished the exam
// and the attempt can be submitted (i.e. they are "ready_to_submit"),
@@ -291,7 +291,7 @@ export function pollAttempt(url) {
export function stopExam() {
return async (dispatch, getState) => {
- const { exam, activeAttempt } = getState().examState;
+ const { exam, activeAttempt } = getState().specialExams;
if (!activeAttempt) {
logError('Failed to stop exam. No active attempt.');
@@ -323,7 +323,7 @@ export function stopExam() {
export function continueExam() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
const attemptId = exam.attempt.attempt_id;
const useLegacyAttemptAPI = exam.attempt.use_legacy_attempt_api;
if (!attemptId) {
@@ -344,7 +344,7 @@ export function continueExam() {
export function resetExam() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
const attemptId = exam.attempt.attempt_id;
const useLegacyAttemptAPI = exam.attempt.use_legacy_attempt_api;
if (!attemptId) {
@@ -361,7 +361,7 @@ export function resetExam() {
export function submitExam() {
return async (dispatch, getState) => {
- const { exam, activeAttempt } = getState().examState;
+ const { exam, activeAttempt } = getState().specialExams;
const { desktop_application_js_url: workerUrl, external_id: attemptExternalId } = activeAttempt || {};
const useWorker = window.Worker && activeAttempt && workerUrl;
@@ -409,7 +409,7 @@ export function submitExam() {
export function expireExam() {
return async (dispatch, getState) => {
- const { exam, activeAttempt } = getState().examState;
+ const { exam, activeAttempt } = getState().specialExams;
const {
desktop_application_js_url: workerUrl,
attempt_id: attemptId,
@@ -452,7 +452,7 @@ export function expireExam() {
*/
export function pingAttempt(timeoutInSeconds, workerUrl) {
return async (dispatch, getState) => {
- const { exam, activeAttempt } = getState().examState;
+ const { exam, activeAttempt } = getState().specialExams;
await pingApplication(timeoutInSeconds, activeAttempt.external_id, workerUrl)
.catch(async (error) => {
const message = error?.message || 'Worker failed to respond.';
@@ -480,7 +480,7 @@ export function pingAttempt(timeoutInSeconds, workerUrl) {
export function startProctoringSoftwareDownload() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
const attemptId = exam.attempt.attempt_id;
const useLegacyAttemptAPI = exam.attempt.use_legacy_attempt_api;
if (!attemptId) {
@@ -501,7 +501,7 @@ export function startProctoringSoftwareDownload() {
export function getExamReviewPolicy() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
if (!exam.id) {
logError('Failed to fetch exam review policy. No exam id.');
handleAPIError(
@@ -536,7 +536,7 @@ export function getAllowProctoringOptOut(allowProctoringOptOut) {
*/
export function checkExamEntry() {
return async (dispatch, getState) => {
- const { exam } = getState().examState;
+ const { exam } = getState().specialExams;
// Check only applies to LTI exams
if (
!exam?.attempt
diff --git a/src/exam/Exam.jsx b/src/exam/Exam.jsx
index 4a2049ad..306b306e 100644
--- a/src/exam/Exam.jsx
+++ b/src/exam/Exam.jsx
@@ -1,15 +1,15 @@
-/* eslint-disable react-hooks/exhaustive-deps */
-import React, { useContext, useEffect, useState } from 'react';
+import React, { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert, Spinner } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { ExamTimerBlock } from '../timer';
import Instructions from '../instructions';
-import ExamStateContext from '../context';
import ExamAPIError from './ExamAPIError';
-import { ExamStatus, ExamType } from '../constants';
+import { ExamStatus, ExamType, IS_STARTED_STATUS } from '../constants';
import messages from './messages';
+import { getProctoringSettings } from '../data';
/**
* Exam component is intended to render exam instructions before and after exam.
@@ -23,12 +23,12 @@ import messages from './messages';
const Exam = ({
isGated, isTimeLimited, originalUserIsStaff, canAccessProctoredExams, children, intl,
}) => {
- const state = useContext(ExamStateContext);
const {
- isLoading, activeAttempt, showTimer, stopExam, exam,
- expireExam, pollAttempt, apiErrorMsg, pingAttempt,
- getProctoringSettings, submitExam,
- } = state;
+ isLoading, activeAttempt, exam, apiErrorMsg,
+ } = useSelector(state => state.specialExams);
+ const dispatch = useDispatch();
+
+ const showTimer = !!(activeAttempt && IS_STARTED_STATUS(activeAttempt.attempt_status));
const {
attempt,
@@ -61,7 +61,7 @@ const Exam = ({
if (proctoredExamTypes.includes(examType)) {
// only fetch proctoring settings for a proctored exam
if (examId) {
- getProctoringSettings();
+ dispatch(getProctoringSettings());
}
// Only exclude Timed Exam when restricting access to exams
@@ -70,7 +70,8 @@ const Exam = ({
// this makes sure useEffect gets called only one time after the exam has been fetched
// we can't leave this empty since initially exam is just an empty object, so
// API calls above would not get triggered
- }, [examId]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [examId, dispatch]);
if (isLoading) {
return (
@@ -104,14 +105,7 @@ const Exam = ({
)}
{showTimer && (
-
+
)}
{ // show the error message only if you are in the exam sequence
isTimeLimited && apiErrorMsg &&
diff --git a/src/exam/ExamAPIError.jsx b/src/exam/ExamAPIError.jsx
index a6b0e271..157f7e10 100644
--- a/src/exam/ExamAPIError.jsx
+++ b/src/exam/ExamAPIError.jsx
@@ -1,15 +1,14 @@
-import React, { useContext } from 'react';
+import React from 'react';
+import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { Alert, Hyperlink, Icon } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
-import ExamStateContext from '../context';
import messages from './messages';
const ExamAPIError = ({ intl }) => {
- const state = useContext(ExamStateContext);
const { SITE_NAME, SUPPORT_URL } = getConfig();
- const { apiErrorMsg } = state;
+ const { apiErrorMsg } = useSelector(state => state.specialExams);
const shouldShowApiErrorMsg = !!apiErrorMsg && !apiErrorMsg.includes('<');
return (
diff --git a/src/exam/ExamAPIError.test.jsx b/src/exam/ExamAPIError.test.jsx
index b8d276d9..5eeea9ab 100644
--- a/src/exam/ExamAPIError.test.jsx
+++ b/src/exam/ExamAPIError.test.jsx
@@ -1,9 +1,7 @@
import '@testing-library/jest-dom';
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
-import { store } from '../data';
-import { render } from '../setupTest';
-import ExamStateProvider from '../core/ExamStateProvider';
+import { initializeTestStore, render } from '../setupTest';
import ExamAPIError from './ExamAPIError';
const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
@@ -13,22 +11,20 @@ jest.mock('@edx/frontend-platform', () => ({
}));
getConfig.mockImplementation(() => originalConfig);
-jest.mock('../data', () => ({
- store: {},
-}));
-store.subscribe = jest.fn();
-store.dispatch = jest.fn();
-
describe('ExamAPIError', () => {
const defaultMessage = 'A system error has occurred with your exam.';
+ let store;
+
+ beforeEach(() => {
+ store = initializeTestStore();
+ });
+
it('renders with the default information', () => {
- store.getState = () => ({ examState: {} });
+ store.getState = () => ({ specialExams: {} });
const tree = render(
-
-
- ,
+ ,
{ store },
);
@@ -42,12 +38,10 @@ describe('ExamAPIError', () => {
};
getConfig.mockImplementation(() => config);
- store.getState = () => ({ examState: {} });
+ store.getState = () => ({ specialExams: {} });
const { getByTestId } = render(
-
-
- ,
+ ,
{ store },
);
@@ -58,28 +52,24 @@ describe('ExamAPIError', () => {
it('renders error details when provided', () => {
store.getState = () => ({
- examState: { apiErrorMsg: 'Something bad has happened' },
+ specialExams: { apiErrorMsg: 'Something bad has happened' },
});
const { queryByTestId } = render(
-
-
- ,
+ ,
{ store },
);
- expect(queryByTestId('error-details')).toHaveTextContent(store.getState().examState.apiErrorMsg);
+ expect(queryByTestId('error-details')).toHaveTextContent(store.getState().specialExams.apiErrorMsg);
});
it('renders default message when error is HTML', () => {
store.getState = () => ({
- examState: { apiErrorMsg: '' },
+ specialExams: { apiErrorMsg: '' },
});
const { queryByTestId } = render(
-
-
- ,
+ ,
{ store },
);
@@ -88,13 +78,11 @@ describe('ExamAPIError', () => {
it('renders default message when there is no error message', () => {
store.getState = () => ({
- examState: { apiErrorMsg: '' },
+ specialExams: { apiErrorMsg: '' },
});
const { queryByTestId } = render(
-
-
- ,
+ ,
{ store },
);
diff --git a/src/exam/ExamWrapper.jsx b/src/exam/ExamWrapper.jsx
index 68b7eee8..8b5ccde6 100644
--- a/src/exam/ExamWrapper.jsx
+++ b/src/exam/ExamWrapper.jsx
@@ -1,14 +1,18 @@
+import { useDispatch, useSelector } from 'react-redux';
import React, { useContext, useEffect } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import PropTypes from 'prop-types';
import Exam from './Exam';
-import ExamStateContext from '../context';
+import {
+ getExamAttemptsData,
+ getAllowProctoringOptOut,
+ checkExamEntry,
+} from '../data';
/**
* Exam wrapper is responsible for triggering initial exam data fetching and rendering Exam.
*/
const ExamWrapper = ({ children, ...props }) => {
- const state = useContext(ExamStateContext);
const { authenticatedUser } = useContext(AppContext);
const {
sequence,
@@ -17,9 +21,13 @@ const ExamWrapper = ({ children, ...props }) => {
originalUserIsStaff,
canAccessProctoredExams,
} = props;
- const { getExamAttemptsData, getAllowProctoringOptOut, checkExamEntry } = state;
+
+ const { isLoading } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const loadInitialData = async () => {
- await getExamAttemptsData(courseId, sequence.id);
+ await dispatch(getExamAttemptsData(courseId, sequence.id));
await getAllowProctoringOptOut(sequence.allowProctoringOptOut);
await checkExamEntry();
};
@@ -28,7 +36,7 @@ const ExamWrapper = ({ children, ...props }) => {
useEffect(() => {
// fetch exam data on exam sequences or if no exam data has been fetched yet
- if (sequence.isTimeLimited || state.isLoading) {
+ if (sequence.isTimeLimited || isLoading) {
loadInitialData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/src/exam/ExamWrapper.test.jsx b/src/exam/ExamWrapper.test.jsx
index bee6ed74..7a6b23bc 100644
--- a/src/exam/ExamWrapper.test.jsx
+++ b/src/exam/ExamWrapper.test.jsx
@@ -2,32 +2,21 @@ import '@testing-library/jest-dom';
import { Factory } from 'rosie';
import React from 'react';
import SequenceExamWrapper from './ExamWrapper';
-import { store, startTimedExam } from '../data';
-import { getExamAttemptsData } from '../data/thunks';
-import { render, waitFor } from '../setupTest';
-import ExamStateProvider from '../core/ExamStateProvider';
+import { getExamAttemptsData, startTimedExam } from '../data';
+import { render, waitFor, initializeTestStore } from '../setupTest';
import { ExamStatus, ExamType } from '../constants';
-jest.mock('../data', () => ({
- store: {},
- startTimedExam: jest.fn(),
-}));
-
-// because of the way ExamStateProvider and other locations inconsistantly import from
-// thunks directly instead of using the data module we need to mock the underlying
-// thunk file. It would be nice to clean this up in the future.
-jest.mock('../data/thunks', () => {
+jest.mock('../data', () => {
const originalModule = jest.requireActual('../data/thunks');
return {
...originalModule,
getExamAttemptsData: jest.fn(),
+ startTimedExam: jest.fn(),
};
});
getExamAttemptsData.mockReturnValue(jest.fn());
startTimedExam.mockReturnValue(jest.fn());
-store.subscribe = jest.fn();
-store.dispatch = jest.fn();
describe('SequenceExamWrapper', () => {
const sequence = {
@@ -35,22 +24,21 @@ describe('SequenceExamWrapper', () => {
isTimeLimited: true,
};
const courseId = 'course-v1:test+test+test';
+ let store;
beforeEach(() => {
jest.clearAllMocks();
- store.getState = () => ({
- examState: Factory.build('examState'),
+ store = initializeTestStore({
+ specialExams: Factory.build('specialExams'),
isLoading: false,
});
});
it('is successfully rendered and shows instructions if the user is not staff', () => {
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('exam-instructions-title')).toHaveTextContent('Subsection is a Timed Exam (30 minutes)');
@@ -59,18 +47,16 @@ describe('SequenceExamWrapper', () => {
it('is successfully rendered and shows instructions for proctored exam', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
}),
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('proctored-exam-instructions-title')).toHaveTextContent('This exam is proctored');
@@ -78,16 +64,14 @@ describe('SequenceExamWrapper', () => {
it('shows loader if isLoading true', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
isLoading: true,
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('spinner')).toBeInTheDocument();
@@ -95,17 +79,15 @@ describe('SequenceExamWrapper', () => {
it('shows exam api error component together with other content if there is an error', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
apiErrorMsg: 'Something bad has happened.',
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('exam-instructions-title')).toHaveTextContent('Subsection is a Timed Exam (30 minutes)');
@@ -114,17 +96,15 @@ describe('SequenceExamWrapper', () => {
it('does not show exam api error component on a non-exam sequence', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
apiErrorMsg: 'Something bad has happened.',
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('exam-instructions-title')).not.toBeInTheDocument();
@@ -133,11 +113,9 @@ describe('SequenceExamWrapper', () => {
it('does not fetch exam data if already loaded and the sequence is not an exam', async () => {
render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
// assert the exam data is not fetched
@@ -147,17 +125,15 @@ describe('SequenceExamWrapper', () => {
it('does fetch exam data for non exam sequences if not already loaded', async () => {
// this would only occur if the user deeplinks directly to a non-exam sequence
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
isLoading: true,
}),
});
render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
await waitFor(() => expect(getExamAttemptsData).toHaveBeenCalled());
@@ -165,11 +141,9 @@ describe('SequenceExamWrapper', () => {
it('does not take any actions if sequence item is not exam', () => {
const { getByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(getByTestId('sequence-content')).toHaveTextContent('children');
@@ -180,11 +154,9 @@ describe('SequenceExamWrapper', () => {
authenticatedUser: null,
};
const { getByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store, appContext },
);
expect(getByTestId('sequence-content')).toHaveTextContent('children');
@@ -192,18 +164,16 @@ describe('SequenceExamWrapper', () => {
it('renders exam content without an active attempt if the user is staff', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
}),
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
@@ -211,7 +181,7 @@ describe('SequenceExamWrapper', () => {
it('renders exam content for staff masquerading as a learner', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
passed_due_date: false,
@@ -220,11 +190,9 @@ describe('SequenceExamWrapper', () => {
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
@@ -236,18 +204,16 @@ describe('SequenceExamWrapper', () => {
gated: true,
};
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
}),
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
@@ -255,7 +221,7 @@ describe('SequenceExamWrapper', () => {
it('does not display masquerade alert if specified learner is in the middle of the exam', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
attempt: {
@@ -267,11 +233,9 @@ describe('SequenceExamWrapper', () => {
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
@@ -280,7 +244,7 @@ describe('SequenceExamWrapper', () => {
it('does not display masquerade alert if learner can view the exam after the due date', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.TIMED,
attempt: {
@@ -292,11 +256,9 @@ describe('SequenceExamWrapper', () => {
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
@@ -305,11 +267,9 @@ describe('SequenceExamWrapper', () => {
it('does not display masquerade alert if sequence is not time gated', () => {
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
@@ -318,7 +278,7 @@ describe('SequenceExamWrapper', () => {
it('shows access denied if learner is not accessible to proctoring exams', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
attempt: null,
@@ -328,15 +288,13 @@ describe('SequenceExamWrapper', () => {
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('no-access')).toHaveTextContent('You do not have access to proctored exams with your current enrollment.');
@@ -345,7 +303,7 @@ describe('SequenceExamWrapper', () => {
it('learner has access to timed exams', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.TIMED,
attempt: null,
@@ -355,15 +313,13 @@ describe('SequenceExamWrapper', () => {
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('no-access')).toBeNull();
@@ -372,7 +328,7 @@ describe('SequenceExamWrapper', () => {
it('learner has access to content that are not exams', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: '',
attempt: null,
@@ -382,15 +338,13 @@ describe('SequenceExamWrapper', () => {
}),
});
const { queryByTestId } = render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
expect(queryByTestId('no-access')).toBeNull();
diff --git a/src/hocs.jsx b/src/hocs.jsx
deleted file mode 100644
index 049ec614..00000000
--- a/src/hocs.jsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import { getDisplayName } from './helpers';
-import { store as examStore } from './data';
-
-// eslint-disable-next-line import/prefer-default-export
-export const withExamStore = (WrappedComponent, mapStateToProps = null, dispatchActions = null) => {
- const ConnectedComp = connect(mapStateToProps, dispatchActions)(WrappedComponent);
- const retValue = (props) => ;
- retValue.displayName = `WithExamStore(${getDisplayName(WrappedComponent)})`;
- return retValue;
-};
diff --git a/src/index.jsx b/src/index.jsx
index 84ae8cf9..87192b53 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -2,8 +2,8 @@
export { default } from './core/SequenceExamWrapper';
export { default as OuterExamTimer } from './core/OuterExamTimer';
export {
- getExamAccess,
- isExam,
- fetchExamAccess,
+ useExamAccessToken,
+ useFetchExamAccessToken,
+ useIsExam,
} from './api';
-export { store } from './data';
+export { reducer } from './data';
diff --git a/src/instructions/Instructions.test.jsx b/src/instructions/Instructions.test.jsx
index 22cbba99..191591a2 100644
--- a/src/instructions/Instructions.test.jsx
+++ b/src/instructions/Instructions.test.jsx
@@ -3,27 +3,25 @@ import { Factory } from 'rosie';
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/dom';
import Instructions from './index';
-import { store, getExamAttemptsData, startTimedExam } from '../data';
+import {
+ continueExam, getExamAttemptsData, startProctoredExam, startTimedExam, submitExam,
+} from '../data';
import { pollExamAttempt, softwareDownloadAttempt } from '../data/api';
-import { continueExam, submitExam } from '../data/thunks';
import Emitter from '../data/emitter';
import { TIMER_REACHED_NULL } from '../timer/events';
import {
- render, screen, act, initializeMockApp,
+ render, screen, act, initializeMockApp, initializeTestStore,
} from '../setupTest';
-import ExamStateProvider from '../core/ExamStateProvider';
import {
ExamStatus, ExamType, INCOMPLETE_STATUSES,
} from '../constants';
jest.mock('../data', () => ({
- store: {},
- getExamAttemptsData: jest.fn(),
- startTimedExam: jest.fn(),
-}));
-jest.mock('../data/thunks', () => ({
continueExam: jest.fn(),
+ getExamAttemptsData: jest.fn(),
getExamReviewPolicy: jest.fn(),
+ startProctoredExam: jest.fn(),
+ startTimedExam: jest.fn(),
submitExam: jest.fn(),
}));
jest.mock('../data/api', () => ({
@@ -33,25 +31,27 @@ jest.mock('../data/api', () => ({
continueExam.mockReturnValue(jest.fn());
submitExam.mockReturnValue(jest.fn());
getExamAttemptsData.mockReturnValue(jest.fn());
+startProctoredExam.mockReturnValue(jest.fn());
startTimedExam.mockReturnValue(jest.fn());
pollExamAttempt.mockReturnValue(Promise.resolve({}));
-store.subscribe = jest.fn();
-store.dispatch = jest.fn();
describe('SequenceExamWrapper', () => {
+ let store;
+
beforeEach(() => {
initializeMockApp();
+ store = initializeTestStore();
+ store.subscribe = jest.fn();
+ store.dispatch = jest.fn();
});
it('Start exam instructions can be successfully rendered', () => {
- store.getState = () => ({ examState: Factory.build('examState') });
+ store.getState = () => ({ specialExams: Factory.build('specialExams') });
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('start-exam-button')).toHaveTextContent('I am ready to start this timed exam.');
@@ -59,7 +59,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions are not shown when exam is started', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
attempt: Factory.build('attempt', {
@@ -70,11 +70,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('sequence-content')).toHaveTextContent('Sequence');
@@ -87,7 +85,7 @@ describe('SequenceExamWrapper', () => {
['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: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
proctoringSettings: Factory.build('proctoringSettings', {
learner_notification_from_email: learnerEmail,
integration_specific_email: integrationEmail,
@@ -99,11 +97,9 @@ describe('SequenceExamWrapper', () => {
});
const { queryByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -126,7 +122,7 @@ describe('SequenceExamWrapper', () => {
it('Shows practice exam entrance instructions when receives practice exam', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PRACTICE,
}),
@@ -134,11 +130,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('exam-instructions-title')).toHaveTextContent('Try a proctored exam');
@@ -146,7 +140,7 @@ describe('SequenceExamWrapper', () => {
it('Shows failed prerequisites page if user has failed prerequisites for the exam', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
timeIsOver: true,
allowProctoringOptOut: true,
exam: Factory.build('exam', {
@@ -166,11 +160,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -185,7 +177,7 @@ describe('SequenceExamWrapper', () => {
it('Shows pending prerequisites page if user has failed prerequisites for the exam', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
timeIsOver: true,
exam: Factory.build('exam', {
is_proctored: true,
@@ -203,11 +195,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -218,7 +208,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions for error status', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
timeIsOver: true,
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
@@ -230,11 +220,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(screen.getByText('Error with proctored exam')).toBeInTheDocument();
@@ -242,7 +230,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions for ready to resume state', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
timeIsOver: true,
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
@@ -255,11 +243,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(screen.getByText('Your exam is ready to be resumed.')).toBeInTheDocument();
@@ -272,7 +258,7 @@ describe('SequenceExamWrapper', () => {
attempt_status: ExamStatus.READY_TO_SUBMIT,
});
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: attempt,
exam: Factory.build('exam', {
attempt,
@@ -281,11 +267,9 @@ describe('SequenceExamWrapper', () => {
});
const { queryByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -308,7 +292,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions for submitted status', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
attempt: Factory.build('attempt', {
attempt_status: ExamStatus.SUBMITTED,
@@ -318,11 +302,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('exam.submittedExamInstructions.title')).toHaveTextContent('You have submitted your timed exam.');
@@ -330,7 +312,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions when exam time is over', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
timeIsOver: true,
exam: Factory.build('exam', {
attempt: Factory.build('attempt', {
@@ -341,11 +323,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('exam.submittedExamInstructions.title')).toHaveTextContent('The time allotted for this exam has expired.');
@@ -353,7 +333,7 @@ describe('SequenceExamWrapper', () => {
it.each(['integration@example.com', ''])('Shows correct rejected onboarding exam instructions when attempt is rejected and integration email is "%s"', (integrationEmail) => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
proctoringSettings: Factory.build('proctoringSettings', {
integration_specific_email: integrationEmail,
}),
@@ -368,11 +348,9 @@ describe('SequenceExamWrapper', () => {
});
const { queryByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -388,7 +366,7 @@ describe('SequenceExamWrapper', () => {
it('Shows submit onboarding exam instructions if exam is onboarding and attempt status is ready_to_submit', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -401,11 +379,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -414,7 +390,7 @@ describe('SequenceExamWrapper', () => {
it('Shows error onboarding exam instructions if exam is onboarding and attempt status is error', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -427,11 +403,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -441,7 +415,7 @@ describe('SequenceExamWrapper', () => {
it('Shows submitted onboarding exam instructions if exam is onboarding and attempt status is submitted', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
proctoringSettings: Factory.build('proctoringSettings', {
integration_specific_email: 'test@example.com',
learner_notification_from_email: 'test_notification@example.com',
@@ -458,11 +432,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -479,7 +451,7 @@ describe('SequenceExamWrapper', () => {
it('Shows verified onboarding exam instructions if exam is onboarding and attempt status is verified', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
proctoringSettings: Factory.build('proctoringSettings', {
integration_specific_email: 'test@example.com',
}),
@@ -495,11 +467,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -509,7 +479,7 @@ describe('SequenceExamWrapper', () => {
it('Shows error practice exam instructions if exam is onboarding and attempt status is error', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -522,11 +492,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -536,7 +504,7 @@ describe('SequenceExamWrapper', () => {
it('Shows submitted practice exam instructions if exam is onboarding and attempt status is submitted', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -549,11 +517,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -563,7 +529,7 @@ describe('SequenceExamWrapper', () => {
it('Does not show expired page if exam is passed due date and is practice', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -574,11 +540,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -587,7 +551,7 @@ describe('SequenceExamWrapper', () => {
it.each([ExamType.TIMED, ExamType.PROCTORED, ExamType.ONBOARDING])('Shows expired page when exam is passed due date and is %s', (examType) => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -599,11 +563,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -615,7 +577,7 @@ describe('SequenceExamWrapper', () => {
`Shows expired page when exam is ${examType} and has passed due date and attempt is in %s status`,
(item) => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -630,11 +592,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -645,7 +605,7 @@ describe('SequenceExamWrapper', () => {
it('Shows exam content for timed exam if attempt status is submitted, due date has passed and hide after due is set to false', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
type: ExamType.TIMED,
@@ -659,11 +619,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
@@ -672,7 +630,7 @@ describe('SequenceExamWrapper', () => {
it('Shows submitted exam page for proctored exams if attempt status is submitted, due date has passed and hide after due is set to false', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
@@ -686,11 +644,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
@@ -699,7 +655,7 @@ describe('SequenceExamWrapper', () => {
it('Shows submitted page when proctored exam is in second_review_required status', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -712,11 +668,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -725,7 +679,7 @@ describe('SequenceExamWrapper', () => {
it('Shows correct download instructions for LTI provider if attempt status is created, with support email and phone', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
proctoringSettings: Factory.build('proctoringSettings', {
provider_name: 'LTI Provider',
@@ -743,11 +697,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -762,7 +714,7 @@ describe('SequenceExamWrapper', () => {
it('Shows correct download instructions for LTI provider if attempt status is created with support URL', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
proctoringSettings: Factory.build('proctoringSettings', {
provider_name: 'LTI Provider',
@@ -781,11 +733,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -802,7 +752,7 @@ describe('SequenceExamWrapper', () => {
it('Hides support contact info on download instructions for LTI provider if not provided', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
proctoringSettings: Factory.build('proctoringSettings', {
provider_name: 'LTI Provider',
@@ -818,11 +768,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -840,7 +788,7 @@ describe('SequenceExamWrapper', () => {
assign: mockAssign,
};
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
proctoringSettings: Factory.build('proctoringSettings', {
provider_name: 'LTI Provider',
@@ -861,11 +809,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
fireEvent.click(screen.getByText('Start System Check'));
@@ -888,7 +834,7 @@ describe('SequenceExamWrapper', () => {
'instruction 3',
];
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
proctoringSettings: Factory.build('proctoringSettings', {
provider_name: 'Provider Name',
@@ -911,11 +857,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -931,7 +875,7 @@ describe('SequenceExamWrapper', () => {
it('Shows correct download instructions for legacy rpnow provider if attempt status is created', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
proctoringSettings: Factory.build('proctoringSettings', {
provider_name: 'Provider Name',
@@ -951,11 +895,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(screen.getByDisplayValue('1234-5678-9012-3456')).toBeInTheDocument();
@@ -966,7 +908,7 @@ describe('SequenceExamWrapper', () => {
it('Shows error message if receives unknown attempt status', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
type: ExamType.TIMED,
@@ -978,11 +920,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- children
-
- ,
+
+ children
+ ,
{ store },
);
@@ -991,7 +931,7 @@ describe('SequenceExamWrapper', () => {
it('Shows ready to start page when proctored exam is in ready_to_start status', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -1004,11 +944,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -1017,7 +955,7 @@ describe('SequenceExamWrapper', () => {
it('Shows loading spinner while waiting to start exam', async () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: {},
exam: Factory.build('exam', {
is_proctored: true,
@@ -1031,11 +969,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
diff --git a/src/instructions/SubmitInstructions.jsx b/src/instructions/SubmitInstructions.jsx
index a93a29ef..816c3e3f 100644
--- a/src/instructions/SubmitInstructions.jsx
+++ b/src/instructions/SubmitInstructions.jsx
@@ -1,17 +1,20 @@
-import React, { useContext, useEffect, useState } from 'react';
+import React, { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import { Button, Container } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import Emitter from '../data/emitter';
import { ExamType } from '../constants';
+import { continueExam } from '../data';
import { SubmitProctoredExamInstructions } from './proctored_exam';
import { SubmitTimedExamInstructions } from './timed_exam';
import Footer from './proctored_exam/Footer';
-import ExamStateContext from '../context';
import { TIMER_REACHED_NULL } from '../timer/events';
const SubmitExamInstructions = () => {
- const state = useContext(ExamStateContext);
- const { exam, continueExam, activeAttempt } = state;
+ const { exam, activeAttempt } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const { time_remaining_seconds: timeRemaining } = activeAttempt;
const { type: examType } = exam || {};
const [canContinue, setCanContinue] = useState(timeRemaining > 0);
@@ -33,7 +36,7 @@ const SubmitExamInstructions = () => {
?
: }
{canContinue && (
-
+ dispatch(continueExam())} data-testid="continue-exam-button">
{
- const state = useContext(ExamStateContext);
- const { exam } = state;
+ const { exam } = useSelector(state => state.specialExams);
const {
attempt,
type: examType,
diff --git a/src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx b/src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx
index d3cbc8bf..e3b2ca7b 100644
--- a/src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx
+++ b/src/instructions/onboarding_exam/EntranceOnboardingExamInstructions.jsx
@@ -1,11 +1,15 @@
-import React, { useContext } from 'react';
+import React from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, MailtoLink } from '@edx/paragon';
-import ExamStateContext from '../../context';
+
+import { createProctoredExamAttempt } from '../../data';
const EntranceOnboardingExamInstructions = () => {
- const state = useContext(ExamStateContext);
- const { createProctoredExamAttempt, proctoringSettings } = state;
+ const { proctoringSettings } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const {
provider_name: providerName,
learner_notification_from_email: learnerNotificationFromEmail,
@@ -89,7 +93,7 @@ const EntranceOnboardingExamInstructions = () => {
dispatch(createProctoredExamAttempt())}
>
{
- const state = useContext(ExamStateContext);
- const { resetExam } = state;
+ const dispatch = useDispatch();
return (
@@ -25,7 +26,7 @@ const ErrorOnboardingExamInstructions = () => {
dispatch(resetExam())}
>
{
- const state = useContext(ExamStateContext);
- const { proctoringSettings, resetExam } = state;
+ const { proctoringSettings } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const { integration_specific_email: integrationSpecificEmail } = proctoringSettings || {};
return (
@@ -34,7 +38,7 @@ const RejectedOnboardingExamInstructions = () => {
dispatch(resetExam())}
>
{
const [isConfirm, confirm] = useToggle(false);
- const state = useContext(ExamStateContext);
- const { proctoringSettings, resetExam } = state;
+
+ const { proctoringSettings } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const {
learner_notification_from_email: learnerNotificationFromEmail,
integration_specific_email: integrationSpecificEmail,
@@ -70,7 +75,7 @@ const SubmittedOnboardingExamInstructions = () => {
dispatch(resetExam())}
disabled={!isConfirm}
>
{
- const state = useContext(ExamStateContext);
+ const { proctoringSettings } = useSelector(state => state.specialExams);
+
const {
integration_specific_email: integrationSpecificEmail,
- } = state.proctoringSettings || {};
+ } = proctoringSettings || {};
return (
diff --git a/src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx b/src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx
index 844db80e..a7440705 100644
--- a/src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx
+++ b/src/instructions/practice_exam/EntrancePracticeExamInstructions.jsx
@@ -1,11 +1,12 @@
-import React, { useContext } from 'react';
+import React from 'react';
+import { useDispatch } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
-import ExamStateContext from '../../context';
+
+import { createProctoredExamAttempt } from '../../data';
const EntrancePracticeExamInstructions = () => {
- const state = useContext(ExamStateContext);
- const { createProctoredExamAttempt } = state;
+ const dispatch = useDispatch();
return (
<>
@@ -26,7 +27,7 @@ const EntrancePracticeExamInstructions = () => {
dispatch(createProctoredExamAttempt())}
>
{
- const state = useContext(ExamStateContext);
- const { resetExam } = state;
+ const dispatch = useDispatch();
return (
@@ -37,7 +38,7 @@ const ErrorPracticeExamInstructions = () => {
dispatch(resetExam())}
>
{
- const state = useContext(ExamStateContext);
- const { resetExam } = state;
+ const dispatch = useDispatch();
return (
@@ -25,7 +26,7 @@ const SubmittedPracticeExamInstructions = () => {
dispatch(resetExam())}
>
{
- const state = useContext(ExamStateContext);
- const { exam, createProctoredExamAttempt, allowProctoringOptOut } = state;
+ const { exam, allowProctoringOptOut } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const { attempt } = exam || {};
const { total_time: totalTime = 0 } = attempt;
@@ -54,7 +58,7 @@ const EntranceProctoredExamInstructions = ({ skipProctoredExam }) => {
dispatch(createProctoredExamAttempt())}
>
{
- const state = useContext(ExamStateContext);
- const {
- proctoring_escalation_email: proctoringEscalationEmail,
- } = state.proctoringSettings || {};
+ const { proctoring_escalation_email: proctoringEscalationEmail } = useSelector(
+ state => state.specialExams?.proctoringSettings,
+ ) || {};
+
const platformName = getConfig().SITE_NAME;
const contactUsUrl = getConfig().CONTACT_URL;
diff --git a/src/instructions/proctored_exam/OnboardingErrorExamInstructions.jsx b/src/instructions/proctored_exam/OnboardingErrorExamInstructions.jsx
index ea872d9c..74b06081 100644
--- a/src/instructions/proctored_exam/OnboardingErrorExamInstructions.jsx
+++ b/src/instructions/proctored_exam/OnboardingErrorExamInstructions.jsx
@@ -1,13 +1,12 @@
-import React, { useContext } from 'react';
+import React from 'react';
+import { useSelector } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Container, MailtoLink, Hyperlink } from '@edx/paragon';
-import ExamStateContext from '../../context';
import { ExamStatus } from '../../constants';
import Footer from './Footer';
const OnboardingErrorProctoredExamInstructions = () => {
- const state = useContext(ExamStateContext);
- const { exam, proctoringSettings } = state;
+ const { exam, proctoringSettings } = useSelector(state => state.specialExams);
const { attempt, onboarding_link: onboardingLink } = exam;
const {
integration_specific_email: integrationSpecificEmail,
diff --git a/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx b/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx
index bfd56f9a..4729a4ca 100644
--- a/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx
+++ b/src/instructions/proctored_exam/ProctoredExamInstructions.test.jsx
@@ -3,10 +3,13 @@ import { Factory } from 'rosie';
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/dom';
import Instructions from '../index';
-import { store, getExamAttemptsData } from '../../data';
-import { submitExam } from '../../data/thunks';
-import { initializeMockApp, render, screen } from '../../setupTest';
-import ExamStateProvider from '../../core/ExamStateProvider';
+import { getExamAttemptsData, submitExam } from '../../data';
+import {
+ initializeMockApp,
+ initializeTestStore,
+ render,
+ screen,
+} from '../../setupTest';
import {
ExamType,
ExamStatus,
@@ -14,27 +17,28 @@ import {
} from '../../constants';
jest.mock('../../data', () => ({
- store: {},
getExamAttemptsData: jest.fn(),
-}));
-jest.mock('../../data/thunks', () => ({
getExamReviewPolicy: jest.fn(),
submitExam: jest.fn(),
}));
+
submitExam.mockReturnValue(jest.fn());
getExamAttemptsData.mockReturnValue(jest.fn());
-store.subscribe = jest.fn();
-store.dispatch = jest.fn();
describe('SequenceExamWrapper', () => {
+ let store;
+
beforeEach(() => {
initializeMockApp();
jest.clearAllMocks();
+ store = initializeTestStore();
+ store.subscribe = jest.fn();
+ store.dispatch = jest.fn();
});
it('Start exam instructions can be successfully rendered', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
allowProctoringOptOut: true,
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
@@ -44,11 +48,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('start-exam-button')).toHaveTextContent('Continue to my proctored exam.');
@@ -65,7 +67,7 @@ describe('SequenceExamWrapper', () => {
attempt_status: ExamStatus.STARTED,
});
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: attempt,
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
@@ -76,11 +78,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('sequence-content')).toHaveTextContent('Sequence');
@@ -88,7 +88,7 @@ describe('SequenceExamWrapper', () => {
it('Shows correct instructions when attempt status is ready_to_start', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
is_proctored: true,
@@ -101,11 +101,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(screen.getByTestId('proctored-exam-instructions-rulesLink')).toHaveTextContent('Rules for Online Proctored Exams');
@@ -115,7 +113,7 @@ describe('SequenceExamWrapper', () => {
it('Shows correct instructions when attempt status is ready_to_start and attempt has no total time', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
is_proctored: true,
@@ -130,11 +128,9 @@ describe('SequenceExamWrapper', () => {
});
render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(screen.getByTestId('proctored-exam-instructions-rulesLink')).toHaveTextContent('Rules for Online Proctored Exams');
@@ -144,7 +140,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions are shown when attempt status is submitted', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
is_proctored: true,
@@ -156,11 +152,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('proctored-exam-instructions-title')).toHaveTextContent('You have submitted this proctored exam for review');
@@ -172,7 +166,7 @@ describe('SequenceExamWrapper', () => {
use_legacy_attempt_api: true,
});
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: attempt,
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
@@ -183,11 +177,9 @@ describe('SequenceExamWrapper', () => {
});
const { queryByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -209,7 +201,7 @@ describe('SequenceExamWrapper', () => {
attempt_id: 1,
});
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
activeAttempt: attempt,
exam: Factory.build('exam', {
is_proctored: true,
@@ -220,11 +212,9 @@ describe('SequenceExamWrapper', () => {
});
const { queryByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
@@ -238,7 +228,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions are shown when attempt status is verified', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
is_proctored: true,
@@ -250,11 +240,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('proctored-exam-instructions-title')).toHaveTextContent('Your proctoring session was reviewed successfully.');
@@ -262,7 +250,7 @@ describe('SequenceExamWrapper', () => {
it('Instructions are shown when attempt status is rejected', () => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
is_proctored: true,
@@ -274,11 +262,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
expect(getByTestId('proctored-exam-instructions-title'))
@@ -287,7 +273,7 @@ describe('SequenceExamWrapper', () => {
it.each(ONBOARDING_ERRORS)('Renders correct onboarding error instructions when status is %s ', (status) => {
store.getState = () => ({
- examState: Factory.build('examState', {
+ specialExams: Factory.build('specialExams', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
is_proctored: true,
@@ -299,11 +285,9 @@ describe('SequenceExamWrapper', () => {
});
const { getByTestId } = render(
-
-
- Sequence
-
- ,
+
+ Sequence
+ ,
{ store },
);
diff --git a/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx b/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx
index 49b92acd..b7ad932e 100644
--- a/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx
+++ b/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx
@@ -1,31 +1,30 @@
-import React, { useContext, useEffect, useState } from 'react';
+import React, { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { Button, Container, Spinner } from '@edx/paragon';
-import ExamStateContext from '../../context';
import Footer from './Footer';
+import { getExamReviewPolicy, startProctoredExam } from '../../data';
+
const ReadyToStartProctoredExamInstructions = () => {
- const state = useContext(ExamStateContext);
- const {
- exam,
- getExamReviewPolicy,
- startProctoredExam,
- } = state;
+ const { exam } = useSelector(state => state.specialExams);
const { attempt, reviewPolicy } = exam;
+
+ const dispatch = useDispatch();
+
const examDuration = attempt.total_time ? attempt.total_time : exam.total_time;
const platformName = getConfig().SITE_NAME;
const rulesUrl = getConfig().PROCTORED_EXAM_RULES_URL;
const [beginExamClicked, setBeginExamClicked] = useState(false);
useEffect(() => {
- getExamReviewPolicy();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ dispatch(getExamReviewPolicy());
+ }, [dispatch]);
const handleStart = () => {
setBeginExamClicked(true);
- startProctoredExam();
+ dispatch(startProctoredExam());
};
return (
diff --git a/src/instructions/proctored_exam/SkipProctoredExamInstruction.jsx b/src/instructions/proctored_exam/SkipProctoredExamInstruction.jsx
index 167b5dc5..9f0b9e30 100644
--- a/src/instructions/proctored_exam/SkipProctoredExamInstruction.jsx
+++ b/src/instructions/proctored_exam/SkipProctoredExamInstruction.jsx
@@ -1,13 +1,14 @@
-import React, { useContext } from 'react';
+import React from 'react';
+import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, Container } from '@edx/paragon';
-import ExamStateContext from '../../context';
import Footer from './Footer';
+import { skipProctoringExam } from '../../data';
+
const SkipProctoredExamInstruction = ({ cancelSkipProctoredExam }) => {
- const state = useContext(ExamStateContext);
- const { skipProctoringExam } = state;
+ const dispatch = useDispatch();
return (
<>
@@ -30,7 +31,7 @@ const SkipProctoredExamInstruction = ({ cancelSkipProctoredExam }) => {
data-testid="skip-confirm-exam-button"
variant="primary"
className="mr-3 mb-2"
- onClick={skipProctoringExam}
+ onClick={() => dispatch(skipProctoringExam())}
>
{
- const state = useContext(ExamStateContext);
- const {
- submitExam,
- exam,
- activeAttempt,
- } = state;
+ const { exam, activeAttempt } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const { type: examType, attempt } = exam || {};
const { exam_display_name: examName } = activeAttempt;
const examHasLtiProvider = !attempt.use_legacy_attempt_api;
@@ -21,7 +20,7 @@ const SubmitProctoredExamInstructions = () => {
if (examHasLtiProvider) {
window.location.assign(submitLtiAttemptUrl);
} else {
- submitExam();
+ dispatch(submitExam());
}
};
diff --git a/src/instructions/proctored_exam/download-instructions/index.jsx b/src/instructions/proctored_exam/download-instructions/index.jsx
index 298d81c2..626d1157 100644
--- a/src/instructions/proctored_exam/download-instructions/index.jsx
+++ b/src/instructions/proctored_exam/download-instructions/index.jsx
@@ -1,10 +1,11 @@
-import React, { useContext, useState } from 'react';
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Container } from '@edx/paragon';
-import ExamStateContext from '../../../context';
import { ExamStatus } from '../../../constants';
+import { getExamAttemptsData } from '../../../data';
import WarningModal from '../WarningModal';
import { pollExamAttempt, softwareDownloadAttempt } from '../../../data/api';
import messages from '../messages';
@@ -16,18 +17,20 @@ import Footer from '../Footer';
import SkipProctoredExamButton from '../SkipProctoredExamButton';
const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam }) => {
- const state = useContext(ExamStateContext);
const {
proctoringSettings,
exam,
- getExamAttemptsData,
allowProctoringOptOut,
- } = state;
+ } = useSelector(state => state.specialExams);
+
+ const dispatch = useDispatch();
+
const {
attempt,
course_id: courseId,
content_id: sequenceId,
} = exam;
+
const {
exam_started_poll_url: pollUrl,
attempt_code: examCode,
@@ -35,6 +38,7 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam })
software_download_url: downloadUrl,
use_legacy_attempt_api: useLegacyAttemptApi,
} = attempt;
+
const {
provider_name: providerName,
provider_tech_support_email: supportEmail,
@@ -42,6 +46,7 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam })
provider_tech_support_url: supportURL,
exam_proctoring_backend: proctoringBackend,
} = proctoringSettings;
+
const examHasLtiProvider = !useLegacyAttemptApi;
const { instructions } = proctoringBackend || {};
const [systemCheckStatus, setSystemCheckStatus] = useState('');
@@ -70,7 +75,7 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam })
pollExamAttempt(pollUrl, sequenceId)
.then((data) => (
data.status === ExamStatus.READY_TO_START
- ? getExamAttemptsData(courseId, sequenceId)
+ ? dispatch(getExamAttemptsData(courseId, sequenceId))
: setSystemCheckStatus('failure')
));
};
diff --git a/src/instructions/proctored_exam/prerequisites-instructions/index.jsx b/src/instructions/proctored_exam/prerequisites-instructions/index.jsx
index 2634678f..8c4670cc 100644
--- a/src/instructions/proctored_exam/prerequisites-instructions/index.jsx
+++ b/src/instructions/proctored_exam/prerequisites-instructions/index.jsx
@@ -1,15 +1,15 @@
-import React, { useContext } from 'react';
+import React from 'react';
+import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { Container } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
-import ExamStateContext from '../../../context';
import PendingPrerequisitesProctoredExamInstructions from './Pending';
import FailedPrerequisitesProctoredExamInstructions from './Failed';
import Footer from '../Footer';
const PrerequisitesProctoredExamInstructions = ({ skipProctoredExam }) => {
- const state = useContext(ExamStateContext);
- const { exam, allowProctoringOptOut } = state;
+ const { exam, allowProctoringOptOut } = useSelector(state => state.specialExams);
+
const { prerequisite_status: prerequisitesData } = exam;
const { pending_prerequisites: pending, failed_prerequisites: failed } = prerequisitesData;
diff --git a/src/instructions/timed_exam/StartTimedExamInstructions.jsx b/src/instructions/timed_exam/StartTimedExamInstructions.jsx
index 8b70afc2..9f13bc4a 100644
--- a/src/instructions/timed_exam/StartTimedExamInstructions.jsx
+++ b/src/instructions/timed_exam/StartTimedExamInstructions.jsx
@@ -1,11 +1,12 @@
-import React, { useContext } from 'react';
+import React from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
-import ExamStateContext from '../../context';
+import { startTimedExam } from '../../data';
const StartTimedExamInstructions = () => {
- const state = useContext(ExamStateContext);
- const { exam, startTimedExam } = state;
+ const { exam } = useSelector(state => state.specialExams);
+ const dispatch = useDispatch();
const examDuration = exam.total_time;
return (
@@ -38,7 +39,7 @@ const StartTimedExamInstructions = () => {
dispatch(startTimedExam())}
>
{
- const state = useContext(ExamStateContext);
- const { submitExam } = state;
+ const dispatch = useDispatch();
return (
<>
@@ -27,7 +28,7 @@ const SubmitTimedExamInstructions = () => {
defaultMessage="After you submit your exam, your exam will be graded."
/>
-
+ dispatch(submitExam())} className="mr-2" data-testid="end-exam-button">
{
- const state = useContext(ExamStateContext);
+ const { timeIsOver } = useSelector(state => state.specialExams);
return (
- {state.timeIsOver
+ {timeIsOver
? (
({
- examStore: {},
-}));
+// We do a partial mock to avoid mocking out other exported values (e.g. the store and the Emitter).
+jest.mock('../data', () => {
+ const originalModule = jest.requireActual('../data');
+
+ return {
+ __esModule: true,
+ ...originalModule,
+ stopExam: jest.fn(),
+ submitExam: jest.fn(),
+ };
+});
describe('ExamTimerBlock', () => {
let attempt;
let store;
- const stopExamAttempt = jest.fn();
- const expireExamAttempt = () => { };
- const pollAttempt = () => { };
- const submitAttempt = jest.fn();
- submitAttempt.mockReturnValue(jest.fn());
- stopExamAttempt.mockReturnValue(jest.fn());
+ submitExam.mockReturnValue(jest.fn());
+ stopExam.mockReturnValue(jest.fn());
- beforeEach(async () => {
+ beforeEach(() => {
const preloadedState = {
- examState: {
+ specialExams: {
isLoading: true,
timeIsOver: false,
activeAttempt: {
@@ -40,20 +44,13 @@ describe('ExamTimerBlock', () => {
},
},
};
- store = await initializeTestStore(preloadedState);
- examStore.getState = store.getState;
- attempt = store.getState().examState.activeAttempt;
+ store = initializeTestStore(preloadedState);
+ attempt = store.getState().specialExams.activeAttempt;
});
it('renders items correctly', async () => {
render(
- ,
+ ,
);
expect(screen.getByRole('alert')).toBeInTheDocument();
@@ -63,9 +60,9 @@ describe('ExamTimerBlock', () => {
expect(screen.getByRole('button', { name: 'End My Exam' })).toBeInTheDocument();
});
- it('renders without activeAttempt return null', async () => {
+ it('renders without activeAttempt return null', () => {
const preloadedState = {
- examState: {
+ specialExams: {
isLoading: true,
timeIsOver: false,
activeAttempt: null,
@@ -73,29 +70,17 @@ describe('ExamTimerBlock', () => {
exam: {},
},
};
- const testStore = await initializeTestStore(preloadedState);
- attempt = testStore.getState().examState.activeAttempt;
+ const testStore = initializeTestStore(preloadedState);
+ attempt = testStore.getState().specialExams.activeAttempt;
const { container } = render(
- ,
+ ,
);
expect(container.firstChild).not.toBeInTheDocument();
});
it('changes behavior when clock time decreases low threshold', async () => {
render(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveClass('alert-warning');
@@ -103,7 +88,7 @@ describe('ExamTimerBlock', () => {
it('changes behavior when clock time decreases critically low threshold', async () => {
const preloadedState = {
- examState: {
+ specialExams: {
isLoading: true,
timeIsOver: false,
activeAttempt: {
@@ -121,17 +106,10 @@ describe('ExamTimerBlock', () => {
},
},
};
- const testStore = await initializeTestStore(preloadedState);
- examStore.getState = store.testStore;
- attempt = testStore.getState().examState.activeAttempt;
+ const testStore = initializeTestStore(preloadedState);
+ attempt = testStore.getState().specialExams.activeAttempt;
render(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:00:05')).toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveClass('alert-danger');
@@ -139,13 +117,7 @@ describe('ExamTimerBlock', () => {
it('toggles timer visibility correctly', async () => {
render(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument());
expect(screen.getByRole('alert')).toBeInTheDocument();
@@ -162,13 +134,7 @@ describe('ExamTimerBlock', () => {
it('toggles long text visibility on show more/less', async () => {
render(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument());
expect(screen.getByRole('alert')).toBeInTheDocument();
@@ -184,7 +150,7 @@ describe('ExamTimerBlock', () => {
it('submits exam if time reached 00:00 and user clicks end my exam button', async () => {
const preloadedState = {
- examState: {
+ specialExams: {
isLoading: true,
timeIsOver: false,
activeAttempt: {
@@ -202,44 +168,31 @@ describe('ExamTimerBlock', () => {
},
},
};
- const testStore = await initializeTestStore(preloadedState);
- examStore.getState = store.testStore;
- attempt = testStore.getState().examState.activeAttempt;
+ const testStore = initializeTestStore(preloadedState);
+ attempt = testStore.getState().specialExams.activeAttempt;
render(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:00:00')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('end-button', { name: 'Show more' }));
- expect(submitAttempt).toHaveBeenCalledTimes(1);
+ expect(submitExam).toHaveBeenCalledTimes(1);
});
it('stops exam if time has not reached 00:00 and user clicks end my exam button', async () => {
render(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('end-button'));
- expect(stopExamAttempt).toHaveBeenCalledTimes(1);
+ expect(stopExam).toHaveBeenCalledTimes(1);
});
it('Update exam timer when attempt time_remaining_seconds is smaller than displayed time', async () => {
const preloadedState = {
- examState: {
+ specialExams: {
isLoading: true,
timeIsOver: false,
activeAttempt: {
@@ -257,38 +210,24 @@ describe('ExamTimerBlock', () => {
},
},
};
- let testStore = await initializeTestStore(preloadedState);
- examStore.getState = store.testStore;
- attempt = testStore.getState().examState.activeAttempt;
+ let testStore = initializeTestStore(preloadedState);
+ attempt = testStore.getState().specialExams.activeAttempt;
const { rerender } = render(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:03:59')).toBeInTheDocument());
- preloadedState.examState.activeAttempt = {
+ preloadedState.specialExams.activeAttempt = {
...attempt,
time_remaining_seconds: 20,
};
- testStore = await initializeTestStore(preloadedState);
- examStore.getState = store.testStore;
- const updatedAttempt = testStore.getState().examState.activeAttempt;
+ testStore = initializeTestStore(preloadedState);
+ const updatedAttempt = testStore.getState().specialExams.activeAttempt;
expect(updatedAttempt.time_remaining_seconds).toBe(20);
rerender(
- ,
+ ,
);
await waitFor(() => expect(screen.getByText('00:00:19')).toBeInTheDocument());
@@ -306,10 +245,10 @@ describe('ExamTimerBlock', () => {
'30 minutes': 1800,
};
Object.keys(timesToTest).forEach((timeString) => {
- it(`Accessibility time string ${timeString} appears as expected based seconds remaining: ${timesToTest[timeString]}`, async () => {
+ it(`Accessibility time string ${timeString} appears as expected based seconds remaining: ${timesToTest[timeString]}`, () => {
// create a state with the respective number of seconds
const preloadedState = {
- examState: {
+ specialExams: {
isLoading: true,
timeIsOver: false,
activeAttempt: {
@@ -329,23 +268,16 @@ describe('ExamTimerBlock', () => {
};
// Store it in the state
- const testStore = await initializeTestStore(preloadedState);
- examStore.getState = store.testStore;
- attempt = testStore.getState().examState.activeAttempt;
+ const testStore = initializeTestStore(preloadedState);
+ attempt = testStore.getState().specialExams.activeAttempt;
// render an exam timer block with that data
render(
- ,
+ ,
);
// expect the a11y string to be a certain output
- await waitFor(() => expect(screen.getByText(`you have ${timeString} remaining`)).toBeInTheDocument());
+ expect(screen.getByText(`you have ${timeString} remaining`)).toBeInTheDocument();
});
});
});
diff --git a/src/timer/ExamTimerBlock.jsx b/src/timer/ExamTimerBlock.jsx
index 3744e11a..95f3dcb5 100644
--- a/src/timer/ExamTimerBlock.jsx
+++ b/src/timer/ExamTimerBlock.jsx
@@ -1,11 +1,13 @@
import React, { useEffect, useState } from 'react';
-import PropTypes from 'prop-types';
+import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { Button, Alert, useToggle } from '@edx/paragon';
import CountDownTimer from './CountDownTimer';
import { ExamStatus, IS_STARTED_STATUS } from '../constants';
import TimerProvider from './TimerProvider';
-import { Emitter } from '../data';
+import {
+ Emitter, expireExam, stopExam, submitExam,
+} from '../data';
import {
TIMER_IS_CRITICALLY_LOW,
TIMER_IS_LOW,
@@ -16,13 +18,12 @@ import {
/**
* Exam timer block component.
*/
-const ExamTimerBlock = injectIntl(({
- attempt, stopExamAttempt, expireExamAttempt, pollExamAttempt,
- intl, pingAttempt, submitExam,
-}) => {
+const ExamTimerBlock = injectIntl(({ intl }) => {
+ const { activeAttempt: attempt } = useSelector(state => state.specialExams);
const [isShowMore, showMore, showLess] = useToggle(false);
const [alertVariant, setAlertVariant] = useState('info');
const [timeReachedNull, setTimeReachedNull] = useState(false);
+ const dispatch = useDispatch();
if (!attempt || !IS_STARTED_STATUS(attempt.attempt_status)) {
return null;
@@ -36,29 +37,29 @@ const ExamTimerBlock = injectIntl(({
// if timer reached 00:00 submit exam right away
// instead of trying to move user to ready_to_submit page
if (timeReachedNull) {
- submitExam();
+ dispatch(submitExam());
} else {
- stopExamAttempt();
+ dispatch(stopExam());
}
};
useEffect(() => {
Emitter.once(TIMER_IS_LOW, onLowTime);
Emitter.once(TIMER_IS_CRITICALLY_LOW, onCriticalLowTime);
- Emitter.once(TIMER_LIMIT_REACHED, expireExamAttempt);
+ Emitter.once(TIMER_LIMIT_REACHED, () => dispatch(expireExam()));
Emitter.once(TIMER_REACHED_NULL, onTimeReachedNull);
return () => {
Emitter.off(TIMER_IS_LOW, onLowTime);
Emitter.off(TIMER_IS_CRITICALLY_LOW, onCriticalLowTime);
- Emitter.off(TIMER_LIMIT_REACHED, expireExamAttempt);
+ Emitter.off(TIMER_LIMIT_REACHED, () => dispatch(expireExam()));
Emitter.off(TIMER_REACHED_NULL, onTimeReachedNull);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [dispatch]);
return (
-
+
{
- const { activeAttempt, exam } = state.examState;
- return { attempt: activeAttempt, timeLimitMins: exam.time_limit_mins };
-};
-
const getFormattedRemainingTime = (timeLeft) => ({
hours: Math.floor(timeLeft / (60 * 60)),
minutes: Math.floor((timeLeft / 60) % 60),
seconds: Math.floor(timeLeft % 60),
});
-const TimerServiceProvider = ({
- children, attempt, timeLimitMins, pollHandler, pingHandler,
+const TimerProvider = ({
+ children,
}) => {
+ const { activeAttempt: attempt, exam } = useSelector(state => state.specialExams);
+ const { time_limit_mins: timeLimitMins } = exam;
const [timeState, setTimeState] = useState({});
const [limitReached, setLimitReached] = useToggle(false);
const {
@@ -44,6 +41,8 @@ const TimerServiceProvider = ({
const LOW_TIME = timeLimitMins * 60 * TIME_LIMIT_LOW_PCT;
let liveInterval = null;
+ const dispatch = useDispatch();
+
const getTimeString = () => Object.values(timeState).map(
item => {
// Do not show timer negative value.
@@ -55,7 +54,7 @@ const TimerServiceProvider = ({
const pollExam = () => {
// poll url may be null if this is an LTI exam
- pollHandler(attempt.exam_started_poll_url);
+ dispatch(pollAttempt(attempt.exam_started_poll_url));
};
const processTimeLeft = (timer, secondsLeft) => {
@@ -92,7 +91,7 @@ const TimerServiceProvider = ({
}
// if exam is proctored ping provider app
if (workerUrl && timerTick % pingInterval === pingInterval / 2) {
- pingHandler(pingInterval, workerUrl);
+ dispatch(pingAttempt(pingInterval, workerUrl));
}
}, 1000);
return () => {
@@ -101,7 +100,7 @@ const TimerServiceProvider = ({
liveInterval = null;
}
};
- }, [timeRemaining]);
+ }, [timeRemaining, dispatch]);
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values
@@ -115,24 +114,7 @@ const TimerServiceProvider = ({
);
};
-TimerServiceProvider.propTypes = {
- attempt: PropTypes.shape({
- time_remaining_seconds: PropTypes.number.isRequired,
- exam_started_poll_url: PropTypes.string,
- desktop_application_js_url: PropTypes.string,
- ping_interval: PropTypes.number,
- taking_as_proctored: PropTypes.bool,
- attempt_status: PropTypes.string.isRequired,
- }).isRequired,
- timeLimitMins: PropTypes.number.isRequired,
+TimerProvider.propTypes = {
children: PropTypes.element.isRequired,
- pollHandler: PropTypes.func,
- pingHandler: PropTypes.func,
};
-
-TimerServiceProvider.defaultProps = {
- pollHandler: () => {},
- pingHandler: () => {},
-};
-
-export default withExamStore(TimerServiceProvider, mapStateToProps);
+export default TimerProvider;