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:
Michael Roytman
2024-02-08 13:58:19 -05:00
committed by GitHub
parent 174de4bc1b
commit 170cbe1da0
8 changed files with 306 additions and 111 deletions

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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) {

View File

@@ -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,
},