Skip to content

Commit

Permalink
refactor: replace use of withExamStore higher-order component in Time…
Browse files Browse the repository at this point in the history
…rServiceProvider

This commit replaces the use of the withExamStore higher-order component with the useDispatch and useSelector hooks in TimerServiceProvider.

This commit also refactors components that use the TimerServiceProvider so that they no longer need to pass state and action creators via props. The TimerServiceProvider now gets whatever state it needs directly from the Redux store with a useSelector hook and imports and dispatches thunks directly by importing them from the data directory.

The original pattern was to use the withExamStore higher-order component to provide context to the TimerServiceProvider and its children. This context contained provided the Redux store state and action creators as props by using the connect API. This posed a problem for our need to merge the frontend-app-learning and frontend-lib-special-exams stores, because the special exams store is initialized in this repository and used by the higher-order component. In order to eventually be able to remove the creation of the store in this repository, we have to remove references to the store by interfacing with the Redux more directly by using the useDispatch and useSelector hooks.
  • Loading branch information
MichaelRoytman committed Feb 2, 2024
1 parent 4b8832f commit 10ee30d
Show file tree
Hide file tree
Showing 6 changed files with 77 additions and 170 deletions.
25 changes: 10 additions & 15 deletions src/core/OuterExamTimer.jsx
Original file line number Diff line number Diff line change
@@ -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]);

Expand All @@ -29,14 +31,7 @@ const ExamTimer = ({ courseId }) => {
return (
<div className="d-flex flex-column justify-content-center">
{showTimer && (
<ExamTimerBlock
attempt={activeAttempt}
stopExamAttempt={stopExam}
submitExam={submitExam}
expireExamAttempt={expireExam}
pollExamAttempt={pollAttempt}
pingAttempt={pingAttempt}
/>
<ExamTimerBlock />
)}
{apiErrorMsg && <ExamAPIError />}
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@ export {
examRequiresAccessToken,
} from './thunks';

export {
expireExamAttempt,
} from './slice';

export { default as store } from './store';
export { default as Emitter } from './emitter';
13 changes: 2 additions & 11 deletions src/exam/Exam.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -104,14 +102,7 @@ const Exam = ({
</Alert>
)}
{showTimer && (
<ExamTimerBlock
attempt={activeAttempt}
stopExamAttempt={stopExam}
submitExam={submitExam}
expireExamAttempt={expireExam}
pollExamAttempt={pollAttempt}
pingAttempt={pingAttempt}
/>
<ExamTimerBlock />
)}
{ // show the error message only if you are in the exam sequence
isTimeLimited && apiErrorMsg && <ExamAPIError />
Expand Down
129 changes: 36 additions & 93 deletions src/timer/CountDownTimer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);

expect(screen.getByRole('alert')).toBeInTheDocument();
Expand All @@ -76,26 +79,14 @@ describe('ExamTimerBlock', () => {
const testStore = await initializeTestStore(preloadedState);
attempt = testStore.getState().specialExams.activeAttempt;
const { container } = render(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
expect(container.firstChild).not.toBeInTheDocument();
});

it('changes behavior when clock time decreases low threshold', async () => {
render(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveClass('alert-warning');
Expand All @@ -122,30 +113,18 @@ describe('ExamTimerBlock', () => {
},
};
const testStore = await initializeTestStore(preloadedState);
examStore.getState = store.testStore;
specialExams.getState = store.testStore;
attempt = testStore.getState().specialExams.activeAttempt;
render(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
await waitFor(() => expect(screen.getByText('00:00:05')).toBeInTheDocument());
expect(screen.getByRole('alert')).toHaveClass('alert-danger');
});

it('toggles timer visibility correctly', async () => {
render(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument());
expect(screen.getByRole('alert')).toBeInTheDocument();
Expand All @@ -162,13 +141,7 @@ describe('ExamTimerBlock', () => {

it('toggles long text visibility on show more/less', async () => {
render(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument());
expect(screen.getByRole('alert')).toBeInTheDocument();
Expand Down Expand Up @@ -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(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
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(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
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 () => {
Expand All @@ -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(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);
await waitFor(() => expect(screen.getByText('00:03:59')).toBeInTheDocument());

Expand All @@ -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(
<ExamTimerBlock
attempt={updatedAttempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);

await waitFor(() => expect(screen.getByText('00:00:19')).toBeInTheDocument());
Expand Down Expand Up @@ -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(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
<ExamTimerBlock />,
);

// expect the a11y string to be a certain output
Expand Down
Loading

0 comments on commit 10ee30d

Please sign in to comment.