refactor: Migrate courseImport from redux store to React query (#2902)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
167
src/import-page/CourseImportContext.tsx
Normal file
167
src/import-page/CourseImportContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
51
src/import-page/data/apiHooks.ts
Normal file
51
src/import-page/data/apiHooks.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/import-page/file-section/FileSection.test.tsx
Normal file
58
src/import-page/file-section/FileSection.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/import-page/file-section/FileSection.tsx
Normal file
43
src/import-page/file-section/FileSection.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
22
src/import-page/import-sidebar/ImportSidebar.test.tsx
Normal file
22
src/import-page/import-sidebar/ImportSidebar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
24
src/import-page/import-stepper/ImportStepper.test.tsx
Normal file
24
src/import-page/import-stepper/ImportStepper.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user