feat: create Studio Home Page MFE (#589)
This commit is contained in:
296
src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx
Normal file
296
src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useParams } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
Dropdown,
|
||||
ActionRow,
|
||||
StatefulButton,
|
||||
TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { Info as InfoIcon } from '@edx/paragon/icons';
|
||||
import { TypeaheadDropdown } from '@edx/frontend-lib-content-components';
|
||||
|
||||
import AlertMessage from '../alert-message';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { getSavingStatus } from '../data/selectors';
|
||||
import { getStudioHomeData } from '../../studio-home/data/selectors';
|
||||
import { updatePostErrors } from '../data/slice';
|
||||
import { updateCreateOrRerunCourseQuery } from '../data/thunks';
|
||||
import { useCreateOrRerunCourse } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const CreateOrRerunCourseForm = ({
|
||||
title,
|
||||
isCreateNewCourse,
|
||||
initialValues,
|
||||
onClickCancel,
|
||||
}) => {
|
||||
const { courseId } = useParams();
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const { allowToCreateNewOrg } = useSelector(getStudioHomeData);
|
||||
const runFieldReference = useRef(null);
|
||||
const displayNameFieldReference = useRef(null);
|
||||
|
||||
const {
|
||||
intl,
|
||||
errors,
|
||||
values,
|
||||
postErrors,
|
||||
isFormFilled,
|
||||
isFormInvalid,
|
||||
organizations,
|
||||
showErrorBanner,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
setFieldValue,
|
||||
} = useCreateOrRerunCourse(initialValues);
|
||||
|
||||
const newCourseFields = [
|
||||
{
|
||||
label: intl.formatMessage(messages.courseDisplayNameLabel),
|
||||
helpText: intl.formatMessage(
|
||||
isCreateNewCourse
|
||||
? messages.courseDisplayNameCreateHelpText
|
||||
: messages.courseDisplayNameRerunHelpText,
|
||||
),
|
||||
name: 'displayName',
|
||||
value: values.displayName,
|
||||
placeholder: intl.formatMessage(messages.courseDisplayNamePlaceholder),
|
||||
disabled: false,
|
||||
ref: displayNameFieldReference,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.courseOrgLabel),
|
||||
helpText: isCreateNewCourse
|
||||
? intl.formatMessage(messages.courseOrgCreateHelpText, {
|
||||
strong: <strong>{intl.formatMessage(messages.courseNoteOrgNameIsPartStrong)}</strong>,
|
||||
})
|
||||
: intl.formatMessage(messages.courseOrgRerunHelpText, {
|
||||
strong: (
|
||||
<>
|
||||
<br />
|
||||
<strong>
|
||||
{intl.formatMessage(messages.courseNoteNoSpaceAllowedStrong)}
|
||||
</strong>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
name: 'org',
|
||||
value: values.org,
|
||||
options: organizations,
|
||||
placeholder: intl.formatMessage(messages.courseOrgPlaceholder),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.courseNumberLabel),
|
||||
helpText: isCreateNewCourse
|
||||
? intl.formatMessage(messages.courseNumberCreateHelpText, {
|
||||
strong: (
|
||||
<strong>
|
||||
{intl.formatMessage(messages.courseNotePartCourseURLRequireStrong)}
|
||||
</strong>
|
||||
),
|
||||
})
|
||||
: intl.formatMessage(messages.courseNumberRerunHelpText),
|
||||
name: 'number',
|
||||
value: values.number,
|
||||
placeholder: intl.formatMessage(messages.courseNumberPlaceholder),
|
||||
disabled: !isCreateNewCourse,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.courseRunLabel),
|
||||
helpText: isCreateNewCourse
|
||||
? intl.formatMessage(messages.courseRunCreateHelpText, {
|
||||
strong: (
|
||||
<strong>
|
||||
{intl.formatMessage(messages.courseNotePartCourseURLRequireStrong)}
|
||||
</strong>
|
||||
),
|
||||
})
|
||||
: intl.formatMessage(messages.courseRunRerunHelpText, {
|
||||
strong: (
|
||||
<>
|
||||
<br />
|
||||
<strong>
|
||||
{intl.formatMessage(messages.courseNoteNoSpaceAllowedStrong)}
|
||||
</strong>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
name: 'run',
|
||||
value: values.run,
|
||||
placeholder: intl.formatMessage(messages.courseRunPlaceholder),
|
||||
disabled: false,
|
||||
ref: runFieldReference,
|
||||
},
|
||||
];
|
||||
|
||||
const createButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(isCreateNewCourse ? messages.createButton : messages.rerunCreateButton),
|
||||
pending: intl.formatMessage(isCreateNewCourse ? messages.creatingButton : messages.rerunningCreateButton),
|
||||
},
|
||||
disabledStates: [STATEFUL_BUTTON_STATES.pending],
|
||||
};
|
||||
|
||||
const handleOnClickCreate = () => {
|
||||
const courseData = isCreateNewCourse ? values : { ...values, sourceCourseKey: courseId };
|
||||
dispatch(updateCreateOrRerunCourseQuery(courseData));
|
||||
};
|
||||
|
||||
const handleOnClickCancel = () => {
|
||||
dispatch(updatePostErrors({}));
|
||||
onClickCancel();
|
||||
};
|
||||
|
||||
const handleCustomBlurForDropdown = (e) => {
|
||||
// it needs to correct handleOnChange Form.Autosuggest
|
||||
const { value, name } = e.target;
|
||||
setFieldValue(name, value);
|
||||
handleBlur(e);
|
||||
};
|
||||
|
||||
const renderOrgField = (field) => (allowToCreateNewOrg ? (
|
||||
<TypeaheadDropdown
|
||||
readOnly={false}
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
controlClassName={classNames({ 'is-invalid': hasErrorField(field.name) })}
|
||||
options={field.options}
|
||||
placeholder={field.placeholder}
|
||||
handleBlur={handleCustomBlurForDropdown}
|
||||
handleChange={(value) => setFieldValue(field.name, value)}
|
||||
noOptionsMessage={intl.formatMessage(messages.courseOrgNoOptions)}
|
||||
helpMessage=""
|
||||
errorMessage=""
|
||||
floatingLabel=""
|
||||
/>
|
||||
) : (
|
||||
<Dropdown className="mr-2">
|
||||
<Dropdown.Toggle id={`${field.name}-dropdown`} variant="outline-primary">
|
||||
{field.value || intl.formatMessage(messages.courseOrgNoOptions)}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{field.options?.map((value) => (
|
||||
<Dropdown.Item
|
||||
key={value}
|
||||
onClick={() => setFieldValue(field.name, value)}
|
||||
>
|
||||
{value}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
// it needs to display the initial focus for the field depending on the current page
|
||||
if (!isCreateNewCourse) {
|
||||
runFieldReference?.current?.focus();
|
||||
} else {
|
||||
displayNameFieldReference?.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="create-or-rerun-course-form">
|
||||
<TransitionReplace>
|
||||
{showErrorBanner ? (
|
||||
<AlertMessage
|
||||
variant="danger"
|
||||
icon={InfoIcon}
|
||||
title={postErrors.errMsg}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(
|
||||
messages.alertErrorExistsAriaLabelledBy,
|
||||
)}
|
||||
aria-describedby={intl.formatMessage(
|
||||
messages.alertErrorExistsAriaDescribedBy,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
<h3 className="mb-3">{title}</h3>
|
||||
<Form>
|
||||
{newCourseFields.map((field) => (
|
||||
<Form.Group
|
||||
className={classNames('form-group-custom', {
|
||||
'form-group-custom_isInvalid': hasErrorField(field.name),
|
||||
})}
|
||||
key={field.label}
|
||||
>
|
||||
<Form.Label>{field.label}</Form.Label>
|
||||
{field.name !== 'org' ? (
|
||||
<Form.Control
|
||||
value={field.value}
|
||||
placeholder={field.placeholder}
|
||||
name={field.name}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
isInvalid={hasErrorField(field.name)}
|
||||
disabled={field.disabled}
|
||||
ref={field?.ref}
|
||||
/>
|
||||
) : renderOrgField(field)}
|
||||
<Form.Text>{field.helpText}</Form.Text>
|
||||
{hasErrorField(field.name) && (
|
||||
<Form.Control.Feedback
|
||||
className="feedback-error"
|
||||
type="invalid"
|
||||
hasIcon={false}
|
||||
>
|
||||
<span className="x-small">{errors[field.name]}</span>
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
))}
|
||||
<ActionRow className="justify-content-start">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={handleOnClickCancel}
|
||||
>
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</Button>
|
||||
<StatefulButton
|
||||
key="save-button"
|
||||
className="ml-3"
|
||||
onClick={handleOnClickCreate}
|
||||
disabled={!isFormFilled || isFormInvalid}
|
||||
state={
|
||||
savingStatus === RequestStatus.PENDING
|
||||
? STATEFUL_BUTTON_STATES.pending
|
||||
: STATEFUL_BUTTON_STATES.default
|
||||
}
|
||||
{...createButtonState}
|
||||
/>
|
||||
</ActionRow>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CreateOrRerunCourseForm.defaultProps = {
|
||||
title: '',
|
||||
isCreateNewCourse: false,
|
||||
};
|
||||
|
||||
CreateOrRerunCourseForm.propTypes = {
|
||||
title: PropTypes.string,
|
||||
initialValues: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
number: PropTypes.string.isRequired,
|
||||
run: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
isCreateNewCourse: PropTypes.bool,
|
||||
onClickCancel: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CreateOrRerunCourseForm;
|
||||
@@ -0,0 +1,18 @@
|
||||
.create-or-rerun-course-form {
|
||||
.form-group-custom {
|
||||
&:not(:last-child) {
|
||||
margin-bottom: $spacer;
|
||||
}
|
||||
|
||||
.pgn__form-label {
|
||||
font: normal 1.125rem/1.75rem $font-family-base;
|
||||
color: $gray-700;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.pgn__form-control-description,
|
||||
.pgn__form-text {
|
||||
margin-top: .62rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { studioHomeMock } from '../../studio-home/__mocks__';
|
||||
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
|
||||
import { fetchStudioHomeData } from '../../studio-home/data/thunks';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import { updateCreateOrRerunCourseQuery } from '../data/thunks';
|
||||
import { getCreateOrRerunCourseUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
import { CreateOrRerunCourseForm } from '.';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useParams: () => ({
|
||||
courseId: 'course-id-mock',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const onClickCancelMock = jest.fn();
|
||||
|
||||
const RootWrapper = (props) => (
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<CreateOrRerunCourseForm {...props} />
|
||||
</AppProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const props = {
|
||||
title: 'Mocked title',
|
||||
isCreateNewCourse: true,
|
||||
initialValues: {
|
||||
displayName: '',
|
||||
org: '',
|
||||
number: '',
|
||||
run: '',
|
||||
},
|
||||
onClickCancel: onClickCancelMock,
|
||||
};
|
||||
|
||||
describe('<CreateOrRerunCourseForm />', async () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
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();
|
||||
|
||||
expect(getByText(messages.courseOrgLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.courseOrgNoOptions.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(getByText(messages.courseNumberLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(getByText(messages.courseRunLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(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 rerun course form with help text successfully', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should call handleOnClickCancel if button cancel clicked', async () => {
|
||||
const { getByRole } = render(<RootWrapper {...props} />);
|
||||
const cancelBtn = getByRole('button', { name: messages.cancelButton.defaultMessage });
|
||||
act(() => {
|
||||
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 });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
{
|
||||
payload: {},
|
||||
type: 'generic/updatePostErrors',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should be disabled create button if form not filled', () => {
|
||||
const { getByRole } = render(<RootWrapper {...props} />);
|
||||
const createBtn = getByRole('button', { name: messages.createButton.defaultMessage });
|
||||
expect(createBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be disabled rerun button if form not filled', () => {
|
||||
const initialProps = { ...props, isCreateNewCourse: false };
|
||||
const { getByRole } = render(<RootWrapper {...initialProps} />);
|
||||
const rerunBtn = 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);
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(displayNameInput, { target: { value: 'foo course name' } });
|
||||
fireEvent.click(orgInput);
|
||||
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
|
||||
fireEvent.change(runInput, { target: { value: 'number with invalid (=) symbol' } });
|
||||
});
|
||||
|
||||
waitFor(() => {
|
||||
expect(createBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
const { getByText } = render(<RootWrapper {...props} />);
|
||||
expect(getByText('aaa')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error on field', () => {
|
||||
const { getByPlaceholderText, getByText } = render(<RootWrapper {...props} />);
|
||||
const numberInput = getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
|
||||
});
|
||||
|
||||
waitFor(() => {
|
||||
expect(getByText(messages.noSpaceError)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
4
src/generic/create-or-rerun-course/constants.js
Normal file
4
src/generic/create-or-rerun-course/constants.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const redirectToCourseIndex = (url) => `${url}/outline`;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { redirectToCourseIndex };
|
||||
122
src/generic/create-or-rerun-course/hooks.jsx
Normal file
122
src/generic/create-or-rerun-course/hooks.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { getStudioHomeData } from '../../studio-home/data/selectors';
|
||||
import {
|
||||
getRedirectUrlObj,
|
||||
getOrganizations,
|
||||
getPostErrors,
|
||||
getSavingStatus,
|
||||
} 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) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const redirectUrlObj = useSelector(getRedirectUrlObj);
|
||||
const createOrRerunCourseSavingStatus = useSelector(getSavingStatus);
|
||||
const allOrganizations = useSelector(getOrganizations);
|
||||
const postErrors = useSelector(getPostErrors);
|
||||
const {
|
||||
allowToCreateNewOrg,
|
||||
allowedOrganizations,
|
||||
} = useSelector(getStudioHomeData);
|
||||
const [isFormFilled, setFormFilled] = useState(false);
|
||||
const [showErrorBanner, setShowErrorBanner] = useState(false);
|
||||
const organizations = allowToCreateNewOrg ? allOrganizations : allowedOrganizations;
|
||||
const specialCharsRule = /^[a-zA-Z0-9_\-.'*~\s]+$/;
|
||||
const noSpaceRule = /^\S*$/;
|
||||
const validationSchema = Yup.object().shape({
|
||||
displayName: Yup.string().required(
|
||||
intl.formatMessage(messages.requiredFieldError),
|
||||
),
|
||||
org: Yup.string()
|
||||
.required(intl.formatMessage(messages.requiredFieldError))
|
||||
.matches(
|
||||
specialCharsRule,
|
||||
intl.formatMessage(messages.disallowedCharsError),
|
||||
)
|
||||
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
|
||||
number: Yup.string()
|
||||
.required(intl.formatMessage(messages.requiredFieldError))
|
||||
.matches(
|
||||
specialCharsRule,
|
||||
intl.formatMessage(messages.disallowedCharsError),
|
||||
)
|
||||
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
|
||||
run: Yup.string()
|
||||
.required(intl.formatMessage(messages.requiredFieldError))
|
||||
.matches(
|
||||
specialCharsRule,
|
||||
intl.formatMessage(messages.disallowedCharsError),
|
||||
)
|
||||
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
|
||||
});
|
||||
|
||||
const {
|
||||
values, errors, touched, handleChange, handleBlur, setFieldValue,
|
||||
} = useFormik({
|
||||
initialValues,
|
||||
enableReinitialize: true,
|
||||
validateOnBlur: false,
|
||||
validationSchema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (allowToCreateNewOrg) {
|
||||
dispatch(fetchOrganizationsQuery());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFormFilled(Object.values(values).every((i) => i));
|
||||
dispatch(updatePostErrors({}));
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowErrorBanner(!!postErrors.errMsg);
|
||||
}, [postErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (createOrRerunCourseSavingStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
const { url } = redirectUrlObj;
|
||||
if (url) {
|
||||
history.push(redirectToCourseIndex(url));
|
||||
}
|
||||
} else if (createOrRerunCourseSavingStatus === RequestStatus.FAILED) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
}
|
||||
}, [createOrRerunCourseSavingStatus]);
|
||||
|
||||
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
|
||||
const isFormInvalid = Object.keys(errors).length;
|
||||
|
||||
return {
|
||||
intl,
|
||||
errors,
|
||||
values,
|
||||
postErrors,
|
||||
isFormFilled,
|
||||
isFormInvalid,
|
||||
organizations,
|
||||
showErrorBanner,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
setFieldValue,
|
||||
setShowErrorBanner,
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { useCreateOrRerunCourse };
|
||||
2
src/generic/create-or-rerun-course/index.js
Normal file
2
src/generic/create-or-rerun-course/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CreateOrRerunCourseForm } from './CreateOrRerunCourseForm';
|
||||
export { useCreateOrRerunCourse } from './hooks';
|
||||
130
src/generic/create-or-rerun-course/messages.js
Normal file
130
src/generic/create-or-rerun-course/messages.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
courseDisplayNameLabel: {
|
||||
id: 'course-authoring.create-or-rerun-course.display-name.label',
|
||||
defaultMessage: 'Course name',
|
||||
},
|
||||
courseDisplayNamePlaceholder: {
|
||||
id: 'course-authoring.create-or-rerun-course.display-name.placeholder',
|
||||
defaultMessage: 'e.g. Introduction to Computer Science',
|
||||
},
|
||||
courseDisplayNameCreateHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.create.display-name.help-text',
|
||||
defaultMessage: 'The public display name for your course. This cannot be changed, but you can set a different display name in advanced settings later.',
|
||||
},
|
||||
courseDisplayNameRerunHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.rerun.display-name.help-text',
|
||||
defaultMessage: 'The public display name for the new course. (This name is often the same as the original course name.)',
|
||||
},
|
||||
courseOrgLabel: {
|
||||
id: 'course-authoring.create-or-rerun-course.org.label',
|
||||
defaultMessage: 'Organization',
|
||||
},
|
||||
courseOrgPlaceholder: {
|
||||
id: 'course-authoring.create-or-rerun-course.org.placeholder',
|
||||
defaultMessage: 'e.g. UniversityX or OrganizationX',
|
||||
},
|
||||
courseOrgNoOptions: {
|
||||
id: 'course-authoring.create-or-rerun-course.org.no-options',
|
||||
defaultMessage: 'No options',
|
||||
},
|
||||
courseOrgCreateHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.create.org.help-text',
|
||||
defaultMessage: 'The name of the organization sponsoring the course. {strong} This cannot be changed, but you can set a different display name in advanced settings later.',
|
||||
},
|
||||
courseOrgRerunHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.rerun.org.help-text',
|
||||
defaultMessage: 'The name of the organization sponsoring the new course. (This name is often the same as the original organization name.) {strong}',
|
||||
},
|
||||
courseNoteNoSpaceAllowedStrong: {
|
||||
id: 'course-authoring.create-or-rerun-course.no-space-allowed.strong',
|
||||
defaultMessage: 'Note: No spaces or special characters are allowed.',
|
||||
},
|
||||
courseNoteOrgNameIsPartStrong: {
|
||||
id: 'course-authoring.create-or-rerun-course.org.help-text.strong',
|
||||
defaultMessage: 'Note: The organization name is part of the course URL.',
|
||||
},
|
||||
courseNumberLabel: {
|
||||
id: 'course-authoring.create-or-rerun-course.number.label',
|
||||
defaultMessage: 'Course number',
|
||||
},
|
||||
courseNumberPlaceholder: {
|
||||
id: 'course-authoring.create-or-rerun-course.number.placeholder',
|
||||
defaultMessage: 'e.g. CS101',
|
||||
},
|
||||
courseNumberCreateHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.create.number.help-text',
|
||||
defaultMessage: 'The unique number that identifies your course within your organization. {strong}',
|
||||
},
|
||||
courseNumberRerunHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.rerun.number.help-text',
|
||||
defaultMessage: 'The unique number that identifies the new course within the organization. (This number will be the same as the original course number and cannot be changed.)',
|
||||
},
|
||||
courseNotePartCourseURLRequireStrong: {
|
||||
id: 'course-authoring.create-or-rerun-course.number.help-text.strong',
|
||||
defaultMessage: 'Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.',
|
||||
},
|
||||
courseRunLabel: {
|
||||
id: 'course-authoring.create-or-rerun-course.run.label',
|
||||
defaultMessage: 'Course run',
|
||||
},
|
||||
courseRunPlaceholder: {
|
||||
id: 'course-authoring.create-or-rerun-course.run.placeholder',
|
||||
defaultMessage: 'e.g. 2014_T1',
|
||||
},
|
||||
courseRunCreateHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.create.run.help-text',
|
||||
defaultMessage: 'The term in which your course will run. {strong}',
|
||||
},
|
||||
courseRunRerunHelpText: {
|
||||
id: 'course-authoring.create-or-rerun-course.create.rerun.help-text',
|
||||
defaultMessage: 'The term in which the new course will run. (This value is often different than the original course run value.){strong}',
|
||||
},
|
||||
defaultPlaceholder: {
|
||||
id: 'course-authoring.create-or-rerun-course.default-placeholder',
|
||||
defaultMessage: 'Label',
|
||||
},
|
||||
createButton: {
|
||||
id: 'course-authoring.create-or-rerun-course.create.button.create',
|
||||
defaultMessage: 'Create',
|
||||
},
|
||||
rerunCreateButton: {
|
||||
id: 'course-authoring.create-or-rerun-course.rerun.button.create',
|
||||
defaultMessage: 'Create re-run',
|
||||
},
|
||||
creatingButton: {
|
||||
id: 'course-authoring.create-or-rerun-course.button.creating',
|
||||
defaultMessage: 'Creating',
|
||||
},
|
||||
rerunningCreateButton: {
|
||||
id: 'course-authoring.create-or-rerun-course.rerun.button.rerunning',
|
||||
defaultMessage: 'Processing re-run request',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'course-authoring.create-or-rerun-course.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
requiredFieldError: {
|
||||
id: 'course-authoring.create-or-rerun-course.required.error',
|
||||
defaultMessage: 'Required field.',
|
||||
},
|
||||
disallowedCharsError: {
|
||||
id: 'course-authoring.create-or-rerun-course.disallowed-chars.error',
|
||||
defaultMessage: 'Please do not use any spaces or special characters in this field.',
|
||||
},
|
||||
noSpaceError: {
|
||||
id: 'course-authoring.create-or-rerun-course.no-space.error',
|
||||
defaultMessage: 'Please do not use any spaces in this field.',
|
||||
},
|
||||
alertErrorExistsAriaLabelledBy: {
|
||||
id: 'course-authoring.create-or-rerun-course.error.already-exists.labelledBy',
|
||||
defaultMessage: 'alert-already-exists-title',
|
||||
},
|
||||
alertErrorExistsAriaDescribedBy: {
|
||||
id: 'course-authoring.create-or-rerun-course.error.already-exists.aria.describedBy',
|
||||
defaultMessage: 'alert-confirmation-description',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
44
src/generic/data/api.js
Normal file
44
src/generic/data/api.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCreateOrRerunCourseUrl = new URL('course/', getApiBaseUrl()).href;
|
||||
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
|
||||
export const getOrganizationsUrl = new URL('organizations', getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* Get's organizations data.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getOrganizations() {
|
||||
const { data } = await getAuthenticatedHttpClient().get(
|
||||
getOrganizationsUrl,
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get's course rerun data.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseRerun(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(
|
||||
getCourseRerunUrl(courseId),
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or rerun course with data.
|
||||
* @param {object} data
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function createOrRerunCourse(courseData) {
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
getCreateOrRerunCourseUrl,
|
||||
convertObjectToSnakeCase(courseData, true),
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
75
src/generic/data/api.test.js
Normal file
75
src/generic/data/api.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import {
|
||||
createOrRerunCourse,
|
||||
getApiBaseUrl,
|
||||
getOrganizations,
|
||||
getCreateOrRerunCourseUrl,
|
||||
getCourseRerunUrl,
|
||||
getCourseRerun,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('generic api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get organizations', async () => {
|
||||
const organizationsData = ['edX', 'org'];
|
||||
const queryUrl = new URL('organizations', getApiBaseUrl()).href;
|
||||
axiosMock.onGet(queryUrl).reply(200, organizationsData);
|
||||
const result = await getOrganizations();
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(queryUrl);
|
||||
expect(result).toEqual(organizationsData);
|
||||
});
|
||||
|
||||
it('should get course rerun', async () => {
|
||||
const courseId = 'course-mock-id';
|
||||
const courseRerunData = {
|
||||
allowUnicodeCourseId: false,
|
||||
courseCreatorStatus: 'granted',
|
||||
displayName: 'Demonstration Course',
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
run: 'Demo_Course',
|
||||
};
|
||||
axiosMock.onGet(getCourseRerunUrl(courseId)).reply(200, courseRerunData);
|
||||
const result = await getCourseRerun(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getCourseRerunUrl(courseId));
|
||||
expect(result).toEqual(courseRerunData);
|
||||
});
|
||||
|
||||
it('should post create or rerun course', async () => {
|
||||
const courseRerunData = {
|
||||
allowUnicodeCourseId: false,
|
||||
courseCreatorStatus: 'granted',
|
||||
displayName: 'Demonstration Course',
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
run: 'Demo_Course',
|
||||
};
|
||||
axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200, courseRerunData);
|
||||
const result = await createOrRerunCourse(courseRerunData);
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl);
|
||||
expect(result).toEqual(courseRerunData);
|
||||
});
|
||||
});
|
||||
7
src/generic/data/selectors.js
Normal file
7
src/generic/data/selectors.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const getLoadingStatuses = (state) => state.generic.loadingStatuses;
|
||||
export const getSavingStatus = (state) => state.generic.savingStatus;
|
||||
export const getOrganizations = (state) => state.generic.organizations;
|
||||
export const getCourseData = (state) => state.generic.createOrRerunCourse.courseData;
|
||||
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
|
||||
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
|
||||
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
|
||||
59
src/generic/data/slice.js
Normal file
59
src/generic/data/slice.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'generic',
|
||||
initialState: {
|
||||
loadingStatuses: {
|
||||
organizationLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseRerunLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
savingStatus: '',
|
||||
organizations: [],
|
||||
createOrRerunCourse: {
|
||||
courseData: {},
|
||||
courseRerunData: {},
|
||||
redirectUrlObj: {},
|
||||
postErrors: {},
|
||||
},
|
||||
},
|
||||
reducers: {
|
||||
fetchOrganizations: (state, { payload }) => {
|
||||
state.organizations = payload;
|
||||
},
|
||||
updateLoadingStatuses: (state, { payload }) => {
|
||||
state.loadingStatuses = { ...state.loadingStatuses, ...payload };
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateCourseData: (state, { payload }) => {
|
||||
state.createOrRerunCourse.courseData = payload;
|
||||
},
|
||||
updateCourseRerunData: (state, { payload }) => {
|
||||
state.createOrRerunCourse.courseRerunData = payload;
|
||||
},
|
||||
updateRedirectUrlObj: (state, { payload }) => {
|
||||
state.createOrRerunCourse.redirectUrlObj = payload;
|
||||
},
|
||||
updatePostErrors: (state, { payload }) => {
|
||||
state.createOrRerunCourse.postErrors = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchOrganizations,
|
||||
updatePostErrors,
|
||||
updateCourseRerunData,
|
||||
updateLoadingStatuses,
|
||||
updateSavingStatus,
|
||||
updateCourseData,
|
||||
updateRedirectUrlObj,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
51
src/generic/data/thunks.js
Normal file
51
src/generic/data/thunks.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api';
|
||||
import {
|
||||
fetchOrganizations,
|
||||
updatePostErrors,
|
||||
updateLoadingStatuses,
|
||||
updateRedirectUrlObj,
|
||||
updateCourseRerunData,
|
||||
updateSavingStatus,
|
||||
} from './slice';
|
||||
|
||||
export function fetchOrganizationsQuery() {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const organizations = await getOrganizations();
|
||||
dispatch(fetchOrganizations(organizations));
|
||||
dispatch(updateLoadingStatuses({ organizationLoadingStatus: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatuses({ organizationLoadingStatus: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseRerunQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const courseRerun = await getCourseRerun(courseId);
|
||||
dispatch(updateCourseRerunData(courseRerun));
|
||||
dispatch(updateLoadingStatuses({ courseRerunLoadingStatus: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatuses({ courseRerunLoadingStatus: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCreateOrRerunCourseQuery(courseData) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
const response = await createOrRerunCourse(courseData);
|
||||
dispatch(updateRedirectUrlObj('url' in response ? response : {}));
|
||||
dispatch(updatePostErrors('errMsg' in response ? response : {}));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
123
src/generic/help-sidebar/HelpSidebar.jsx
Normal file
123
src/generic/help-sidebar/HelpSidebar.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { otherLinkURLParams } from './constants';
|
||||
import messages from './messages';
|
||||
import HelpSidebarLink from './HelpSidebarLink';
|
||||
|
||||
const HelpSidebar = ({
|
||||
intl,
|
||||
courseId,
|
||||
showOtherSettings,
|
||||
proctoredExamSettingsUrl,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const { pathname } = useLocation();
|
||||
const {
|
||||
grading,
|
||||
courseTeam,
|
||||
advancedSettings,
|
||||
scheduleAndDetails,
|
||||
groupConfigurations,
|
||||
} = otherLinkURLParams;
|
||||
|
||||
const showOtherLink = (params) => !pathname.includes(params);
|
||||
const generateLegacyURL = (urlParameter) => {
|
||||
const referObj = new URL(`${urlParameter}/${courseId}`, getConfig().STUDIO_BASE_URL);
|
||||
return referObj.href;
|
||||
};
|
||||
|
||||
const scheduleAndDetailsDestination = generateLegacyURL(scheduleAndDetails);
|
||||
const gradingDestination = generateLegacyURL(grading);
|
||||
const courseTeamDestination = generateLegacyURL(courseTeam);
|
||||
const advancedSettingsDestination = generateLegacyURL(advancedSettings);
|
||||
const groupConfigurationsDestination = generateLegacyURL(groupConfigurations);
|
||||
|
||||
return (
|
||||
<aside className={classNames('help-sidebar', className)}>
|
||||
<div className="help-sidebar-about">{children}</div>
|
||||
{showOtherSettings && (
|
||||
<>
|
||||
<hr />
|
||||
<div className="help-sidebar-other">
|
||||
<h4 className="help-sidebar-other-title">
|
||||
{intl.formatMessage(messages.sidebarTitleOther)}
|
||||
</h4>
|
||||
<nav
|
||||
className="help-sidebar-other-links"
|
||||
aria-label={intl.formatMessage(messages.sidebarTitleOther)}
|
||||
>
|
||||
<ul className="p-0 mb-0">
|
||||
{showOtherLink(scheduleAndDetails) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={scheduleAndDetailsDestination}
|
||||
title={intl.formatMessage(
|
||||
messages.sidebarLinkToScheduleAndDetails,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(grading) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={gradingDestination}
|
||||
title={intl.formatMessage(messages.sidebarLinkToGrading)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(courseTeam) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={courseTeamDestination}
|
||||
title={intl.formatMessage(messages.sidebarLinkToCourseTeam)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(groupConfigurations) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={groupConfigurationsDestination}
|
||||
title={intl.formatMessage(
|
||||
messages.sidebarLinkToGroupConfigurations,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(advancedSettings) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={advancedSettingsDestination}
|
||||
title={intl.formatMessage(messages.sidebarLinkToAdvancedSettings)}
|
||||
/>
|
||||
)}
|
||||
{proctoredExamSettingsUrl && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={proctoredExamSettingsUrl}
|
||||
title={intl.formatMessage(
|
||||
messages.sidebarLinkToProctoredExamSettings,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
HelpSidebar.defaultProps = {
|
||||
proctoredExamSettingsUrl: '',
|
||||
className: undefined,
|
||||
courseId: undefined,
|
||||
showOtherSettings: false,
|
||||
};
|
||||
|
||||
HelpSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string,
|
||||
showOtherSettings: PropTypes.bool,
|
||||
proctoredExamSettingsUrl: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(HelpSidebar);
|
||||
@@ -1,6 +1,4 @@
|
||||
.help-sidebar {
|
||||
margin-top: 8.563rem;
|
||||
|
||||
.help-sidebar-about {
|
||||
.help-sidebar-about-title {
|
||||
color: $black;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import HelpSidebar from '.';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../../store';
|
||||
import messages from './messages';
|
||||
import { HelpSidebar } from '.';
|
||||
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
let store;
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
@@ -15,11 +18,15 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
const RootWrapper = (props) => (
|
||||
<IntlProvider locale="en">
|
||||
<HelpSidebar {...props}>
|
||||
<p>Test children</p>
|
||||
</HelpSidebar>
|
||||
</IntlProvider>
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<HelpSidebar
|
||||
{...props}
|
||||
>
|
||||
<p>Test children</p>
|
||||
</HelpSidebar>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const props = {
|
||||
@@ -29,6 +36,19 @@ const props = {
|
||||
};
|
||||
|
||||
describe('HelpSidebar', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('renders children correctly', () => {
|
||||
const { getByText } = render(<RootWrapper {...props} />);
|
||||
expect(getByText('Test children')).toBeTruthy();
|
||||
@@ -57,7 +77,7 @@ describe('HelpSidebar', () => {
|
||||
});
|
||||
|
||||
it('should render proctored mfe url only if passed not empty value', () => {
|
||||
const initialProps = { ...props, proctoredExamSettingsUrl: 'http:/link-to' };
|
||||
const initialProps = { ...props, showOtherSettings: true, proctoredExamSettingsUrl: 'http:/link-to' };
|
||||
const { getByText } = render(<RootWrapper {...initialProps} />);
|
||||
expect(getByText(messages.sidebarLinkToProctoredExamSettings.defaultMessage)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@ const HelpSidebarLink = ({ as, pathToPage, title }) => {
|
||||
const TagElement = as;
|
||||
return (
|
||||
<TagElement className="sidebar-link">
|
||||
<Hyperlink destination={pathToPage}>
|
||||
<Hyperlink
|
||||
destination={pathToPage}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{title}
|
||||
</Hyperlink>
|
||||
</TagElement>
|
||||
|
||||
@@ -6,4 +6,5 @@ export const otherLinkURLParams = {
|
||||
advancedSettings: 'settings/advanced',
|
||||
groupConfigurations: 'group_configurations',
|
||||
proctoredExamSettings: 'proctored-exam-settings',
|
||||
studioHome: 'home',
|
||||
};
|
||||
|
||||
2
src/generic/help-sidebar/index.js
Normal file
2
src/generic/help-sidebar/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as HelpSidebar } from './HelpSidebar';
|
||||
export { default as HelpSidebarLink } from './HelpSidebarLink';
|
||||
@@ -1,119 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import HelpSidebarLink from './HelpSidebarLink';
|
||||
import { otherLinkURLParams } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
const HelpSidebar = ({
|
||||
intl,
|
||||
courseId,
|
||||
showOtherSettings,
|
||||
proctoredExamSettingsUrl,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const { pathname } = useLocation();
|
||||
const {
|
||||
grading,
|
||||
courseTeam,
|
||||
advancedSettings,
|
||||
scheduleAndDetails,
|
||||
groupConfigurations,
|
||||
} = otherLinkURLParams;
|
||||
|
||||
const showOtherLink = (params) => !pathname.includes(params);
|
||||
const generateLegacyURL = (urlParameter) => {
|
||||
const referObj = new URL(`${urlParameter}/${courseId}`, getConfig().STUDIO_BASE_URL);
|
||||
return referObj.href;
|
||||
};
|
||||
|
||||
const scheduleAndDetailsDestination = generateLegacyURL(scheduleAndDetails);
|
||||
const gradingDestination = generateLegacyURL(grading);
|
||||
const courseTeamDestination = generateLegacyURL(courseTeam);
|
||||
const advancedSettingsDestination = generateLegacyURL(advancedSettings);
|
||||
const groupConfigurationsDestination = generateLegacyURL(groupConfigurations);
|
||||
|
||||
return (
|
||||
<aside className={classNames('help-sidebar', className)}>
|
||||
<div className="help-sidebar-about">{children}</div>
|
||||
<hr />
|
||||
{showOtherSettings && (
|
||||
<div className="help-sidebar-other">
|
||||
<h4 className="help-sidebar-other-title">
|
||||
{intl.formatMessage(messages.sidebarTitleOther)}
|
||||
</h4>
|
||||
<nav
|
||||
className="help-sidebar-other-links"
|
||||
aria-label={intl.formatMessage(messages.sidebarTitleOther)}
|
||||
>
|
||||
<ul className="p-0 mb-0">
|
||||
{showOtherLink(scheduleAndDetails) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={scheduleAndDetailsDestination}
|
||||
title={intl.formatMessage(
|
||||
messages.sidebarLinkToScheduleAndDetails,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(grading) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={gradingDestination}
|
||||
title={intl.formatMessage(messages.sidebarLinkToGrading)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(courseTeam) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={courseTeamDestination}
|
||||
title={intl.formatMessage(messages.sidebarLinkToCourseTeam)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(groupConfigurations) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={groupConfigurationsDestination}
|
||||
title={intl.formatMessage(
|
||||
messages.sidebarLinkToGroupConfigurations,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showOtherLink(advancedSettings) && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={advancedSettingsDestination}
|
||||
title={intl.formatMessage(messages.sidebarLinkToAdvancedSettings)}
|
||||
/>
|
||||
)}
|
||||
{proctoredExamSettingsUrl && (
|
||||
<HelpSidebarLink
|
||||
pathToPage={proctoredExamSettingsUrl}
|
||||
title={intl.formatMessage(
|
||||
messages.sidebarLinkToProctoredExamSettings,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
HelpSidebar.defaultProps = {
|
||||
proctoredExamSettingsUrl: '',
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
HelpSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
showOtherSettings: PropTypes.bool.isRequired,
|
||||
proctoredExamSettingsUrl: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(HelpSidebar);
|
||||
@@ -66,14 +66,15 @@ const InternetConnectionAlert = ({
|
||||
|
||||
InternetConnectionAlert.defaultProps = {
|
||||
isQueryPending: false,
|
||||
onQueryProcessing: null,
|
||||
onQueryProcessing: () => ({}),
|
||||
onInternetConnectionFailed: () => ({}),
|
||||
};
|
||||
|
||||
InternetConnectionAlert.propTypes = {
|
||||
isFailed: PropTypes.bool.isRequired,
|
||||
isQueryPending: PropTypes.bool,
|
||||
onQueryProcessing: PropTypes.func,
|
||||
onInternetConnectionFailed: PropTypes.func.isRequired,
|
||||
onInternetConnectionFailed: PropTypes.func,
|
||||
};
|
||||
|
||||
export default InternetConnectionAlert;
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
@import "./sub-header/SubHeader";
|
||||
@import "./section-sub-header/SectionSubHeader";
|
||||
@import "./processing-notification/ProccessingNotification";
|
||||
@import "./create-or-rerun-course/CreateOrRerunCourseForm";
|
||||
@import "./WysiwygEditor";
|
||||
@import "./course-stepper/CouseStepper";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow } from '@edx/paragon';
|
||||
|
||||
const SubHeader = ({
|
||||
title, subtitle, contentTitle, description, instruction, headerActions,
|
||||
@@ -11,9 +12,9 @@ const SubHeader = ({
|
||||
{title}
|
||||
</h2>
|
||||
{headerActions && (
|
||||
<div className="ml-auto sub-header-actions">
|
||||
<ActionRow className="ml-auto sub-header-actions">
|
||||
{headerActions}
|
||||
</div>
|
||||
</ActionRow>
|
||||
)}
|
||||
</header>
|
||||
<header className="sub-header-content">
|
||||
@@ -28,12 +29,14 @@ const SubHeader = ({
|
||||
SubHeader.defaultProps = {
|
||||
instruction: '',
|
||||
description: '',
|
||||
subtitle: '',
|
||||
contentTitle: '',
|
||||
headerActions: null,
|
||||
};
|
||||
SubHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
subtitle: PropTypes.string.isRequired,
|
||||
contentTitle: PropTypes.string.isRequired,
|
||||
subtitle: PropTypes.string,
|
||||
contentTitle: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
instruction: PropTypes.oneOfType([
|
||||
PropTypes.element,
|
||||
|
||||
Reference in New Issue
Block a user