refactor: Move redux to react query in course checklist (#2870)

This commit is contained in:
Chris Chávez
2026-02-19 13:10:18 -05:00
committed by GitHub
parent 01ddf0d2ad
commit 42f26e7404
9 changed files with 230 additions and 232 deletions

View File

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

View File

@@ -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 = () => {
/>
<AriaLiveRegion
{...{
isCourseLaunchChecklistLoading,
isCourseBestPracticeChecklistLoading,
isCourseLaunchChecklistLoading: isPendingLaunchData,
isCourseBestPracticeChecklistLoading: isPendingBestPacticeData,
enableQuality,
}}
/>
@@ -75,7 +66,7 @@ const CourseChecklist = () => {
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
isLoading={isCourseLaunchChecklistLoading}
isLoading={isPendingLaunchData}
/>
{enableQuality && (
<ChecklistSection
@@ -83,7 +74,7 @@ const CourseChecklist = () => {
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
isLoading={isCourseBestPracticeChecklistLoading}
isLoading={isPendingBestPacticeData}
/>
)}
</Stack>

View File

@@ -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<courseLaunchData>}
*/
export async function getCourseLaunch({
courseId,
gradedOnly,
validateOras,
all,
}) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseLaunchApiUrl({
courseId, gradedOnly, validateOras, all,
}));
return camelCaseObject(data);
}

View File

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

View File

@@ -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<string, any>;
subsection: Record<string, any>;
units: Record<string, any>;
videos: Record<string, any>;
}
/**
* Get course best practices.
*/
export async function getCourseBestPractices({
courseId,
excludeGraded,
all,
}: CourseBestPracticesRequest): Promise<CourseBestPractices> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
return camelCaseObject(data);
}
export interface CourseLaunchData {
isSelfPaced: boolean;
dates: {
hasEndDate: boolean;
hasStartDate: boolean;
};
assignments: Record<string, any>;
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<CourseLaunchData> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseLaunchApiUrl({
courseId, gradedOnly, validateOras, all,
}));
return camelCaseObject(data);
}

View File

@@ -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<CourseLaunchData, AxiosError>({
queryKey: courseChecklistQueryKeys.courseLaunch(params),
queryFn: () => getCourseLaunch(params),
refetchOnMount: 'always',
})
);

View File

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

View File

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

View File

@@ -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<string, any>;
courseOutline: Record<string, any>;
courseUnit: Record<string, any>;
courseChecklist: Record<string, any>;
certificates: {
loadingStatus: RequestStatusType;
savingStatus: any;
@@ -91,7 +89,6 @@ export default function initializeStore(preloadedState: Partial<DeprecatedReduxS
videos: videosReducer,
courseOutline: courseOutlineReducer,
courseUnit: courseUnitReducer,
courseChecklist: courseChecklistReducer,
certificates: certificatesReducer,
groupConfigurations: groupConfigurationsReducer,
textbooks: textbooksReducer,