Refactor use of frontend-lib-special-exams public API to use hooks. (#1284)
* feat: refactor use of frontend-lib-special-exams public API to hooks This commit refactors the use of the frontend-lib-special-exams public API to use hooks. This commit also imports the root reducer from the frontend-lib-special-exams library. This root reducer is used for the specialExams slice when configuring the store for this application. * feat: update special exams version * feat: update snapshots --------- Co-authored-by: Alie Langston <alangsto@wellesley.edu>
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -20,7 +20,7 @@
|
||||
"@edx/frontend-component-footer": "12.2.1",
|
||||
"@edx/frontend-component-header": "4.6.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^1.21.0",
|
||||
"@edx/frontend-lib-special-exams": "2.27.0",
|
||||
"@edx/frontend-lib-special-exams": "2.28.0",
|
||||
"@edx/frontend-platform": "5.5.2",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/paragon": "20.46.0",
|
||||
@@ -3540,9 +3540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-lib-special-exams": {
|
||||
"version": "2.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.27.0.tgz",
|
||||
"integrity": "sha512-osKuKq1+RfoLBKeDabmUf82bRP+xQu3USGodhNi4Mt0jX9FWJdZsN0CulhFW+mGFManz8dzKyXnO1AXDX1/I8g==",
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.28.0.tgz",
|
||||
"integrity": "sha512-rDWkyIeXtolKv2gqwO3St0OkofGz8mFCbaCjyS5hQQlKSP0BZZWX1efPl/KUmRBCBtM6thwwQTwSOqo0O0pjbg==",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "12.2.1",
|
||||
"@edx/frontend-component-header": "4.6.0",
|
||||
"@edx/frontend-lib-special-exams": "2.27.0",
|
||||
"@edx/frontend-lib-special-exams": "2.28.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^1.21.0",
|
||||
"@edx/frontend-platform": "5.5.2",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
|
||||
@@ -312,6 +312,70 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
"course_id": "",
|
||||
"desktop_application_js_url": "",
|
||||
"exam_display_name": "",
|
||||
"exam_started_poll_url": "",
|
||||
"exam_type": "",
|
||||
"exam_url_path": "",
|
||||
"external_id": "",
|
||||
"in_timed_exam": true,
|
||||
"ping_interval": null,
|
||||
"taking_as_proctored": true,
|
||||
"time_remaining_seconds": null,
|
||||
"use_legacy_attempt_api": true,
|
||||
},
|
||||
"backend": "",
|
||||
"content_id": "",
|
||||
"course_id": "",
|
||||
"due_date": null,
|
||||
"exam_name": "",
|
||||
"external_id": "",
|
||||
"hide_after_due": false,
|
||||
"id": null,
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
"provider_name": "",
|
||||
"provider_tech_support_email": "",
|
||||
"provider_tech_support_phone": "",
|
||||
"provider_tech_support_url": "",
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
@@ -512,6 +576,70 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
"course_id": "",
|
||||
"desktop_application_js_url": "",
|
||||
"exam_display_name": "",
|
||||
"exam_started_poll_url": "",
|
||||
"exam_type": "",
|
||||
"exam_url_path": "",
|
||||
"external_id": "",
|
||||
"in_timed_exam": true,
|
||||
"ping_interval": null,
|
||||
"taking_as_proctored": true,
|
||||
"time_remaining_seconds": null,
|
||||
"use_legacy_attempt_api": true,
|
||||
},
|
||||
"backend": "",
|
||||
"content_id": "",
|
||||
"course_id": "",
|
||||
"due_date": null,
|
||||
"exam_name": "",
|
||||
"external_id": "",
|
||||
"hide_after_due": false,
|
||||
"id": null,
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
"provider_name": "",
|
||||
"provider_tech_support_email": "",
|
||||
"provider_tech_support_phone": "",
|
||||
"provider_tech_support_url": "",
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
@@ -718,6 +846,70 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
"course_id": "",
|
||||
"desktop_application_js_url": "",
|
||||
"exam_display_name": "",
|
||||
"exam_started_poll_url": "",
|
||||
"exam_type": "",
|
||||
"exam_url_path": "",
|
||||
"external_id": "",
|
||||
"in_timed_exam": true,
|
||||
"ping_interval": null,
|
||||
"taking_as_proctored": true,
|
||||
"time_remaining_seconds": null,
|
||||
"use_legacy_attempt_api": true,
|
||||
},
|
||||
"backend": "",
|
||||
"content_id": "",
|
||||
"course_id": "",
|
||||
"due_date": null,
|
||||
"exam_name": "",
|
||||
"external_id": "",
|
||||
"hide_after_due": false,
|
||||
"id": null,
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
"provider_name": "",
|
||||
"provider_tech_support_email": "",
|
||||
"provider_tech_support_phone": "",
|
||||
"provider_tech_support_url": "",
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
|
||||
import { useExamAccessToken, useFetchExamAccessToken, useIsExam } from '@edx/frontend-lib-special-exams';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
accessToken: 'accessToken',
|
||||
@@ -12,14 +12,23 @@ export const stateKeys = StrictDict({
|
||||
const useExamAccess = ({
|
||||
id,
|
||||
}) => {
|
||||
const [accessToken, setAccessToken] = useKeyedState(stateKeys.accessToken, '');
|
||||
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam());
|
||||
const isExam = useIsExam();
|
||||
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam);
|
||||
|
||||
const fetchExamAccessToken = useFetchExamAccessToken();
|
||||
|
||||
// NOTE: We cannot use this hook in the useEffect hook below to grab the updated exam access token in the finally
|
||||
// block, due to the rules of hooks. Instead, we get the value of the exam access token from a call to the hook.
|
||||
// When the fetchExamAccessToken call completes, the useExamAccess hook will re-run
|
||||
// (due to a change to the Redux store, and, thus, a change to the the context), at which point the updated
|
||||
// exam access token will be fetched via the useExamAccessToken hook call below.
|
||||
// The important detail is that there should never be a return value (false, '').
|
||||
const examAccessToken = useExamAccessToken();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isExam()) {
|
||||
fetchExamAccess()
|
||||
if (isExam) {
|
||||
fetchExamAccessToken()
|
||||
.finally(() => {
|
||||
const examAccess = getExamAccess();
|
||||
setAccessToken(examAccess);
|
||||
setBlockAccess(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -30,7 +39,7 @@ const useExamAccess = ({
|
||||
|
||||
return {
|
||||
blockAccess,
|
||||
accessToken,
|
||||
accessToken: examAccessToken,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { waitFor } from '../../../../../setupTest';
|
||||
import useExamAccess, { stateKeys } from './useExamAccess';
|
||||
|
||||
const getEffect = (prereqs) => {
|
||||
const { calls } = React.useEffect.mock;
|
||||
const match = calls.filter(call => isEqual(call[1], prereqs));
|
||||
return match.length ? match[0][0] : null;
|
||||
};
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useEffect: jest.fn(),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
jest.mock('@edx/frontend-lib-special-exams', () => ({
|
||||
getExamAccess: jest.fn(),
|
||||
fetchExamAccess: jest.fn(),
|
||||
isExam: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
const id = 'test-id';
|
||||
|
||||
const mockFetchExamAccess = Promise.resolve();
|
||||
fetchExamAccess.mockReturnValue(mockFetchExamAccess);
|
||||
|
||||
const testAccessToken = 'test-access-token';
|
||||
getExamAccess.mockReturnValue(testAccessToken);
|
||||
|
||||
describe('useExamAccess hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes access token to empty string', () => {
|
||||
useExamAccess({ id });
|
||||
state.expectInitializedWith(stateKeys.accessToken, '');
|
||||
});
|
||||
it('initializes blockAccess to true if is an exam', () => {
|
||||
useExamAccess({ id });
|
||||
state.expectInitializedWith(stateKeys.blockAccess, false);
|
||||
});
|
||||
it('initializes blockAccess to false if is not an exam', () => {
|
||||
isExam.mockReturnValueOnce(true);
|
||||
useExamAccess({ id });
|
||||
state.expectInitializedWith(stateKeys.blockAccess, true);
|
||||
});
|
||||
describe('effects - on id change', () => {
|
||||
let useEffectCb;
|
||||
beforeEach(() => {
|
||||
useExamAccess({ id });
|
||||
useEffectCb = getEffect([id], React);
|
||||
});
|
||||
it('does not call fetchExamAccess if not an exam', () => {
|
||||
useEffectCb();
|
||||
expect(fetchExamAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
it('fetches and sets exam access if isExam', async () => {
|
||||
isExam.mockReturnValueOnce(true);
|
||||
useEffectCb();
|
||||
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
|
||||
state.expectSetStateCalledWith(stateKeys.accessToken, testAccessToken);
|
||||
state.expectSetStateCalledWith(stateKeys.blockAccess, false);
|
||||
});
|
||||
const testError = 'test-error';
|
||||
it('logs error if fetchExamAccess fails', async () => {
|
||||
isExam.mockReturnValueOnce(true);
|
||||
fetchExamAccess.mockReturnValueOnce(Promise.reject(testError));
|
||||
useEffectCb();
|
||||
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
|
||||
expect(logError).toHaveBeenCalledWith(testError);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('forwards blockAccess and accessToken from state fields', () => {
|
||||
const testBlockAccess = 'test-block-access';
|
||||
state.mockVals({
|
||||
blockAccess: testBlockAccess,
|
||||
accessToken: testAccessToken,
|
||||
});
|
||||
const out = useExamAccess({ id });
|
||||
expect(out.blockAccess).toEqual(testBlockAccess);
|
||||
expect(out.accessToken).toEqual(testAccessToken);
|
||||
state.resetVals();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useExamAccessToken, useFetchExamAccessToken, useIsExam } from '@edx/frontend-lib-special-exams';
|
||||
|
||||
import { initializeMockApp } from '../../../../../setupTest';
|
||||
import useExamAccess from './useExamAccess';
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
jest.mock('@edx/frontend-lib-special-exams', () => ({
|
||||
useExamAccessToken: jest.fn(),
|
||||
useFetchExamAccessToken: jest.fn(),
|
||||
useIsExam: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
const id = 'test-id';
|
||||
|
||||
const mockFetchExamAccessToken = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
useFetchExamAccessToken.mockReturnValue(mockFetchExamAccessToken);
|
||||
|
||||
const testAccessToken = 'test-access-token';
|
||||
|
||||
describe('useExamAccess hook', () => {
|
||||
beforeAll(async () => {
|
||||
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
|
||||
await initializeMockApp();
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock implementations from previous test runs may not have been "consumed", so reset mock implementations.
|
||||
useExamAccessToken.mockReset();
|
||||
useExamAccessToken.mockReturnValueOnce('');
|
||||
useExamAccessToken.mockReturnValueOnce(testAccessToken);
|
||||
});
|
||||
describe.only('behavior', () => {
|
||||
it('returns accessToken and blockAccess and doesn\'t call token API if not an exam', () => {
|
||||
const { result } = renderHook(() => useExamAccess({ id }));
|
||||
const { accessToken, blockAccess } = result.current;
|
||||
|
||||
expect(accessToken).toEqual('');
|
||||
expect(blockAccess).toBe(false);
|
||||
expect(mockFetchExamAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
it('returns true for blockAccess if an exam but accessToken not yet fetched', () => {
|
||||
useIsExam.mockImplementation(() => (true));
|
||||
|
||||
const { result } = renderHook(() => useExamAccess({ id }));
|
||||
const { accessToken, blockAccess } = result.current;
|
||||
|
||||
expect(accessToken).toEqual('');
|
||||
expect(blockAccess).toBe(true);
|
||||
expect(mockFetchExamAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
it('returns false for blockAccess if an exam and accessToken fetch succeeds', async () => {
|
||||
useIsExam.mockImplementation(() => (true));
|
||||
const { result, waitForNextUpdate } = renderHook(() => useExamAccess({ id }));
|
||||
|
||||
// We wait for the promise to resolve and for updates to state to complete so that blockAccess is updated.
|
||||
await waitForNextUpdate();
|
||||
|
||||
const { accessToken, blockAccess } = result.current;
|
||||
|
||||
expect(accessToken).toEqual(testAccessToken);
|
||||
expect(blockAccess).toBe(false);
|
||||
expect(mockFetchExamAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
it('returns false for blockAccess if an exam and accessToken fetch fails', async () => {
|
||||
useIsExam.mockImplementation(() => (true));
|
||||
|
||||
const testError = 'test-error';
|
||||
mockFetchExamAccessToken.mockImplementationOnce(() => Promise.reject(testError));
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(() => useExamAccess({ id }));
|
||||
|
||||
// We wait for the promise to resolve and for updates to state to complete so that blockAccess is updated.
|
||||
await waitForNextUpdate();
|
||||
|
||||
const { accessToken, blockAccess } = result.current;
|
||||
|
||||
expect(accessToken).toEqual(testAccessToken);
|
||||
expect(blockAccess).toBe(false);
|
||||
expect(mockFetchExamAccessToken).toHaveBeenCalled();
|
||||
expect(logError).toHaveBeenCalledWith(testError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { render as rtlRender } from '@testing-library/react';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
|
||||
import { reducer as specialExamsReducer } from '@edx/frontend-lib-special-exams';
|
||||
import AppProvider from '@edx/frontend-platform/react/AppProvider';
|
||||
import { reducer as courseHomeReducer } from './course-home/data';
|
||||
import { reducer as coursewareReducer } from './courseware/data/slice';
|
||||
@@ -118,6 +119,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
|
||||
courseware: coursewareReducer,
|
||||
courseHome: courseHomeReducer,
|
||||
learningAssistant: learningAssistantReducer,
|
||||
specialExams: specialExamsReducer,
|
||||
},
|
||||
});
|
||||
if (overrideStore) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { reducer as specialExamsReducer } from '@edx/frontend-lib-special-exams';
|
||||
import { reducer as courseHomeReducer } from './course-home/data';
|
||||
import { reducer as coursewareReducer } from './courseware/data/slice';
|
||||
import { reducer as recommendationsReducer } from './courseware/course/course-exit/data/slice';
|
||||
@@ -13,6 +14,7 @@ export default function initializeStore() {
|
||||
courseware: coursewareReducer,
|
||||
courseHome: courseHomeReducer,
|
||||
learningAssistant: learningAssistantReducer,
|
||||
specialExams: specialExamsReducer,
|
||||
recommendations: recommendationsReducer,
|
||||
tours: toursReducer,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user