feat: create Studio Home Page MFE (#589)

This commit is contained in:
ruzniaievdm
2023-09-19 17:04:43 +03:00
committed by GitHub
parent 6f238bdbe0
commit e099243437
107 changed files with 5976 additions and 309 deletions

View 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;

View File

@@ -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;
}
}
}

View File

@@ -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();
});
});
});

View File

@@ -0,0 +1,4 @@
const redirectToCourseIndex = (url) => `${url}/outline`;
// eslint-disable-next-line import/prefer-default-export
export { redirectToCourseIndex };

View 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 };

View File

@@ -0,0 +1,2 @@
export { default as CreateOrRerunCourseForm } from './CreateOrRerunCourseForm';
export { useCreateOrRerunCourse } from './hooks';

View 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
View 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);
}

View 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);
});
});

View 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
View 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;

View 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;
}
};
}

View 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);

View File

@@ -1,6 +1,4 @@
.help-sidebar {
margin-top: 8.563rem;
.help-sidebar-about {
.help-sidebar-about-title {
color: $black;

View File

@@ -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();
});

View File

@@ -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>

View File

@@ -6,4 +6,5 @@ export const otherLinkURLParams = {
advancedSettings: 'settings/advanced',
groupConfigurations: 'group_configurations',
proctoredExamSettings: 'proctored-exam-settings',
studioHome: 'home',
};

View File

@@ -0,0 +1,2 @@
export { default as HelpSidebar } from './HelpSidebar';
export { default as HelpSidebarLink } from './HelpSidebarLink';

View File

@@ -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);

View File

@@ -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;

View File

@@ -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";

View File

@@ -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,