fix: studio home UI bugs (#611)
This commit is contained in:
@@ -79,14 +79,3 @@
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-group-wrapper {
|
||||
position: relative;
|
||||
z-index: $zindex-dropdown;
|
||||
margin-left: auto;
|
||||
|
||||
.dropdown-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('<CourseRerunSideBar />', () => {
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
expect(getByText(messages.sectionTitle1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionDescription1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionDescription1.defaultMessage, { exact: false })).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionTitle2.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionDescription2.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionTitle3.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useHelpUrls } from '../../help-urls/hooks';
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
@@ -10,11 +10,23 @@ import messages from './messages';
|
||||
const CourseRerunSideBar = () => {
|
||||
const intl = useIntl();
|
||||
const { default: learnMoreUrl } = useHelpUrls(['default']);
|
||||
const defaultCourseDate = new Date(Date.UTC(2030, 0, 1, 0, 0));
|
||||
const localizedCourseDate = (
|
||||
<FormattedDate
|
||||
value={defaultCourseDate}
|
||||
year="numeric"
|
||||
month="long"
|
||||
day="2-digit"
|
||||
hour="numeric"
|
||||
minute="numeric"
|
||||
/>
|
||||
);
|
||||
|
||||
const sidebarMessages = [
|
||||
{
|
||||
title: intl.formatMessage(messages.sectionTitle1),
|
||||
description: intl.formatMessage(messages.sectionDescription1),
|
||||
description: `${intl.formatMessage(messages.sectionDescription1)}`,
|
||||
date: localizedCourseDate,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage(messages.sectionTitle2),
|
||||
@@ -38,13 +50,18 @@ const CourseRerunSideBar = () => {
|
||||
showOtherSettings={false}
|
||||
className="mt-3"
|
||||
>
|
||||
{sidebarMessages.map(({ title, description, link }, index) => {
|
||||
{sidebarMessages.map(({
|
||||
title,
|
||||
description,
|
||||
link,
|
||||
date,
|
||||
}, index) => {
|
||||
const isLastSection = index === sidebarMessages.length - 1;
|
||||
|
||||
return (
|
||||
<div key={uuid()}>
|
||||
<h4 className="help-sidebar-about-title">{title}</h4>
|
||||
<p className="help-sidebar-about-descriptions">{description}</p>
|
||||
<p className="help-sidebar-about-descriptions">{description} {date}</p>
|
||||
{!!link && (
|
||||
<Hyperlink
|
||||
className="small"
|
||||
|
||||
@@ -7,7 +7,7 @@ const messages = defineMessages({
|
||||
},
|
||||
sectionDescription1: {
|
||||
id: 'course-authoring.course-rerun.sidebar.section-1.description',
|
||||
defaultMessage: 'The new course is set to start on January 1, 2030 at midnight (UTC).',
|
||||
defaultMessage: 'The new course is set to start on',
|
||||
},
|
||||
sectionTitle2: {
|
||||
id: 'course-authoring.course-rerun.sidebar.section-2.title',
|
||||
|
||||
@@ -42,24 +42,24 @@ const CourseRerun = ({ courseId }) => {
|
||||
return (
|
||||
<>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" className="m-4">
|
||||
<Container size="xl" className="small p-4 mt-3">
|
||||
<section className="mb-4">
|
||||
<article>
|
||||
<section>
|
||||
<header className="d-flex">
|
||||
<h3 className="align-self-center font-weight-normal mb-0">{intl.formatMessage(messages.rerunTitle)}</h3>
|
||||
<Stack>
|
||||
<h2>
|
||||
{intl.formatMessage(messages.rerunTitle)} {displayName}
|
||||
</h2>
|
||||
<span className="large">{originalCourseData}</span>
|
||||
</Stack>
|
||||
<ActionRow className="ml-auto">
|
||||
<Button variant="outline-primary" onClick={handleRerunCourseCancel}>
|
||||
<Button variant="outline-primary" size="sm" onClick={handleRerunCourseCancel}>
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</header>
|
||||
<hr />
|
||||
<Stack>
|
||||
<h3>{originalCourseData}</h3>
|
||||
<h2>{displayName}</h2>
|
||||
</Stack>
|
||||
<hr />
|
||||
</section>
|
||||
</article>
|
||||
<Layout
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
rerunTitle: {
|
||||
id: 'course-authoring.course-rerun.title',
|
||||
defaultMessage: 'Create a re-run of a course',
|
||||
defaultMessage: 'Create a re-run of',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'course-authoring.course-rerun.actions.button.cancel',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ModalError from '../../generic/modal-error/ModalError';
|
||||
@@ -17,9 +17,8 @@ const ExportModalError = ({
|
||||
const isErrorModalOpen = useSelector(getIsErrorModalOpen);
|
||||
const { msg: errorMessage, unitUrl: unitErrorUrl } = useSelector(getError);
|
||||
|
||||
const handleUnitRedirect = () => { window.location.href = unitErrorUrl; };
|
||||
const handleRedirectCourseHome = () => history.push(`/course/${courseId}/outline`);
|
||||
|
||||
const handleUnitRedirect = () => { window.location.assign(unitErrorUrl); };
|
||||
const handleRedirectCourseHome = () => { window.location.assign(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`); };
|
||||
return (
|
||||
<ModalError
|
||||
isOpen={isErrorModalOpen}
|
||||
|
||||
@@ -246,7 +246,7 @@ const CreateOrRerunCourseForm = ({
|
||||
type="invalid"
|
||||
hasIcon={false}
|
||||
>
|
||||
<span className="x-small">{errors[field.name]}</span>
|
||||
{errors[field.name]}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
render,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
@@ -22,6 +25,7 @@ import { updateCreateOrRerunCourseQuery } from '../data/thunks';
|
||||
import { getCreateOrRerunCourseUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
import { CreateOrRerunCourseForm } from '.';
|
||||
import { initialState } from './factories/mockApiResponses';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
@@ -30,15 +34,9 @@ jest.mock('react-router', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const onClickCancelMock = jest.fn();
|
||||
|
||||
@@ -62,7 +60,13 @@ const props = {
|
||||
onClickCancel: onClickCancelMock,
|
||||
};
|
||||
|
||||
describe('<CreateOrRerunCourseForm />', async () => {
|
||||
const mockStore = async () => {
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
|
||||
await executeThunk(fetchStudioHomeData, store.dispatch);
|
||||
};
|
||||
|
||||
describe('<CreateOrRerunCourseForm />', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
@@ -74,116 +78,136 @@ describe('<CreateOrRerunCourseForm />', async () => {
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200);
|
||||
|
||||
await executeThunk(fetchStudioHomeData, store.dispatch);
|
||||
await executeThunk(updateCreateOrRerunCourseQuery, store.dispatch);
|
||||
useSelector.mockReturnValue(studioHomeMock);
|
||||
});
|
||||
|
||||
it('renders form successfully', () => {
|
||||
const { getByText, getByPlaceholderText } = render(
|
||||
<RootWrapper {...props} />,
|
||||
);
|
||||
expect(getByText(props.title)).toBeInTheDocument();
|
||||
expect(getByText(messages.courseDisplayNameLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
it('renders form successfully', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
|
||||
expect(getByText(messages.courseOrgLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.courseOrgNoOptions.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(props.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.courseDisplayNameLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(getByText(messages.courseNumberLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.courseOrgLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.courseOrgNoOptions.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(getByText(messages.courseRunLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.courseNumberLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(messages.courseRunLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders create course form with help text successfully', () => {
|
||||
const { getByText, getByRole } = render(<RootWrapper {...props} />);
|
||||
expect(getByText(messages.courseDisplayNameCreateHelpText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText('The name of the organization sponsoring the course.', { exact: false })).toBeInTheDocument();
|
||||
expect(getByText('The unique number that identifies your course within your organization.', { exact: false })).toBeInTheDocument();
|
||||
expect(getByText('The term in which your course will run.', { exact: false })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.createButton.defaultMessage })).toBeInTheDocument();
|
||||
it('renders create course form with help text successfully', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
expect(screen.getByText(messages.courseDisplayNameCreateHelpText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText('The name of the organization sponsoring the course.', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('The unique number that identifies your course within your organization.', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('The term in which your course will run.', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.createButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rerun course form with help text successfully', () => {
|
||||
it('renders rerun course form with help text successfully', async () => {
|
||||
const initialProps = { ...props, isCreateNewCourse: false };
|
||||
const { getByText, getByRole } = render(
|
||||
<RootWrapper {...initialProps} />,
|
||||
);
|
||||
expect(getByText(messages.courseDisplayNameRerunHelpText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText('The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)', { exact: false })).toBeInTheDocument();
|
||||
expect(getByText(messages.courseNumberRerunHelpText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText('The term in which the new course will run. (This value is often different than the original course run value.)', { exact: false })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.rerunCreateButton.defaultMessage })).toBeInTheDocument();
|
||||
render(<RootWrapper {...initialProps} />);
|
||||
await mockStore();
|
||||
|
||||
expect(screen.getByText(messages.courseDisplayNameRerunHelpText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText('The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.courseNumberRerunHelpText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText('The term in which the new course will run. (This value is often different than the original course run value.)', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.rerunCreateButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleOnClickCancel if button cancel clicked', async () => {
|
||||
const { getByRole } = render(<RootWrapper {...props} />);
|
||||
const cancelBtn = getByRole('button', { name: messages.cancelButton.defaultMessage });
|
||||
act(() => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
const cancelBtn = screen.getByRole('button', { name: messages.cancelButton.defaultMessage });
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
expect(onClickCancelMock).toHaveBeenCalled();
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
{
|
||||
payload: {},
|
||||
type: 'generic/updatePostErrors',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call handleOnClickCreate if button create clicked', async () => {
|
||||
const { getByPlaceholderText, getByText, getByRole } = render(<RootWrapper {...props} />);
|
||||
const displayNameInput = getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
|
||||
const orgInput = getByText(messages.courseOrgNoOptions.defaultMessage);
|
||||
const numberInput = getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
const runInput = getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
|
||||
const createBtn = getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
describe('handleOnClickCreate', () => {
|
||||
delete window.location;
|
||||
window.location = { assign: jest.fn() };
|
||||
it('should call window.location.assign with url', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
const url = '/course/courseId';
|
||||
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
|
||||
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
|
||||
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
|
||||
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(displayNameInput, { target: { value: 'foo course name' } });
|
||||
fireEvent.click(orgInput);
|
||||
fireEvent.change(numberInput, { target: { value: '777' } });
|
||||
fireEvent.change(runInput, { target: { value: '1' } });
|
||||
fireEvent.click(createBtn);
|
||||
await act(async () => {
|
||||
userEvent.type(displayNameInput, 'foo course name');
|
||||
fireEvent.click(orgInput);
|
||||
userEvent.type(numberInput, '777');
|
||||
userEvent.type(runInput, '1');
|
||||
userEvent.click(createBtn);
|
||||
});
|
||||
await axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200, { url });
|
||||
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
|
||||
|
||||
expect(window.location.assign).toHaveBeenCalledWith(`${process.env.STUDIO_BASE_URL}${url}`);
|
||||
});
|
||||
it('should call window.location.assign with url and destinationCourseKey', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
const url = '/course/';
|
||||
const destinationCourseKey = 'courseKey';
|
||||
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
|
||||
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
|
||||
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
|
||||
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
await axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200, { url, destinationCourseKey });
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
{
|
||||
payload: {},
|
||||
type: 'generic/updatePostErrors',
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
userEvent.type(displayNameInput, 'foo course name');
|
||||
fireEvent.click(orgInput);
|
||||
userEvent.type(numberInput, '777');
|
||||
userEvent.type(runInput, '1');
|
||||
userEvent.click(createBtn);
|
||||
});
|
||||
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
|
||||
|
||||
expect(window.location.assign).toHaveBeenCalledWith(`${process.env.STUDIO_BASE_URL}${url}${destinationCourseKey}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be disabled create button if form not filled', () => {
|
||||
const { getByRole } = render(<RootWrapper {...props} />);
|
||||
const createBtn = getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
it('should be disabled create button if form not filled', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
expect(createBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be disabled rerun button if form not filled', () => {
|
||||
it('should be disabled rerun button if form not filled', async () => {
|
||||
const initialProps = { ...props, isCreateNewCourse: false };
|
||||
const { getByRole } = render(<RootWrapper {...initialProps} />);
|
||||
const rerunBtn = getByRole('button', { name: messages.rerunCreateButton.defaultMessage });
|
||||
render(<RootWrapper {...initialProps} />);
|
||||
await mockStore();
|
||||
const rerunBtn = screen.getByRole('button', { name: messages.rerunCreateButton.defaultMessage });
|
||||
expect(rerunBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be disabled create button if form has error', () => {
|
||||
const { getByRole, getByPlaceholderText, getByText } = render(<RootWrapper {...props} />);
|
||||
const createBtn = getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
const displayNameInput = getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
|
||||
const orgInput = getByText(messages.courseOrgNoOptions.defaultMessage);
|
||||
const numberInput = getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
const runInput = getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
|
||||
it('should be disabled create button if form has error', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
|
||||
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
|
||||
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
fireEvent.change(displayNameInput, { target: { value: 'foo course name' } });
|
||||
fireEvent.click(orgInput);
|
||||
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
|
||||
@@ -195,37 +219,54 @@ describe('<CreateOrRerunCourseForm />', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows typeahead dropdown with allowed to create org permissions', () => {
|
||||
useSelector.mockReturnValue({ ...studioHomeMock, allowToCreateNewOrg: true });
|
||||
const { getByPlaceholderText } = render(<RootWrapper {...props} />);
|
||||
expect(getByPlaceholderText(messages.courseOrgPlaceholder.defaultMessage));
|
||||
});
|
||||
|
||||
it('shows button pending state', () => {
|
||||
useSelector.mockReturnValue(RequestStatus.PENDING);
|
||||
const { getByRole } = render(<RootWrapper {...props} />);
|
||||
expect(getByRole('button', { name: messages.creatingButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
it('shows alert error if postErrors presents', () => {
|
||||
useSelector.mockReturnValue({
|
||||
errMsg: 'aaa',
|
||||
orgErrMsg: 'bbb',
|
||||
courseErrMsg: 'ccc',
|
||||
it('shows typeahead dropdown with allowed to create org permissions', async () => {
|
||||
const updatedStudioData = { ...studioHomeMock, allowToCreateNewOrg: true };
|
||||
store = initializeStore({
|
||||
...initialState,
|
||||
studioHome: {
|
||||
...initialState.studioHome,
|
||||
studioHomeData: updatedStudioData,
|
||||
},
|
||||
});
|
||||
const { getByText } = render(<RootWrapper {...props} />);
|
||||
expect(getByText('aaa')).toBeInTheDocument();
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
|
||||
expect(screen.getByPlaceholderText(messages.courseOrgPlaceholder.defaultMessage));
|
||||
});
|
||||
|
||||
it('shows error on field', () => {
|
||||
const { getByPlaceholderText, getByText } = render(<RootWrapper {...props} />);
|
||||
const numberInput = getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
it('shows button pending state', async () => {
|
||||
store = initializeStore({
|
||||
...initialState,
|
||||
generic: {
|
||||
...initialState.generic,
|
||||
savingStatus: RequestStatus.PENDING,
|
||||
},
|
||||
});
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
expect(screen.getByRole('button', { name: messages.creatingButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
it('shows alert error if postErrors presents', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
await axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200, { errMsg: 'aaa' });
|
||||
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
|
||||
|
||||
expect(screen.getByText('aaa')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error on field', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
|
||||
});
|
||||
|
||||
waitFor(() => {
|
||||
expect(getByText(messages.noSpaceError)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.noSpaceError)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
const redirectToCourseIndex = (url) => `${url}/outline`;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { redirectToCourseIndex };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { studioHomeMock } from '../../../studio-home/__mocks__';
|
||||
|
||||
export const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
export const initialState = {
|
||||
generic: {
|
||||
createOrRerunCourse: {
|
||||
courseData: {},
|
||||
courseRerunData: {},
|
||||
redirectUrlObj: {},
|
||||
postErrors: {},
|
||||
},
|
||||
loadingStatuses: {
|
||||
organizationLoadingStatus: 'successful', courseRerunLoadingStatus: 'successful',
|
||||
},
|
||||
organizations: ['krisEdx', 'krisEd', 'DeveloperInc', 'importMit', 'testX', 'edX', 'developerInb'],
|
||||
savingStatus: '',
|
||||
},
|
||||
studioHome: {
|
||||
loadingStatuses: {
|
||||
studioHomeLoadingStatus: RequestStatus.SUCCESSFUL,
|
||||
courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
savingStatuses: {
|
||||
courseCreatorSavingStatus: '',
|
||||
deleteNotificationSavingStatus: '',
|
||||
},
|
||||
studioHomeData: studioHomeMock,
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '../data/selectors';
|
||||
import { updateSavingStatus, updatePostErrors } from '../data/slice';
|
||||
import { fetchOrganizationsQuery } from '../data/thunks';
|
||||
import { redirectToCourseIndex } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
const useCreateOrRerunCourse = (initialValues) => {
|
||||
@@ -88,9 +87,16 @@ const useCreateOrRerunCourse = (initialValues) => {
|
||||
useEffect(() => {
|
||||
if (createOrRerunCourseSavingStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
const { url } = redirectUrlObj;
|
||||
const { url, destinationCourseKey } = redirectUrlObj;
|
||||
// New courses' url to the outline page is provided in the url. However, for course
|
||||
// re-runs the url is /course/. The actual destination for the rer-run's outline
|
||||
// is in the destionationCourseKey attribute from the api.
|
||||
if (url) {
|
||||
history.push(redirectToCourseIndex(url));
|
||||
if (destinationCourseKey) {
|
||||
window.location.assign(`${getConfig().STUDIO_BASE_URL}${url}${destinationCourseKey}`);
|
||||
} else {
|
||||
window.location.assign(`${getConfig().STUDIO_BASE_URL}${url}`);
|
||||
}
|
||||
}
|
||||
} else if (createOrRerunCourseSavingStatus === RequestStatus.FAILED) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import { Add as AddIcon } from '@edx/paragon/icons/es5';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import Loading from '../generic/Loading';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
@@ -17,7 +18,6 @@ import HomeSidebar from './home-sidebar';
|
||||
import TabsSection from './tabs-section';
|
||||
import OrganizationSection from './organization-section';
|
||||
import VerifyEmailLayout from './verify-email-layout';
|
||||
import ProcessingCourses from './processing-courses';
|
||||
import CreateNewCourseForm from './create-new-course-form';
|
||||
import messages from './messages';
|
||||
import { useStudioHome } from './hooks';
|
||||
@@ -40,12 +40,11 @@ const StudioHome = ({ intl }) => {
|
||||
userIsActive,
|
||||
studioShortName,
|
||||
studioRequestEmail,
|
||||
libraryAuthoringMfeUrl,
|
||||
redirectToLibraryAuthoringMfe,
|
||||
splitStudioHome,
|
||||
} = studioHomeData;
|
||||
|
||||
if (isLoadingPage) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
function getHeaderButtons() {
|
||||
const headerButtons = [];
|
||||
|
||||
@@ -69,6 +68,26 @@ const StudioHome = ({ intl }) => {
|
||||
);
|
||||
}
|
||||
|
||||
let libraryHref = `${getConfig().STUDIO_BASE_URL}/home#libraries-tab`;
|
||||
if (splitStudioHome) {
|
||||
libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`;
|
||||
}
|
||||
if (redirectToLibraryAuthoringMfe) {
|
||||
libraryHref = `${libraryAuthoringMfeUrl}/create`;
|
||||
}
|
||||
headerButtons.push(
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={AddIcon}
|
||||
size="sm"
|
||||
disabled={showNewCourseContainer}
|
||||
href={libraryHref}
|
||||
data-testid="new-library-button"
|
||||
>
|
||||
{intl.formatMessage(messages.addNewLibraryBtnText)}
|
||||
</Button>,
|
||||
);
|
||||
|
||||
return headerButtons;
|
||||
}
|
||||
|
||||
@@ -77,7 +96,7 @@ const StudioHome = ({ intl }) => {
|
||||
return (
|
||||
<>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" className="studio-home">
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<section className="mb-4">
|
||||
<article className="studio-home-sub-header">
|
||||
<section>
|
||||
@@ -99,16 +118,22 @@ const StudioHome = ({ intl }) => {
|
||||
>
|
||||
<Layout.Element>
|
||||
<section>
|
||||
{showNewCourseContainer && (
|
||||
<CreateNewCourseForm handleOnClickCancel={() => setShowNewCourseContainer(false)} />
|
||||
{isLoadingPage ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
{showNewCourseContainer && (
|
||||
<CreateNewCourseForm handleOnClickCancel={() => setShowNewCourseContainer(false)} />
|
||||
)}
|
||||
{isShowOrganizationDropdown && <OrganizationSection />}
|
||||
<TabsSection
|
||||
tabsData={studioHomeData}
|
||||
showNewCourseContainer={showNewCourseContainer}
|
||||
onClickNewCourse={() => setShowNewCourseContainer(true)}
|
||||
isShowProcessing={isShowProcessing}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isShowOrganizationDropdown && <OrganizationSection />}
|
||||
{isShowProcessing && <ProcessingCourses />}
|
||||
<TabsSection
|
||||
tabsData={studioHomeData}
|
||||
showNewCourseContainer={showNewCourseContainer}
|
||||
onClickNewCourse={() => setShowNewCourseContainer(true)}
|
||||
/>
|
||||
</section>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
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';
|
||||
@@ -105,6 +105,7 @@ describe('<StudioHome />', async () => {
|
||||
it('shows the spinner before the query is complete', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
studioHomeLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
userIsActive: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -114,6 +115,42 @@ describe('<StudioHome />', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('render new library button', () => {
|
||||
it('href should include #libraries-tab', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const createNewLibraryButton = getByTestId('new-library-button');
|
||||
expect(createNewLibraryButton.getAttribute('href')).toBe(`${getConfig().STUDIO_BASE_URL}/home#libraries-tab`);
|
||||
});
|
||||
it('href should include home_library', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
|
||||
splitStudioHome: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const createNewLibraryButton = getByTestId('new-library-button');
|
||||
expect(createNewLibraryButton.getAttribute('href')).toBe(`${getConfig().STUDIO_BASE_URL}/home_library`);
|
||||
});
|
||||
it('href should include create', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
|
||||
redirectToLibraryAuthoringMfe: true,
|
||||
});
|
||||
const libraryAuthoringMfeUrl = 'http://localhost:3001';
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const createNewLibraryButton = getByTestId('new-library-button');
|
||||
expect(createNewLibraryButton.getAttribute('href')).toBe(`${libraryAuthoringMfeUrl}/create`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render create new course container', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
|
||||
@@ -60,7 +60,7 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
librariesEnabled: true,
|
||||
libraryAuthoringMfeUrl: 'http://somewhere',
|
||||
libraryAuthoringMfeUrl: 'http://localhost:3001',
|
||||
optimizationEnabled: false,
|
||||
redirectToLibraryAuthoringMfe: false,
|
||||
requestCourseCreatorUrl: '/request_course_creator',
|
||||
|
||||
@@ -28,6 +28,7 @@ const CardItem = ({
|
||||
return (
|
||||
<Card className="card-item">
|
||||
<Card.Header
|
||||
size="sm"
|
||||
title={!readOnlyItem ? (
|
||||
<Hyperlink
|
||||
className="card-item-title"
|
||||
|
||||
@@ -121,24 +121,25 @@ const CollapsibleStateWithAction = ({ state, className }) => {
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Body className="collapsible-body bg-light-200 py-3 px-3.5">
|
||||
<Collapsible.Body className="collapsible-body bg-light-white py-3 px-3.5">
|
||||
<p className="small text-gray-700">{description}</p>
|
||||
<h5 className="text-gray-700">{actionTitle}</h5>
|
||||
{[COURSE_CREATOR_STATES.denied, COURSE_CREATOR_STATES.pending].includes(state) ? (
|
||||
<div
|
||||
className={classNames('py-1 px-2.5 rounded-sm', {
|
||||
'bg-danger-300': state === COURSE_CREATOR_STATES.denied,
|
||||
'bg-warning-600': state === COURSE_CREATOR_STATES.pending,
|
||||
'bg-danger-100': state === COURSE_CREATOR_STATES.denied,
|
||||
'bg-warning-100': state === COURSE_CREATOR_STATES.pending,
|
||||
})}
|
||||
>
|
||||
<span className="d-inline-block text-white font-weight-bold m-2.5">
|
||||
<span className="d-inline-block text-black font-weight-bold m-2.5">
|
||||
{stateName}
|
||||
</span>
|
||||
<span className="text-white small">{actionText}</span>
|
||||
<span className="text-gray-700 small">{actionText}</span>
|
||||
</div>
|
||||
) : (
|
||||
<StatefulButton
|
||||
key="request-button"
|
||||
size="sm"
|
||||
onClick={() => dispatch(requestCourseCreatorQuery())}
|
||||
state={requestButtonCurrentState}
|
||||
{...requestButtonStates}
|
||||
|
||||
@@ -9,6 +9,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.studio-home.add-new-course.btn.text',
|
||||
defaultMessage: 'New course',
|
||||
},
|
||||
addNewLibraryBtnText: {
|
||||
id: 'course-authoring.studio-home.add-new-library.btn.text',
|
||||
defaultMessage: 'New library',
|
||||
},
|
||||
emailStaffBtnText: {
|
||||
id: 'course-authoring.studio-home.email-staff.btn.text',
|
||||
defaultMessage: 'Email staff to create course',
|
||||
|
||||
@@ -46,7 +46,7 @@ const OrganizationSection = ({ intl }) => {
|
||||
{intl.formatMessage(messages.organizationTitle)}
|
||||
</h3>
|
||||
<Form.Group className="organization-section-form d-flex align-items-baseline">
|
||||
<FormLabel className="organization-section-form-label w-50">
|
||||
<FormLabel isInline className="organization-section-form-label">
|
||||
{intl.formatMessage(messages.organizationLabel)}
|
||||
</FormLabel>
|
||||
<TypeaheadDropdown
|
||||
|
||||
@@ -31,18 +31,19 @@ const CourseItem = ({ course }) => {
|
||||
{isInProgress && (
|
||||
<Card className="card-item">
|
||||
<Card.Header
|
||||
size="sm"
|
||||
title={<p className="card-item-title">{displayName}</p>}
|
||||
subtitle={subtitle}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<Icon src={RotateRightIcon} className="spinner-icon text-gray-300" />
|
||||
<Icon src={RotateRightIcon} className="spinner-icon" />
|
||||
<ActionRow.Spacer />
|
||||
<span className="small text-gray-300">{intl.formatMessage(messages.itemInProgressActionText)}</span>
|
||||
<span className="small">{intl.formatMessage(messages.itemInProgressActionText)}</span>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Divider />
|
||||
<Card.Section className="p-3.5 small text-black bg-gray-100">
|
||||
<Card.Section className="p-3.5 small text-gray-700 bg-light-200">
|
||||
{intl.formatMessage(messages.itemInProgressFooterText, {
|
||||
refresh: (
|
||||
<Hyperlink destination="/home">
|
||||
@@ -57,22 +58,23 @@ const CourseItem = ({ course }) => {
|
||||
{isFailed && (
|
||||
<Card className="card-item">
|
||||
<Card.Header
|
||||
size="sm"
|
||||
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>
|
||||
<Icon src={WarningIcon} className="text-danger-500" />
|
||||
<span className="small">{intl.formatMessage(messages.itemIsFailedActionText)}</span>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Divider />
|
||||
<Card.Footer className="p-3.5 small text-white bg-danger-300 align-content-between">
|
||||
<Card.Footer className="p-3.5 small text-gray-700 bg-danger-100 align-content-between">
|
||||
<span className="w-75 mr-auto">{intl.formatMessage(messages.itemFailedFooterText)}</span>
|
||||
<Button
|
||||
onClick={() => dispatch(handleDeleteNotificationQuery(dismissLink))}
|
||||
iconBefore={CloseIcon}
|
||||
variant="outline-danger"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
>
|
||||
{intl.formatMessage(messages.itemFailedFooterButton)}
|
||||
|
||||
@@ -13,11 +13,11 @@ const ProcessingCourses = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-gray-300">
|
||||
<div className="text-gray-500 small">
|
||||
{intl.formatMessage(messages.processingTitle)}
|
||||
</p>
|
||||
</div>
|
||||
<hr />
|
||||
<Stack gap={3}>
|
||||
<Stack gap={3} className="border-bottom border-light-400 mb-4 px-4 pt-3">
|
||||
{inProcessCourseActions.map((course) => (
|
||||
<CourseItem
|
||||
course={course}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
.organization-section-form {
|
||||
margin: $spacer 0;
|
||||
margin: $spacer 0 -8px;
|
||||
|
||||
.organization-section-form-label {
|
||||
color: $gray-700;
|
||||
|
||||
@@ -8,11 +8,13 @@ import CardItem from '../../card-item';
|
||||
import CollapsibleStateWithAction from '../../collapsible-state-with-action';
|
||||
import { sortAlphabeticallyArray } from '../utils';
|
||||
import ContactAdministrator from './contact-administrator';
|
||||
import ProcessingCourses from '../../processing-courses';
|
||||
|
||||
const CoursesTab = ({
|
||||
coursesDataItems,
|
||||
showNewCourseContainer,
|
||||
onClickNewCourse,
|
||||
isShowProcessing,
|
||||
}) => {
|
||||
const {
|
||||
courseCreatorStatus,
|
||||
@@ -27,6 +29,7 @@ const CoursesTab = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isShowProcessing && <ProcessingCourses />}
|
||||
{coursesDataItems?.length ? (
|
||||
sortAlphabeticallyArray(coursesDataItems).map(
|
||||
({
|
||||
@@ -84,6 +87,7 @@ CoursesTab.propTypes = {
|
||||
).isRequired,
|
||||
showNewCourseContainer: PropTypes.bool.isRequired,
|
||||
onClickNewCourse: PropTypes.func.isRequired,
|
||||
isShowProcessing: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default CoursesTab;
|
||||
|
||||
@@ -11,7 +11,7 @@ import ArchivedTab from './archived-tab';
|
||||
import CoursesTab from './courses-tab';
|
||||
|
||||
const TabsSection = ({
|
||||
intl, tabsData, showNewCourseContainer, onClickNewCourse,
|
||||
intl, tabsData, showNewCourseContainer, onClickNewCourse, isShowProcessing,
|
||||
}) => {
|
||||
const TABS_LIST = {
|
||||
courses: 'courses',
|
||||
@@ -40,6 +40,7 @@ const TabsSection = ({
|
||||
coursesDataItems={courses}
|
||||
showNewCourseContainer={showNewCourseContainer}
|
||||
onClickNewCourse={onClickNewCourse}
|
||||
isShowProcessing={isShowProcessing}
|
||||
/>
|
||||
</Tab>,
|
||||
);
|
||||
@@ -73,7 +74,7 @@ const TabsSection = ({
|
||||
|
||||
const handleSelectTab = (tab) => {
|
||||
if (tab === TABS_LIST.libraries && redirectToLibraryAuthoringMfe) {
|
||||
window.location.href = libraryAuthoringMfeUrl;
|
||||
window.location.assign(libraryAuthoringMfeUrl);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,6 +124,7 @@ TabsSection.propTypes = {
|
||||
}).isRequired,
|
||||
showNewCourseContainer: PropTypes.bool.isRequired,
|
||||
onClickNewCourse: PropTypes.func.isRequired,
|
||||
isShowProcessing: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TabsSection);
|
||||
|
||||
Reference in New Issue
Block a user