diff --git a/src/course-checklist/CourseChecklist.test.jsx b/src/course-checklist/CourseChecklist.test.jsx
index 5c03fe930..0be7a7a46 100644
--- a/src/course-checklist/CourseChecklist.test.jsx
+++ b/src/course-checklist/CourseChecklist.test.jsx
@@ -1,16 +1,12 @@
import {
render,
- waitFor,
screen,
initializeMocks,
} from '@src/testUtils';
import '@testing-library/jest-dom';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
-import { RequestStatus } from '../data/constants';
-import { executeThunk } from '../utils';
import { getCourseLaunchApiUrl, getCourseBestPracticesApiUrl } from './data/api';
-import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import {
courseId,
generateCourseLaunchData,
@@ -20,7 +16,6 @@ import messages from './messages';
import CourseChecklist from './index';
let axiosMock;
-let store;
const renderComponent = () => {
render(
@@ -33,22 +28,18 @@ const renderComponent = () => {
const mockStore = async (status) => {
axiosMock.onGet(getCourseLaunchApiUrl(courseId)).reply(status, generateCourseLaunchData());
axiosMock.onGet(getCourseBestPracticesApiUrl(courseId)).reply(status, generateCourseBestPracticesData());
-
- await executeThunk(fetchCourseLaunchQuery(courseId), store.dispatch);
- await executeThunk(fetchCourseBestPracticesQuery(courseId), store.dispatch);
};
describe('CourseChecklistPage', () => {
beforeEach(async () => {
const mocks = initializeMocks();
- store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
describe('renders', () => {
describe('if enable_quality prop is true', () => {
it('two checklist components ', async () => {
- renderComponent();
await mockStore(200);
+ renderComponent();
expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible();
@@ -56,9 +47,9 @@ describe('CourseChecklistPage', () => {
});
describe('an aria-live region with', () => {
- it('an aria-live region', () => {
+ it('an aria-live region', async () => {
renderComponent();
- const ariaLiveRegion = screen.getByRole('status');
+ const ariaLiveRegion = await screen.findByRole('status');
expect(ariaLiveRegion).toBeDefined();
@@ -66,29 +57,17 @@ describe('CourseChecklistPage', () => {
});
it('correct content when the launch checklist has loaded', async () => {
- renderComponent();
await mockStore(404);
- await waitFor(() => {
- const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
-
- expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
-
- expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
- });
+ renderComponent();
+ expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
it('correct content when the best practices checklist is loading', async () => {
- renderComponent();
await mockStore(404);
- await waitFor(() => {
- const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
-
- expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
-
- expect(
- screen.getByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
- ).toBeInTheDocument();
- });
+ renderComponent();
+ expect(
+ await screen.findByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
+ ).toBeInTheDocument();
});
});
});
@@ -111,27 +90,15 @@ describe('CourseChecklistPage', () => {
describe('an aria-live region with', () => {
it('correct content when the launch checklist has loaded', async () => {
- renderComponent();
await mockStore(404);
- await waitFor(() => {
- const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
-
- expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
-
- expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
- });
+ renderComponent();
+ expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
it('correct content when the best practices checklist is loading', async () => {
- renderComponent();
await mockStore(404);
- await waitFor(() => {
- const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
-
- expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
-
- expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
- });
+ renderComponent();
+ expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
});
});
});
@@ -144,11 +111,7 @@ describe('CourseChecklistPage', () => {
renderComponent();
- await waitFor(() => {
- const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
- expect(launchChecklistStatus).toEqual(RequestStatus.DENIED);
- expect(screen.getByRole('alert')).toBeInTheDocument();
- });
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
});
});
});
diff --git a/src/course-checklist/CourseChecklist.tsx b/src/course-checklist/CourseChecklist.tsx
index a8f17d780..0d27e471f 100644
--- a/src/course-checklist/CourseChecklist.tsx
+++ b/src/course-checklist/CourseChecklist.tsx
@@ -1,42 +1,33 @@
-import { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
-import { useDispatch, useSelector } from 'react-redux';
import { Container, Stack } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
-import { DeprecatedReduxState } from '@src/store';
import SubHeader from '../generic/sub-header/SubHeader';
import messages from './messages';
import AriaLiveRegion from './AriaLiveRegion';
-import { RequestStatus } from '../data/constants';
import ChecklistSection from './ChecklistSection';
-import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
+import { useCourseBestPractices, useCourseLaunch } from './data/apiHooks';
const CourseChecklist = () => {
const intl = useIntl();
- const dispatch = useDispatch();
const { courseId, courseDetails } = useCourseAuthoringContext();
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
- useEffect(() => {
- dispatch(fetchCourseLaunchQuery({ courseId }));
- dispatch(fetchCourseBestPracticesQuery({ courseId }));
- }, [courseId]);
+ const {
+ data: bestPracticeData,
+ isPending: isPendingBestPacticeData,
+ } = useCourseBestPractices({ courseId });
const {
- loadingStatus,
- launchData,
- bestPracticeData,
- } = useSelector((state: DeprecatedReduxState) => state.courseChecklist);
+ data: launchData,
+ isPending: isPendingLaunchData,
+ failureReason: launchError,
+ } = useCourseLaunch({ courseId });
- const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
-
- const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
- const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
- const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED;
+ const isLoadingDenied = launchError?.response?.status === 403;
if (isLoadingDenied) {
return (
@@ -64,8 +55,8 @@ const CourseChecklist = () => {
/>
@@ -75,7 +66,7 @@ const CourseChecklist = () => {
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
- isLoading={isCourseLaunchChecklistLoading}
+ isLoading={isPendingLaunchData}
/>
{enableQuality && (
{
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
- isLoading={isCourseBestPracticeChecklistLoading}
+ isLoading={isPendingBestPacticeData}
/>
)}
diff --git a/src/course-checklist/data/api.js b/src/course-checklist/data/api.js
deleted file mode 100644
index c9524005c..000000000
--- a/src/course-checklist/data/api.js
+++ /dev/null
@@ -1,64 +0,0 @@
-// @ts-check
-import { camelCaseObject, getConfig } from '@edx/frontend-platform';
-import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-
-const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
-
-export const getCourseBestPracticesApiUrl = ({
- courseId,
- excludeGraded,
- all,
-}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
-
-export const getCourseLaunchApiUrl = ({
- courseId,
- gradedOnly,
- validateOras,
- all,
-}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
-
-/**
- * Get course best practices.
- * @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
- * @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>}
- */
-export async function getCourseBestPractices({
- courseId,
- excludeGraded,
- all,
-}) {
- const { data } = await getAuthenticatedHttpClient()
- .get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
-
- return camelCaseObject(data);
-}
-
-/** @typedef {object} courseLaunchData
- * @property {boolean} isSelfPaced
- * @property {object} dates
- * @property {object} assignments
- * @property {object} grades
- * @property {number} grades.sum_of_weights
- * @property {object} certificates
- * @property {object} updates
- * @property {object} proctoring
- */
-
-/**
- * Get course launch.
- * @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
- * @returns {Promise}
- */
-export async function getCourseLaunch({
- courseId,
- gradedOnly,
- validateOras,
- all,
-}) {
- const { data } = await getAuthenticatedHttpClient()
- .get(getCourseLaunchApiUrl({
- courseId, gradedOnly, validateOras, all,
- }));
-
- return camelCaseObject(data);
-}
diff --git a/src/course-checklist/data/api.test.ts b/src/course-checklist/data/api.test.ts
new file mode 100644
index 000000000..9f0a6a43a
--- /dev/null
+++ b/src/course-checklist/data/api.test.ts
@@ -0,0 +1,52 @@
+import { initializeMocks } from '@src/testUtils';
+import {
+ CourseBestPracticesRequest,
+ CourseLaunchRequest,
+ getCourseBestPractices,
+ getCourseBestPracticesApiUrl,
+ getCourseLaunch,
+ getCourseLaunchApiUrl,
+} from './api';
+
+let axiosMock;
+
+describe('course checklist data API', () => {
+ beforeEach(() => {
+ ({ axiosMock } = initializeMocks());
+ });
+
+ describe('getCourseBestPractices', () => {
+ it('should fetch course best practices', async () => {
+ const params: CourseBestPracticesRequest = {
+ courseId: 'course-v1:edX+DemoX+Demo_Course',
+ excludeGraded: true,
+ all: true,
+ };
+ const url = getCourseBestPracticesApiUrl(params);
+ axiosMock.onGet(url).reply(200, { is_self_paced: false });
+
+ const result = await getCourseBestPractices(params);
+
+ expect(axiosMock.history.get[0].url).toEqual(url);
+ expect(result).toEqual({ isSelfPaced: false });
+ });
+ });
+
+ describe('getCourseLaunch', () => {
+ it('should fetch course launch validation', async () => {
+ const params: CourseLaunchRequest = {
+ courseId: 'course-v1:edX+DemoX+Demo_Course',
+ gradedOnly: true,
+ validateOras: true,
+ all: true,
+ };
+ const url = getCourseLaunchApiUrl(params);
+ axiosMock.onGet(url).reply(200, { is_self_paced: false });
+
+ const result = await getCourseLaunch(params);
+
+ expect(axiosMock.history.get[0].url).toEqual(url);
+ expect(result).toEqual({ isSelfPaced: false });
+ });
+ });
+});
diff --git a/src/course-checklist/data/api.ts b/src/course-checklist/data/api.ts
new file mode 100644
index 000000000..df1b3aec3
--- /dev/null
+++ b/src/course-checklist/data/api.ts
@@ -0,0 +1,98 @@
+import { camelCaseObject, getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
+
+export interface CourseBestPracticesRequest {
+ courseId: string;
+ excludeGraded?: boolean;
+ all?: boolean;
+}
+
+export const getCourseBestPracticesApiUrl = ({
+ courseId,
+ excludeGraded = true,
+ all = true,
+}: CourseBestPracticesRequest) => (
+ `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`
+);
+
+export interface CourseLaunchRequest {
+ courseId: string;
+ gradedOnly?: boolean;
+ validateOras?: boolean;
+ all?: boolean;
+}
+
+export const getCourseLaunchApiUrl = ({
+ courseId,
+ gradedOnly = true,
+ validateOras = true,
+ all = true,
+}: CourseLaunchRequest) => (
+ `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`
+);
+
+export interface CourseBestPractices {
+ isSelfPaced: boolean;
+ sections: Record;
+ subsection: Record;
+ units: Record;
+ videos: Record;
+}
+
+/**
+ * Get course best practices.
+ */
+export async function getCourseBestPractices({
+ courseId,
+ excludeGraded,
+ all,
+}: CourseBestPracticesRequest): Promise {
+ const { data } = await getAuthenticatedHttpClient()
+ .get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
+
+ return camelCaseObject(data);
+}
+
+export interface CourseLaunchData {
+ isSelfPaced: boolean;
+ dates: {
+ hasEndDate: boolean;
+ hasStartDate: boolean;
+ };
+ assignments: Record;
+ grades: {
+ hasGradingPolicy: boolean;
+ sumOfWeights: number;
+ };
+ certificates: {
+ hasCertificate: boolean;
+ isActivated: boolean;
+ isEnabled: boolean;
+ };
+ updates: {
+ hasUpdate: boolean;
+ };
+ proctoring: {
+ hasProctoringEscalationEmail: boolean;
+ needsProctoringEscalationEmail: boolean;
+ };
+}
+
+/**
+ * Get course launch.
+ */
+export async function getCourseLaunch({
+ courseId,
+ gradedOnly,
+ validateOras,
+ all,
+}: CourseLaunchRequest): Promise {
+ const { data } = await getAuthenticatedHttpClient()
+ .get(getCourseLaunchApiUrl({
+ courseId, gradedOnly, validateOras, all,
+ }));
+
+ return camelCaseObject(data);
+}
diff --git a/src/course-checklist/data/apiHooks.ts b/src/course-checklist/data/apiHooks.ts
new file mode 100644
index 000000000..cdf772ab3
--- /dev/null
+++ b/src/course-checklist/data/apiHooks.ts
@@ -0,0 +1,52 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import { AxiosError } from 'axios';
+import { useQuery } from '@tanstack/react-query';
+import {
+ CourseBestPracticesRequest,
+ CourseLaunchData,
+ CourseLaunchRequest,
+ getCourseBestPractices,
+ getCourseLaunch,
+} from './api';
+
+export const courseChecklistQueryKeys = {
+ all: ['courseChecklist'],
+ courseBestPractices: (params: CourseBestPracticesRequest) => [
+ ...courseChecklistQueryKeys.all,
+ 'bestPractices',
+ params,
+ ],
+ courseLaunch: (params: CourseLaunchRequest) => [
+ ...courseChecklistQueryKeys.all,
+ 'launch',
+ params,
+ ],
+};
+
+/**
+ * Hook to fetch course best practices.
+ *
+ * It is necessary to update on each mount, because it is not known
+ * for sure whether the checklist has been updated or not.
+ */
+export const useCourseBestPractices = (params: CourseBestPracticesRequest) => (
+ useQuery({
+ queryKey: courseChecklistQueryKeys.courseBestPractices(params),
+ queryFn: () => getCourseBestPractices(params),
+ refetchOnMount: 'always',
+ })
+);
+
+/**
+ * Hook to fetch course launch validation.
+ *
+ * It is necessary to update on each mount, because it is not known
+ * for sure whether the checklist has been updated or not.
+ */
+export const useCourseLaunch = (params: CourseLaunchRequest) => (
+ useQuery({
+ queryKey: courseChecklistQueryKeys.courseLaunch(params),
+ queryFn: () => getCourseLaunch(params),
+ refetchOnMount: 'always',
+ })
+);
diff --git a/src/course-checklist/data/slice.js b/src/course-checklist/data/slice.js
deleted file mode 100644
index d50c7ecb2..000000000
--- a/src/course-checklist/data/slice.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/* eslint-disable no-param-reassign */
-import { createSlice } from '@reduxjs/toolkit';
-
-import { RequestStatus } from '../../data/constants';
-
-const slice = createSlice({
- name: 'courseChecklist',
- initialState: {
- loadingStatus: {
- launchChecklistStatus: RequestStatus.IN_PROGRESS,
- bestPracticeChecklistStatus: RequestStatus.IN_PROGRESS,
- },
- launchData: {},
- bestPracticeData: {},
- },
- reducers: {
- fetchLaunchChecklistSuccess: (state, { payload }) => {
- state.launchData = payload.data;
- },
- updateLaunchChecklistStatus: (state, { payload }) => {
- state.loadingStatus.launchChecklistStatus = payload.status;
- },
- fetchBestPracticeChecklistSuccess: (state, { payload }) => {
- state.bestPracticeData = payload.data;
- },
- updateBestPracticeChecklisttStatus: (state, { payload }) => {
- state.loadingStatus.bestPracticeChecklistStatus = payload.status;
- },
- },
-});
-
-export const {
- fetchLaunchChecklistSuccess,
- updateLaunchChecklistStatus,
- fetchBestPracticeChecklistSuccess,
- updateBestPracticeChecklisttStatus,
-} = slice.actions;
-
-export const {
- reducer,
-} = slice;
diff --git a/src/course-checklist/data/thunks.js b/src/course-checklist/data/thunks.js
deleted file mode 100644
index f428d9f4f..000000000
--- a/src/course-checklist/data/thunks.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import { RequestStatus } from '../../data/constants';
-import {
- getCourseBestPractices,
- getCourseLaunch,
-} from './api';
-import {
- fetchLaunchChecklistSuccess,
- updateLaunchChecklistStatus,
- fetchBestPracticeChecklistSuccess,
- updateBestPracticeChecklisttStatus,
-} from './slice';
-
-export function fetchCourseLaunchQuery({
- courseId,
- gradedOnly = true,
- validateOras = true,
- all = true,
-}) {
- return async (dispatch) => {
- try {
- const data = await getCourseLaunch({
- courseId, gradedOnly, validateOras, all,
- });
- dispatch(fetchLaunchChecklistSuccess({ data }));
- dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL }));
- } catch (error) {
- if (error.response && error.response.status === 403) {
- dispatch(updateLaunchChecklistStatus({ status: RequestStatus.DENIED }));
- } else {
- dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
- }
- }
- };
-}
-
-export function fetchCourseBestPracticesQuery({
- courseId,
- excludeGraded = true,
- all = true,
-}) {
- return async (dispatch) => {
- try {
- const data = await getCourseBestPractices({ courseId, excludeGraded, all });
- dispatch(fetchBestPracticeChecklistSuccess({ data }));
- dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL }));
- } catch {
- dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED }));
- }
- };
-}
diff --git a/src/store.ts b/src/store.ts
index 8eb87ce75..99c5fa2e2 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -25,7 +25,6 @@ import { reducer as courseImportReducer } from './import-page/data/slice';
import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice';
import { reducer as courseOutlineReducer } from './course-outline/data/slice';
import { reducer as courseUnitReducer } from './course-unit/data/slice';
-import { reducer as courseChecklistReducer } from './course-checklist/data/slice';
import { reducer as textbooksReducer } from './textbooks/data/slice';
import { reducer as certificatesReducer } from './certificates/data/slice';
import { reducer as groupConfigurationsReducer } from './group-configurations/data/slice';
@@ -56,7 +55,6 @@ export interface DeprecatedReduxState {
videos: Record;
courseOutline: Record;
courseUnit: Record;
- courseChecklist: Record;
certificates: {
loadingStatus: RequestStatusType;
savingStatus: any;
@@ -91,7 +89,6 @@ export default function initializeStore(preloadedState: Partial