Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement export page #586

Merged
merged 2 commits into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}

Check warning on line 113 in src/export-page/CourseExportPage.jsx

View check run for this annotation

Codecov / codecov/patch

src/export-page/CourseExportPage.jsx#L113

Added line #L113 was not covered by tests
/>
</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;
}
}
127 changes: 127 additions & 0 deletions src/export-page/CourseExportPage.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import { getConfig, 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 Cookies from 'universal-cookie';
import initializeStore from '../store';
import stepperMessages from './export-stepper/messages';
import modalErrorMessages from './export-modal-error/messages';
import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api';
import { EXPORT_STAGES } from './data/constants';
import { exportPageMock } from './__mocks__';
import messages from './messages';
import CourseExportPage from './CourseExportPage';

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

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

jest.mock('universal-cookie', () => {
const mCookie = {
get: jest.fn(),
set: jest.fn(),
};
return jest.fn(() => mCookie);
});

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);
cookies = new Cookies();
cookies.get.mockReturnValue(null);
});
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(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
});
it('should show modal error', async () => {
axiosMock
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } });
const { getByText, queryByText, container } = render(<RootWrapper />);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return
await new Promise((r) => setTimeout(r, 3500));
expect(getByText(/There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages. The raw error message is: test error/i));
const closeModalWindowButton = getByText('Return to export');
fireEvent.click(closeModalWindowButton);
expect(queryByText(modalErrorMessages.errorCancelButtonUnit.defaultMessage)).not.toBeInTheDocument();
fireEvent.click(closeModalWindowButton);
});
it('should fetch status without clicking when cookies has', async () => {
cookies.get.mockReturnValue({ date: 1679787000 });
const { getByText } = render(<RootWrapper />);
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
});
it('should show download path', async () => {
axiosMock
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' });
const { getByText, container } = render(<RootWrapper />);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return
await new Promise((r) => setTimeout(r, 3500));
const downloadButton = getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage);
expect(downloadButton).toBeInTheDocument();
expect(downloadButton.getAttribute('href')).toEqual(`${getConfig().STUDIO_BASE_URL}/test-download-path.test`);
});
});
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);
}
Loading