Skip to content

Commit

Permalink
feat: implement export page
Browse files Browse the repository at this point in the history
  • Loading branch information
kyrylo-kh committed Sep 5, 2023
1 parent a1793ef commit dff7343
Show file tree
Hide file tree
Showing 53 changed files with 2,124 additions and 21 deletions.
1 change: 0 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''
Expand Down
1 change: 0 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''
Expand Down
1 change: 0 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
ENABLE_NEW_GRADING_PAGE = true
ENABLE_NEW_COURSE_TEAM_PAGE = true
ENABLE_NEW_IMPORT_PAGE = true
ENABLE_NEW_EXPORT_PAGE = true
ENABLE_UNIT_PAGE = true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true
BBB_LEARN_MORE_URL=''
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"react-transition-group": "4.4.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"yup": "0.31.1"
},
Expand Down
6 changes: 2 additions & 4 deletions src/CourseAuthoringRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import CourseExportPage from './export-page/CourseExportPage';

/**
* As of this writing, these routes are mounted at a path prefixed with the following:
Expand Down Expand Up @@ -105,10 +106,7 @@ const CourseAuthoringRoutes = ({ courseId }) => {
)}
</PageRoute>
<PageRoute path={`${path}/export`}>
{process.env.ENABLE_NEW_EXPORT_PAGE === 'true'
&& (
<Placeholder />
)}
<CourseExportPage courseId={courseId} />
</PageRoute>
</Switch>
</CourseAuthoringPage>
Expand Down
127 changes: 127 additions & 0 deletions src/export-page/CourseExportPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Container, Layout, Button, Card,
} from '@edx/paragon';
import { ArrowCircleDown as ArrowCircleDownIcon } from '@edx/paragon/icons';
import Cookies from 'universal-cookie';
import { getConfig } from '@edx/frontend-platform';
import { Helmet } from 'react-helmet';

import InternetConnectionAlert from '../generic/internet-connection-alert';
import SubHeader from '../generic/sub-header/SubHeader';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import messages from './messages';
import ExportSidebar from './export-sidebar/ExportSidebar';
import {
getCurrentStage, getError, getExportTriggered, getLoadingStatus, getSavingStatus,
} from './data/selectors';
import { startExportingCourse } from './data/thunks';
import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants';
import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './data/slice';
import ExportModalError from './export-modal-error/ExportModalError';
import ExportFooter from './export-footer/ExportFooter';
import ExportStepper from './export-stepper/ExportStepper';

const CourseExportPage = ({ intl, courseId }) => {
const dispatch = useDispatch();
const exportTriggered = useSelector(getExportTriggered);
const courseDetails = useModel('courseDetails', courseId);
const currentStage = useSelector(getCurrentStage);
const { msg: errorMessage } = useSelector(getError);
const loadingStatus = useSelector(getLoadingStatus);
const savingStatus = useSelector(getSavingStatus);
const cookies = new Cookies();
const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS;
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;

useEffect(() => {
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
if (cookieData) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateExportTriggered(true));
dispatch(updateSuccessDate(cookieData.date));
}
}, []);

return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
headingTitle: intl.formatMessage(messages.headingTitle),
courseName: courseDetails?.name,
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Container size="xl" className="m-4 export">
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<article>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
/>
<p>{intl.formatMessage(messages.description1, { studioShortName: getConfig().STUDIO_SHORT_NAME })}</p>
<p>{intl.formatMessage(messages.description2)}</p>
<Card>
<Card.Header
className="h3 px-3 text-black mb-4"
title={intl.formatMessage(messages.titleUnderButton)}
/>
{isShowExportButton && (
<Card.Section className="px-3 py-1">
<Button
size="lg"
block
className="mb-4"
onClick={() => dispatch(startExportingCourse(courseId))}
iconBefore={ArrowCircleDownIcon}
>
{intl.formatMessage(messages.buttonTitle)}
</Button>
</Card.Section>
)}
</Card>
{exportTriggered && <ExportStepper courseId={courseId} />}
<ExportFooter />
</article>
</Layout.Element>
<Layout.Element>
<ExportSidebar courseId={courseId} />
</Layout.Element>
</Layout>
</section>
<ExportModalError courseId={courseId} />
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={anyRequestFailed}
isQueryPending={anyRequestInProgress}
onInternetConnectionFailed={() => null}
/>
</div>
</>
);
};

CourseExportPage.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};

CourseExportPage.defaultProps = {};

export default injectIntl(CourseExportPage);
9 changes: 9 additions & 0 deletions src/export-page/CourseExportPage.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@import "./export-stepper/ExportStepper";
@import "./export-footer/ExportFooter";
@import "./export-sidebar/ExportSidebar";

.export {
.help-sidebar {
margin-top: 7.188rem;
}
}
79 changes: 79 additions & 0 deletions src/export-page/CourseExportPage.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { Helmet } from 'react-helmet';

import initializeStore from '../store';
import { exportPageMock } from './__mocks__';
import messages from './messages';
import CourseExportPage from './CourseExportPage';
import { postExportCourseApiUrl } from './data/api';

let store;
let axiosMock;
const courseId = '123';
const courseName = 'About Node JS';

jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: courseName,
}),
}));

const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CourseExportPage intl={injectIntl} courseId={courseId} />
</IntlProvider>
</AppProvider>
);

describe('<CourseExportPage />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(postExportCourseApiUrl(courseId))
.reply(200, exportPageMock);
});
it('should render page title correctly', async () => {
render(<RootWrapper />);
await waitFor(() => {
const helmet = Helmet.peek();
expect(helmet.title).toEqual(
`${messages.headingTitle.defaultMessage} | ${courseName} | ${process.env.SITE_NAME}`,
);
});
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const exportPageElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(exportPageElement).toBeInTheDocument();
expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
});
});
it('should start exporting on click', async () => {
const { getByText, container } = render(<RootWrapper />);
const button = container.querySelector('.btn-primary');
fireEvent.click(button);
expect(getByText(/Preparing to start the export/i)).toBeInTheDocument();
});
});
3 changes: 3 additions & 0 deletions src/export-page/__mocks__/exportPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
exportStatus: 1,
};
2 changes: 2 additions & 0 deletions src/export-page/__mocks__/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as exportPageMock } from './exportPage';
19 changes: 19 additions & 0 deletions src/export-page/data/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const postExportCourseApiUrl = (courseId) => new URL(`export/${courseId}`, getApiBaseUrl()).href;
export const getExportStatusApiUrl = (courseId) => new URL(`export_status/${courseId}`, getApiBaseUrl()).href;

export async function startCourseExporting(courseId) {
const { data } = await getAuthenticatedHttpClient()
.post(postExportCourseApiUrl(courseId));
return camelCaseObject(data);
}

export async function getExportStatus(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getExportStatusApiUrl(courseId));
return camelCaseObject(data);
}
8 changes: 8 additions & 0 deletions src/export-page/data/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const LAST_EXPORT_COOKIE_NAME = 'lastexport';
export const EXPORT_STAGES = {
PREPARING: 0,
EXPORTING: 1,
COMPRESSING: 2,
SUCCESS: 3,
};
export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy';
8 changes: 8 additions & 0 deletions src/export-page/data/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const getExportTriggered = (state) => state.courseExport.exportTriggered;
export const getCurrentStage = (state) => state.courseExport.currentStage;
export const getDownloadPath = (state) => state.courseExport.downloadPath;
export const getSuccessDate = (state) => state.courseExport.successDate;
export const getError = (state) => state.courseExport.error;
export const getIsErrorModalOpen = (state) => state.courseExport.isErrorModalOpen;
export const getLoadingStatus = (state) => state.courseExport.loadingStatus;
export const getSavingStatus = (state) => state.courseExport.savingStatus;
63 changes: 63 additions & 0 deletions src/export-page/data/slice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
exportTriggered: false,
currentStage: 0,
error: { msg: null, unitUrl: null },
downloadPath: null,
successDate: null,
isErrorModalOpen: false,
loadingStatus: '',
savingStatus: '',
};

const slice = createSlice({
name: 'exportPage',
initialState,
reducers: {
updateExportTriggered: (state, { payload }) => {
state.exportTriggered = payload;
},
updateCurrentStage: (state, { payload }) => {
if (payload >= state.currentStage) {
state.currentStage = payload;
}
},
updateDownloadPath: (state, { payload }) => {
state.downloadPath = payload;
},
updateSuccessDate: (state, { payload }) => {
state.successDate = payload;
},
updateError: (state, { payload }) => {
state.error = payload;
},
updateIsErrorModalOpen: (state, { payload }) => {
state.isErrorModalOpen = payload;
},
reset: () => initialState,
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});

export const {
updateExportTriggered,
updateCurrentStage,
updateDownloadPath,
updateSuccessDate,
updateError,
updateIsErrorModalOpen,
reset,
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;

export const {
reducer,
} = slice;
Loading

0 comments on commit dff7343

Please sign in to comment.