Skip to content

Commit

Permalink
feat [BD-26]: Timer bar on non-sequence pages (#30)
Browse files Browse the repository at this point in the history
* feat: Timer bar on non-sequence pages

* feat: update fetchExamAttempts URL

* test: add tests for stopExam

* feat: add separate timer component for non-sequence pages

* fix: don't show timer for not authenticated user on non-sequence pages

* fix: use required props for exam wrapper

Co-authored-by: Viktor Rusakov <[email protected]>
  • Loading branch information
UvgenGen and viktorrusakov authored Jul 9, 2021
1 parent 51b3905 commit 790a476
Show file tree
Hide file tree
Showing 14 changed files with 242 additions and 78 deletions.
32 changes: 32 additions & 0 deletions src/core/ExamStateProvider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React 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 }) => (
<ExamStateContext.Provider value={{
...state,
showTimer: !!(state.activeAttempt && IS_STARTED_STATUS(state.activeAttempt.attempt_status)),
}}
>
{children}
</ExamStateContext.Provider>
);

const mapStateToProps = (state) => ({ ...state.examState });

const ExamStateProvider = withExamStore(
StateProvider,
mapStateToProps,
dispatchActions,
);

export default ExamStateProvider;
64 changes: 64 additions & 0 deletions src/core/OuterExamTimer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useEffect, useContext } from 'react';
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';

const ExamTimer = ({ courseId }) => {
const state = useContext(ExamStateContext);
const { authenticatedUser } = useContext(AppContext);
const {
activeAttempt, showTimer, stopExam, submitExam,
expireExam, pollAttempt, apiErrorMsg, pingAttempt,
getExamAttemptsData,
} = state;

// if user is not authenticated they cannot have active exam, so no need for timer
// (also exam API would return 403 error)
if (!authenticatedUser) {
return null;
}

useEffect(() => {
getExamAttemptsData(courseId);
}, [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}
/>
)}
{apiErrorMsg && <ExamAPIError />}
</div>
);
};

ExamTimer.propTypes = {
courseId: PropTypes.string.isRequired,
};

/**
* OuterExamTimer is the component responsible for showing exam timer on non-sequence pages.
* @param courseId - Id of a course that is checked for active exams, if there is one the timer
* will be shown.
*/
const OuterExamTimer = ({ courseId }) => (
<ExamStateProvider>
<ExamTimer courseId={courseId} />
</ExamStateProvider>
);

OuterExamTimer.propTypes = {
courseId: PropTypes.string.isRequired,
};

export default OuterExamTimer;
58 changes: 58 additions & 0 deletions src/core/OuterExamTimer.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import '@testing-library/jest-dom';
import { Factory } from 'rosie';
import React from 'react';
import OuterExamTimer from './OuterExamTimer';
import { store, getExamAttemptsData } from '../data';
import { render } from '../setupTest';
import { ExamStatus } from '../constants';

jest.mock('../data', () => ({
store: {},
getExamAttemptsData: jest.fn(),
Emitter: {
on: () => jest.fn(),
once: () => jest.fn(),
off: () => jest.fn(),
emit: () => jest.fn(),
},
}));
getExamAttemptsData.mockReturnValue(jest.fn());
store.subscribe = jest.fn();
store.dispatch = jest.fn();

describe('OuterExamTimer', () => {
const courseId = 'course-v1:test+test+test';

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: {
activeAttempt: attempt,
exam: {},
},
});

const { queryByTestId } = render(
<OuterExamTimer courseId={courseId} />,
{ store },
);
expect(queryByTestId('exam-timer')).toBeInTheDocument();
});

it('does not render timer if there is no exam in progress', () => {
store.getState = () => ({
examState: {
activeAttempt: {},
exam: {},
},
});

const { queryByTestId } = render(
<OuterExamTimer courseId={courseId} />,
{ store },
);
expect(queryByTestId('exam-timer')).not.toBeInTheDocument();
});
});
22 changes: 22 additions & 0 deletions src/core/SequenceExamWrapper.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import ExamWrapper from '../exam/ExamWrapper';
import ExamStateProvider from './ExamStateProvider';

/**
* SequenceExamWrapper is the component responsible for handling special exams.
* It takes control over rendering exam instructions unless exam is started only if
* current sequence item is timed exam. Otherwise, renders any children elements passed.
* @param children - Current course sequence item content (e.g. unit, navigation buttons etc.)
* @returns {JSX.Element}
* @example
* <SequenceExamWrapper sequence={sequence} courseId={courseId}>
* {sequenceContent}
* </SequenceExamWrapper>
*/
const SequenceExamWrapper = (props) => (
<ExamStateProvider>
<ExamWrapper {...props} />
</ExamStateProvider>
);

export default SequenceExamWrapper;
6 changes: 5 additions & 1 deletion src/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ const BASE_API_URL = '/api/edx_proctoring/v1/proctored_exam/attempt';

export async function fetchExamAttemptsData(courseId, sequenceId) {
const url = new URL(
`${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}/content_id/${sequenceId}?is_learning_mfe=true`,
`${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}`,
);
if (sequenceId) {
url.searchParams.append('content_id', sequenceId);
}
url.searchParams.append('is_learning_mfe', true);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return data;
}
Expand Down
39 changes: 37 additions & 2 deletions src/data/redux.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const exam = Factory.build('exam', { attempt: Factory.build('attempt') });
const { course_id: courseId, content_id: contentId, attempt } = exam;
const fetchExamAttemptsDataUrl = `${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}/content_id/${contentId}?is_learning_mfe=true`;
const fetchExamAttemptsDataUrl = `${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}`
+ `?content_id=${encodeURIComponent(contentId)}&is_learning_mfe=true`;
const updateAttemptStatusUrl = `${getConfig().LMS_BASE_URL}${BASE_API_URL}/${attempt.attempt_id}`;
let store;

Expand Down Expand Up @@ -186,7 +187,7 @@ describe('Data layer integration tests', () => {
it('Should stop exam, and update attempt and exam', async () => {
axiosMock.onGet(fetchExamAttemptsDataUrl).replyOnce(200, { exam, active_attempt: attempt });
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: readyToSubmitExam, active_attempt: {} });
axiosMock.onPost(updateAttemptStatusUrl).reply(200, { exam_attempt_id: readyToSubmitAttempt.attempt_id });
axiosMock.onPut(updateAttemptStatusUrl).reply(200, { exam_attempt_id: readyToSubmitAttempt.attempt_id });

await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
Expand All @@ -197,6 +198,40 @@ describe('Data layer integration tests', () => {
expect(state.examState.exam.attempt.attempt_status).toBe(ExamStatus.READY_TO_SUBMIT);
});

it('Should stop exam, and redirect to sequence if no exam attempt', async () => {
const { location } = window;
delete window.location;
window.location = {
href: '',
};

axiosMock.onGet(fetchExamAttemptsDataUrl).replyOnce(200, { exam: {}, active_attempt: attempt });
axiosMock.onPut(updateAttemptStatusUrl).reply(200, { exam_attempt_id: readyToSubmitAttempt.attempt_id });

await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
const state = store.getState();
expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);

await executeThunk(thunks.stopExam(), store.dispatch, store.getState);
expect(axiosMock.history.put[0].url).toEqual(updateAttemptStatusUrl);
expect(window.location.href).toEqual(attempt.exam_url_path);

window.location = location;
});

it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(fetchExamAttemptsDataUrl).replyOnce(200, { exam: {}, active_attempt: attempt });
axiosMock.onPut(updateAttemptStatusUrl).networkError();

await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);

await executeThunk(thunks.stopExam(), store.dispatch, store.getState);
state = store.getState();
expect(state.examState.apiErrorMsg).toBe('Network Error');
});

it('Should fail to fetch if no active attempt', async () => {
axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam: Factory.build('exam'), active_attempt: {} });
axiosMock.onGet(updateAttemptStatusUrl).reply(200, { exam_attempt_id: readyToSubmitAttempt.attempt_id });
Expand Down
16 changes: 11 additions & 5 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,19 @@ export function stopExam() {
}

const { attempt_id: attemptId, exam_url_path: examUrl } = activeAttempt;
if (!exam.attempt || attemptId !== exam.attempt.attempt_id) {
try {
await stopAttempt(attemptId);
window.location.href = examUrl;
} catch (error) {
handleAPIError(error, dispatch);
}
return;
}

await updateAttemptAfter(
exam.course_id, exam.content_id, stopAttempt(attemptId),
)(dispatch);

if (attemptId !== exam.attempt.attempt_id) {
window.location.href = examUrl;
}
};
}

Expand Down Expand Up @@ -338,7 +344,7 @@ export function expireExam() {
}

await updateAttemptAfter(
exam.course_id, exam.content_id, submitAttempt(attemptId),
activeAttempt.course_id, exam.content_id, submitAttempt(attemptId),
)(dispatch);
dispatch(expireExamAttempt());

Expand Down
5 changes: 0 additions & 5 deletions src/exam/Exam.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ const Exam = ({ isTimeLimited, children }) => {

const sequenceContent = <>{children}</>;

// Temporary fix for CR-3842. We need to show this timer but the end exam button does not
// fully work yet.
const allowEndExam = !!examId;

return (
<div className="d-flex flex-column justify-content-center">
{showTimer && (
Expand All @@ -63,7 +59,6 @@ const Exam = ({ isTimeLimited, children }) => {
expireExamAttempt={expireExam}
pollExamAttempt={pollAttempt}
pingAttempt={pingAttempt}
allowEndExam={allowEndExam}
/>
)}
{apiErrorMsg && <ExamAPIError />}
Expand Down
2 changes: 1 addition & 1 deletion src/exam/ExamAPIError.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import '@testing-library/jest-dom';
import React from 'react';
import { store } from '../data';
import { render, fireEvent } from '../setupTest';
import { ExamStateProvider } from '../index';
import ExamStateProvider from '../core/ExamStateProvider';
import ExamAPIError from './ExamAPIError';

jest.mock('../data', () => ({
Expand Down
2 changes: 1 addition & 1 deletion src/exam/ExamWrapper.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React from 'react';
import SequenceExamWrapper from './ExamWrapper';
import { store, getExamAttemptsData, startTimedExam } from '../data';
import { render } from '../setupTest';
import { ExamStateProvider } from '../index';
import ExamStateProvider from '../core/ExamStateProvider';
import { ExamType } from '../constants';

jest.mock('../data', () => ({
Expand Down
52 changes: 2 additions & 50 deletions src/index.jsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,2 @@
import React from 'react';
import ExamWrapper from './exam/ExamWrapper';
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 }) => (
<ExamStateContext.Provider value={{
...state,
showTimer: !!(state.activeAttempt && IS_STARTED_STATUS(state.activeAttempt.attempt_status)),
}}
>
{children}
</ExamStateContext.Provider>
);

const mapStateToProps = (state) => ({ ...state.examState });

export const ExamStateProvider = withExamStore(
StateProvider,
mapStateToProps,
dispatchActions,
);

/**
* SequenceExamWrapper is the component responsible for handling special exams.
* It takes control over rendering exam instructions unless exam is started only if
* current sequence item is timed exam. Otherwise, renders any children elements passed.
* @param children - Current course sequence item content (e.g. unit, navigation buttons etc.)
* @returns {JSX.Element}
* @example
* <SequenceExamWrapper sequence={sequence} courseId={courseId}>
* {sequenceContent}
* </SequenceExamWrapper>
*/
const SequenceExamWrapper = (props) => (
<ExamStateProvider>
<ExamWrapper {...props} />
</ExamStateProvider>
);

export default SequenceExamWrapper;
export { default } from './core/SequenceExamWrapper';
export { default as OuterExamTimer } from './core/OuterExamTimer';
2 changes: 1 addition & 1 deletion src/instructions/Instructions.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { continueExam, submitExam } from '../data/thunks';
import Emitter from '../data/emitter';
import { TIMER_REACHED_NULL } from '../timer/events';
import { render, screen, act } from '../setupTest';
import { ExamStateProvider } from '../index';
import ExamStateProvider from '../core/ExamStateProvider';
import {
ExamStatus, ExamType, INCOMPLETE_STATUSES, VerificationStatus,
} from '../constants';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Instructions from '../index';
import { store, getExamAttemptsData } from '../../data';
import { submitExam } from '../../data/thunks';
import { render, screen } from '../../setupTest';
import { ExamStateProvider } from '../../index';
import ExamStateProvider from '../../core/ExamStateProvider';
import {
ExamType,
ExamStatus,
Expand Down
Loading

0 comments on commit 790a476

Please sign in to comment.