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 ced1d07c..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.examState }); - -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..18886f7e 100644 --- a/src/core/OuterExamTimer.jsx +++ b/src/core/OuterExamTimer.jsx @@ -1,23 +1,31 @@ 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)); }, [courseId]); // if user is not authenticated they cannot have active exam, so no need for timer @@ -31,11 +39,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 +61,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/__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/store.js b/src/data/store.js index 916de85c..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: { - examState: examReducer, - }, -}); +// 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..2f68ccbc 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -473,7 +473,8 @@ export function pingAttempt(timeoutInSeconds, workerUrl) { // eslint-disable-next-line function-paren-newline await updateAttemptAfter( - exam.course_id, exam.content_id, endExamWithFailure(activeAttempt.attempt_id, message))(dispatch); + exam.course_id, exam.content_id, endExamWithFailure(activeAttempt.attempt_id, message) + )(dispatch); }); }; } diff --git a/src/exam/Exam.jsx b/src/exam/Exam.jsx index 4a2049ad..6ccdb401 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 @@ -106,11 +113,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 b8d276d9..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(); @@ -23,12 +22,10 @@ describe('ExamAPIError', () => { const defaultMessage = 'A system error has occurred with your exam.'; it('renders with the default information', () => { - store.getState = () => ({ examState: {} }); + store.getState = () => ({ specialExams: {} }); const tree = render( - - - , + , { store }, ); @@ -42,12 +39,10 @@ describe('ExamAPIError', () => { }; getConfig.mockImplementation(() => config); - store.getState = () => ({ examState: {} }); + store.getState = () => ({ specialExams: {} }); const { getByTestId } = render( - - - , + , { store }, ); @@ -58,28 +53,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 +79,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..04c26fae 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,9 @@ 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 }, []); // 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 bee6ed74..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', () => ({ @@ -39,18 +38,16 @@ describe('SequenceExamWrapper', () => { beforeEach(() => { jest.clearAllMocks(); store.getState = () => ({ - examState: Factory.build('examState'), + 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 +56,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 +73,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 +88,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 +105,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 +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 @@ -147,17 +134,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 +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'); @@ -192,18 +173,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 +190,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 +199,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('sequence-content')).toHaveTextContent('children'); @@ -236,18 +213,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 +230,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 +242,9 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('sequence-content')).toHaveTextContent('children'); @@ -280,7 +253,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 +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'); @@ -318,7 +287,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 +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.'); @@ -345,7 +312,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 +322,13 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('no-access')).toBeNull(); @@ -372,7 +337,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 +347,13 @@ describe('SequenceExamWrapper', () => { }), }); const { queryByTestId } = render( - - -
children
-
-
, + +
children
+
, { store }, ); expect(queryByTestId('no-access')).toBeNull(); diff --git a/src/timer/ExamTimerBlock.jsx b/src/timer/ExamTimerBlock.jsx index 3744e11a..4310eb2e 100644 --- a/src/timer/ExamTimerBlock.jsx +++ b/src/timer/ExamTimerBlock.jsx @@ -54,7 +54,6 @@ const ExamTimerBlock = injectIntl(({ Emitter.off(TIMER_LIMIT_REACHED, expireExamAttempt); Emitter.off(TIMER_REACHED_NULL, onTimeReachedNull); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (