refactor: Migrate courseImport from redux store to React query (#2902)

This commit is contained in:
Chris Chávez
2026-02-26 14:34:13 -05:00
committed by GitHub
parent 3599630cd7
commit c4a09a2b43
27 changed files with 536 additions and 540 deletions

View File

@@ -31,6 +31,7 @@ import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
import { CourseImportProvider } from './import-page/CourseImportContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -141,7 +142,13 @@ const CourseAuthoringRoutes = () => {
/>
<Route
path="import"
element={<PageWrap><CourseImportPage /></PageWrap>}
element={(
<PageWrap>
<CourseImportProvider>
<CourseImportPage />
</CourseImportProvider>
</PageWrap>
)}
/>
<Route
path="export"

View File

@@ -1,6 +1,4 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen, initializeMocks } from '@src/testUtils';
import CourseStepper from '.';
@@ -24,35 +22,34 @@ const stepsMock = [
];
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CourseStepper steps={stepsMock} {...props} />
</IntlProvider>,
<CourseStepper steps={stepsMock} {...props} />,
);
describe('<CourseStepper />', () => {
it('renders CourseStepper correctly', () => {
const {
getByText, getByTestId, getAllByTestId, queryByTestId,
} = renderComponent({ activeKey: 0 });
beforeEach(() => {
initializeMocks();
});
const steps = getAllByTestId('course-stepper__step');
it('renders CourseStepper correctly', () => {
renderComponent({ activeKey: 0 });
const steps = screen.getAllByTestId('course-stepper__step');
expect(steps.length).toBe(stepsMock.length);
stepsMock.forEach((step) => {
expect(getByText(step.title)).toBeInTheDocument();
expect(getByText(step.description)).toBeInTheDocument();
expect(getByTestId(`${step.title}-icon`)).toBeInTheDocument();
expect(screen.getByText(step.title)).toBeInTheDocument();
expect(screen.getByText(step.description)).toBeInTheDocument();
expect(screen.getByTestId(`${step.title}-icon`)).toBeInTheDocument();
});
const percentElement = queryByTestId('course-stepper__step-percent');
expect(percentElement).toBeNull();
expect(screen.queryByTestId('course-stepper__step-percent')).toBeNull();
});
it('marks the active and done steps correctly', () => {
const activeKey = 1;
const { getAllByTestId } = renderComponent({ activeKey });
renderComponent({ activeKey });
const steps = getAllByTestId('course-stepper__step');
const steps = screen.getAllByTestId('course-stepper__step');
stepsMock.forEach((_, index) => {
const stepElement = steps[index];
if (index === activeKey) {
@@ -71,37 +68,46 @@ describe('<CourseStepper />', () => {
});
it('mark the error step correctly', () => {
const { getAllByTestId } = renderComponent({ activeKey: 1, hasError: true });
renderComponent({ activeKey: 1, hasError: true });
const errorStep = getAllByTestId('course-stepper__step')[1];
const errorStep = screen.getAllByTestId('course-stepper__step')[1];
expect(errorStep).toHaveClass('error');
});
it('shows error message for error step', () => {
const errorMessage = 'Some error text';
const { getAllByTestId } = renderComponent({ activeKey: 1, hasError: true, errorMessage });
renderComponent({ activeKey: 1, hasError: true, errorMessage });
const errorStep = getAllByTestId('course-stepper__step')[1];
const errorStep = screen.getAllByTestId('course-stepper__step')[1];
expect(errorStep).toHaveClass('error');
});
it('shows percentage for active step', () => {
const percent = 50;
const { getByTestId } = renderComponent({ activeKey: 1, percent });
renderComponent({ activeKey: 1, percent });
const percentElement = getByTestId('course-stepper__step-percent');
const percentElement = screen.getByTestId('course-stepper__step-percent');
expect(percentElement).toBeInTheDocument();
expect(percentElement).toHaveTextContent(`${percent}%`);
});
it('shows null when steps length equal to zero', () => {
const { queryByTestId } = render(
<IntlProvider locale="en">
<CourseStepper steps={[]} activeKey={0} />
</IntlProvider>,
);
it('renders titleComponent instead of title when provided', () => {
const customTitle = <span data-testid="custom-title">Custom Title Component</span>;
const stepsWithTitleComponent = [
{ ...stepsMock[0], titleComponent: customTitle },
...stepsMock.slice(1),
];
const steps = queryByTestId('[data-testid="course-stepper__step"]');
renderComponent({ steps: stepsWithTitleComponent, activeKey: 0 });
expect(screen.getByTestId('custom-title')).toBeInTheDocument();
expect(screen.queryByText(stepsMock[0].title)).not.toBeInTheDocument();
});
it('shows null when steps length equal to zero', () => {
renderComponent({ steps: [], activeKey: 0 });
const steps = screen.queryByTestId('[data-testid="course-stepper__step"]');
expect(steps).toBe(null);
});
});

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { ReactElement } from 'react';
import classNames from 'classnames';
import {
Settings as SettingsIcon,
ManageHistory as SuccessIcon,
@@ -6,16 +7,26 @@ import {
CheckCircle,
} from '@openedx/paragon/icons';
import { Icon } from '@openedx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export interface CourseStepperProps {
steps: {
title: string;
description: string;
titleComponent?: ReactElement;
}[];
activeKey: number;
percent?: number | boolean;
errorMessage?: string | null;
hasError?: boolean;
}
const CourseStepper = ({
steps,
activeKey,
percent,
hasError,
errorMessage,
}) => {
percent = false,
hasError = false,
errorMessage = '',
}: CourseStepperProps) => {
const getStepperSettings = (index) => {
const lastStepIndex = steps.length - 1;
const isActiveStep = index === activeKey;
@@ -42,7 +53,7 @@ const CourseStepper = ({
};
return {
stepIcon: getStepIcon(index),
stepIcon: getStepIcon(),
isPercentShow: Boolean(percent) && percent !== 100 && isActiveStep && !hasError,
isErrorMessageShow: isErrorStep && errorMessage,
isActiveClass: isActiveStep && !isLastStep && !hasError,
@@ -53,7 +64,7 @@ const CourseStepper = ({
return (
<div className="course-stepper">
{steps.length ? steps.map(({ title, description }, index) => {
{steps.length ? steps.map(({ title, description, titleComponent }, index) => {
const {
stepIcon,
isPercentShow,
@@ -74,10 +85,10 @@ const CourseStepper = ({
data-testid="course-stepper__step"
>
<div className="course-stepper__step-icon">
<Icon src={stepIcon} alt={title} data-testid={`${title}-icon`} />
<Icon src={stepIcon} data-testid={`${title}-icon`} />
</div>
<div className="course-stepper__step-info">
<h3 className="h4 title course-stepper__step-title font-weight-600">{title}</h3>
<h3 className="h4 title course-stepper__step-title font-weight-600">{titleComponent ?? title}</h3>
{isPercentShow && (
<p
className="course-stepper__step-percent font-weight-400"
@@ -97,21 +108,4 @@ const CourseStepper = ({
);
};
CourseStepper.defaultProps = {
percent: false,
hasError: false,
errorMessage: '',
};
CourseStepper.propTypes = {
steps: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
})).isRequired,
activeKey: PropTypes.number.isRequired,
percent: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
errorMessage: PropTypes.string,
hasError: PropTypes.bool,
};
export default CourseStepper;

View File

@@ -0,0 +1,167 @@
import {
createContext, useContext, useEffect, useMemo, useState,
} from 'react';
import moment from 'moment';
import Cookies from 'universal-cookie';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useImportStatus, useStartCourseImporting } from './data/apiHooks';
import { setImportCookie } from './utils';
import { IMPORT_STAGES, LAST_IMPORT_COOKIE_NAME } from './data/constants';
import messages from './messages';
export type CourseImportContextData = {
importTriggered: boolean;
progress?: number;
fileName?: string;
currentStage: number;
anyRequestFailed: boolean;
anyRequestInProgress: boolean;
isLoadingDenied: boolean;
handleOnProcessUpload: (props: OnProcessUploadProps) => Promise<void>;
formattedErrorMessage: string;
successDate?: number;
};
/**
* Course Import Context.
* Always available when we're in the context of the Course Import Page.
*
* Get this using `useCourseImportContext()`
*/
const CourseImportContext = createContext<CourseImportContextData | undefined>(undefined);
type CourseImportProviderProps = {
children?: React.ReactNode;
};
type OnProcessUploadProps = {
fileData: any;
requestConfig: Record<string, any>;
handleError: (error: any) => void;
};
export const CourseImportProvider = ({ children }: CourseImportProviderProps) => {
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const [isStopFetching, setStopFetching] = useState(false);
const [importTriggered, setImportTriggered] = useState(false);
const [currentStage, setCurrentStage] = useState(0);
const [fileName, setFileName] = useState<string>();
const importMutation = useStartCourseImporting(courseId);
const [progress, updateProgress] = useState<number>(0);
const [successDate, setSuccessDate] = useState<number>();
const cookies = new Cookies();
useEffect(() => {
const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME);
if (cookieData) {
setImportTriggered(true);
setFileName(cookieData.fileName);
setSuccessDate(cookieData.date);
}
}, []);
const reset = () => {
setCurrentStage(0);
updateProgress(0);
setImportTriggered(false);
setStopFetching(false);
setFileName(undefined);
};
const handleOnProcessUpload = async ({
fileData,
requestConfig,
handleError,
}: OnProcessUploadProps) => {
reset();
const file = fileData.get('file');
setFileName(file.name);
setImportTriggered(true);
importMutation.mutateAsync({
fileData: file,
requestConfig,
handleError,
updateProgress,
}).then(() => {
const momentData = moment().valueOf();
setImportCookie(momentData, file.name);
setSuccessDate(momentData);
}).catch((error) => {
handleError(error);
});
};
const {
data: importStatusData,
isError: isErrorImportStatus,
isPending: isPendingImportStatus,
failureReason: importStatusError,
} = useImportStatus(courseId, isStopFetching, fileName);
const errorMessage = importStatusData?.message;
const anyRequestFailed = isErrorImportStatus || importMutation.isError || Boolean(errorMessage);
const anyRequestInProgress = isPendingImportStatus || importMutation.isPending;
const formattedErrorMessage = anyRequestFailed ? errorMessage || intl.formatMessage(messages.defaultErrorMessage) : '';
const isLoadingDenied = importStatusError?.response?.status === 403;
useEffect(() => {
const polledStage = importStatusData?.importStatus;
if (polledStage !== undefined && polledStage >= 0) {
setCurrentStage(polledStage);
}
}, [importStatusData?.importStatus]);
useEffect(() => {
if (currentStage === IMPORT_STAGES.SUCCESS || anyRequestFailed) {
setStopFetching(true);
}
}, [currentStage, anyRequestFailed]);
const context = useMemo<CourseImportContextData>(() => {
const contextValue = {
importTriggered,
progress,
fileName,
currentStage,
anyRequestFailed,
anyRequestInProgress,
isLoadingDenied,
handleOnProcessUpload,
formattedErrorMessage,
successDate,
};
return contextValue;
}, [
importTriggered,
progress,
fileName,
currentStage,
anyRequestFailed,
anyRequestInProgress,
isLoadingDenied,
handleOnProcessUpload,
formattedErrorMessage,
successDate,
]);
return (
<CourseImportContext.Provider value={context}>
{children}
</CourseImportContext.Provider>
);
};
export function useCourseImportContext(): CourseImportContextData {
const ctx = useContext(CourseImportContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useCourseImportContext() was used in a component without a <CourseImportProvider> ancestor.');
}
return ctx;
}

View File

@@ -1,17 +1,17 @@
import { screen } from '@testing-library/react';
import { Helmet } from 'react-helmet';
import Cookies from 'universal-cookie';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { getCourseDetailsUrl } from '@src/data/api';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import { initializeMocks, render, waitFor } from '@src/testUtils';
import messages from './messages';
import CourseImportPage from './CourseImportPage';
import { getImportStatusApiUrl } from './data/api';
import { IMPORT_STAGES } from './data/constants';
import stepperMessages from './import-stepper/messages';
import { CourseImportProvider } from './CourseImportContext';
let store;
let axiosMock;
let cookies;
const courseId = '123';
@@ -27,7 +27,9 @@ jest.mock('universal-cookie', () => {
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<CourseImportPage />
<CourseImportProvider>
<CourseImportPage />
</CourseImportProvider>
</CourseAuthoringProvider>,
);
@@ -38,7 +40,6 @@ describe('<CourseImportPage />', () => {
username: 'username',
};
const mocks = initializeMocks({ user });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.test'))
@@ -49,6 +50,7 @@ describe('<CourseImportPage />', () => {
cookies = new Cookies();
cookies.get.mockReturnValue(null);
});
it('should render page title correctly', async () => {
renderComponent();
await waitFor(() => {
@@ -58,67 +60,59 @@ describe('<CourseImportPage />', () => {
);
});
});
it('should render without errors', async () => {
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const importPageElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(importPageElement).toBeInTheDocument();
expect(getByText(messages.description1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description3.defaultMessage)).toBeInTheDocument();
renderComponent();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const importPageElement = screen.getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(importPageElement).toBeInTheDocument();
expect(screen.getByText(messages.description1.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.description2.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.description3.defaultMessage)).toBeInTheDocument();
});
it('should fetch status without clicking when cookies has', async () => {
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.test' });
const { getByText } = renderComponent();
expect(getByText(stepperMessages.stepperUnpackingDescription.defaultMessage)).toBeInTheDocument();
cookies.get.mockReturnValue({ date: 1679787000, fileName: 'testFileName.test' });
renderComponent();
expect(screen.getByText(stepperMessages.stepperUnpackingDescription.defaultMessage)).toBeInTheDocument();
});
it('should show error', async () => {
const errorMessage = 'This is a test error message';
axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.tar.gz'))
.reply(200, { importStatus: -IMPORT_STAGES.UPDATING, message: '' });
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.tar.gz' });
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(stepperMessages.defaultErrorMessage.defaultMessage)).toBeInTheDocument();
}, { timeout: 4000 });
.reply(200, { importStatus: -IMPORT_STAGES.UPDATING, message: errorMessage });
cookies.get.mockReturnValue({ date: 1679787000, fileName: 'testFileName.tar.gz' });
renderComponent();
expect(await screen.findByText(errorMessage)).toBeInTheDocument();
});
it('should show success button', async () => {
axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.tar.gz'))
.reply(200, { importStatus: IMPORT_STAGES.SUCCESS, message: '' });
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.tar.gz' });
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(stepperMessages.viewOutlineButton.defaultMessage)).toBeInTheDocument();
}, { timeout: 4000 });
cookies.get.mockReturnValue({ date: 1679787000, fileName: 'testFileName.tar.gz' });
renderComponent();
expect(await screen.findByText(stepperMessages.viewOutlineButton.defaultMessage)).toBeInTheDocument();
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.tar.gz'))
.reply(403);
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.tar.gz' });
const { getByRole } = renderComponent();
await waitFor(() => {
expect(getByRole('alert')).toBeInTheDocument();
}, { timeout: 4000 });
const { loadingStatus } = store.getState().courseImport;
expect(loadingStatus).toEqual(RequestStatus.DENIED);
cookies.get.mockReturnValue({ date: 1679787000, fileName: 'testFileName.tar.gz' });
renderComponent();
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
it('sets loading status to FAILED upon receiving a 404 response from the API', async () => {
it('shows an error message upon receiving a 404 response from the API', async () => {
axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.tar.gz'))
.reply(404);
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.tar.gz' });
cookies.get.mockReturnValue({ date: 1679787000, fileName: 'testFileName.tar.gz' });
renderComponent();
await waitFor(() => {
const { loadingStatus } = store.getState().courseImport;
expect(loadingStatus).toEqual(RequestStatus.FAILED);
}, { timeout: 4000 });
expect(await screen.findByText(messages.defaultErrorMessage.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -1,50 +1,30 @@
/* eslint-disable max-len */
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container, Layout,
} from '@openedx/paragon';
import Cookies from 'universal-cookie';
import { Helmet } from 'react-helmet';
import SubHeader from '@src/generic/sub-header/SubHeader';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import { RequestStatus } from '@src/data/constants';
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import {
updateFileName, updateImportTriggered, updateSavingStatus, updateSuccessDate,
} from './data/slice';
import ImportStepper from './import-stepper/ImportStepper';
import { getImportTriggered, getLoadingStatus, getSavingStatus } from './data/selectors';
import { LAST_IMPORT_COOKIE_NAME } from './data/constants';
import ImportSidebar from './import-sidebar/ImportSidebar';
import FileSection from './file-section/FileSection';
import messages from './messages';
import { useCourseImportContext } from './CourseImportContext';
const CourseImportPage = () => {
const intl = useIntl();
const dispatch = useDispatch();
const cookies = new Cookies();
const { courseId, courseDetails } = useCourseAuthoringContext();
const importTriggered = useSelector(getImportTriggered);
const savingStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
const isLoadingDenied = loadingStatus === RequestStatus.DENIED;
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;
useEffect(() => {
const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME);
if (cookieData) {
dispatch(updateSavingStatus(RequestStatus.SUCCESSFUL));
dispatch(updateImportTriggered(true));
dispatch(updateFileName(cookieData.fileName));
dispatch(updateSuccessDate(cookieData.date));
}
}, []);
const { courseDetails } = useCourseAuthoringContext();
const {
importTriggered,
anyRequestFailed,
anyRequestInProgress,
isLoadingDenied,
} = useCourseImportContext();
if (isLoadingDenied) {
return (
@@ -83,12 +63,12 @@ const CourseImportPage = () => {
<p className="small">{intl.formatMessage(messages.description1)}</p>
<p className="small">{intl.formatMessage(messages.description2)}</p>
<p className="small">{intl.formatMessage(messages.description3)}</p>
<FileSection courseId={courseId} />
{importTriggered && <ImportStepper courseId={courseId} />}
<FileSection />
{importTriggered && <ImportStepper />}
</article>
</Layout.Element>
<Layout.Element>
<ImportSidebar courseId={courseId} />
<ImportSidebar />
</Layout.Element>
</Layout>
</section>

View File

@@ -1,7 +1,6 @@
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 { getImportStatus, postImportCourseApiUrl, startCourseImporting } from './api';
let axiosMock;
@@ -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(() => {
@@ -25,6 +16,7 @@ describe('API Functions', () => {
});
it('should fetch status on start importing', async () => {
// @ts-ignore
const file = new File(['(⌐□_□)'], 'download.tar.gz', { size: 20 });
const data = { importStatus: 1 };
axiosMock.onPost(postImportCourseApiUrl(courseId)).reply(200, data);

View File

@@ -5,14 +5,20 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const postImportCourseApiUrl = (courseId) => `${getApiBaseUrl()}/import/${courseId}`;
export const getImportStatusApiUrl = (courseId, fileName) => `${getApiBaseUrl()}/import_status/${courseId}/${fileName}`;
export interface ImportStatusData {
importStatus: number,
message?: string,
}
/**
* Start import course.
* @param {string} courseId
* @param {File} fileData
* @param {Record<string, any>} requestConfig
* @returns {Promise<Record<string, any>>}
*/
export async function startCourseImporting(courseId, fileData, requestConfig, updateProgress) {
export async function startCourseImporting(
courseId: string,
fileData: File,
requestConfig: Record<string, any>,
updateProgress: (percent: number) => void,
): Promise<ImportStatusData> {
const chunkSize = 20 * 1000000; // 20 MB
const fileSize = fileData.size || 0;
const chunkLength = Math.ceil(fileSize / chunkSize);
@@ -54,12 +60,13 @@ export async function startCourseImporting(courseId, fileData, requestConfig, up
/**
* Get import status.
* @param {string} courseId
* @param {string} fileName
* @returns {Promise<Object>}
*/
export async function getImportStatus(courseId, fileName) {
export async function getImportStatus(
courseId: string,
fileName: string,
): Promise<ImportStatusData> {
const { data } = await getAuthenticatedHttpClient()
.get(getImportStatusApiUrl(courseId, fileName));
return camelCaseObject(data);
}

View File

@@ -0,0 +1,51 @@
/* eslint-disable import/no-extraneous-dependencies */
import { AxiosError } from 'axios';
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
import { getImportStatus, startCourseImporting, type ImportStatusData } from './api';
export const importQueryKeys = {
all: ['courseImport'],
/** Key for the import status of a specific file in a course */
importStatus: (courseId: string, fileName: string) => [
...importQueryKeys.all,
courseId,
fileName,
],
};
interface StartCourseImportingProps {
updateProgress: (percent: number) => void;
fileData: File;
requestConfig: Record<string, any>;
handleError: (error: any) => void;
}
/**
* Returns a mutation to start uploading and importing a course file.
* Handles chunked file uploads and reports upload progress via `updateProgress`.
*/
export const useStartCourseImporting = (courseId: string) => (
useMutation({
mutationFn: ({ fileData, requestConfig, updateProgress }: StartCourseImportingProps) => (
startCourseImporting(courseId, fileData, requestConfig, updateProgress)
),
onError: (error, { handleError }) => handleError(error),
})
);
/**
* Polls the import status for a given file being imported into a course.
* Only enabled when `fileName` is provided.
*/
export const useImportStatus = (
courseId: string,
stopRefetch: boolean,
fileName?: string,
) => (
useQuery<ImportStatusData, AxiosError>({
queryKey: importQueryKeys.importStatus(courseId, fileName ?? ''),
queryFn: fileName ? () => getImportStatus(courseId, fileName!) : skipToken,
refetchInterval: (fileName && !stopRefetch) ? 3000 : false,
})
);

View File

@@ -1,8 +0,0 @@
export const getProgress = (state) => state.courseImport.progress;
export const getCurrentStage = (state) => state.courseImport.currentStage;
export const getImportTriggered = (state) => state.courseImport.importTriggered;
export const getFileName = (state) => state.courseImport.fileName;
export const getError = (state) => state.courseImport.error;
export const getLoadingStatus = (state) => state.courseImport.loadingStatus;
export const getSavingStatus = (state) => state.courseImport.savingStatus;
export const getSuccessDate = (state) => state.courseImport.successDate;

View File

@@ -1,63 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
currentStage: 0,
error: { hasError: false, message: '' },
progress: 0,
importTriggered: false,
fileName: null,
loadingStatus: '',
savingStatus: '',
successDate: null,
};
const slice = createSlice({
name: 'importPage',
initialState,
reducers: {
updateCurrentStage: (state, { payload }) => {
if (payload >= state.currentStage) {
state.currentStage = payload;
}
},
updateError: (state, { payload }) => {
state.error = { ...state.error, ...payload };
},
updateProgress: (state, { payload }) => {
state.progress = payload;
},
updateImportTriggered: (state, { payload }) => {
state.importTriggered = payload;
},
updateFileName: (state, { payload }) => {
state.fileName = payload;
},
reset: () => initialState,
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload;
},
updateSuccessDate: (state, { payload }) => {
state.successDate = payload;
},
},
});
export const {
updateCurrentStage,
updateError,
updateProgress,
updateImportTriggered,
updateFileName,
reset,
updateLoadingStatus,
updateSavingStatus,
updateSuccessDate,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,68 +0,0 @@
import Cookies from 'universal-cookie';
import moment from 'moment';
import { RequestStatus } from '../../data/constants';
import { setImportCookie } from '../utils';
import { getImportStatus, startCourseImporting } from './api';
import {
reset, updateCurrentStage, updateError, updateFileName, updateProgress,
updateImportTriggered, updateLoadingStatus, updateSavingStatus, updateSuccessDate,
} from './slice';
import { IMPORT_STAGES, LAST_IMPORT_COOKIE_NAME } from './constants';
export function fetchImportStatus(courseId, fileName) {
return async (dispatch) => {
try {
dispatch(updateLoadingStatus(RequestStatus.IN_PROGRESS));
const { importStatus, message } = await getImportStatus(courseId, fileName);
dispatch(updateCurrentStage(Math.abs(importStatus)));
const cookies = new Cookies();
const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME);
if (importStatus < 0) {
dispatch(updateError({ hasError: true, message }));
} else if (importStatus === IMPORT_STAGES.SUCCESS && !cookieData?.completed) {
dispatch(updateSuccessDate(moment().valueOf()));
}
if (!cookieData?.completed) {
setImportCookie(moment().valueOf(), importStatus === IMPORT_STAGES.SUCCESS, fileName);
}
dispatch(updateLoadingStatus(RequestStatus.SUCCESSFUL));
return true;
} catch (error: any) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus(RequestStatus.DENIED));
} else {
dispatch(updateLoadingStatus(RequestStatus.FAILED));
}
return false;
}
};
}
export function handleProcessUpload(courseId, fileData, requestConfig, handleError) {
return async (dispatch) => {
try {
const file = fileData.get('file');
dispatch(reset());
dispatch(updateSavingStatus(RequestStatus.PENDING));
dispatch(updateFileName(file.name));
dispatch(updateImportTriggered(true));
const { importStatus } = await startCourseImporting(
courseId,
file,
requestConfig,
(percent) => dispatch(updateProgress(percent)),
);
dispatch(updateCurrentStage(importStatus));
setImportCookie(moment().valueOf(), importStatus === IMPORT_STAGES.SUCCESS, file.name);
dispatch(updateSavingStatus(RequestStatus.SUCCESSFUL));
return true;
} catch (error) {
handleError(error);
dispatch(updateSavingStatus(RequestStatus.FAILED));
return false;
}
};
}

View File

@@ -1,56 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Card, Dropzone } from '@openedx/paragon';
import { IMPORT_STAGES } from '../data/constants';
import {
getCurrentStage, getError, getFileName, getImportTriggered,
} from '../data/selectors';
import messages from './messages';
import { handleProcessUpload } from '../data/thunks';
const FileSection = ({ courseId }) => {
const intl = useIntl();
const dispatch = useDispatch();
const importTriggered = useSelector(getImportTriggered);
const currentStage = useSelector(getCurrentStage);
const fileName = useSelector(getFileName);
const { hasError } = useSelector(getError);
const isShowedDropzone = !importTriggered || currentStage === IMPORT_STAGES.SUCCESS || hasError;
return (
<Card>
<Card.Header
className="h3 px-3 text-black"
title={intl.formatMessage(messages.headingTitle)}
subtitle={fileName && intl.formatMessage(messages.fileChosen, { fileName })}
/>
<Card.Section className="px-3 pt-2 pb-4">
{isShowedDropzone
&& (
<Dropzone
onProcessUpload={
({ fileData, requestConfig, handleError }) => dispatch(handleProcessUpload(
courseId,
fileData,
requestConfig,
handleError,
))
}
accept={{ 'application/x-tar.gz': ['.tar.gz'] }}
data-testid="dropzone"
style={{ height: '200px' }}
/>
)}
</Card.Section>
</Card>
);
};
FileSection.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default FileSection;

View File

@@ -1,61 +0,0 @@
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import initializeStore from '../../store';
import messages from './messages';
import FileSection from './FileSection';
let store;
const courseId = '123';
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<FileSection courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<FileSection />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
});
});
it('should displays Dropzone when import is not triggered or in success stage or has an error', async () => {
const { getByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(getByTestId('dropzone')).toBeInTheDocument();
});
});
it('should work Dropzone', async () => {
const {
getByText, getByTestId, queryByTestId, container,
} = render(<RootWrapper />);
const dropzoneElement = getByTestId('dropzone');
const file = new File(['file contents'], 'example.tar.gz', { type: 'application/gzip' });
fireEvent.drop(dropzoneElement, { dataTransfer: { files: [file], types: ['Files'] } });
await waitFor(() => {
expect(getByText('File chosen: example.tar.gz')).toBeInTheDocument();
expect(queryByTestId(container, 'dropzone')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,58 @@
import {
fireEvent,
render,
initializeMocks,
screen,
waitFor,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import messages from './messages';
import FileSection from './FileSection';
import { CourseImportProvider } from '../CourseImportContext';
import { getImportStatusApiUrl, postImportCourseApiUrl } from '../data/api';
const courseId = '123';
let axiosMock;
const renderComponent = () => render(
<CourseAuthoringProvider courseId={courseId}>
<CourseImportProvider>
<FileSection />
</CourseImportProvider>
</CourseAuthoringProvider>,
);
describe('<FileSection />', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
});
it('should render without errors', async () => {
renderComponent();
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
});
it('should displays Dropzone when import is not triggered or in success stage or has an error', async () => {
renderComponent();
expect(await screen.findByTestId('dropzone')).toBeInTheDocument();
});
it('should work Dropzone', async () => {
axiosMock.onPost(postImportCourseApiUrl(courseId)).reply(200, { importStatus: 1 });
axiosMock.onGet(getImportStatusApiUrl(courseId, 'example.tar.gz')).reply(200, { importStatus: 1, message: '' });
renderComponent();
const dropzoneElement = screen.getByTestId('dropzone');
const file = new File(['file contents'], 'example.tar.gz', { type: 'application/gzip' });
fireEvent.drop(dropzoneElement, { dataTransfer: { files: [file], types: ['Files'] } });
expect(await screen.findByText('File chosen: example.tar.gz')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByTestId('dropzone')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,43 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Card, Dropzone } from '@openedx/paragon';
import { IMPORT_STAGES } from '../data/constants';
import messages from './messages';
import { useCourseImportContext } from '../CourseImportContext';
const FileSection = () => {
const intl = useIntl();
const {
importTriggered,
currentStage,
fileName,
anyRequestFailed,
handleOnProcessUpload,
} = useCourseImportContext();
const isShowedDropzone = !importTriggered || currentStage === IMPORT_STAGES.SUCCESS || anyRequestFailed;
return (
<Card>
<Card.Header
className="h3 px-3 text-black"
title={intl.formatMessage(messages.headingTitle)}
subtitle={fileName && intl.formatMessage(messages.fileChosen, { fileName })}
/>
<Card.Section className="px-3 pt-2 pb-4">
{isShowedDropzone
&& (
<Dropzone
onProcessUpload={handleOnProcessUpload}
accept={{ 'application/x-tar.gz': ['.tar.gz'] }}
data-testid="dropzone"
style={{ height: '200px' }}
/>
)}
</Card.Section>
</Card>
);
};
export default FileSection;

View File

@@ -1,17 +0,0 @@
// @ts-check
import { initializeMocks, render } from '../../testUtils';
import messages from './messages';
import ImportSidebar from './ImportSidebar';
const courseId = 'course-123';
describe('<ImportSidebar />', () => {
beforeEach(() => {
initializeMocks();
});
it('render sidebar correctly', () => {
const { getByText } = render(<ImportSidebar courseId={courseId} />);
expect(getByText(messages.title1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.importedContentHeading.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,22 @@
import { initializeMocks, render, screen } from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import messages from './messages';
import ImportSidebar from './ImportSidebar';
const courseId = 'course-123';
describe('<ImportSidebar />', () => {
beforeEach(() => {
initializeMocks();
});
it('render sidebar correctly', () => {
render(
<CourseAuthoringProvider courseId={courseId}>
<ImportSidebar />
</CourseAuthoringProvider>,
);
expect(screen.getByText(messages.title1.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.importedContentHeading.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -1,17 +1,15 @@
import React from 'react';
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 '@src/generic/help-sidebar';
import { useHelpUrls } from '@src/help-urls/hooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { HelpSidebar } from '../../generic/help-sidebar';
import { useHelpUrls } from '../../help-urls/hooks';
import messages from './messages';
const ImportSidebar = ({
courseId,
}) => {
const ImportSidebar = () => {
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const { importCourse: importLearnMoreUrl } = useHelpUrls(['importCourse']);
return (
<HelpSidebar courseId={courseId}>
@@ -40,7 +38,7 @@ const ImportSidebar = ({
<hr />
<Hyperlink
className="small"
href={importLearnMoreUrl}
destination={importLearnMoreUrl}
target="_blank"
>
{intl.formatMessage(messages.learnMoreButtonTitle)}
@@ -49,8 +47,4 @@ const ImportSidebar = ({
);
};
ImportSidebar.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default ImportSidebar;

View File

@@ -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 ImportStepper from './ImportStepper';
const courseId = 'course-123';
let store;
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<ImportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<ImportStepper />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('render stepper correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
import { render, initializeMocks, screen } from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import messages from './messages';
import ImportStepper from './ImportStepper';
import { CourseImportProvider } from '../CourseImportContext';
const renderComponent = () => render(
<CourseAuthoringProvider courseId="123456">
<CourseImportProvider>
<ImportStepper />
</CourseImportProvider>
</CourseAuthoringProvider>,
);
describe('<ImportStepper />', () => {
beforeEach(() => {
initializeMocks();
});
it('render stepper correctly', () => {
renderComponent();
expect(screen.getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -1,50 +1,30 @@
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 { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { RequestStatus } from '../../data/constants';
import CourseStepper from '../../generic/course-stepper';
import CourseStepper from '@src/generic/course-stepper';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { IMPORT_STAGES } from '../data/constants';
import { fetchImportStatus } from '../data/thunks';
import {
getCurrentStage, getError, getFileName, getLoadingStatus, getProgress, getSavingStatus, getSuccessDate,
} from '../data/selectors';
import messages from './messages';
import { useCourseImportContext } from '../CourseImportContext';
const ImportStepper = ({ courseId }) => {
const ImportStepper = () => {
const intl = useIntl();
const currentStage = useSelector(getCurrentStage);
const fileName = useSelector(getFileName);
const { hasError, message: errorMessage } = useSelector(getError);
const progress = useSelector(getProgress);
const dispatch = useDispatch();
const loadingStatus = useSelector(getLoadingStatus);
const savingStatus = useSelector(getSavingStatus);
const successDate = useSelector(getSuccessDate);
const isStopFetching = currentStage === IMPORT_STAGES.SUCCESS
|| loadingStatus === RequestStatus.FAILED
|| savingStatus === RequestStatus.FAILED
|| hasError;
const formattedErrorMessage = hasError ? errorMessage || intl.formatMessage(messages.defaultErrorMessage) : '';
useEffect(() => {
const id = setInterval(() => {
if (isStopFetching) {
clearInterval(id);
} else if (fileName) {
dispatch(fetchImportStatus(courseId, fileName));
}
}, 3000);
return () => clearInterval(id);
});
const { courseId } = useCourseAuthoringContext();
const {
progress,
currentStage,
formattedErrorMessage,
anyRequestFailed,
successDate,
} = useCourseImportContext();
let successTitle = intl.formatMessage(messages.stepperSuccessTitle);
const handleRedirectCourseOutline = () => window.location.replace(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`);
const successTitle = intl.formatMessage(messages.stepperSuccessTitle);
let successTitleComponent;
const localizedSuccessDate = successDate ? (
<FormattedDate
value={successDate}
@@ -56,16 +36,13 @@ const ImportStepper = ({ courseId }) => {
/>
) : null;
if (localizedSuccessDate && currentStage === IMPORT_STAGES.SUCCESS) {
const successWithDate = (
successTitleComponent = (
<>
{successTitle} ({localizedSuccessDate})
</>
);
successTitle = successWithDate;
}
const handleRedirectCourseOutline = () => window.location.replace(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`);
const steps = [
{
title: intl.formatMessage(messages.stepperUploadingTitle),
@@ -87,6 +64,7 @@ const ImportStepper = ({ courseId }) => {
title: successTitle,
description: intl.formatMessage(messages.stepperSuccessDescription),
key: IMPORT_STAGES.SUCCESS,
titleComponent: successTitleComponent,
},
];
@@ -94,11 +72,10 @@ const ImportStepper = ({ courseId }) => {
<section>
<h3 className="mt-4">{intl.formatMessage(messages.stepperHeaderTitle)}</h3>
<CourseStepper
courseId={courseId}
percent={currentStage === IMPORT_STAGES.UPLOADING ? progress : null}
percent={currentStage === IMPORT_STAGES.UPLOADING ? progress : undefined}
steps={steps}
activeKey={currentStage}
hasError={hasError}
hasError={anyRequestFailed}
errorMessage={formattedErrorMessage}
/>
{currentStage === IMPORT_STAGES.SUCCESS && (
@@ -108,8 +85,4 @@ const ImportStepper = ({ courseId }) => {
);
};
ImportStepper.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default ImportStepper;

View File

@@ -45,10 +45,6 @@ const messages = defineMessages({
id: 'course-authoring.import.stepper.button.outline',
defaultMessage: 'View updated outline',
},
defaultErrorMessage: {
id: 'course-authoring.import.stepper.error.default',
defaultMessage: 'Error importing course',
},
stepperHeaderTitle: {
id: 'course-authoring.import.stepper.header.title',
defaultMessage: 'Course import status',

View File

@@ -25,6 +25,10 @@ const messages = defineMessages({
id: 'course-authoring.import.description3',
defaultMessage: 'The import process has five stages. During the first two stages, you must stay on this page. You can leave this page after the unpacking stage has completed. We recommend, however, that you don\'t make important changes to your course until the import operation has completed.',
},
defaultErrorMessage: {
id: 'course-authoring.import.stepper.error.default',
defaultMessage: 'Error importing course',
},
});
export default messages;

View File

@@ -15,13 +15,12 @@ describe('setImportCookie', () => {
it('should set the import cookie with the provided data', () => {
const cookiesSetMock = jest.spyOn(Cookies.prototype, 'set');
const date = moment('2023-07-24').valueOf();
const completed = true;
const fileName = 'testFileName.test';
setImportCookie(date, completed, fileName);
setImportCookie(date, fileName);
expect(cookiesSetMock).toHaveBeenCalledWith(
LAST_IMPORT_COOKIE_NAME,
{ date, completed, fileName },
{ date, fileName },
{ path: '/some-path' },
);

View File

@@ -6,10 +6,9 @@ import { LAST_IMPORT_COOKIE_NAME } from './data/constants';
* Sets an import-related cookie with the provided information.
*
* @param date - Date of import (unix timestamp).
* @param {boolean} completed - Indicates if import was completed successfully.
* @param {string} fileName - File name.
*/
export const setImportCookie = (date: number, completed: boolean, fileName: string): void => {
export const setImportCookie = (date: number, fileName: string): void => {
const cookies = new Cookies();
cookies.set(LAST_IMPORT_COOKIE_NAME, { date, completed, fileName }, { path: window.location.pathname });
cookies.set(LAST_IMPORT_COOKIE_NAME, { date, fileName }, { path: window.location.pathname });
};

View File

@@ -20,7 +20,6 @@ import { reducer as processingNotificationReducer } from './generic/processing-n
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 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';
@@ -49,7 +48,6 @@ export interface DeprecatedReduxState {
courseExport: Record<string, any>;
courseOptimizer: Record<string, any>;
generic: Record<string, any>;
courseImport: Record<string, any>;
videos: Record<string, any>;
courseOutline: Record<string, any>;
courseUnit: Record<string, any>;
@@ -82,7 +80,6 @@ export default function initializeStore(preloadedState: Partial<DeprecatedReduxS
courseExport: courseExportReducer,
courseOptimizer: courseOptimizerReducer,
generic: genericReducer,
courseImport: courseImportReducer,
videos: videosReducer,
courseOutline: courseOutlineReducer,
courseUnit: courseUnitReducer,