feat: create Studio Home Page MFE (#589)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
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 { studioHomeMock } from '../__mocks__';
|
||||
import messages from './messages';
|
||||
import ProcessingCourses from '.';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
let store;
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ProcessingCourses />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<ProcessingCourses />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
useSelector.mockReturnValue(studioHomeMock);
|
||||
});
|
||||
it('renders successfully processing courses', () => {
|
||||
const studioHomeInitial = {
|
||||
...studioHomeMock,
|
||||
inProcessCourseActions: [{ a: '1' }, { b: '2' }],
|
||||
};
|
||||
useSelector.mockReturnValue(studioHomeInitial);
|
||||
const { getByText, queryAllByTestId } = render(<RootWrapper />);
|
||||
expect(getByText(messages.processingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryAllByTestId('course-item')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders successfully empty list', () => {
|
||||
const { getByText, queryAllByTestId } = render(<RootWrapper />);
|
||||
expect(getByText(messages.processingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryAllByTestId('course-item')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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 MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import initializeStore from '../../../store';
|
||||
import { executeThunk } from '../../../utils';
|
||||
import { handleDeleteNotificationQuery } from '../../data/thunks';
|
||||
import { getCourseNotificationUrl } from '../../data/api';
|
||||
import messages from './messages';
|
||||
import CourseItem from '.';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
const RootWrapper = (props) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<CourseItem {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const course = {
|
||||
displayName: 'course-name-fake',
|
||||
courseKey: 'course-key-fake',
|
||||
org: 'course-org-fake',
|
||||
number: 'course-number-fake',
|
||||
run: 'course-run-fake',
|
||||
isInProgress: true,
|
||||
isFailed: true,
|
||||
dismissLink: 'course-dismiss-link-fake',
|
||||
};
|
||||
|
||||
const props = { course };
|
||||
|
||||
describe('<CourseItem />', async () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onDelete(getCourseNotificationUrl(course.dismissLink)).reply(200);
|
||||
await executeThunk(handleDeleteNotificationQuery(course.dismissLink), store.dispatch);
|
||||
});
|
||||
|
||||
it('renders successfully', () => {
|
||||
const { getByText, getAllByText } = render(<RootWrapper {...props} />);
|
||||
const subtitle = `${course.org} / ${course.number} / ${course.run}`;
|
||||
expect(getAllByText(course.displayName)).toHaveLength(2);
|
||||
expect(getAllByText(subtitle)).toHaveLength(2);
|
||||
expect(getByText(messages.itemInProgressActionText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.itemIsFailedActionText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.itemFailedFooterText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.itemFailedFooterButton.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
100
src/studio-home/processing-courses/course-item/index.jsx
Normal file
100
src/studio-home/processing-courses/course-item/index.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Card,
|
||||
Hyperlink,
|
||||
Button,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
Warning as WarningIcon,
|
||||
RotateRight as RotateRightIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { handleDeleteNotificationQuery } from '../../data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
const CourseItem = ({ course }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
displayName, org, number, run, isInProgress, isFailed, dismissLink,
|
||||
} = course;
|
||||
const subtitle = `${org} / ${number} / ${run}`;
|
||||
|
||||
return (
|
||||
<div data-testid="course-item">
|
||||
{isInProgress && (
|
||||
<Card className="card-item">
|
||||
<Card.Header
|
||||
title={<p className="card-item-title">{displayName}</p>}
|
||||
subtitle={subtitle}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<Icon src={RotateRightIcon} className="spinner-icon text-gray-300" />
|
||||
<ActionRow.Spacer />
|
||||
<span className="small text-gray-300">{intl.formatMessage(messages.itemInProgressActionText)}</span>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Divider />
|
||||
<Card.Section className="p-3.5 small text-black bg-gray-100">
|
||||
{intl.formatMessage(messages.itemInProgressFooterText, {
|
||||
refresh: (
|
||||
<Hyperlink destination="/home">
|
||||
{intl.formatMessage(messages.itemInProgressFooterHyperlink)}
|
||||
</Hyperlink>
|
||||
),
|
||||
})}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isFailed && (
|
||||
<Card className="card-item">
|
||||
<Card.Header
|
||||
title={<p className="card-item-title">{displayName}</p>}
|
||||
subtitle={subtitle}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<Icon src={WarningIcon} className="text-danger-300" />
|
||||
<span className="small text-danger-300">{intl.formatMessage(messages.itemIsFailedActionText)}</span>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Divider />
|
||||
<Card.Footer className="p-3.5 small text-white bg-danger-300 align-content-between">
|
||||
<span className="w-75 mr-auto">{intl.formatMessage(messages.itemFailedFooterText)}</span>
|
||||
<Button
|
||||
onClick={() => dispatch(handleDeleteNotificationQuery(dismissLink))}
|
||||
iconBefore={CloseIcon}
|
||||
variant="outline-danger"
|
||||
size="sm"
|
||||
>
|
||||
{intl.formatMessage(messages.itemFailedFooterButton)}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseItem.propTypes = {
|
||||
course: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
courseKey: PropTypes.string.isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
number: PropTypes.string.isRequired,
|
||||
run: PropTypes.string.isRequired,
|
||||
isFailed: PropTypes.bool.isRequired,
|
||||
isInProgress: PropTypes.bool.isRequired,
|
||||
dismissLink: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseItem;
|
||||
30
src/studio-home/processing-courses/course-item/messages.js
Normal file
30
src/studio-home/processing-courses/course-item/messages.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
itemInProgressFooterText: {
|
||||
id: 'course-authoring.studio-home.processing.course-item.footer.in-progress',
|
||||
defaultMessage: 'The new course will be added to your course list in 5-10 minutes. Return to this page or {refresh} to update the course list. The new course will need some manual configuration.',
|
||||
},
|
||||
itemInProgressFooterHyperlink: {
|
||||
id: 'course-authoring.studio-home.processing.course-item.footer.in-progress.hyperlink',
|
||||
defaultMessage: 'refresh it',
|
||||
},
|
||||
itemInProgressActionText: {
|
||||
id: 'course-authoring.studio-home.processing.course-item.action.in-progress',
|
||||
defaultMessage: 'Configuring as re-run',
|
||||
},
|
||||
itemIsFailedActionText: {
|
||||
id: 'course-authoring.studio-home.processing.course-item.action.failed',
|
||||
defaultMessage: 'Configuration error',
|
||||
},
|
||||
itemFailedFooterText: {
|
||||
id: 'course-authoring.studio-home.processing.course-item.footer.failed',
|
||||
defaultMessage: 'A system error occurred while your course was being processed. Please go to the original course to try the re-run again, or contact your PM for assistance.',
|
||||
},
|
||||
itemFailedFooterButton: {
|
||||
id: 'course-authoring.studio-home.processing.course-item.footer.failed.button',
|
||||
defaultMessage: 'Dismiss',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
32
src/studio-home/processing-courses/index.jsx
Normal file
32
src/studio-home/processing-courses/index.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Stack } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { getStudioHomeData } from '../data/selectors';
|
||||
import CourseItem from './course-item';
|
||||
import messages from './messages';
|
||||
|
||||
const ProcessingCourses = () => {
|
||||
const intl = useIntl();
|
||||
const { inProcessCourseActions } = useSelector(getStudioHomeData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-gray-300">
|
||||
{intl.formatMessage(messages.processingTitle)}
|
||||
</p>
|
||||
<hr />
|
||||
<Stack gap={3}>
|
||||
{inProcessCourseActions.map((course) => (
|
||||
<CourseItem
|
||||
course={course}
|
||||
key={course.courseKey}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessingCourses;
|
||||
10
src/studio-home/processing-courses/messages.js
Normal file
10
src/studio-home/processing-courses/messages.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
processingTitle: {
|
||||
id: 'course-authoring.studio-home.processing.title',
|
||||
defaultMessage: 'Courses being processed',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
Reference in New Issue
Block a user