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