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 && ( -