Skip to content

Commit c4a09a2

Browse files
authored
refactor: Migrate courseImport from redux store to React query (#2902)
1 parent 3599630 commit c4a09a2

27 files changed

+534
-538
lines changed

src/CourseAuthoringRoutes.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import GroupConfigurations from './group-configurations';
3131
import { CourseLibraries } from './course-libraries';
3232
import { IframeProvider } from './generic/hooks/context/iFrameContext';
3333
import { CourseAuthoringProvider } from './CourseAuthoringContext';
34+
import { CourseImportProvider } from './import-page/CourseImportContext';
3435

3536
/**
3637
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -141,7 +142,13 @@ const CourseAuthoringRoutes = () => {
141142
/>
142143
<Route
143144
path="import"
144-
element={<PageWrap><CourseImportPage /></PageWrap>}
145+
element={(
146+
<PageWrap>
147+
<CourseImportProvider>
148+
<CourseImportPage />
149+
</CourseImportProvider>
150+
</PageWrap>
151+
)}
145152
/>
146153
<Route
147154
path="export"
Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import React from 'react';
2-
import { render } from '@testing-library/react';
3-
import { IntlProvider } from '@edx/frontend-platform/i18n';
1+
import { render, screen, initializeMocks } from '@src/testUtils';
42

53
import CourseStepper from '.';
64

@@ -24,35 +22,34 @@ const stepsMock = [
2422
];
2523

2624
const renderComponent = (props) => render(
27-
<IntlProvider locale="en">
28-
<CourseStepper steps={stepsMock} {...props} />
29-
</IntlProvider>,
25+
<CourseStepper steps={stepsMock} {...props} />,
3026
);
3127

3228
describe('<CourseStepper />', () => {
29+
beforeEach(() => {
30+
initializeMocks();
31+
});
32+
3333
it('renders CourseStepper correctly', () => {
34-
const {
35-
getByText, getByTestId, getAllByTestId, queryByTestId,
36-
} = renderComponent({ activeKey: 0 });
34+
renderComponent({ activeKey: 0 });
3735

38-
const steps = getAllByTestId('course-stepper__step');
36+
const steps = screen.getAllByTestId('course-stepper__step');
3937
expect(steps.length).toBe(stepsMock.length);
4038

4139
stepsMock.forEach((step) => {
42-
expect(getByText(step.title)).toBeInTheDocument();
43-
expect(getByText(step.description)).toBeInTheDocument();
44-
expect(getByTestId(`${step.title}-icon`)).toBeInTheDocument();
40+
expect(screen.getByText(step.title)).toBeInTheDocument();
41+
expect(screen.getByText(step.description)).toBeInTheDocument();
42+
expect(screen.getByTestId(`${step.title}-icon`)).toBeInTheDocument();
4543
});
4644

47-
const percentElement = queryByTestId('course-stepper__step-percent');
48-
expect(percentElement).toBeNull();
45+
expect(screen.queryByTestId('course-stepper__step-percent')).toBeNull();
4946
});
5047

5148
it('marks the active and done steps correctly', () => {
5249
const activeKey = 1;
53-
const { getAllByTestId } = renderComponent({ activeKey });
50+
renderComponent({ activeKey });
5451

55-
const steps = getAllByTestId('course-stepper__step');
52+
const steps = screen.getAllByTestId('course-stepper__step');
5653
stepsMock.forEach((_, index) => {
5754
const stepElement = steps[index];
5855
if (index === activeKey) {
@@ -71,37 +68,46 @@ describe('<CourseStepper />', () => {
7168
});
7269

7370
it('mark the error step correctly', () => {
74-
const { getAllByTestId } = renderComponent({ activeKey: 1, hasError: true });
71+
renderComponent({ activeKey: 1, hasError: true });
7572

76-
const errorStep = getAllByTestId('course-stepper__step')[1];
73+
const errorStep = screen.getAllByTestId('course-stepper__step')[1];
7774
expect(errorStep).toHaveClass('error');
7875
});
7976

8077
it('shows error message for error step', () => {
8178
const errorMessage = 'Some error text';
82-
const { getAllByTestId } = renderComponent({ activeKey: 1, hasError: true, errorMessage });
79+
renderComponent({ activeKey: 1, hasError: true, errorMessage });
8380

84-
const errorStep = getAllByTestId('course-stepper__step')[1];
81+
const errorStep = screen.getAllByTestId('course-stepper__step')[1];
8582
expect(errorStep).toHaveClass('error');
8683
});
8784

8885
it('shows percentage for active step', () => {
8986
const percent = 50;
90-
const { getByTestId } = renderComponent({ activeKey: 1, percent });
87+
renderComponent({ activeKey: 1, percent });
9188

92-
const percentElement = getByTestId('course-stepper__step-percent');
89+
const percentElement = screen.getByTestId('course-stepper__step-percent');
9390
expect(percentElement).toBeInTheDocument();
9491
expect(percentElement).toHaveTextContent(`${percent}%`);
9592
});
9693

94+
it('renders titleComponent instead of title when provided', () => {
95+
const customTitle = <span data-testid="custom-title">Custom Title Component</span>;
96+
const stepsWithTitleComponent = [
97+
{ ...stepsMock[0], titleComponent: customTitle },
98+
...stepsMock.slice(1),
99+
];
100+
101+
renderComponent({ steps: stepsWithTitleComponent, activeKey: 0 });
102+
103+
expect(screen.getByTestId('custom-title')).toBeInTheDocument();
104+
expect(screen.queryByText(stepsMock[0].title)).not.toBeInTheDocument();
105+
});
106+
97107
it('shows null when steps length equal to zero', () => {
98-
const { queryByTestId } = render(
99-
<IntlProvider locale="en">
100-
<CourseStepper steps={[]} activeKey={0} />
101-
</IntlProvider>,
102-
);
108+
renderComponent({ steps: [], activeKey: 0 });
103109

104-
const steps = queryByTestId('[data-testid="course-stepper__step"]');
110+
const steps = screen.queryByTestId('[data-testid="course-stepper__step"]');
105111
expect(steps).toBe(null);
106112
});
107113
});
Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
1-
import React from 'react';
1+
import { ReactElement } from 'react';
2+
import classNames from 'classnames';
23
import {
34
Settings as SettingsIcon,
45
ManageHistory as SuccessIcon,
56
Warning as ErrorIcon,
67
CheckCircle,
78
} from '@openedx/paragon/icons';
89
import { Icon } from '@openedx/paragon';
9-
import PropTypes from 'prop-types';
10-
import classNames from 'classnames';
10+
11+
export interface CourseStepperProps {
12+
steps: {
13+
title: string;
14+
description: string;
15+
titleComponent?: ReactElement;
16+
}[];
17+
activeKey: number;
18+
percent?: number | boolean;
19+
errorMessage?: string | null;
20+
hasError?: boolean;
21+
}
1122

1223
const CourseStepper = ({
1324
steps,
1425
activeKey,
15-
percent,
16-
hasError,
17-
errorMessage,
18-
}) => {
26+
percent = false,
27+
hasError = false,
28+
errorMessage = '',
29+
}: CourseStepperProps) => {
1930
const getStepperSettings = (index) => {
2031
const lastStepIndex = steps.length - 1;
2132
const isActiveStep = index === activeKey;
@@ -42,7 +53,7 @@ const CourseStepper = ({
4253
};
4354

4455
return {
45-
stepIcon: getStepIcon(index),
56+
stepIcon: getStepIcon(),
4657
isPercentShow: Boolean(percent) && percent !== 100 && isActiveStep && !hasError,
4758
isErrorMessageShow: isErrorStep && errorMessage,
4859
isActiveClass: isActiveStep && !isLastStep && !hasError,
@@ -53,7 +64,7 @@ const CourseStepper = ({
5364

5465
return (
5566
<div className="course-stepper">
56-
{steps.length ? steps.map(({ title, description }, index) => {
67+
{steps.length ? steps.map(({ title, description, titleComponent }, index) => {
5768
const {
5869
stepIcon,
5970
isPercentShow,
@@ -74,10 +85,10 @@ const CourseStepper = ({
7485
data-testid="course-stepper__step"
7586
>
7687
<div className="course-stepper__step-icon">
77-
<Icon src={stepIcon} alt={title} data-testid={`${title}-icon`} />
88+
<Icon src={stepIcon} data-testid={`${title}-icon`} />
7889
</div>
7990
<div className="course-stepper__step-info">
80-
<h3 className="h4 title course-stepper__step-title font-weight-600">{title}</h3>
91+
<h3 className="h4 title course-stepper__step-title font-weight-600">{titleComponent ?? title}</h3>
8192
{isPercentShow && (
8293
<p
8394
className="course-stepper__step-percent font-weight-400"
@@ -97,21 +108,4 @@ const CourseStepper = ({
97108
);
98109
};
99110

100-
CourseStepper.defaultProps = {
101-
percent: false,
102-
hasError: false,
103-
errorMessage: '',
104-
};
105-
106-
CourseStepper.propTypes = {
107-
steps: PropTypes.arrayOf(PropTypes.shape({
108-
title: PropTypes.string.isRequired,
109-
description: PropTypes.string.isRequired,
110-
})).isRequired,
111-
activeKey: PropTypes.number.isRequired,
112-
percent: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
113-
errorMessage: PropTypes.string,
114-
hasError: PropTypes.bool,
115-
};
116-
117111
export default CourseStepper;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
createContext, useContext, useEffect, useMemo, useState,
3+
} from 'react';
4+
import moment from 'moment';
5+
import Cookies from 'universal-cookie';
6+
import { useIntl } from '@edx/frontend-platform/i18n';
7+
8+
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
9+
10+
import { useImportStatus, useStartCourseImporting } from './data/apiHooks';
11+
import { setImportCookie } from './utils';
12+
import { IMPORT_STAGES, LAST_IMPORT_COOKIE_NAME } from './data/constants';
13+
import messages from './messages';
14+
15+
export type CourseImportContextData = {
16+
importTriggered: boolean;
17+
progress?: number;
18+
fileName?: string;
19+
currentStage: number;
20+
anyRequestFailed: boolean;
21+
anyRequestInProgress: boolean;
22+
isLoadingDenied: boolean;
23+
handleOnProcessUpload: (props: OnProcessUploadProps) => Promise<void>;
24+
formattedErrorMessage: string;
25+
successDate?: number;
26+
};
27+
28+
/**
29+
* Course Import Context.
30+
* Always available when we're in the context of the Course Import Page.
31+
*
32+
* Get this using `useCourseImportContext()`
33+
*/
34+
const CourseImportContext = createContext<CourseImportContextData | undefined>(undefined);
35+
36+
type CourseImportProviderProps = {
37+
children?: React.ReactNode;
38+
};
39+
40+
type OnProcessUploadProps = {
41+
fileData: any;
42+
requestConfig: Record<string, any>;
43+
handleError: (error: any) => void;
44+
};
45+
46+
export const CourseImportProvider = ({ children }: CourseImportProviderProps) => {
47+
const intl = useIntl();
48+
const { courseId } = useCourseAuthoringContext();
49+
const [isStopFetching, setStopFetching] = useState(false);
50+
const [importTriggered, setImportTriggered] = useState(false);
51+
const [currentStage, setCurrentStage] = useState(0);
52+
const [fileName, setFileName] = useState<string>();
53+
const importMutation = useStartCourseImporting(courseId);
54+
const [progress, updateProgress] = useState<number>(0);
55+
const [successDate, setSuccessDate] = useState<number>();
56+
57+
const cookies = new Cookies();
58+
59+
useEffect(() => {
60+
const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME);
61+
if (cookieData) {
62+
setImportTriggered(true);
63+
setFileName(cookieData.fileName);
64+
setSuccessDate(cookieData.date);
65+
}
66+
}, []);
67+
68+
const reset = () => {
69+
setCurrentStage(0);
70+
updateProgress(0);
71+
setImportTriggered(false);
72+
setStopFetching(false);
73+
setFileName(undefined);
74+
};
75+
76+
const handleOnProcessUpload = async ({
77+
fileData,
78+
requestConfig,
79+
handleError,
80+
}: OnProcessUploadProps) => {
81+
reset();
82+
const file = fileData.get('file');
83+
setFileName(file.name);
84+
setImportTriggered(true);
85+
importMutation.mutateAsync({
86+
fileData: file,
87+
requestConfig,
88+
handleError,
89+
updateProgress,
90+
}).then(() => {
91+
const momentData = moment().valueOf();
92+
setImportCookie(momentData, file.name);
93+
setSuccessDate(momentData);
94+
}).catch((error) => {
95+
handleError(error);
96+
});
97+
};
98+
99+
const {
100+
data: importStatusData,
101+
isError: isErrorImportStatus,
102+
isPending: isPendingImportStatus,
103+
failureReason: importStatusError,
104+
} = useImportStatus(courseId, isStopFetching, fileName);
105+
106+
const errorMessage = importStatusData?.message;
107+
const anyRequestFailed = isErrorImportStatus || importMutation.isError || Boolean(errorMessage);
108+
const anyRequestInProgress = isPendingImportStatus || importMutation.isPending;
109+
const formattedErrorMessage = anyRequestFailed ? errorMessage || intl.formatMessage(messages.defaultErrorMessage) : '';
110+
const isLoadingDenied = importStatusError?.response?.status === 403;
111+
112+
useEffect(() => {
113+
const polledStage = importStatusData?.importStatus;
114+
if (polledStage !== undefined && polledStage >= 0) {
115+
setCurrentStage(polledStage);
116+
}
117+
}, [importStatusData?.importStatus]);
118+
119+
useEffect(() => {
120+
if (currentStage === IMPORT_STAGES.SUCCESS || anyRequestFailed) {
121+
setStopFetching(true);
122+
}
123+
}, [currentStage, anyRequestFailed]);
124+
125+
const context = useMemo<CourseImportContextData>(() => {
126+
const contextValue = {
127+
importTriggered,
128+
progress,
129+
fileName,
130+
currentStage,
131+
anyRequestFailed,
132+
anyRequestInProgress,
133+
isLoadingDenied,
134+
handleOnProcessUpload,
135+
formattedErrorMessage,
136+
successDate,
137+
};
138+
139+
return contextValue;
140+
}, [
141+
importTriggered,
142+
progress,
143+
fileName,
144+
currentStage,
145+
anyRequestFailed,
146+
anyRequestInProgress,
147+
isLoadingDenied,
148+
handleOnProcessUpload,
149+
formattedErrorMessage,
150+
successDate,
151+
]);
152+
153+
return (
154+
<CourseImportContext.Provider value={context}>
155+
{children}
156+
</CourseImportContext.Provider>
157+
);
158+
};
159+
160+
export function useCourseImportContext(): CourseImportContextData {
161+
const ctx = useContext(CourseImportContext);
162+
if (ctx === undefined) {
163+
/* istanbul ignore next */
164+
throw new Error('useCourseImportContext() was used in a component without a <CourseImportProvider> ancestor.');
165+
}
166+
return ctx;
167+
}

0 commit comments

Comments
 (0)