diff --git a/src/context.jsx b/src/context.jsx index 3005f5a7..89e3698a 100644 --- a/src/context.jsx +++ b/src/context.jsx @@ -1,4 +1,4 @@ -import React from 'react'; - -const ExamStateContext = React.createContext({}); -export default ExamStateContext; +// import React from 'react'; +// +// const ExamStateContext = React.createContext({}); +// export default ExamStateContext; diff --git a/src/core/ExamStateProvider.jsx b/src/core/ExamStateProvider.jsx index 174a2b61..17a2e864 100644 --- a/src/core/ExamStateProvider.jsx +++ b/src/core/ExamStateProvider.jsx @@ -1,35 +1,35 @@ -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.specialExams }); - -const ExamStateProvider = withExamStore( - StateProvider, - mapStateToProps, - dispatchActions, -); - -export default ExamStateProvider; +// 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.specialExams }); +// +// const ExamStateProvider = withExamStore( +// StateProvider, +// mapStateToProps, +// dispatchActions, +// ); +// +// export default ExamStateProvider; diff --git a/src/core/OuterExamTimer.jsx b/src/core/OuterExamTimer.jsx index f5ef7fbf..d7fab5d1 100644 --- a/src/core/OuterExamTimer.jsx +++ b/src/core/OuterExamTimer.jsx @@ -1,23 +1,32 @@ 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, + stopExam, + submitExam, + expireExam, + pollAttempt, + pingAttempt, +} from '../data/thunks'; +import { IS_STARTED_STATUS } from '../constants'; const ExamTimer = ({ courseId }) => { - const state = useContext(ExamStateContext); const { authenticatedUser } = useContext(AppContext); + const { - activeAttempt, showTimer, stopExam, submitExam, - expireExam, pollAttempt, apiErrorMsg, pingAttempt, - getLatestAttemptData, - } = state; + activeAttempt, apiErrorMsg, + } = useSelector(state => state.specialExams); + const dispatch = useDispatch(); + + const showTimer = !!(activeAttempt && IS_STARTED_STATUS(activeAttempt.attempt_status)); useEffect(() => { - getLatestAttemptData(courseId); - // eslint-disable-next-line react-hooks/exhaustive-deps + dispatch(getLatestAttemptData(courseId)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [courseId]); // if user is not authenticated they cannot have active exam, so no need for timer @@ -31,11 +40,11 @@ const ExamTimer = ({ courseId }) => { {showTimer && ( dispatch(stopExam())} + submitExam={() => dispatch(submitExam())} + expireExamAttempt={() => dispatch(expireExam())} + pollExamAttempt={(url) => dispatch(pollAttempt(url))} + pingAttempt={(timeout, url) => dispatch(pingAttempt(timeout, url))} /> )} {apiErrorMsg && } @@ -53,9 +62,7 @@ ExamTimer.propTypes = { * will be shown. */ const OuterExamTimer = ({ courseId }) => ( - - - + ); OuterExamTimer.propTypes = { 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/store.js b/src/data/store.js index 9fdef030..3ab0617a 100644 --- a/src/data/store.js +++ b/src/data/store.js @@ -1,8 +1,8 @@ -import { configureStore } from '@reduxjs/toolkit'; -import examReducer from './slice'; - -export default configureStore({ - reducer: { - specialExams: examReducer, - }, -}); +// import { configureStore } from '@reduxjs/toolkit'; +// import examReducer from './slice'; +// +// export default configureStore({ +// reducer: { +// examState: examReducer, +// }, +// }); diff --git a/src/exam/Exam.jsx b/src/exam/Exam.jsx index 4a2049ad..56c074c0 100644 --- a/src/exam/Exam.jsx +++ b/src/exam/Exam.jsx @@ -1,15 +1,22 @@ -/* 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, + stopExam, + submitExam, + expireExam, + pollAttempt, + pingAttempt, +} from '../data/thunks'; /** * Exam component is intended to render exam instructions before and after exam. @@ -23,12 +30,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 +68,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,6 +77,7 @@ 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 + // eslint-disable-next-line react-hooks/exhaustive-deps }, [examId]); if (isLoading) { @@ -106,11 +114,11 @@ const Exam = ({ {showTimer && ( dispatch(stopExam())} + submitExam={() => dispatch(submitExam())} + expireExamAttempt={() => dispatch(expireExam())} + pollExamAttempt={(url) => dispatch(pollAttempt(url))} + pingAttempt={(timeout, url) => dispatch(pingAttempt(timeout, url))} /> )} { // show the error message only if you are in the exam sequence 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 edb16d1d..1ae2646b 100644 --- a/src/exam/ExamAPIError.test.jsx +++ b/src/exam/ExamAPIError.test.jsx @@ -3,7 +3,6 @@ import React from 'react'; import { getConfig } from '@edx/frontend-platform'; import { store } from '../data'; import { render } from '../setupTest'; -import ExamStateProvider from '../core/ExamStateProvider'; import ExamAPIError from './ExamAPIError'; const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig(); @@ -26,9 +25,7 @@ describe('ExamAPIError', () => { store.getState = () => ({ specialExams: {} }); const tree = render( - - - , + , { store }, ); @@ -45,9 +42,7 @@ describe('ExamAPIError', () => { store.getState = () => ({ specialExams: {} }); const { getByTestId } = render( - - - , + , { store }, ); @@ -62,9 +57,7 @@ describe('ExamAPIError', () => { }); const { queryByTestId } = render( - - - , + , { store }, ); @@ -77,9 +70,7 @@ describe('ExamAPIError', () => { }); const { queryByTestId } = render( - - - , + , { store }, ); @@ -92,9 +83,7 @@ describe('ExamAPIError', () => { }); const { queryByTestId } = render( - - - , + , { store }, ); diff --git a/src/exam/ExamWrapper.jsx b/src/exam/ExamWrapper.jsx index 68b7eee8..229de2c2 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/thunks'; /** * 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,10 +36,10 @@ 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 + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // if the user is browsing public content (not logged in) they cannot be in an exam diff --git a/src/exam/ExamWrapper.test.jsx b/src/exam/ExamWrapper.test.jsx index 04e43ab1..ee1d78e5 100644 --- a/src/exam/ExamWrapper.test.jsx +++ b/src/exam/ExamWrapper.test.jsx @@ -5,7 +5,6 @@ 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 { ExamStatus, ExamType } from '../constants'; jest.mock('../data', () => ({ @@ -46,11 +45,9 @@ describe('SequenceExamWrapper', () => { 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)'); @@ -66,11 +63,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('proctored-exam-instructions-title')).toHaveTextContent('This exam is proctored'); @@ -83,11 +78,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('spinner')).toBeInTheDocument(); @@ -101,11 +94,9 @@ describe('SequenceExamWrapper', () => { }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('exam-instructions-title')).toHaveTextContent('Subsection is a Timed Exam (30 minutes)'); @@ -120,11 +111,9 @@ describe('SequenceExamWrapper', () => { }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('exam-instructions-title')).not.toBeInTheDocument(); @@ -133,11 +122,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 @@ -153,11 +140,9 @@ describe('SequenceExamWrapper', () => { }); render( - - -
children
-
-
, + +
children
+
, { store }, ); await waitFor(() => expect(getExamAttemptsData).toHaveBeenCalled()); @@ -165,11 +150,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 +163,9 @@ describe('SequenceExamWrapper', () => { authenticatedUser: null, }; const { getByTestId } = render( - - -
children
-
-
, + +
children
+
, { store, appContext }, ); expect(getByTestId('sequence-content')).toHaveTextContent('children'); @@ -199,11 +180,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('sequence-content')).toHaveTextContent('children'); @@ -220,11 +199,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('sequence-content')).toHaveTextContent('children'); @@ -243,11 +220,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('sequence-content')).toHaveTextContent('children'); @@ -267,11 +242,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('sequence-content')).toHaveTextContent('children'); @@ -292,11 +265,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('sequence-content')).toHaveTextContent('children'); @@ -305,11 +276,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'); @@ -328,15 +297,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.'); @@ -355,15 +322,13 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('no-access')).toBeNull(); @@ -382,15 +347,13 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('no-access')).toBeNull(); diff --git a/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx b/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx index 7839d00a..5e3ce50f 100644 --- a/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx +++ b/src/instructions/proctored_exam/ReadyToStartProctoredExamInstructions.jsx @@ -20,7 +20,7 @@ const ReadyToStartProctoredExamInstructions = () => { useEffect(() => { dispatch(getExamReviewPolicy()); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleStart = () => { diff --git a/src/timer/CountDownTimer.test.jsx b/src/timer/CountDownTimer.test.jsx index 0aa7b909..35fd2818 100644 --- a/src/timer/CountDownTimer.test.jsx +++ b/src/timer/CountDownTimer.test.jsx @@ -7,7 +7,7 @@ import { import examStore from '../data/store'; jest.mock('../data/store', () => ({ - examStore: {}, + specialExams: {}, })); describe('ExamTimerBlock', () => { diff --git a/src/timer/ExamTimerBlock.jsx b/src/timer/ExamTimerBlock.jsx index 3744e11a..a268ba39 100644 --- a/src/timer/ExamTimerBlock.jsx +++ b/src/timer/ExamTimerBlock.jsx @@ -54,7 +54,7 @@ const ExamTimerBlock = injectIntl(({ Emitter.off(TIMER_LIMIT_REACHED, expireExamAttempt); Emitter.off(TIMER_REACHED_NULL, onTimeReachedNull); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (