fix: studio home UI bugs (#611)

This commit is contained in:
Kristin Aoki
2023-09-28 18:36:51 -04:00
committed by GitHub
parent 4840666664
commit 01ddac380f
24 changed files with 340 additions and 185 deletions

View File

@@ -246,7 +246,7 @@ const CreateOrRerunCourseForm = ({
type="invalid"
hasIcon={false}
>
<span className="x-small">{errors[field.name]}</span>
{errors[field.name]}
</Form.Control.Feedback>
)}
</Form.Group>

View File

@@ -1,11 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import {
act,
fireEvent,
screen,
render,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ReactDOM from 'react-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -22,6 +25,7 @@ import { updateCreateOrRerunCourseQuery } from '../data/thunks';
import { getCreateOrRerunCourseUrl } from '../data/api';
import messages from './messages';
import { CreateOrRerunCourseForm } from '.';
import { initialState } from './factories/mockApiResponses';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
@@ -30,15 +34,9 @@ jest.mock('react-router', () => ({
}),
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
useDispatch: () => mockDispatch,
}));
let axiosMock;
let store;
ReactDOM.createPortal = jest.fn(node => node);
const onClickCancelMock = jest.fn();
@@ -62,7 +60,13 @@ const props = {
onClickCancel: onClickCancelMock,
};
describe('<CreateOrRerunCourseForm />', async () => {
const mockStore = async () => {
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
await executeThunk(fetchStudioHomeData, store.dispatch);
};
describe('<CreateOrRerunCourseForm />', () => {
afterEach(() => jest.clearAllMocks());
beforeEach(async () => {
initializeMockApp({
@@ -74,116 +78,136 @@ describe('<CreateOrRerunCourseForm />', async () => {
},
});
store = initializeStore();
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200);
await executeThunk(fetchStudioHomeData, store.dispatch);
await executeThunk(updateCreateOrRerunCourseQuery, store.dispatch);
useSelector.mockReturnValue(studioHomeMock);
});
it('renders form successfully', () => {
const { getByText, getByPlaceholderText } = render(
<RootWrapper {...props} />,
);
expect(getByText(props.title)).toBeInTheDocument();
expect(getByText(messages.courseDisplayNameLabel.defaultMessage)).toBeInTheDocument();
expect(getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage)).toBeInTheDocument();
it('renders form successfully', async () => {
render(<RootWrapper {...props} />);
await mockStore();
expect(getByText(messages.courseOrgLabel.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.courseOrgNoOptions.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(props.title)).toBeInTheDocument();
expect(screen.getByText(messages.courseDisplayNameLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.courseNumberLabel.defaultMessage)).toBeInTheDocument();
expect(getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseOrgLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseOrgNoOptions.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.courseRunLabel.defaultMessage)).toBeInTheDocument();
expect(getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseNumberLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseRunLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage)).toBeInTheDocument();
});
it('renders create course form with help text successfully', () => {
const { getByText, getByRole } = render(<RootWrapper {...props} />);
expect(getByText(messages.courseDisplayNameCreateHelpText.defaultMessage)).toBeInTheDocument();
expect(getByText('The name of the organization sponsoring the course.', { exact: false })).toBeInTheDocument();
expect(getByText('The unique number that identifies your course within your organization.', { exact: false })).toBeInTheDocument();
expect(getByText('The term in which your course will run.', { exact: false })).toBeInTheDocument();
expect(getByRole('button', { name: messages.createButton.defaultMessage })).toBeInTheDocument();
it('renders create course form with help text successfully', async () => {
render(<RootWrapper {...props} />);
await mockStore();
expect(screen.getByText(messages.courseDisplayNameCreateHelpText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText('The name of the organization sponsoring the course.', { exact: false })).toBeInTheDocument();
expect(screen.getByText('The unique number that identifies your course within your organization.', { exact: false })).toBeInTheDocument();
expect(screen.getByText('The term in which your course will run.', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.createButton.defaultMessage })).toBeInTheDocument();
});
it('renders rerun course form with help text successfully', () => {
it('renders rerun course form with help text successfully', async () => {
const initialProps = { ...props, isCreateNewCourse: false };
const { getByText, getByRole } = render(
<RootWrapper {...initialProps} />,
);
expect(getByText(messages.courseDisplayNameRerunHelpText.defaultMessage)).toBeInTheDocument();
expect(getByText('The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)', { exact: false })).toBeInTheDocument();
expect(getByText(messages.courseNumberRerunHelpText.defaultMessage)).toBeInTheDocument();
expect(getByText('The term in which the new course will run. (This value is often different than the original course run value.)', { exact: false })).toBeInTheDocument();
expect(getByRole('button', { name: messages.rerunCreateButton.defaultMessage })).toBeInTheDocument();
render(<RootWrapper {...initialProps} />);
await mockStore();
expect(screen.getByText(messages.courseDisplayNameRerunHelpText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText('The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)', { exact: false })).toBeInTheDocument();
expect(screen.getByText(messages.courseNumberRerunHelpText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText('The term in which the new course will run. (This value is often different than the original course run value.)', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.rerunCreateButton.defaultMessage })).toBeInTheDocument();
});
it('should call handleOnClickCancel if button cancel clicked', async () => {
const { getByRole } = render(<RootWrapper {...props} />);
const cancelBtn = getByRole('button', { name: messages.cancelButton.defaultMessage });
act(() => {
render(<RootWrapper {...props} />);
await mockStore();
const cancelBtn = screen.getByRole('button', { name: messages.cancelButton.defaultMessage });
await act(async () => {
fireEvent.click(cancelBtn);
});
expect(onClickCancelMock).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith(
{
payload: {},
type: 'generic/updatePostErrors',
},
);
});
it('should call handleOnClickCreate if button create clicked', async () => {
const { getByPlaceholderText, getByText, getByRole } = render(<RootWrapper {...props} />);
const displayNameInput = getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
const createBtn = getByRole('button', { name: messages.createButton.defaultMessage });
describe('handleOnClickCreate', () => {
delete window.location;
window.location = { assign: jest.fn() };
it('should call window.location.assign with url', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const url = '/course/courseId';
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
act(() => {
fireEvent.change(displayNameInput, { target: { value: 'foo course name' } });
fireEvent.click(orgInput);
fireEvent.change(numberInput, { target: { value: '777' } });
fireEvent.change(runInput, { target: { value: '1' } });
fireEvent.click(createBtn);
await act(async () => {
userEvent.type(displayNameInput, 'foo course name');
fireEvent.click(orgInput);
userEvent.type(numberInput, '777');
userEvent.type(runInput, '1');
userEvent.click(createBtn);
});
await axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200, { url });
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
expect(window.location.assign).toHaveBeenCalledWith(`${process.env.STUDIO_BASE_URL}${url}`);
});
it('should call window.location.assign with url and destinationCourseKey', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const url = '/course/';
const destinationCourseKey = 'courseKey';
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
await axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200, { url, destinationCourseKey });
expect(mockDispatch).toHaveBeenCalledWith(
{
payload: {},
type: 'generic/updatePostErrors',
},
);
await act(async () => {
userEvent.type(displayNameInput, 'foo course name');
fireEvent.click(orgInput);
userEvent.type(numberInput, '777');
userEvent.type(runInput, '1');
userEvent.click(createBtn);
});
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
expect(window.location.assign).toHaveBeenCalledWith(`${process.env.STUDIO_BASE_URL}${url}${destinationCourseKey}`);
});
});
it('should be disabled create button if form not filled', () => {
const { getByRole } = render(<RootWrapper {...props} />);
const createBtn = getByRole('button', { name: messages.createButton.defaultMessage });
it('should be disabled create button if form not filled', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
expect(createBtn).toBeDisabled();
});
it('should be disabled rerun button if form not filled', () => {
it('should be disabled rerun button if form not filled', async () => {
const initialProps = { ...props, isCreateNewCourse: false };
const { getByRole } = render(<RootWrapper {...initialProps} />);
const rerunBtn = getByRole('button', { name: messages.rerunCreateButton.defaultMessage });
render(<RootWrapper {...initialProps} />);
await mockStore();
const rerunBtn = screen.getByRole('button', { name: messages.rerunCreateButton.defaultMessage });
expect(rerunBtn).toBeDisabled();
});
it('should be disabled create button if form has error', () => {
const { getByRole, getByPlaceholderText, getByText } = render(<RootWrapper {...props} />);
const createBtn = getByRole('button', { name: messages.createButton.defaultMessage });
const displayNameInput = getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
it('should be disabled create button if form has error', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
act(() => {
await act(async () => {
fireEvent.change(displayNameInput, { target: { value: 'foo course name' } });
fireEvent.click(orgInput);
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
@@ -195,37 +219,54 @@ describe('<CreateOrRerunCourseForm />', async () => {
});
});
it('shows typeahead dropdown with allowed to create org permissions', () => {
useSelector.mockReturnValue({ ...studioHomeMock, allowToCreateNewOrg: true });
const { getByPlaceholderText } = render(<RootWrapper {...props} />);
expect(getByPlaceholderText(messages.courseOrgPlaceholder.defaultMessage));
});
it('shows button pending state', () => {
useSelector.mockReturnValue(RequestStatus.PENDING);
const { getByRole } = render(<RootWrapper {...props} />);
expect(getByRole('button', { name: messages.creatingButton.defaultMessage })).toBeInTheDocument();
});
it('shows alert error if postErrors presents', () => {
useSelector.mockReturnValue({
errMsg: 'aaa',
orgErrMsg: 'bbb',
courseErrMsg: 'ccc',
it('shows typeahead dropdown with allowed to create org permissions', async () => {
const updatedStudioData = { ...studioHomeMock, allowToCreateNewOrg: true };
store = initializeStore({
...initialState,
studioHome: {
...initialState.studioHome,
studioHomeData: updatedStudioData,
},
});
const { getByText } = render(<RootWrapper {...props} />);
expect(getByText('aaa')).toBeInTheDocument();
render(<RootWrapper {...props} />);
await mockStore();
expect(screen.getByPlaceholderText(messages.courseOrgPlaceholder.defaultMessage));
});
it('shows error on field', () => {
const { getByPlaceholderText, getByText } = render(<RootWrapper {...props} />);
const numberInput = getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
it('shows button pending state', async () => {
store = initializeStore({
...initialState,
generic: {
...initialState.generic,
savingStatus: RequestStatus.PENDING,
},
});
render(<RootWrapper {...props} />);
await mockStore();
expect(screen.getByRole('button', { name: messages.creatingButton.defaultMessage })).toBeInTheDocument();
});
act(() => {
it('shows alert error if postErrors presents', async () => {
render(<RootWrapper {...props} />);
await mockStore();
await axiosMock.onPost(getCreateOrRerunCourseUrl).reply(200, { errMsg: 'aaa' });
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
expect(screen.getByText('aaa')).toBeInTheDocument();
});
it('shows error on field', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
await act(async () => {
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
});
waitFor(() => {
expect(getByText(messages.noSpaceError)).toBeInTheDocument();
expect(screen.getByText(messages.noSpaceError)).toBeInTheDocument();
});
});
});

View File

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

View File

@@ -0,0 +1,31 @@
import { RequestStatus } from '../../../data/constants';
import { studioHomeMock } from '../../../studio-home/__mocks__';
export const courseId = 'course-v1:edX+DemoX+Demo_Course';
export const initialState = {
generic: {
createOrRerunCourse: {
courseData: {},
courseRerunData: {},
redirectUrlObj: {},
postErrors: {},
},
loadingStatuses: {
organizationLoadingStatus: 'successful', courseRerunLoadingStatus: 'successful',
},
organizations: ['krisEdx', 'krisEd', 'DeveloperInc', 'importMit', 'testX', 'edX', 'developerInb'],
savingStatus: '',
},
studioHome: {
loadingStatuses: {
studioHomeLoadingStatus: RequestStatus.SUCCESSFUL,
courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS,
},
savingStatuses: {
courseCreatorSavingStatus: '',
deleteNotificationSavingStatus: '',
},
studioHomeData: studioHomeMock,
},
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useFormik } from 'formik';
import * as Yup from 'yup';
@@ -15,7 +15,6 @@ import {
} from '../data/selectors';
import { updateSavingStatus, updatePostErrors } from '../data/slice';
import { fetchOrganizationsQuery } from '../data/thunks';
import { redirectToCourseIndex } from './constants';
import messages from './messages';
const useCreateOrRerunCourse = (initialValues) => {
@@ -88,9 +87,16 @@ const useCreateOrRerunCourse = (initialValues) => {
useEffect(() => {
if (createOrRerunCourseSavingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatus({ status: '' }));
const { url } = redirectUrlObj;
const { url, destinationCourseKey } = redirectUrlObj;
// New courses' url to the outline page is provided in the url. However, for course
// re-runs the url is /course/. The actual destination for the rer-run's outline
// is in the destionationCourseKey attribute from the api.
if (url) {
history.push(redirectToCourseIndex(url));
if (destinationCourseKey) {
window.location.assign(`${getConfig().STUDIO_BASE_URL}${url}${destinationCourseKey}`);
} else {
window.location.assign(`${getConfig().STUDIO_BASE_URL}${url}`);
}
}
} else if (createOrRerunCourseSavingStatus === RequestStatus.FAILED) {
dispatch(updateSavingStatus({ status: '' }));