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

@@ -79,14 +79,3 @@
color: $black;
}
}
.dropdown-group-wrapper {
position: relative;
z-index: $zindex-dropdown;
margin-left: auto;
.dropdown-container {
position: absolute;
width: 100%;
}
}

View File

@@ -44,7 +44,7 @@ describe('<CourseRerunSideBar />', () => {
const { getByText } = renderComponent();
expect(getByText(messages.sectionTitle1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionDescription1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionDescription1.defaultMessage, { exact: false })).toBeInTheDocument();
expect(getByText(messages.sectionTitle2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionDescription2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionTitle3.defaultMessage)).toBeInTheDocument();

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { v4 as uuid } from 'uuid';
import { Hyperlink } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import { useHelpUrls } from '../../help-urls/hooks';
import { HelpSidebar } from '../../generic/help-sidebar';
@@ -10,11 +10,23 @@ import messages from './messages';
const CourseRerunSideBar = () => {
const intl = useIntl();
const { default: learnMoreUrl } = useHelpUrls(['default']);
const defaultCourseDate = new Date(Date.UTC(2030, 0, 1, 0, 0));
const localizedCourseDate = (
<FormattedDate
value={defaultCourseDate}
year="numeric"
month="long"
day="2-digit"
hour="numeric"
minute="numeric"
/>
);
const sidebarMessages = [
{
title: intl.formatMessage(messages.sectionTitle1),
description: intl.formatMessage(messages.sectionDescription1),
description: `${intl.formatMessage(messages.sectionDescription1)}`,
date: localizedCourseDate,
},
{
title: intl.formatMessage(messages.sectionTitle2),
@@ -38,13 +50,18 @@ const CourseRerunSideBar = () => {
showOtherSettings={false}
className="mt-3"
>
{sidebarMessages.map(({ title, description, link }, index) => {
{sidebarMessages.map(({
title,
description,
link,
date,
}, index) => {
const isLastSection = index === sidebarMessages.length - 1;
return (
<div key={uuid()}>
<h4 className="help-sidebar-about-title">{title}</h4>
<p className="help-sidebar-about-descriptions">{description}</p>
<p className="help-sidebar-about-descriptions">{description} {date}</p>
{!!link && (
<Hyperlink
className="small"

View File

@@ -7,7 +7,7 @@ const messages = defineMessages({
},
sectionDescription1: {
id: 'course-authoring.course-rerun.sidebar.section-1.description',
defaultMessage: 'The new course is set to start on January 1, 2030 at midnight (UTC).',
defaultMessage: 'The new course is set to start on',
},
sectionTitle2: {
id: 'course-authoring.course-rerun.sidebar.section-2.title',

View File

@@ -42,24 +42,24 @@ const CourseRerun = ({ courseId }) => {
return (
<>
<Header isHiddenMainMenu />
<Container size="xl" className="m-4">
<Container size="xl" className="small p-4 mt-3">
<section className="mb-4">
<article>
<section>
<header className="d-flex">
<h3 className="align-self-center font-weight-normal mb-0">{intl.formatMessage(messages.rerunTitle)}</h3>
<Stack>
<h2>
{intl.formatMessage(messages.rerunTitle)} {displayName}
</h2>
<span className="large">{originalCourseData}</span>
</Stack>
<ActionRow className="ml-auto">
<Button variant="outline-primary" onClick={handleRerunCourseCancel}>
<Button variant="outline-primary" size="sm" onClick={handleRerunCourseCancel}>
{intl.formatMessage(messages.cancelButton)}
</Button>
</ActionRow>
</header>
<hr />
<Stack>
<h3>{originalCourseData}</h3>
<h2>{displayName}</h2>
</Stack>
<hr />
</section>
</article>
<Layout

View File

@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
rerunTitle: {
id: 'course-authoring.course-rerun.title',
defaultMessage: 'Create a re-run of a course',
defaultMessage: 'Create a re-run of',
},
cancelButton: {
id: 'course-authoring.course-rerun.actions.button.cancel',

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import ModalError from '../../generic/modal-error/ModalError';
@@ -17,9 +17,8 @@ const ExportModalError = ({
const isErrorModalOpen = useSelector(getIsErrorModalOpen);
const { msg: errorMessage, unitUrl: unitErrorUrl } = useSelector(getError);
const handleUnitRedirect = () => { window.location.href = unitErrorUrl; };
const handleRedirectCourseHome = () => history.push(`/course/${courseId}/outline`);
const handleUnitRedirect = () => { window.location.assign(unitErrorUrl); };
const handleRedirectCourseHome = () => { window.location.assign(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`); };
return (
<ModalError
isOpen={isErrorModalOpen}

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: '' }));

View File

@@ -7,6 +7,7 @@ import {
} from '@edx/paragon';
import { Add as AddIcon } from '@edx/paragon/icons/es5';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import Loading from '../generic/Loading';
import InternetConnectionAlert from '../generic/internet-connection-alert';
@@ -17,7 +18,6 @@ import HomeSidebar from './home-sidebar';
import TabsSection from './tabs-section';
import OrganizationSection from './organization-section';
import VerifyEmailLayout from './verify-email-layout';
import ProcessingCourses from './processing-courses';
import CreateNewCourseForm from './create-new-course-form';
import messages from './messages';
import { useStudioHome } from './hooks';
@@ -40,12 +40,11 @@ const StudioHome = ({ intl }) => {
userIsActive,
studioShortName,
studioRequestEmail,
libraryAuthoringMfeUrl,
redirectToLibraryAuthoringMfe,
splitStudioHome,
} = studioHomeData;
if (isLoadingPage) {
return <Loading />;
}
function getHeaderButtons() {
const headerButtons = [];
@@ -69,6 +68,26 @@ const StudioHome = ({ intl }) => {
);
}
let libraryHref = `${getConfig().STUDIO_BASE_URL}/home#libraries-tab`;
if (splitStudioHome) {
libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`;
}
if (redirectToLibraryAuthoringMfe) {
libraryHref = `${libraryAuthoringMfeUrl}/create`;
}
headerButtons.push(
<Button
variant="outline-primary"
iconBefore={AddIcon}
size="sm"
disabled={showNewCourseContainer}
href={libraryHref}
data-testid="new-library-button"
>
{intl.formatMessage(messages.addNewLibraryBtnText)}
</Button>,
);
return headerButtons;
}
@@ -77,7 +96,7 @@ const StudioHome = ({ intl }) => {
return (
<>
<Header isHiddenMainMenu />
<Container size="xl" className="studio-home">
<Container size="xl" className="p-4 mt-3">
<section className="mb-4">
<article className="studio-home-sub-header">
<section>
@@ -99,16 +118,22 @@ const StudioHome = ({ intl }) => {
>
<Layout.Element>
<section>
{showNewCourseContainer && (
<CreateNewCourseForm handleOnClickCancel={() => setShowNewCourseContainer(false)} />
{isLoadingPage ? (
<Loading />
) : (
<>
{showNewCourseContainer && (
<CreateNewCourseForm handleOnClickCancel={() => setShowNewCourseContainer(false)} />
)}
{isShowOrganizationDropdown && <OrganizationSection />}
<TabsSection
tabsData={studioHomeData}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing}
/>
</>
)}
{isShowOrganizationDropdown && <OrganizationSection />}
{isShowProcessing && <ProcessingCourses />}
<TabsSection
tabsData={studioHomeData}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={() => setShowNewCourseContainer(true)}
/>
</section>
</Layout.Element>
<Layout.Element>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -105,6 +105,7 @@ describe('<StudioHome />', async () => {
it('shows the spinner before the query is complete', async () => {
useSelector.mockReturnValue({
studioHomeLoadingStatus: RequestStatus.IN_PROGRESS,
userIsActive: true,
});
await act(async () => {
@@ -114,6 +115,42 @@ describe('<StudioHome />', async () => {
});
});
describe('render new library button', () => {
it('href should include #libraries-tab', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
});
const { getByTestId } = render(<RootWrapper />);
const createNewLibraryButton = getByTestId('new-library-button');
expect(createNewLibraryButton.getAttribute('href')).toBe(`${getConfig().STUDIO_BASE_URL}/home#libraries-tab`);
});
it('href should include home_library', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
splitStudioHome: true,
});
const { getByTestId } = render(<RootWrapper />);
const createNewLibraryButton = getByTestId('new-library-button');
expect(createNewLibraryButton.getAttribute('href')).toBe(`${getConfig().STUDIO_BASE_URL}/home_library`);
});
it('href should include create', async () => {
useSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
redirectToLibraryAuthoringMfe: true,
});
const libraryAuthoringMfeUrl = 'http://localhost:3001';
const { getByTestId } = render(<RootWrapper />);
const createNewLibraryButton = getByTestId('new-library-button');
expect(createNewLibraryButton.getAttribute('href')).toBe(`${libraryAuthoringMfeUrl}/create`);
});
});
it('should render create new course container', async () => {
useSelector.mockReturnValue({
...studioHomeMock,

View File

@@ -60,7 +60,7 @@ module.exports = {
},
],
librariesEnabled: true,
libraryAuthoringMfeUrl: 'http://somewhere',
libraryAuthoringMfeUrl: 'http://localhost:3001',
optimizationEnabled: false,
redirectToLibraryAuthoringMfe: false,
requestCourseCreatorUrl: '/request_course_creator',

View File

@@ -28,6 +28,7 @@ const CardItem = ({
return (
<Card className="card-item">
<Card.Header
size="sm"
title={!readOnlyItem ? (
<Hyperlink
className="card-item-title"

View File

@@ -121,24 +121,25 @@ const CollapsibleStateWithAction = ({ state, className }) => {
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body bg-light-200 py-3 px-3.5">
<Collapsible.Body className="collapsible-body bg-light-white py-3 px-3.5">
<p className="small text-gray-700">{description}</p>
<h5 className="text-gray-700">{actionTitle}</h5>
{[COURSE_CREATOR_STATES.denied, COURSE_CREATOR_STATES.pending].includes(state) ? (
<div
className={classNames('py-1 px-2.5 rounded-sm', {
'bg-danger-300': state === COURSE_CREATOR_STATES.denied,
'bg-warning-600': state === COURSE_CREATOR_STATES.pending,
'bg-danger-100': state === COURSE_CREATOR_STATES.denied,
'bg-warning-100': state === COURSE_CREATOR_STATES.pending,
})}
>
<span className="d-inline-block text-white font-weight-bold m-2.5">
<span className="d-inline-block text-black font-weight-bold m-2.5">
{stateName}
</span>
<span className="text-white small">{actionText}</span>
<span className="text-gray-700 small">{actionText}</span>
</div>
) : (
<StatefulButton
key="request-button"
size="sm"
onClick={() => dispatch(requestCourseCreatorQuery())}
state={requestButtonCurrentState}
{...requestButtonStates}

View File

@@ -9,6 +9,10 @@ const messages = defineMessages({
id: 'course-authoring.studio-home.add-new-course.btn.text',
defaultMessage: 'New course',
},
addNewLibraryBtnText: {
id: 'course-authoring.studio-home.add-new-library.btn.text',
defaultMessage: 'New library',
},
emailStaffBtnText: {
id: 'course-authoring.studio-home.email-staff.btn.text',
defaultMessage: 'Email staff to create course',

View File

@@ -46,7 +46,7 @@ const OrganizationSection = ({ intl }) => {
{intl.formatMessage(messages.organizationTitle)}
</h3>
<Form.Group className="organization-section-form d-flex align-items-baseline">
<FormLabel className="organization-section-form-label w-50">
<FormLabel isInline className="organization-section-form-label">
{intl.formatMessage(messages.organizationLabel)}
</FormLabel>
<TypeaheadDropdown

View File

@@ -31,18 +31,19 @@ const CourseItem = ({ course }) => {
{isInProgress && (
<Card className="card-item">
<Card.Header
size="sm"
title={<p className="card-item-title">{displayName}</p>}
subtitle={subtitle}
actions={(
<ActionRow>
<Icon src={RotateRightIcon} className="spinner-icon text-gray-300" />
<Icon src={RotateRightIcon} className="spinner-icon" />
<ActionRow.Spacer />
<span className="small text-gray-300">{intl.formatMessage(messages.itemInProgressActionText)}</span>
<span className="small">{intl.formatMessage(messages.itemInProgressActionText)}</span>
</ActionRow>
)}
/>
<Card.Divider />
<Card.Section className="p-3.5 small text-black bg-gray-100">
<Card.Section className="p-3.5 small text-gray-700 bg-light-200">
{intl.formatMessage(messages.itemInProgressFooterText, {
refresh: (
<Hyperlink destination="/home">
@@ -57,22 +58,23 @@ const CourseItem = ({ course }) => {
{isFailed && (
<Card className="card-item">
<Card.Header
size="sm"
title={<p className="card-item-title">{displayName}</p>}
subtitle={subtitle}
actions={(
<ActionRow>
<Icon src={WarningIcon} className="text-danger-300" />
<span className="small text-danger-300">{intl.formatMessage(messages.itemIsFailedActionText)}</span>
<Icon src={WarningIcon} className="text-danger-500" />
<span className="small">{intl.formatMessage(messages.itemIsFailedActionText)}</span>
</ActionRow>
)}
/>
<Card.Divider />
<Card.Footer className="p-3.5 small text-white bg-danger-300 align-content-between">
<Card.Footer className="p-3.5 small text-gray-700 bg-danger-100 align-content-between">
<span className="w-75 mr-auto">{intl.formatMessage(messages.itemFailedFooterText)}</span>
<Button
onClick={() => dispatch(handleDeleteNotificationQuery(dismissLink))}
iconBefore={CloseIcon}
variant="outline-danger"
variant="tertiary"
size="sm"
>
{intl.formatMessage(messages.itemFailedFooterButton)}

View File

@@ -13,11 +13,11 @@ const ProcessingCourses = () => {
return (
<>
<p className="text-gray-300">
<div className="text-gray-500 small">
{intl.formatMessage(messages.processingTitle)}
</p>
</div>
<hr />
<Stack gap={3}>
<Stack gap={3} className="border-bottom border-light-400 mb-4 px-4 pt-3">
{inProcessCourseActions.map((course) => (
<CourseItem
course={course}

View File

@@ -18,7 +18,7 @@
}
.organization-section-form {
margin: $spacer 0;
margin: $spacer 0 -8px;
.organization-section-form-label {
color: $gray-700;

View File

@@ -8,11 +8,13 @@ import CardItem from '../../card-item';
import CollapsibleStateWithAction from '../../collapsible-state-with-action';
import { sortAlphabeticallyArray } from '../utils';
import ContactAdministrator from './contact-administrator';
import ProcessingCourses from '../../processing-courses';
const CoursesTab = ({
coursesDataItems,
showNewCourseContainer,
onClickNewCourse,
isShowProcessing,
}) => {
const {
courseCreatorStatus,
@@ -27,6 +29,7 @@ const CoursesTab = ({
return (
<>
{isShowProcessing && <ProcessingCourses />}
{coursesDataItems?.length ? (
sortAlphabeticallyArray(coursesDataItems).map(
({
@@ -84,6 +87,7 @@ CoursesTab.propTypes = {
).isRequired,
showNewCourseContainer: PropTypes.bool.isRequired,
onClickNewCourse: PropTypes.func.isRequired,
isShowProcessing: PropTypes.bool.isRequired,
};
export default CoursesTab;

View File

@@ -11,7 +11,7 @@ import ArchivedTab from './archived-tab';
import CoursesTab from './courses-tab';
const TabsSection = ({
intl, tabsData, showNewCourseContainer, onClickNewCourse,
intl, tabsData, showNewCourseContainer, onClickNewCourse, isShowProcessing,
}) => {
const TABS_LIST = {
courses: 'courses',
@@ -40,6 +40,7 @@ const TabsSection = ({
coursesDataItems={courses}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
isShowProcessing={isShowProcessing}
/>
</Tab>,
);
@@ -73,7 +74,7 @@ const TabsSection = ({
const handleSelectTab = (tab) => {
if (tab === TABS_LIST.libraries && redirectToLibraryAuthoringMfe) {
window.location.href = libraryAuthoringMfeUrl;
window.location.assign(libraryAuthoringMfeUrl);
}
};
@@ -123,6 +124,7 @@ TabsSection.propTypes = {
}).isRequired,
showNewCourseContainer: PropTypes.bool.isRequired,
onClickNewCourse: PropTypes.func.isRequired,
isShowProcessing: PropTypes.bool.isRequired,
};
export default injectIntl(TabsSection);