-
Notifications
You must be signed in to change notification settings - Fork 81
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
54 changed files
with
2,202 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module.exports = { | ||
exportStatus: 1, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
Oops, something went wrong.