From 434bbf22a47a6dd5b8b31258f6fc2b9981900488 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Fri, 2 Feb 2024 11:29:25 -0500 Subject: [PATCH] refactor: Refactor references to examStore to use the useDispatch and useSelector hooks API. (#131) * feat: rename examStore to specialExams in preparation for use in frontend-app-learning This commit renames the examStore to specialExams. This is in preparation for the use of the exam reducer in the frontend-app-learning React application. * refactor: replace use of context with thunks and Redux store in instructions components This commit replaces the use of the ExamStateContext with the use of thunks and the Redux store in the instructions components. The original pattern was to use the withExamStore higher-order component to provide context to the instructions components. This context contained provided the Redux store state and action creators as props by using the connect API. This posed a problem for our need to merge the frontend-app-learning and frontend-lib-special-exams stores, because the special exams store is initialized in this repository and used by the higher-order component. In order to eventually be able to remove the creation of the store in this repository, we have to remove references to the store by interfacing with the Redux more directly by using the useDispatch and useSelector hooks. * test: remove references to ExamStateProvider from tests for instructions components This commit removes references to the ExamStateProvider from tests for instructions components. Because these components have been refactored to no longe rely on the ExamStateProvider, they are not required in the component tree. refactor: replace use of withExamStore higher-order component in TimerServiceProvider (#133) This commit replaces the use of the withExamStore higher-order component with the useDispatch and useSelector hooks in TimerServiceProvider. This commit also refactors components that use the TimerServiceProvider so that they no longer need to pass state and action creators via props. The TimerServiceProvider now gets whatever state it needs directly from the Redux store with a useSelector hook and imports and dispatches thunks directly by importing them from the data directory. The original pattern was to use the withExamStore higher-order component to provide context to the TimerServiceProvider and its children. This context contained provided the Redux store state and action creators as props by using the connect API. This posed a problem for our need to merge the frontend-app-learning and frontend-lib-special-exams stores, because the special exams store is initialized in this repository and used by the higher-order component. In order to eventually be able to remove the creation of the store in this repository, we have to remove references to the store by interfacing with the Redux more directly by using the useDispatch and useSelector hooks. feat: refactor non instruction components (#132) Refactor public API functions to no longer import and use store. (#134) * test: remove asynchronicity from initializing test store The initializeTestStore test utility function used async...await keywords for initializing the test store, which isn't necessary. This commit removes the async...await keywords and refactors any tests that use that function. * test: fix mocking of getExamAttemptsData function * feat: refactor public API to use hooks instead of references to exported store This commit refactors the public API, used by the frontend-app-learning application to interact with the frontend-lib-special-exams state, to export a series of hooks. Originally, the public API imported the frontend-lib-special-exams store directly and operated on it in a series of exported functions. This posed a problem for our need to merge the frontend-app-learning and frontend-lib-special-exams stores, because the special exams store is initialized in this repository and used by public API. In order to eventually be able to remove the creation of the store in this repository, we have to remove references to the store by interfacing with the Redux more directly by using the useDispatch and useSelector hooks. This commit also exports the root reducer from this library. This root reducer will be imported by the frontend-app-learning application and used to configure its store. fix: patch bugs found during bash (#135) --- src/api.js | 29 +- src/api.test.jsx | 66 ++-- src/context.jsx | 4 - src/core/ExamStateProvider.jsx | 35 -- src/core/OuterExamTimer.jsx | 32 +- src/core/OuterExamTimer.test.jsx | 17 +- src/core/SequenceExamWrapper.jsx | 5 +- src/data/__factories__/examState.factory.js | 2 +- src/data/__snapshots__/redux.test.jsx.snap | 8 +- src/data/index.js | 5 +- src/data/redux.test.jsx | 148 ++++---- src/data/store.js | 8 - src/data/thunks.js | 32 +- src/exam/Exam.jsx | 32 +- src/exam/ExamAPIError.jsx | 7 +- src/exam/ExamAPIError.test.jsx | 48 +-- src/exam/ExamWrapper.jsx | 18 +- src/exam/ExamWrapper.test.jsx | 218 +++++------ src/hocs.jsx | 12 - src/index.jsx | 8 +- src/instructions/Instructions.test.jsx | 354 +++++++----------- src/instructions/SubmitInstructions.jsx | 13 +- src/instructions/index.jsx | 7 +- .../EntranceOnboardingExamInstructions.jsx | 14 +- .../ErrorOnboardingExamInstructions.jsx | 11 +- .../RejectedOnboardingExamInstructions.jsx | 14 +- .../SubmittedOnboardingExamInstructions.jsx | 15 +- .../VerifiedOnboardingExamInstructions.jsx | 9 +- .../EntrancePracticeExamInstructions.jsx | 11 +- .../ErrorPracticeExamInstructions.jsx | 11 +- .../SubmittedPracticeExamInstructions.jsx | 11 +- .../EntranceProctoredExamInstructions.jsx | 14 +- .../ErrorProctoredExamInstructions.jsx | 12 +- .../OnboardingErrorExamInstructions.jsx | 7 +- .../ProctoredExamInstructions.test.jsx | 122 +++--- .../ReadyToStartProctoredExamInstructions.jsx | 20 +- .../SkipProctoredExamInstruction.jsx | 11 +- .../SubmitProctoredExamInstructions.jsx | 17 +- .../download-instructions/index.jsx | 17 +- .../prerequisites-instructions/index.jsx | 8 +- .../timed_exam/StartTimedExamInstructions.jsx | 11 +- .../SubmitTimedExamInstructions.jsx | 11 +- .../SubmittedTimedExamInstructions.jsx | 8 +- src/setupTest.js | 6 +- src/timer/CountDownTimer.test.jsx | 172 +++------ src/timer/ExamTimerBlock.jsx | 36 +- src/timer/TimerProvider.jsx | 44 +-- 47 files changed, 740 insertions(+), 980 deletions(-) delete mode 100644 src/context.jsx delete mode 100644 src/core/ExamStateProvider.jsx delete mode 100644 src/data/store.js delete mode 100644 src/hocs.jsx 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 && ( -