diff --git a/src/core/OuterExamTimer.jsx b/src/core/OuterExamTimer.jsx index f5ef7fbf..fe70e7d6 100644 --- a/src/core/OuterExamTimer.jsx +++ b/src/core/OuterExamTimer.jsx @@ -1,22 +1,24 @@ 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 { getLatestAttemptData } from '../data'; import { ExamTimerBlock } from '../timer'; import ExamAPIError from '../exam/ExamAPIError'; import ExamStateProvider from './ExamStateProvider'; const ExamTimer = ({ courseId }) => { - const state = useContext(ExamStateContext); + const examState = useContext(ExamStateContext); const { authenticatedUser } = useContext(AppContext); - const { - activeAttempt, showTimer, stopExam, submitExam, - expireExam, pollAttempt, apiErrorMsg, pingAttempt, - getLatestAttemptData, - } = state; + const { showTimer } = examState; + + 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 +31,7 @@ const ExamTimer = ({ courseId }) => { return (
{showTimer && ( - + )} {apiErrorMsg && }
diff --git a/src/data/index.js b/src/data/index.js index 27409017..701c493b 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -18,5 +18,9 @@ export { examRequiresAccessToken, } from './thunks'; +export { + expireExamAttempt, +} from './slice'; + export { default as store } from './store'; export { default as Emitter } from './emitter'; diff --git a/src/exam/Exam.jsx b/src/exam/Exam.jsx index 4a2049ad..7c19b766 100644 --- a/src/exam/Exam.jsx +++ b/src/exam/Exam.jsx @@ -25,9 +25,7 @@ const Exam = ({ }) => { const state = useContext(ExamStateContext); const { - isLoading, activeAttempt, showTimer, stopExam, exam, - expireExam, pollAttempt, apiErrorMsg, pingAttempt, - getProctoringSettings, submitExam, + isLoading, showTimer, exam, apiErrorMsg, getProctoringSettings, } = state; const { @@ -104,14 +102,7 @@ const Exam = ({ )} {showTimer && ( - + )} { // show the error message only if you are in the exam sequence isTimeLimited && apiErrorMsg && diff --git a/src/timer/CountDownTimer.test.jsx b/src/timer/CountDownTimer.test.jsx index 0aa7b909..dc050bf2 100644 --- a/src/timer/CountDownTimer.test.jsx +++ b/src/timer/CountDownTimer.test.jsx @@ -4,21 +4,30 @@ import { ExamTimerBlock } from './index'; import { render, screen, initializeTestStore, fireEvent, } from '../setupTest'; -import examStore from '../data/store'; +import { stopExam, submitExam } from '../data'; +import specialExams from '../data/store'; jest.mock('../data/store', () => ({ - examStore: {}, + specialExams: {}, })); +// We do a partial mock to avoid mocking out other exported values (e.g. the store and the Emitter). +jest.mock('../data', () => { + const originalModule = jest.requireActual('../data'); + + return { + __esModule: true, + ...originalModule, + stopExam: jest.fn(), + submitExam: jest.fn(), + }; +}); + describe('ExamTimerBlock', () => { let attempt; let store; - const stopExamAttempt = jest.fn(); - const expireExamAttempt = () => { }; - const pollAttempt = () => { }; - const submitAttempt = jest.fn(); - submitAttempt.mockReturnValue(jest.fn()); - stopExamAttempt.mockReturnValue(jest.fn()); + submitExam.mockReturnValue(jest.fn()); + stopExam.mockReturnValue(jest.fn()); beforeEach(async () => { const preloadedState = { @@ -41,19 +50,13 @@ describe('ExamTimerBlock', () => { }, }; store = await initializeTestStore(preloadedState); - examStore.getState = store.getState; + specialExams.getState = store.getState; attempt = store.getState().specialExams.activeAttempt; }); it('renders items correctly', async () => { render( - , + , ); expect(screen.getByRole('alert')).toBeInTheDocument(); @@ -76,26 +79,14 @@ describe('ExamTimerBlock', () => { const testStore = await initializeTestStore(preloadedState); attempt = testStore.getState().specialExams.activeAttempt; const { container } = render( - , + , ); expect(container.firstChild).not.toBeInTheDocument(); }); it('changes behavior when clock time decreases low threshold', async () => { render( - , + , ); await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); expect(screen.getByRole('alert')).toHaveClass('alert-warning'); @@ -122,16 +113,10 @@ describe('ExamTimerBlock', () => { }, }; const testStore = await initializeTestStore(preloadedState); - examStore.getState = store.testStore; + specialExams.getState = store.testStore; attempt = testStore.getState().specialExams.activeAttempt; render( - , + , ); await waitFor(() => expect(screen.getByText('00:00:05')).toBeInTheDocument()); expect(screen.getByRole('alert')).toHaveClass('alert-danger'); @@ -139,13 +124,7 @@ describe('ExamTimerBlock', () => { it('toggles timer visibility correctly', async () => { render( - , + , ); await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); expect(screen.getByRole('alert')).toBeInTheDocument(); @@ -162,13 +141,7 @@ describe('ExamTimerBlock', () => { it('toggles long text visibility on show more/less', async () => { render( - , + , ); await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); expect(screen.getByRole('alert')).toBeInTheDocument(); @@ -203,38 +176,26 @@ describe('ExamTimerBlock', () => { }, }; const testStore = await initializeTestStore(preloadedState); - examStore.getState = store.testStore; + specialExams.getState = store.testStore; attempt = testStore.getState().specialExams.activeAttempt; render( - , + , ); await waitFor(() => expect(screen.getByText('00:00:00')).toBeInTheDocument()); fireEvent.click(screen.getByTestId('end-button', { name: 'Show more' })); - expect(submitAttempt).toHaveBeenCalledTimes(1); + expect(submitExam).toHaveBeenCalledTimes(1); }); it('stops exam if time has not reached 00:00 and user clicks end my exam button', async () => { render( - , + , ); await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); fireEvent.click(screen.getByTestId('end-button')); - expect(stopExamAttempt).toHaveBeenCalledTimes(1); + expect(stopExam).toHaveBeenCalledTimes(1); }); it('Update exam timer when attempt time_remaining_seconds is smaller than displayed time', async () => { @@ -258,16 +219,10 @@ describe('ExamTimerBlock', () => { }, }; let testStore = await initializeTestStore(preloadedState); - examStore.getState = store.testStore; + specialExams.getState = store.testStore; attempt = testStore.getState().specialExams.activeAttempt; const { rerender } = render( - , + , ); await waitFor(() => expect(screen.getByText('00:03:59')).toBeInTheDocument()); @@ -276,19 +231,13 @@ describe('ExamTimerBlock', () => { time_remaining_seconds: 20, }; testStore = await initializeTestStore(preloadedState); - examStore.getState = store.testStore; + specialExams.getState = store.testStore; const updatedAttempt = testStore.getState().specialExams.activeAttempt; expect(updatedAttempt.time_remaining_seconds).toBe(20); rerender( - , + , ); await waitFor(() => expect(screen.getByText('00:00:19')).toBeInTheDocument()); @@ -330,18 +279,12 @@ describe('ExamTimerBlock', () => { // Store it in the state const testStore = await initializeTestStore(preloadedState); - examStore.getState = store.testStore; + specialExams.getState = store.testStore; attempt = testStore.getState().specialExams.activeAttempt; // render an exam timer block with that data render( - , + , ); // expect the a11y string to be a certain output diff --git a/src/timer/ExamTimerBlock.jsx b/src/timer/ExamTimerBlock.jsx index 3744e11a..fca4c7d7 100644 --- a/src/timer/ExamTimerBlock.jsx +++ b/src/timer/ExamTimerBlock.jsx @@ -1,11 +1,13 @@ import React, { useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; import { Button, Alert, useToggle } from '@edx/paragon'; import CountDownTimer from './CountDownTimer'; import { ExamStatus, IS_STARTED_STATUS } from '../constants'; import TimerProvider from './TimerProvider'; -import { Emitter } from '../data'; +import { + Emitter, expireExamAttempt, stopExam, submitExam, +} from '../data'; import { TIMER_IS_CRITICALLY_LOW, TIMER_IS_LOW, @@ -16,13 +18,12 @@ import { /** * Exam timer block component. */ -const ExamTimerBlock = injectIntl(({ - attempt, stopExamAttempt, expireExamAttempt, pollExamAttempt, - intl, pingAttempt, submitExam, -}) => { +const ExamTimerBlock = injectIntl(({ intl }) => { + const { activeAttempt: attempt } = useSelector(state => state.specialExams); const [isShowMore, showMore, showLess] = useToggle(false); const [alertVariant, setAlertVariant] = useState('info'); const [timeReachedNull, setTimeReachedNull] = useState(false); + const dispatch = useDispatch(); if (!attempt || !IS_STARTED_STATUS(attempt.attempt_status)) { return null; @@ -36,29 +37,29 @@ const ExamTimerBlock = injectIntl(({ // if timer reached 00:00 submit exam right away // instead of trying to move user to ready_to_submit page if (timeReachedNull) { - submitExam(); + dispatch(submitExam()); } else { - stopExamAttempt(); + dispatch(stopExam()); } }; useEffect(() => { Emitter.once(TIMER_IS_LOW, onLowTime); Emitter.once(TIMER_IS_CRITICALLY_LOW, onCriticalLowTime); - Emitter.once(TIMER_LIMIT_REACHED, expireExamAttempt); + Emitter.once(TIMER_LIMIT_REACHED, () => dispatch(expireExamAttempt)); Emitter.once(TIMER_REACHED_NULL, onTimeReachedNull); return () => { Emitter.off(TIMER_IS_LOW, onLowTime); Emitter.off(TIMER_IS_CRITICALLY_LOW, onCriticalLowTime); - Emitter.off(TIMER_LIMIT_REACHED, expireExamAttempt); + Emitter.off(TIMER_LIMIT_REACHED, () => dispatch(expireExamAttempt)); Emitter.off(TIMER_REACHED_NULL, onTimeReachedNull); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - +
{ - const { activeAttempt, exam } = state.specialExams; - return { attempt: activeAttempt, timeLimitMins: exam.time_limit_mins }; -}; - const getFormattedRemainingTime = (timeLeft) => ({ hours: Math.floor(timeLeft / (60 * 60)), minutes: Math.floor((timeLeft / 60) % 60), seconds: Math.floor(timeLeft % 60), }); -const TimerServiceProvider = ({ - children, attempt, timeLimitMins, pollHandler, pingHandler, +const TimerProvider = ({ + children, }) => { + const { activeAttempt: attempt, exam } = useSelector(state => state.specialExams); + const { time_limit_mins: timeLimitMins } = exam; const [timeState, setTimeState] = useState({}); const [limitReached, setLimitReached] = useToggle(false); const { @@ -44,6 +41,8 @@ const TimerServiceProvider = ({ const LOW_TIME = timeLimitMins * 60 * TIME_LIMIT_LOW_PCT; let liveInterval = null; + const dispatch = useDispatch(); + const getTimeString = () => Object.values(timeState).map( item => { // Do not show timer negative value. @@ -55,7 +54,7 @@ const TimerServiceProvider = ({ const pollExam = () => { // poll url may be null if this is an LTI exam - pollHandler(attempt.exam_started_poll_url); + dispatch(pollAttempt(attempt.exam_started_poll_url)); }; const processTimeLeft = (timer, secondsLeft) => { @@ -92,7 +91,7 @@ const TimerServiceProvider = ({ } // if exam is proctored ping provider app if (workerUrl && timerTick % pingInterval === pingInterval / 2) { - pingHandler(pingInterval, workerUrl); + dispatch(pingAttempt(pingInterval, workerUrl)); } }, 1000); return () => { @@ -115,24 +114,7 @@ const TimerServiceProvider = ({ ); }; -TimerServiceProvider.propTypes = { - attempt: PropTypes.shape({ - time_remaining_seconds: PropTypes.number.isRequired, - exam_started_poll_url: PropTypes.string, - desktop_application_js_url: PropTypes.string, - ping_interval: PropTypes.number, - taking_as_proctored: PropTypes.bool, - attempt_status: PropTypes.string.isRequired, - }).isRequired, - timeLimitMins: PropTypes.number.isRequired, +TimerProvider.propTypes = { children: PropTypes.element.isRequired, - pollHandler: PropTypes.func, - pingHandler: PropTypes.func, }; - -TimerServiceProvider.defaultProps = { - pollHandler: () => {}, - pingHandler: () => {}, -}; - -export default withExamStore(TimerServiceProvider, mapStateToProps); +export default TimerProvider;