diff --git a/src/CourseAuthoringRoutes.tsx b/src/CourseAuthoringRoutes.tsx index fbd6e6a5a..4e6f0cdac 100644 --- a/src/CourseAuthoringRoutes.tsx +++ b/src/CourseAuthoringRoutes.tsx @@ -32,6 +32,7 @@ import { CourseLibraries } from './course-libraries'; import { IframeProvider } from './generic/hooks/context/iFrameContext'; import { CourseAuthoringProvider } from './CourseAuthoringContext'; import { CourseImportProvider } from './import-page/CourseImportContext'; +import { CourseExportProvider } from './export-page/CourseExportContext'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -152,7 +153,13 @@ const CourseAuthoringRoutes = () => { /> } + element={( + + + + + + )} /> void; + downloadPath?: string; +}; + +/** + * Course Export Context. + * Always available when we're in the context of the Course Export Page. + * + * Get this using `useCourseExportContext()` + */ +const CourseExportContext = createContext(undefined); + +type CourseExportProviderProps = { + children?: React.ReactNode; +}; + +export const CourseExportProvider = ({ children }: CourseExportProviderProps) => { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const cookies = new Cookies(); + + const [isStopFetching, setStopFetching] = useState(false); + const [exportTriggered, setExportTriggered] = useState(false); + const [successDate, setSuccessDate] = useState(); + + const reset = () => { + setStopFetching(false); + setExportTriggered(false); + setSuccessDate(undefined); + }; + + const { + data: exportStatus, + isPending: isPendingExportStatus, + isError: isErrorExportStatus, + failureReason: exportStatusError, + } = useExportStatus(courseId, isStopFetching, exportTriggered); + const exportMutation = useStartCourseExporting(courseId); + const invalidateExportStatus = useInvalidateExportStatus(courseId); + + const currentStage = exportStatus?.exportStatus ?? 0; + const anyRequestInProgress = exportMutation.isPending || isPendingExportStatus; + const anyRequestFailed = exportMutation.isError || isErrorExportStatus; + const isLoadingDenied = exportStatusError?.response?.status === 403; + + let fetchExportErrorMessage: string | undefined; + let errorUnitUrl; + if (exportStatus?.exportError) { + fetchExportErrorMessage = exportStatus.exportError.rawErrorMsg ?? intl.formatMessage(messages.unknownError); + errorUnitUrl = exportStatus.exportError.editUnitUrl; + } + + let downloadPath; + if (exportStatus?.exportOutput) { + downloadPath = exportStatus.exportOutput; + if (downloadPath.startsWith('/')) { + downloadPath = `${getConfig().STUDIO_BASE_URL}${downloadPath}`; + } + } + + // On mount, restore export state from the cookie set by a previous session, + // so the stepper remains visible if the user navigates away and comes back. + useEffect(() => { + const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); + if (cookieData) { + setExportTriggered(true); + setSuccessDate(cookieData.date); + } + }, []); + + // Stop fetching the export status once the process has reached a terminal state: + // successful completion, a network/request failure, or an application-level export error. + useEffect(() => { + if (currentStage === EXPORT_STAGES.SUCCESS || anyRequestFailed || fetchExportErrorMessage) { + setStopFetching(true); + } + }, [currentStage, anyRequestFailed, fetchExportErrorMessage]); + + const handleStartExportingCourse = async () => { + reset(); + invalidateExportStatus(); + setExportTriggered(true); + await exportMutation.mutateAsync(); + const momentDate = moment().valueOf(); + setExportCookie(momentDate); + setSuccessDate(momentDate); + }; + + const context = useMemo(() => ({ + currentStage, + exportTriggered, + fetchExportErrorMessage, + errorUnitUrl, + anyRequestFailed, + isLoadingDenied, + anyRequestInProgress, + successDate, + handleStartExportingCourse, + downloadPath, + }), [ + currentStage, + exportTriggered, + fetchExportErrorMessage, + errorUnitUrl, + anyRequestFailed, + isLoadingDenied, + anyRequestInProgress, + successDate, + handleStartExportingCourse, + downloadPath, + ]); + + return ( + + {children} + + ); +}; + +export function useCourseExportContext(): CourseExportContextData { + const ctx = useContext(CourseExportContext); + if (ctx === undefined) { + /* istanbul ignore next */ + throw new Error('useCourseExportContext() was used in a component without a ancestor.'); + } + return ctx; +} diff --git a/src/export-page/CourseExportPage.test.jsx b/src/export-page/CourseExportPage.test.tsx similarity index 50% rename from src/export-page/CourseExportPage.test.jsx rename to src/export-page/CourseExportPage.test.tsx index 2abb1700f..6170edd33 100644 --- a/src/export-page/CourseExportPage.test.jsx +++ b/src/export-page/CourseExportPage.test.tsx @@ -1,3 +1,4 @@ +import userEvent from '@testing-library/user-event'; import { getConfig } from '@edx/frontend-platform'; import { Helmet } from 'react-helmet'; import Cookies from 'universal-cookie'; @@ -6,11 +7,10 @@ import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { getCourseDetailsUrl } from '@src/data/api'; import { initializeMocks, - fireEvent, render, + screen, waitFor, -} from '../testUtils'; -import { RequestStatus } from '../data/constants'; +} from '@src/testUtils'; import stepperMessages from './export-stepper/messages'; import modalErrorMessages from './export-modal-error/messages'; import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api'; @@ -18,8 +18,8 @@ import { EXPORT_STAGES } from './data/constants'; import { exportPageMock } from './__mocks__'; import messages from './messages'; import CourseExportPage from './CourseExportPage'; +import { CourseExportProvider } from './CourseExportContext'; -let store; let axiosMock; let cookies; const courseId = '123'; @@ -35,7 +35,9 @@ jest.mock('universal-cookie', () => { const renderComponent = () => render( - + + + , ); @@ -46,17 +48,20 @@ describe('', () => { username: 'username', }; const mocks = initializeMocks({ user }); - store = mocks.reduxStore; axiosMock = mocks.axiosMock; axiosMock - .onGet(postExportCourseApiUrl(courseId)) + .onPost(postExportCourseApiUrl(courseId)) .reply(200, exportPageMock); axiosMock .onGet(getCourseDetailsUrl(courseId, user.username)) .reply(200, { courseId, name: courseName }); + axiosMock + .onGet(getExportStatusApiUrl(courseId)) + .reply(200, { exportStatus: EXPORT_STAGES.PREPARING }); cookies = new Cookies(); cookies.get.mockReturnValue(null); }); + it('should render page title correctly', async () => { renderComponent(); await waitFor(() => { @@ -66,95 +71,96 @@ describe('', () => { ); }); }); + it('should render without errors', async () => { - const { getByText } = renderComponent(); - await waitFor(() => { - expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); - const exportPageElement = getByText(messages.headingTitle.defaultMessage, { - selector: 'h2.sub-header-title', - }); - expect(exportPageElement).toBeInTheDocument(); - expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument(); + renderComponent(); + expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + const exportPageElement = screen.getByText(messages.headingTitle.defaultMessage, { + selector: 'h2.sub-header-title', }); + expect(exportPageElement).toBeInTheDocument(); + expect(screen.getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.description2.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument(); }); + it('should start exporting on click', async () => { - const { getByText, container } = renderComponent(); - const button = container.querySelector('.btn-primary'); - fireEvent.click(button); - expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument(); + const user = userEvent.setup(); + const { container } = renderComponent(); + const button = container.querySelector('.btn-primary')!; + await user.click(button); + expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument(); }); + it('should show modal error', async () => { axiosMock .onGet(getExportStatusApiUrl(courseId)) .reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } }); - const { getByText, queryByText, container } = renderComponent(); - const startExportButton = container.querySelector('.btn-primary'); - fireEvent.click(startExportButton); + const user = userEvent.setup(); + const { container } = renderComponent(); + const startExportButton = container.querySelector('.btn-primary')!; + await user.click(startExportButton); // eslint-disable-next-line no-promise-executor-return await new Promise((r) => setTimeout(r, 3500)); - expect(getByText(/There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages. The raw error message is: test error/i)); - const closeModalWindowButton = getByText('Return to export'); - fireEvent.click(closeModalWindowButton); - expect(queryByText(modalErrorMessages.errorCancelButtonUnit.defaultMessage)).not.toBeInTheDocument(); - fireEvent.click(closeModalWindowButton); + expect(screen.getByText(/There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages. The raw error message is: test error/i)); + const closeModalWindowButton = screen.getByText('Return to export'); + await user.click(closeModalWindowButton); + expect(screen.queryByText(modalErrorMessages.errorCancelButtonUnit.defaultMessage)).not.toBeInTheDocument(); + await user.click(closeModalWindowButton); }); + it('should fetch status without clicking when cookies has', async () => { cookies.get.mockReturnValue({ date: 1679787000 }); - const { getByText } = renderComponent(); - expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument(); + renderComponent(); + expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument(); }); + it('should show download path for relative path', async () => { axiosMock .onGet(getExportStatusApiUrl(courseId)) .reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' }); - const { getByText, container } = renderComponent(); - const startExportButton = container.querySelector('.btn-primary'); - fireEvent.click(startExportButton); - await waitFor(() => { - const downloadButton = getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage); - expect(downloadButton).toBeInTheDocument(); - expect(downloadButton.getAttribute('href')).toEqual(`${getConfig().STUDIO_BASE_URL}/test-download-path.test`); - }, { timeout: 4_000 }); + const user = userEvent.setup(); + const { container } = renderComponent(); + const startExportButton = container.querySelector('.btn-primary')!; + await user.click(startExportButton); + const downloadButton = screen.getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage); + expect(downloadButton).toBeInTheDocument(); + expect(downloadButton.getAttribute('href')).toEqual(`${getConfig().STUDIO_BASE_URL}/test-download-path.test`); }); + it('should show download path for absolute path', async () => { axiosMock .onGet(getExportStatusApiUrl(courseId)) .reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: 'http://test-download-path.test' }); - const { getByText, container } = renderComponent(); - const startExportButton = container.querySelector('.btn-primary'); - fireEvent.click(startExportButton); - await waitFor(() => { - const downloadButton = getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage); - expect(downloadButton).toBeInTheDocument(); - expect(downloadButton.getAttribute('href')).toEqual('http://test-download-path.test'); - }, { timeout: 4_000 }); + const user = userEvent.setup(); + const { container } = renderComponent(); + const startExportButton = container.querySelector('.btn-primary')!; + await user.click(startExportButton); + const downloadButton = screen.getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage); + expect(downloadButton).toBeInTheDocument(); + expect(downloadButton.getAttribute('href')).toEqual('http://test-download-path.test'); }); + it('displays an alert and sets status to DENIED when API responds with 403', async () => { axiosMock .onGet(getExportStatusApiUrl(courseId)) .reply(403); - const { getByRole, container } = renderComponent(); - const startExportButton = container.querySelector('.btn-primary'); - fireEvent.click(startExportButton); - await waitFor(() => { - expect(getByRole('alert')).toBeInTheDocument(); - }, { timeout: 4_000 }); - const { loadingStatus } = store.getState().courseExport; - expect(loadingStatus).toEqual(RequestStatus.DENIED); + const user = userEvent.setup(); + const { container } = renderComponent(); + const startExportButton = container.querySelector('.btn-primary')!; + await user.click(startExportButton); + expect(screen.getByRole('alert')).toBeInTheDocument(); }); - it('sets loading status to FAILED upon receiving a 404 response from the API', async () => { + it('does not show a connection error alert upon receiving a 404 response from the API', async () => { axiosMock .onGet(getExportStatusApiUrl(courseId)) .reply(404); + const user = userEvent.setup(); const { container } = renderComponent(); - const startExportButton = container.querySelector('.btn-primary'); - fireEvent.click(startExportButton); - await waitFor(() => { - const { loadingStatus } = store.getState().courseExport; - expect(loadingStatus).toEqual(RequestStatus.FAILED); - }, { timeout: 4_000 }); + const startExportButton = container.querySelector('.btn-primary')!; + await user.click(startExportButton); + expect(screen.getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); }); diff --git a/src/export-page/CourseExportPage.tsx b/src/export-page/CourseExportPage.tsx index acc23ac04..8f34d1c49 100644 --- a/src/export-page/CourseExportPage.tsx +++ b/src/export-page/CourseExportPage.tsx @@ -1,54 +1,38 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Container, Layout, Button, Card, } from '@openedx/paragon'; import { ArrowCircleDown as ArrowCircleDownIcon } from '@openedx/paragon/icons'; -import Cookies from 'universal-cookie'; import { getConfig } from '@edx/frontend-platform'; import { Helmet } from 'react-helmet'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import InternetConnectionAlert from '../generic/internet-connection-alert'; -import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; -import SubHeader from '../generic/sub-header/SubHeader'; -import { RequestStatus } from '../data/constants'; +import InternetConnectionAlert from '@src/generic/internet-connection-alert'; +import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; +import SubHeader from '@src/generic/sub-header/SubHeader'; + import messages from './messages'; import ExportSidebar from './export-sidebar/ExportSidebar'; -import { - getCurrentStage, getError, getExportTriggered, getLoadingStatus, getSavingStatus, -} from './data/selectors'; -import { startExportingCourse } from './data/thunks'; -import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants'; -import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './data/slice'; +import { EXPORT_STAGES } from './data/constants'; import ExportModalError from './export-modal-error/ExportModalError'; import ExportFooter from './export-footer/ExportFooter'; import ExportStepper from './export-stepper/ExportStepper'; +import { useCourseExportContext } from './CourseExportContext'; const CourseExportPage = () => { const intl = useIntl(); - const dispatch = useDispatch(); - const exportTriggered = useSelector(getExportTriggered); - const { courseId, courseDetails } = useCourseAuthoringContext(); - const currentStage = useSelector(getCurrentStage); - const { msg: errorMessage } = useSelector(getError); - const loadingStatus = useSelector(getLoadingStatus); - const savingStatus = useSelector(getSavingStatus); - const cookies = new Cookies(); - const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS; - const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; - const isLoadingDenied = loadingStatus === RequestStatus.DENIED; - const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; + const { courseDetails } = useCourseAuthoringContext(); + const { + currentStage, + exportTriggered, + fetchExportErrorMessage, + anyRequestFailed, + isLoadingDenied, + anyRequestInProgress, + handleStartExportingCourse, + } = useCourseExportContext(); - useEffect(() => { - const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); - if (cookieData) { - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(updateExportTriggered(true)); - dispatch(updateSuccessDate(cookieData.date)); - } - }, []); + const isShowExportButton = !exportTriggered || fetchExportErrorMessage || currentStage === EXPORT_STAGES.SUCCESS; if (isLoadingDenied) { return ( @@ -97,7 +81,7 @@ const CourseExportPage = () => { size="lg" block className="mb-4" - onClick={() => dispatch(startExportingCourse(courseId))} + onClick={handleStartExportingCourse} iconBefore={ArrowCircleDownIcon} > {intl.formatMessage(messages.buttonTitle)} @@ -105,16 +89,16 @@ const CourseExportPage = () => { )} - {exportTriggered && } + {exportTriggered && } - + - +
getConfig().STUDIO_BASE_URL; -export const postExportCourseApiUrl = (courseId) => new URL(`export/${courseId}`, getApiBaseUrl()).href; -export const getExportStatusApiUrl = (courseId) => new URL(`export_status/${courseId}`, getApiBaseUrl()).href; - -export async function startCourseExporting(courseId) { - const { data } = await getAuthenticatedHttpClient() - .post(postExportCourseApiUrl(courseId)); - return camelCaseObject(data); -} - -export async function getExportStatus(courseId) { - const { data } = await getAuthenticatedHttpClient() - .get(getExportStatusApiUrl(courseId)); - return camelCaseObject(data); -} diff --git a/src/export-page/data/api.test.js b/src/export-page/data/api.test.js index 70acc8b24..779802bb5 100644 --- a/src/export-page/data/api.test.js +++ b/src/export-page/data/api.test.js @@ -1,6 +1,5 @@ -import MockAdapter from 'axios-mock-adapter'; -import { initializeMockApp, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; +import { initializeMocks } from '@src/testUtils'; import { getExportStatus, postExportCourseApiUrl, startCourseExporting } from './api'; @@ -9,15 +8,7 @@ const courseId = 'course-123'; describe('API Functions', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + ({ axiosMock } = initializeMocks()); }); afterEach(() => { diff --git a/src/export-page/data/api.ts b/src/export-page/data/api.ts new file mode 100644 index 000000000..050a4e206 --- /dev/null +++ b/src/export-page/data/api.ts @@ -0,0 +1,27 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const postExportCourseApiUrl = (courseId: string) => new URL(`export/${courseId}`, getApiBaseUrl()).href; +export const getExportStatusApiUrl = (courseId: string) => new URL(`export_status/${courseId}`, getApiBaseUrl()).href; + +export interface ExportStatusData { + exportStatus: number; + exportOutput?: string; // URL to the exported course file + exportError?: { + rawErrorMsg?: string; + editUnitUrl?: string; + } +} + +export async function startCourseExporting(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient() + .post(postExportCourseApiUrl(courseId)); + return camelCaseObject(data); +} + +export async function getExportStatus(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient() + .get(getExportStatusApiUrl(courseId)); + return camelCaseObject(data); +} diff --git a/src/export-page/data/apiHooks.ts b/src/export-page/data/apiHooks.ts new file mode 100644 index 000000000..dee0df77b --- /dev/null +++ b/src/export-page/data/apiHooks.ts @@ -0,0 +1,46 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { AxiosError } from 'axios'; +import { + useQueryClient, skipToken, useMutation, useQuery, +} from '@tanstack/react-query'; + +import { getExportStatus, startCourseExporting, type ExportStatusData } from './api'; + +export const exportQueryKeys = { + all: ['courseExport'], + /** Key for the export status of a specific course */ + exportStatus: (courseId: string) => [...exportQueryKeys.all, courseId], +}; + +/** + * Returns a function to invalidate the export status query for a given course. + */ +export const useInvalidateExportStatus = (courseId: string) => { + const queryClient = useQueryClient(); + return () => queryClient.removeQueries({ queryKey: exportQueryKeys.exportStatus(courseId) }); +}; + +/** + * Returns a mutation to start exporting a course. + */ +export const useStartCourseExporting = (courseId: string) => ( + useMutation({ + mutationFn: () => startCourseExporting(courseId), + }) +); + +/** + * Get the export status for a given course. + * Only fetch while `stopRefetch` is false. + */ +export const useExportStatus = ( + courseId: string, + stopRefetch: boolean, + enabled: boolean, +) => ( + useQuery({ + queryKey: exportQueryKeys.exportStatus(courseId), + queryFn: enabled ? () => getExportStatus(courseId) : skipToken, + refetchInterval: (enabled && !stopRefetch) ? 3000 : false, + }) +); diff --git a/src/export-page/data/selectors.js b/src/export-page/data/selectors.js deleted file mode 100644 index 2c6d0d737..000000000 --- a/src/export-page/data/selectors.js +++ /dev/null @@ -1,8 +0,0 @@ -export const getExportTriggered = (state) => state.courseExport.exportTriggered; -export const getCurrentStage = (state) => state.courseExport.currentStage; -export const getDownloadPath = (state) => state.courseExport.downloadPath; -export const getSuccessDate = (state) => state.courseExport.successDate; -export const getError = (state) => state.courseExport.error; -export const getIsErrorModalOpen = (state) => state.courseExport.isErrorModalOpen; -export const getLoadingStatus = (state) => state.courseExport.loadingStatus; -export const getSavingStatus = (state) => state.courseExport.savingStatus; diff --git a/src/export-page/data/slice.js b/src/export-page/data/slice.js deleted file mode 100644 index f431a6936..000000000 --- a/src/export-page/data/slice.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { createSlice } from '@reduxjs/toolkit'; - -const initialState = { - exportTriggered: false, - currentStage: 0, - error: { msg: null, unitUrl: null }, - downloadPath: null, - successDate: null, - isErrorModalOpen: false, - loadingStatus: '', - savingStatus: '', -}; - -const slice = createSlice({ - name: 'exportPage', - initialState, - reducers: { - updateExportTriggered: (state, { payload }) => { - state.exportTriggered = payload; - }, - updateCurrentStage: (state, { payload }) => { - if (payload >= state.currentStage) { - state.currentStage = payload; - } - }, - updateDownloadPath: (state, { payload }) => { - state.downloadPath = payload; - }, - updateSuccessDate: (state, { payload }) => { - state.successDate = payload; - }, - updateError: (state, { payload }) => { - state.error = payload; - }, - updateIsErrorModalOpen: (state, { payload }) => { - state.isErrorModalOpen = payload; - }, - reset: () => initialState, - updateLoadingStatus: (state, { payload }) => { - state.loadingStatus = payload.status; - }, - updateSavingStatus: (state, { payload }) => { - state.savingStatus = payload.status; - }, - }, -}); - -export const { - updateExportTriggered, - updateCurrentStage, - updateDownloadPath, - updateSuccessDate, - updateError, - updateIsErrorModalOpen, - reset, - updateLoadingStatus, - updateSavingStatus, -} = slice.actions; - -export const { - reducer, -} = slice; diff --git a/src/export-page/data/thunks.test.js b/src/export-page/data/thunks.test.js deleted file mode 100644 index e8dd9762f..000000000 --- a/src/export-page/data/thunks.test.js +++ /dev/null @@ -1,146 +0,0 @@ -import Cookies from 'universal-cookie'; -import { fetchExportStatus } from './thunks'; -import * as api from './api'; -import { EXPORT_STAGES } from './constants'; - -jest.mock('universal-cookie', () => jest.fn().mockImplementation(() => ({ - get: jest.fn().mockImplementation(() => ({ completed: false })), -}))); - -jest.mock('../utils', () => ({ - setExportCookie: jest.fn(), -})); - -describe('fetchExportStatus thunk', () => { - const dispatch = jest.fn(); - const getState = jest.fn(); - const courseId = 'course-123'; - const exportStatus = EXPORT_STAGES.COMPRESSING; - const exportOutput = 'export output'; - const exportError = 'export error'; - let mockGetExportStatus; - - beforeEach(() => { - jest.clearAllMocks(); - - mockGetExportStatus = jest.spyOn(api, 'getExportStatus').mockResolvedValue({ - exportStatus, - exportOutput, - exportError, - }); - }); - - it('should dispatch updateCurrentStage with export status', async () => { - mockGetExportStatus.mockResolvedValue({ - exportStatus, - exportOutput, - exportError, - }); - - await fetchExportStatus(courseId)(dispatch, getState); - - expect(dispatch).toHaveBeenCalledWith({ - payload: exportStatus, - type: 'exportPage/updateCurrentStage', - }); - }); - - it('should dispatch updateError on export error', async () => { - mockGetExportStatus.mockResolvedValue({ - exportStatus, - exportOutput, - exportError, - }); - - await fetchExportStatus(courseId)(dispatch, getState); - - expect(dispatch).toHaveBeenCalledWith({ - payload: { - msg: exportError, - unitUrl: null, - }, - type: 'exportPage/updateError', - }); - }); - - it('should dispatch updateIsErrorModalOpen with true if export error', async () => { - mockGetExportStatus.mockResolvedValue({ - exportStatus, - exportOutput, - exportError, - }); - - await fetchExportStatus(courseId)(dispatch, getState); - - expect(dispatch).toHaveBeenCalledWith({ - payload: true, - type: 'exportPage/updateIsErrorModalOpen', - }); - }); - - it('should not dispatch updateIsErrorModalOpen if no export error', async () => { - mockGetExportStatus.mockResolvedValue({ - exportStatus, - exportOutput, - exportError: null, - }); - - await fetchExportStatus(courseId)(dispatch, getState); - - expect(dispatch).not.toHaveBeenCalledWith({ - payload: false, - type: 'exportPage/updateIsErrorModalOpen', - }); - }); - - it("should dispatch updateDownloadPath if there's export output", async () => { - mockGetExportStatus.mockResolvedValue({ - exportStatus, - exportOutput, - exportError, - }); - - await fetchExportStatus(courseId)(dispatch, getState); - - expect(dispatch).toHaveBeenCalledWith({ - payload: exportOutput, - type: 'exportPage/updateDownloadPath', - }); - }); - - it('should dispatch updateSuccessDate with current date if export status is success', async () => { - mockGetExportStatus.mockResolvedValue({ - exportStatus: - EXPORT_STAGES.SUCCESS, - exportOutput, - exportError, - }); - - await fetchExportStatus(courseId)(dispatch, getState); - - expect(dispatch).toHaveBeenCalledWith({ - payload: expect.any(Number), - type: 'exportPage/updateSuccessDate', - }); - }); - - it('should not dispatch updateSuccessDate with current date if last-export cookie is already set', async () => { - mockGetExportStatus.mockResolvedValue({ - exportStatus: - EXPORT_STAGES.SUCCESS, - exportOutput, - exportError, - }); - - Cookies.mockImplementation(() => ({ - get: jest.fn().mockReturnValueOnce({ completed: true }), - })); - - await fetchExportStatus(courseId)(dispatch, getState); - - expect(dispatch).not.toHaveBeenCalledWith({ - payload: expect.any, - type: 'exportPage/updateSuccessDate', - }); - }); -}); diff --git a/src/export-page/data/thunks.ts b/src/export-page/data/thunks.ts deleted file mode 100644 index 66be266a8..000000000 --- a/src/export-page/data/thunks.ts +++ /dev/null @@ -1,100 +0,0 @@ -import Cookies from 'universal-cookie'; -import moment from 'moment'; -import { getConfig } from '@edx/frontend-platform'; - -import { RequestStatus } from '../../data/constants'; -import { setExportCookie } from '../utils'; -import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './constants'; - -import { - startCourseExporting, - getExportStatus, -} from './api'; -import { - updateExportTriggered, - updateCurrentStage, - updateDownloadPath, - updateSuccessDate, - updateError, - updateIsErrorModalOpen, - reset, - updateLoadingStatus, - updateSavingStatus, -} from './slice'; - -function setExportDate({ - date, exportStatus, exportOutput, dispatch, -}) { - // If there is no cookie for the last export date, set it now. - const cookies = new Cookies(); - const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); - if (!cookieData?.completed) { - setExportCookie(date, exportStatus === EXPORT_STAGES.SUCCESS); - } - // If we don't have export date set yet via cookie, set success date to current date. - if (exportOutput && !cookieData?.completed) { - dispatch(updateSuccessDate(date)); - } -} - -export function startExportingCourse(courseId) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - try { - dispatch(reset()); - dispatch(updateExportTriggered(true)); - const exportData = await startCourseExporting(courseId); - dispatch(updateCurrentStage(exportData.exportStatus)); - setExportCookie(moment().valueOf(), exportData.exportStatus === EXPORT_STAGES.SUCCESS); - - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - return true; - } catch { - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - return false; - } - }; -} - -export function fetchExportStatus(courseId) { - return async (dispatch) => { - dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); - try { - const { - exportStatus, exportOutput, exportError, - } = await getExportStatus(courseId); - dispatch(updateCurrentStage(Math.abs(exportStatus))); - - const date = moment().valueOf(); - - setExportDate({ - date, exportStatus, exportOutput, dispatch, - }); - - if (exportError) { - const errorMessage = exportError.rawErrorMsg || exportError; - const errorUnitUrl = exportError.editUnitUrl || null; - dispatch(updateError({ msg: errorMessage, unitUrl: errorUnitUrl })); - dispatch(updateIsErrorModalOpen(true)); - } - - if (exportOutput) { - if (exportOutput.startsWith('/')) { - dispatch(updateDownloadPath(`${getConfig().STUDIO_BASE_URL}${exportOutput}`)); - } else { - dispatch(updateDownloadPath(exportOutput)); - } - } - - dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); - return true; - } catch (error: any) { - if (error.response && error.response.status === 403) { - dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); - } else { - dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); - } - return false; - } - }; -} diff --git a/src/export-page/export-footer/ExportFooter.jsx b/src/export-page/export-footer/ExportFooter.tsx similarity index 98% rename from src/export-page/export-footer/ExportFooter.jsx rename to src/export-page/export-footer/ExportFooter.tsx index 4edf5b83e..93d7670c0 100644 --- a/src/export-page/export-footer/ExportFooter.jsx +++ b/src/export-page/export-footer/ExportFooter.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Layout } from '@openedx/paragon'; diff --git a/src/export-page/export-modal-error/ExportModalError.jsx b/src/export-page/export-modal-error/ExportModalError.jsx deleted file mode 100644 index 006262ccd..000000000 --- a/src/export-page/export-modal-error/ExportModalError.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { useDispatch, useSelector } from 'react-redux'; -import { getConfig } from '@edx/frontend-platform'; -import PropTypes from 'prop-types'; -import { Error as ErrorIcon } from '@openedx/paragon/icons'; - -import ModalNotification from '../../generic/modal-notification'; -import { getError, getIsErrorModalOpen } from '../data/selectors'; -import { updateIsErrorModalOpen } from '../data/slice'; -import messages from './messages'; - -const ExportModalError = ({ - courseId, -}) => { - const intl = useIntl(); - const dispatch = useDispatch(); - const isErrorModalOpen = useSelector(getIsErrorModalOpen); - const { msg: errorMessage, unitUrl: unitErrorUrl } = useSelector(getError); - - const handleUnitRedirect = () => { window.location.assign(unitErrorUrl); }; - const handleRedirectCourseHome = () => { window.location.assign(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`); }; - return ( - dispatch(updateIsErrorModalOpen(false))} - handleAction={unitErrorUrl ? handleUnitRedirect : handleRedirectCourseHome} - variant="danger" - icon={ErrorIcon} - /> - ); -}; - -ExportModalError.propTypes = { - courseId: PropTypes.string.isRequired, -}; - -ExportModalError.defaultProps = {}; - -export default ExportModalError; diff --git a/src/export-page/export-modal-error/ExportModalError.tsx b/src/export-page/export-modal-error/ExportModalError.tsx new file mode 100644 index 000000000..c63e4fa61 --- /dev/null +++ b/src/export-page/export-modal-error/ExportModalError.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { Error as ErrorIcon } from '@openedx/paragon/icons'; + +import ModalNotification from '@src/generic/modal-notification'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import messages from './messages'; +import { useCourseExportContext } from '../CourseExportContext'; + +const ExportModalError = () => { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const { + fetchExportErrorMessage, + errorUnitUrl, + } = useCourseExportContext(); + + const [isErrorModalOpen, setIsErrorModalOpen] = useState(false); + + useEffect(() => { + if (fetchExportErrorMessage) { + setIsErrorModalOpen(true); + } + }, [fetchExportErrorMessage]); + + const handleUnitRedirect = () => { window.location.assign(errorUnitUrl ?? ''); }; + const handleRedirectCourseHome = () => { window.location.assign(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`); }; + return ( + setIsErrorModalOpen(false)} + handleAction={errorUnitUrl ? handleUnitRedirect : handleRedirectCourseHome} + variant="danger" + icon={ErrorIcon} + /> + ); +}; + +export default ExportModalError; diff --git a/src/export-page/export-sidebar/ExportSidebar.test.jsx b/src/export-page/export-sidebar/ExportSidebar.test.jsx deleted file mode 100644 index 7fe777425..000000000 --- a/src/export-page/export-sidebar/ExportSidebar.test.jsx +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-check -import { initializeMocks, render } from '../../testUtils'; -import messages from './messages'; -import ExportSidebar from './ExportSidebar'; - -const courseId = 'course-123'; - -describe('', () => { - beforeEach(() => { - initializeMocks(); - }); - it('render sidebar correctly', () => { - const { getByText } = render(); - expect(getByText(messages.title1.defaultMessage)).toBeInTheDocument(); - expect(getByText(messages.exportedContentHeading.defaultMessage)).toBeInTheDocument(); - }); -}); diff --git a/src/export-page/export-sidebar/ExportSidebar.test.tsx b/src/export-page/export-sidebar/ExportSidebar.test.tsx new file mode 100644 index 000000000..5783f2fc3 --- /dev/null +++ b/src/export-page/export-sidebar/ExportSidebar.test.tsx @@ -0,0 +1,27 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; + +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import messages from './messages'; +import ExportSidebar from './ExportSidebar'; +import { CourseExportProvider } from '../CourseExportContext'; + +const courseId = 'course-123'; + +const renderComponent = () => render( + + + + + , +); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + it('render sidebar correctly', () => { + renderComponent(); + expect(screen.getByText(messages.title1.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.exportedContentHeading.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/export-page/export-sidebar/ExportSidebar.jsx b/src/export-page/export-sidebar/ExportSidebar.tsx similarity index 82% rename from src/export-page/export-sidebar/ExportSidebar.jsx rename to src/export-page/export-sidebar/ExportSidebar.tsx index 05deae297..7434399ae 100644 --- a/src/export-page/export-sidebar/ExportSidebar.jsx +++ b/src/export-page/export-sidebar/ExportSidebar.tsx @@ -1,14 +1,16 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import PropTypes from 'prop-types'; import { Hyperlink } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; -import { HelpSidebar } from '../../generic/help-sidebar'; -import { useHelpUrls } from '../../help-urls/hooks'; +import { HelpSidebar } from '@src/generic/help-sidebar'; +import { useHelpUrls } from '@src/help-urls/hooks'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; + import messages from './messages'; -const ExportSidebar = ({ courseId }) => { +const ExportSidebar = () => { const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); const { exportCourse: exportLearnMoreUrl } = useHelpUrls(['exportCourse']); return ( @@ -33,13 +35,11 @@ const ExportSidebar = ({ courseId }) => {

{intl.formatMessage(messages.openDownloadFile)}

{intl.formatMessage(messages.openDownloadFileDescription)}


- {intl.formatMessage(messages.learnMoreButtonTitle)} + + {intl.formatMessage(messages.learnMoreButtonTitle)} +
); }; -ExportSidebar.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default ExportSidebar; diff --git a/src/export-page/export-stepper/ExportStepper.test.jsx b/src/export-page/export-stepper/ExportStepper.test.jsx deleted file mode 100644 index 081cd3f1e..000000000 --- a/src/export-page/export-stepper/ExportStepper.test.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { AppProvider } from '@edx/frontend-platform/react'; - -import initializeStore from '../../store'; -import messages from './messages'; -import ExportStepper from './ExportStepper'; - -const courseId = 'course-123'; -let store; - -const RootWrapper = () => ( - - - - - -); - -describe('', () => { - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - }); - it('render stepper correctly', () => { - const { getByText } = render(); - expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument(); - }); -}); diff --git a/src/export-page/export-stepper/ExportStepper.test.tsx b/src/export-page/export-stepper/ExportStepper.test.tsx new file mode 100644 index 000000000..e23091b96 --- /dev/null +++ b/src/export-page/export-stepper/ExportStepper.test.tsx @@ -0,0 +1,27 @@ +import { render, initializeMocks, screen } from '@src/testUtils'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; + +import messages from './messages'; +import ExportStepper from './ExportStepper'; +import { CourseExportProvider } from '../CourseExportContext'; + +const courseId = 'course-123'; + +const renderComponent = () => render( + + + + + , +); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('render stepper correctly', () => { + renderComponent(); + expect(screen.getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/export-page/export-stepper/ExportStepper.jsx b/src/export-page/export-stepper/ExportStepper.tsx similarity index 51% rename from src/export-page/export-stepper/ExportStepper.jsx rename to src/export-page/export-stepper/ExportStepper.tsx index 944a4da28..2ed56ebad 100644 --- a/src/export-page/export-stepper/ExportStepper.jsx +++ b/src/export-page/export-stepper/ExportStepper.tsx @@ -1,42 +1,23 @@ -import React, { useEffect } from 'react'; import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; -import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; import { Button } from '@openedx/paragon'; -import CourseStepper from '../../generic/course-stepper'; -import { - getCurrentStage, getDownloadPath, getError, getLoadingStatus, getSuccessDate, -} from '../data/selectors'; -import { fetchExportStatus } from '../data/thunks'; +import CourseStepper from '@src/generic/course-stepper'; + import { EXPORT_STAGES } from '../data/constants'; -import { RequestStatus } from '../../data/constants'; import messages from './messages'; +import { useCourseExportContext } from '../CourseExportContext'; -const ExportStepper = ({ courseId }) => { +const ExportStepper = () => { const intl = useIntl(); - const currentStage = useSelector(getCurrentStage); - const downloadPath = useSelector(getDownloadPath); - const successDate = useSelector(getSuccessDate); - const loadingStatus = useSelector(getLoadingStatus); - const { msg: errorMessage } = useSelector(getError); - const dispatch = useDispatch(); - const isStopFetching = currentStage === EXPORT_STAGES.SUCCESS - || loadingStatus === RequestStatus.FAILED - || errorMessage; + const { + currentStage, + successDate, + fetchExportErrorMessage, + downloadPath, + } = useCourseExportContext(); - useEffect(() => { - const id = setInterval(() => { - if (isStopFetching) { - clearInterval(id); - } else { - dispatch(fetchExportStatus(courseId)); - } - }, 3000); - return () => clearInterval(id); - }); - - let successTitle = intl.formatMessage(messages.stepperSuccessTitle); + const successTitle = intl.formatMessage(messages.stepperSuccessTitle); + let successTitleComponent; const localizedSuccessDate = successDate ? ( { ) : null; if (localizedSuccessDate && currentStage === EXPORT_STAGES.SUCCESS) { - const successWithDate = ( + successTitleComponent = ( <> {successTitle} ({localizedSuccessDate}) ); - successTitle = successWithDate; } const steps = [ @@ -74,6 +54,7 @@ const ExportStepper = ({ courseId }) => { title: successTitle, description: intl.formatMessage(messages.stepperSuccessDescription), key: EXPORT_STAGES.SUCCESS, + titleComponent: successTitleComponent, }, ]; @@ -81,19 +62,18 @@ const ExportStepper = ({ courseId }) => {

{intl.formatMessage(messages.stepperHeaderTitle)}

- {downloadPath && currentStage === EXPORT_STAGES.SUCCESS && } + {downloadPath && currentStage === EXPORT_STAGES.SUCCESS && ( + + )}
); }; -ExportStepper.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default ExportStepper; diff --git a/src/export-page/messages.ts b/src/export-page/messages.ts index 2c8a44a8b..7182f9c91 100644 --- a/src/export-page/messages.ts +++ b/src/export-page/messages.ts @@ -29,6 +29,11 @@ const messages = defineMessages({ id: 'course-authoring.export.button.title', defaultMessage: 'Export course content', }, + unknownError: { + id: 'course-authoring.export.error.unknown', + defaultMessage: 'An unexpected error occurred. Please try again.', + description: 'Fallback error message shown when the API returns an error in an unexpected format.', + }, }); export default messages; diff --git a/src/export-page/utils.test.ts b/src/export-page/utils.test.ts index ba7fa1ce9..eac8b6504 100644 --- a/src/export-page/utils.test.ts +++ b/src/export-page/utils.test.ts @@ -15,12 +15,11 @@ describe('setExportCookie', () => { it('should set the export cookie with the provided date and completed status', () => { const cookiesSetMock = jest.spyOn(Cookies.prototype, 'set'); const date = moment('2023-07-24').valueOf(); - const completed = true; - setExportCookie(date, completed); + setExportCookie(date); expect(cookiesSetMock).toHaveBeenCalledWith( LAST_EXPORT_COOKIE_NAME, - { date, completed }, + { date }, { path: '/some-path' }, ); diff --git a/src/export-page/utils.ts b/src/export-page/utils.ts index b85a8ffd9..f1a3cbdc8 100644 --- a/src/export-page/utils.ts +++ b/src/export-page/utils.ts @@ -8,12 +8,11 @@ import { LAST_EXPORT_COOKIE_NAME, SUCCESS_DATE_FORMAT } from './data/constants'; * Sets an export-related cookie with the provided information. * * @param date - Date of export (unix timestamp). - * @param {boolean} completed - Indicates if export was completed successfully. * @returns {void} */ -export const setExportCookie = (date: number, completed: boolean): void => { +export const setExportCookie = (date: number): void => { const cookies = new Cookies(); - cookies.set(LAST_EXPORT_COOKIE_NAME, { date, completed }, { path: window.location.pathname }); + cookies.set(LAST_EXPORT_COOKIE_NAME, { date }, { path: window.location.pathname }); }; /** diff --git a/src/store.ts b/src/store.ts index d0f87ba8a..c94e94795 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,7 +16,6 @@ import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/dat import { reducer as filesReducer } from './files-and-videos/files-page/data/slice'; import { reducer as CourseUpdatesReducer } from './course-updates/data/slice'; import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice'; -import { reducer as courseExportReducer } from './export-page/data/slice'; import { reducer as courseOptimizerReducer } from './optimizer-page/data/slice'; import { reducer as genericReducer } from './generic/data/slice'; import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice'; @@ -43,7 +42,6 @@ export interface DeprecatedReduxState { live: Record; courseUpdates: Record; processingNotification: Record; - courseExport: Record; courseOptimizer: Record; generic: Record; videos: Record; @@ -74,7 +72,6 @@ export default function initializeStore(preloadedState: Partial