fix: Use soft nav when clicking a library from studio home (#1306)

This commit is contained in:
Braden MacDonald
2024-09-24 09:32:56 -07:00
committed by GitHub
parent 353ef508df
commit 64d718d198
19 changed files with 282 additions and 351 deletions

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { Alert } from '@openedx/paragon';
import PropTypes from 'prop-types';
const AlertMessage = ({ title, description, ...props }) => (
<Alert {...props}>
<Alert.Heading>{title}</Alert.Heading>
<span>{description}</span>
</Alert>
);
AlertMessage.propTypes = {
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
};
AlertMessage.defaultProps = {
title: undefined,
description: undefined,
};
export default AlertMessage;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { Alert } from '@openedx/paragon';
interface Props extends React.ComponentPropsWithoutRef<typeof Alert> {
title?: string | React.ReactNode;
description?: string | React.ReactNode;
}
const AlertMessage: React.FC<Props> = ({ title, description, ...props }) => (
<Alert {...props}>
<Alert.Heading>{title}</Alert.Heading>
<span>{description}</span>
</Alert>
);
export default AlertMessage;

View File

@@ -8,7 +8,7 @@ import {
Row,
} from '@openedx/paragon';
import { Add as AddIcon, Error } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StudioFooter } from '@edx/frontend-component-footer';
import { getConfig } from '@edx/frontend-platform';
import { useLocation, useNavigate } from 'react-router-dom';
@@ -28,7 +28,8 @@ import messages from './messages';
import { useStudioHome } from './hooks';
import AlertMessage from '../generic/alert-message';
const StudioHome = ({ intl }) => {
const StudioHome = () => {
const intl = useIntl();
const location = useLocation();
const navigate = useNavigate();
@@ -46,7 +47,6 @@ const StudioHome = ({ intl }) => {
hasAbilityToCreateNewCourse,
isFiltered,
setShowNewCourseContainer,
dispatch,
} = useStudioHome(isPaginationCoursesEnabled);
const libMode = getConfig().LIBRARY_MODE;
@@ -64,7 +64,7 @@ const StudioHome = ({ intl }) => {
} = studioHomeData;
const getHeaderButtons = useCallback(() => {
const headerButtons = [];
const headerButtons: JSX.Element[] = [];
if (isFailedLoadingPage || !userIsActive) {
return headerButtons;
@@ -160,11 +160,9 @@ const StudioHome = ({ intl }) => {
)}
{isShowOrganizationDropdown && <OrganizationSection />}
<TabsSection
tabsData={studioHomeData}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing && !isFiltered}
dispatch={dispatch}
isPaginationCoursesEnabled={isPaginationCoursesEnabled}
/>
</section>
@@ -203,8 +201,4 @@ const StudioHome = ({ intl }) => {
);
};
StudioHome.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(StudioHome);
export default StudioHome;

View File

@@ -1,107 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
import { studioHomeMock } from '../__mocks__';
import messages from '../messages';
import initializeStore from '../../store';
import { trimSlashes } from './utils';
import CardItem from '.';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
let store;
const RootWrapper = (props) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CardItem intl={{ formatMessage: jest.fn() }} {...props} />
</IntlProvider>
</AppProvider>
);
describe('<CardItem />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
useSelector.mockReturnValue(studioHomeMock);
});
it('should render course details for non-library course', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText } = render(<RootWrapper {...props} />);
expect(getByText(`${props.org} / ${props.number} / ${props.run}`)).toBeInTheDocument();
});
it('should render correct links for non-library course', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText } = render(<RootWrapper {...props} />);
const courseTitleLink = getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const btnReRunCourse = getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink));
const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});
it('should render correct links for non-library course pagination', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText, getByTestId } = render(<RootWrapper {...props} isPaginated />);
const courseTitleLink = getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const dropDownMenu = getByTestId('toggle-dropdown');
fireEvent.click(dropDownMenu);
const btnReRunCourse = getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink));
const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});
it('should render course details for library course', () => {
const props = { ...studioHomeMock.archivedCourses[0], isLibraries: true };
const { getByText } = render(<RootWrapper {...props} />);
const courseTitleLink = getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
expect(getByText(`${props.org} / ${props.number}`)).toBeInTheDocument();
});
it('should hide rerun button if disallowed', () => {
const props = studioHomeMock.archivedCourses[0];
useSelector.mockReturnValue({ ...studioHomeMock, allowCourseReruns: false });
const { queryByText } = render(<RootWrapper {...props} />);
expect(queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument();
});
it('should be read only course if old mongo course', () => {
const props = studioHomeMock.courses[1];
const { queryByText } = render(<RootWrapper {...props} />);
expect(queryByText(props.displayName)).not.toHaveAttribute('href');
expect(queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument();
expect(queryByText(messages.viewLiveBtnText.defaultMessage)).not.toBeInTheDocument();
});
it('should render course key if displayname is empty', () => {
const props = studioHomeMock.courses[1];
const courseKeyTest = 'course-key';
const { getByText } = render(
<RootWrapper
{...props}
displayName=""
courseKey={courseKeyTest}
lmsLink="lmsLink"
rerunLink="returnLink"
url="url"
/>,
);
expect(getByText(courseKeyTest)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,89 @@
import * as reactRedux from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { studioHomeMock } from '../__mocks__';
import messages from '../messages';
import { trimSlashes } from './utils';
import {
fireEvent,
initializeMocks,
render,
screen,
} from '../../testUtils';
import CardItem from '.';
jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => studioHomeMock);
describe('<CardItem />', () => {
beforeEach(() => {
initializeMocks();
});
it('should render course details for non-library course', () => {
const props = studioHomeMock.archivedCourses[0];
render(<CardItem {...props} />);
expect(screen.getByText(`${props.org} / ${props.number} / ${props.run}`)).toBeInTheDocument();
});
it('should render correct links for non-library course', () => {
const props = studioHomeMock.archivedCourses[0];
render(<CardItem {...props} />);
const courseTitleLink = screen.getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const btnReRunCourse = screen.getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink));
const viewLiveLink = screen.getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});
it('should render correct links for non-library course pagination', () => {
const props = studioHomeMock.archivedCourses[0];
render(<CardItem {...props} isPaginated />);
const courseTitleLink = screen.getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const dropDownMenu = screen.getByTestId('toggle-dropdown');
fireEvent.click(dropDownMenu);
const btnReRunCourse = screen.getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink));
const viewLiveLink = screen.getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});
it('should render course details for library course', () => {
const props = { ...studioHomeMock.archivedCourses[0], isLibraries: true };
render(<CardItem {...props} />);
const courseTitleLink = screen.getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
expect(screen.getByText(`${props.org} / ${props.number}`)).toBeInTheDocument();
});
it('should hide rerun button if disallowed', () => {
const props = studioHomeMock.archivedCourses[0];
// Update our mocked redux data:
jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => (
{ ...studioHomeMock, allowCourseReruns: false }
));
const { queryByText } = render(<CardItem {...props} />);
expect(queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument();
});
it('should be read only course if old mongo course', () => {
const props = studioHomeMock.courses[1];
render(<CardItem {...props} />);
expect(screen.queryByText(props.displayName)).not.toHaveAttribute('href');
expect(screen.queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument();
expect(screen.queryByText(messages.viewLiveBtnText.defaultMessage)).not.toBeInTheDocument();
});
it('should render course key if displayname is empty', () => {
const props = studioHomeMock.courses[1];
const courseKeyTest = 'course-key';
render(
<CardItem
{...props}
displayName=""
courseKey={courseKeyTest}
lmsLink="lmsLink"
rerunLink="returnLink"
url="url"
/>,
);
expect(screen.getByText(courseKeyTest)).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import {
Card,
Hyperlink,
@@ -9,16 +8,40 @@ import {
ActionRow,
} from '@openedx/paragon';
import { MoreHoriz } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { Link } from 'react-router-dom';
import { COURSE_CREATOR_STATES } from '../../constants';
import { getStudioHomeData } from '../data/selectors';
import messages from '../messages';
import { trimSlashes } from './utils';
const CardItem = ({
intl,
interface BaseProps {
displayName: string;
org: string;
number: string;
run?: string;
lmsLink?: string | null;
rerunLink?: string | null;
courseKey?: string;
isLibraries?: boolean;
isPaginated?: boolean;
}
type Props = BaseProps & (
/** If we should open this course/library in this MFE, this is the path to the edit page, e.g. '/course/foo' */
{ path: string, url?: never } |
/**
* If we might be redirecting to the legacy Studio view, this is the URL to redirect to.
* URLs starting with '/' are assumed to be relative to the legacy Studio root.
*/
{ url: string, path?: never }
);
/**
* A card on the Studio home page that represents a Course or a Library
*/
const CardItem: React.FC<Props> = ({
displayName,
lmsLink = '',
rerunLink = '',
@@ -28,14 +51,16 @@ const CardItem = ({
isLibraries = false,
courseKey = '',
isPaginated = false,
path,
url,
}) => {
const intl = useIntl();
const {
allowCourseReruns,
courseCreatorStatus,
rerunCreatorStatus,
} = useSelector(getStudioHomeData);
const destinationUrl = () => new URL(url, getConfig().STUDIO_BASE_URL);
const destinationUrl: string = path ?? new URL(url, getConfig().STUDIO_BASE_URL).toString();
const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`;
const readOnlyItem = !(lmsLink || rerunLink || url);
const showActions = !(readOnlyItem || isLibraries);
@@ -49,12 +74,12 @@ const CardItem = ({
<Card.Header
size="sm"
title={!readOnlyItem ? (
<Hyperlink
<Link
className="card-item-title"
destination={destinationUrl().toString()}
to={destinationUrl}
>
{hasDisplayName}
</Hyperlink>
</Link>
) : (
<span className="card-item-title">{displayName}</span>
)}
@@ -70,7 +95,7 @@ const CardItem = ({
/>
<Dropdown.Menu>
{isShowRerunLink && (
<Dropdown.Item href={trimSlashes(rerunLink)}>
<Dropdown.Item href={trimSlashes(rerunLink ?? '')}>
{messages.btnReRunText.defaultMessage}
</Dropdown.Item>
)}
@@ -84,7 +109,7 @@ const CardItem = ({
{isShowRerunLink && (
<Hyperlink
className="small"
destination={trimSlashes(rerunLink)}
destination={trimSlashes(rerunLink ?? '')}
key={`action-row-rerunLink-${courseKey}`}
>
{intl.formatMessage(messages.btnReRunText)}
@@ -92,7 +117,7 @@ const CardItem = ({
)}
<Hyperlink
className="small ml-3"
destination={lmsLink}
destination={lmsLink ?? ''}
key={`action-row-lmsLink-${courseKey}`}
>
{intl.formatMessage(messages.viewLiveBtnText)}
@@ -105,18 +130,4 @@ const CardItem = ({
);
};
CardItem.propTypes = {
intl: intlShape.isRequired,
displayName: PropTypes.string.isRequired,
lmsLink: PropTypes.string,
rerunLink: PropTypes.string,
org: PropTypes.string.isRequired,
run: PropTypes.string,
number: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
isLibraries: PropTypes.bool,
courseKey: PropTypes.string,
isPaginated: PropTypes.bool,
};
export default injectIntl(CardItem);
export default CardItem;

View File

@@ -4,4 +4,4 @@
* @returns {string} The trimmed string.
*/
// eslint-disable-next-line import/prefer-default-export
export const trimSlashes = (str) => str.replace(/^\/|\/$/g, '');
export const trimSlashes = (str: string): string => str.replace(/^\/|\/$/g, '');

View File

@@ -16,6 +16,7 @@ export async function getStudioHomeData() {
return camelCaseObject(data);
}
/** Get list of courses from the deprecated non-paginated API */
export async function getStudioHomeCourses(search) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`);
return camelCaseObject(data);

View File

@@ -48,36 +48,32 @@ export const generateGetStudioHomeDataApiResponse = () => ({
allowToCreateNewOrg: false,
});
/** Mock for the deprecated /api/contentstore/v1/home/courses endpoint. Note this endpoint is NOT paginated. */
export const generateGetStudioCoursesApiResponse = () => ({
count: 5,
next: null,
previous: null,
numPages: 2,
results: {
courses: [
{
courseKey: 'course-v1:HarvardX+123+2023',
displayName: 'Managing Risk in the Information Age',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '123',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
{
courseKey: 'org.0/course_0/Run_0',
displayName: 'Run 0',
lmsLink: null,
number: 'course_0',
org: 'org.0',
rerunLink: null,
run: 'Run_0',
url: null,
},
],
inProcessCourseActions: [],
},
archivedCourses: /** @type {any[]} */([]),
courses: [
{
courseKey: 'course-v1:HarvardX+123+2023',
displayName: 'Managing Risk in the Information Age',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '123',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
{
courseKey: 'org.0/course_0/Run_0',
displayName: 'Run 0',
lmsLink: null,
number: 'course_0',
org: 'org.0',
rerunLink: null,
run: 'Run_0',
url: null,
},
],
inProcessCourseActions: [],
});
export const generateGetStudioCoursesApiResponseV2 = () => ({

View File

@@ -93,7 +93,6 @@ const useStudioHome = (isPaginated = false) => {
isShowOrganizationDropdown,
hasAbilityToCreateNewCourse,
isFiltered,
dispatch,
setShowNewCourseContainer,
};
};

View File

@@ -1,16 +1,6 @@
import React from 'react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getConfig, initializeMockApp, setConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
waitFor, render, fireEvent, screen, act,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import MockAdapter from 'axios-mock-adapter';
import { Routes, Route } from 'react-router-dom';
import { getConfig, setConfig } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { studioHomeMock } from '../__mocks__';
import messages from '../messages';
import tabMessages from './messages';
@@ -27,6 +17,13 @@ import { executeThunk } from '../../utils';
import { fetchLibraryData, fetchStudioHomeData } from '../data/thunks';
import { getContentLibraryV2ListApiUrl } from '../../library-authoring/data/api';
import contentLibrariesListV2 from '../../library-authoring/__mocks__/contentLibrariesListV2';
import {
initializeMocks,
render as baseRender,
fireEvent,
screen,
waitFor,
} from '../../testUtils';
const { studioShortName } = studioHomeMock;
@@ -36,56 +33,39 @@ const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
const courseApiLinkV2 = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`;
const mockDispatch = jest.fn();
const queryClient = new QueryClient();
const tabSectionComponent = (overrideProps) => (
<TabsSection
intl={{ formatMessage: jest.fn() }}
dispatch={mockDispatch}
isPaginationCoursesEnabled={false}
showNewCourseContainer={false}
onClickNewCourse={() => {}}
isShowProcessing
{...overrideProps}
/>
);
const RootWrapper = (overrideProps) => (
<AppProvider store={store} wrapWithRouter={false}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/home']}>
<Routes>
<Route
path="/home"
element={tabSectionComponent(overrideProps)}
/>
<Route
path="/libraries"
element={tabSectionComponent(overrideProps)}
/>
<Route
path="/libraries-v1"
element={tabSectionComponent(overrideProps)}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
const render = (overrideProps = {}) => baseRender(
<Routes>
<Route
path="/home"
element={tabSectionComponent(overrideProps)}
/>
<Route
path="/libraries"
element={tabSectionComponent(overrideProps)}
/>
<Route
path="/libraries-v1"
element={tabSectionComponent(overrideProps)}
/>
</Routes>,
{ routerProps: { initialEntries: ['/home'] } },
);
describe('<TabsSection />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const newMocks = initializeMocks({ initialState });
store = newMocks.reduxStore;
axiosMock = newMocks.axiosMock;
setConfig({
...getConfig(),
LIBRARY_MODE: 'mixed',
@@ -94,7 +74,7 @@ describe('<TabsSection />', () => {
});
it('should render all tabs correctly', async () => {
const data = generateGetStudioHomeDataApiResponse();
const data: any = generateGetStudioHomeDataApiResponse();
data.archivedCourses = [{
courseKey: 'course-v1:MachineLearning+123+2023',
displayName: 'Machine Learning',
@@ -106,7 +86,7 @@ describe('<TabsSection />', () => {
url: '/course/course-v1:MachineLearning+123+2023',
}];
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -127,7 +107,7 @@ describe('<TabsSection />', () => {
const data = generateGetStudioHomeDataApiResponse();
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -148,7 +128,7 @@ describe('<TabsSection />', () => {
const data = generateGetStudioHomeDataApiResponse();
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -163,8 +143,8 @@ describe('<TabsSection />', () => {
describe('course tab', () => {
it('should render specific course details', async () => {
render(<RootWrapper />);
const { results: data } = generateGetStudioCoursesApiResponse();
render();
const data = generateGetStudioCoursesApiResponse();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -178,9 +158,9 @@ describe('<TabsSection />', () => {
it('should render default sections when courses are empty', async () => {
const data = generateGetStudioCoursesApiResponse();
data.results.courses = [];
data.courses = [];
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -195,7 +175,7 @@ describe('<TabsSection />', () => {
});
it('should render course fetch failure alert', async () => {
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(404);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -204,7 +184,7 @@ describe('<TabsSection />', () => {
});
it('should render pagination when there are courses', async () => {
render(<RootWrapper isPaginationCoursesEnabled />);
render({ isPaginationCoursesEnabled: true });
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLinkV2).reply(200, generateGetStudioCoursesApiResponseV2());
await executeThunk(fetchStudioHomeData('', true, {}, true), store.dispatch);
@@ -224,7 +204,7 @@ describe('<TabsSection />', () => {
it('should not render pagination when there are not courses', async () => {
const data = generateGetStudioCoursesApiResponseV2();
data.results.courses = [];
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLinkV2).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -236,7 +216,7 @@ describe('<TabsSection />', () => {
it('should set the url path to "/home" when switching away then back to courses tab', async () => {
const data = generateGetStudioCoursesApiResponseV2();
data.results.courses = [];
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLinkV2).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -250,9 +230,7 @@ describe('<TabsSection />', () => {
axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse());
await executeThunk(fetchLibraryData(), store.dispatch);
const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage);
await act(async () => {
fireEvent.click(librariesTab);
});
fireEvent.click(librariesTab);
// confirm that the url path has changed
expect(librariesTab).toHaveClass('active');
@@ -262,9 +240,7 @@ describe('<TabsSection />', () => {
// switch back to courses tab
const coursesTab = screen.getByText(tabMessages.coursesTabTitle.defaultMessage);
await act(async () => {
fireEvent.click(coursesTab);
});
fireEvent.click(coursesTab);
// confirm that the url path is /home
expect(coursesTab).toHaveClass('active');
@@ -281,7 +257,7 @@ describe('<TabsSection />', () => {
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -295,7 +271,7 @@ describe('<TabsSection />', () => {
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -310,8 +286,8 @@ describe('<TabsSection />', () => {
describe('archived tab', () => {
it('should switch to Archived tab and render specific archived course details', async () => {
render(<RootWrapper />);
const { results: data } = generateGetStudioCoursesApiResponse();
render();
const data = generateGetStudioCoursesApiResponse();
data.archivedCourses = studioHomeMock.archivedCourses;
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, data);
@@ -331,7 +307,7 @@ describe('<TabsSection />', () => {
const data = generateGetStudioCoursesApiResponse();
data.archivedCourses = [];
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -347,34 +323,33 @@ describe('<TabsSection />', () => {
});
describe('library tab', () => {
beforeEach(() => {
axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse());
});
it('should switch to Legacy Libraries tab and render specific v1 library details', async () => {
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
await executeThunk(fetchLibraryData(), store.dispatch);
const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage);
await act(async () => {
fireEvent.click(librariesTab);
});
fireEvent.click(librariesTab);
expect(librariesTab).toHaveClass('active');
expect(screen.getByText(studioHomeMock.libraries[0].displayName)).toBeVisible();
expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible();
expect(screen.getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible();
});
it('should switch to Libraries tab and render specific v2 library details', async () => {
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage);
await act(async () => {
fireEvent.click(librariesTab);
});
fireEvent.click(librariesTab);
expect(librariesTab).toHaveClass('active');
@@ -397,20 +372,18 @@ describe('<TabsSection />', () => {
LIBRARY_MODE: 'v1 only',
});
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
await executeThunk(fetchLibraryData(), store.dispatch);
const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage);
await act(async () => {
fireEvent.click(librariesTab);
});
fireEvent.click(librariesTab);
expect(librariesTab).toHaveClass('active');
expect(screen.getByText(studioHomeMock.libraries[0].displayName)).toBeVisible();
expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible();
expect(screen.getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible();
});
@@ -421,14 +394,12 @@ describe('<TabsSection />', () => {
LIBRARY_MODE: 'v2 only',
});
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage);
await act(async () => {
fireEvent.click(librariesTab);
});
fireEvent.click(librariesTab);
expect(librariesTab).toHaveClass('active');
@@ -449,7 +420,7 @@ describe('<TabsSection />', () => {
const data = generateGetStudioHomeDataApiResponse();
data.librariesEnabled = false;
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -461,7 +432,7 @@ describe('<TabsSection />', () => {
const data = generateGetStudioHomeDataApiResponse();
data.redirectToLibraryAuthoringMfe = true;
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
@@ -474,20 +445,18 @@ describe('<TabsSection />', () => {
});
it('should render libraries fetch failure alert', async () => {
render(<RootWrapper />);
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(libraryApiLink).reply(404);
await executeThunk(fetchStudioHomeData(), store.dispatch);
await executeThunk(fetchLibraryData(), store.dispatch);
const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage);
await act(async () => {
fireEvent.click(librariesTab);
});
fireEvent.click(librariesTab);
expect(librariesTab).toHaveClass('active');
expect(screen.getByText(tabMessages.librariesTabErrorMessage.defaultMessage)).toBeVisible();
expect(await screen.findByText(tabMessages.librariesTabErrorMessage.defaultMessage)).toBeVisible();
});
});
});

View File

@@ -10,8 +10,6 @@ import { initialState } from '../../factories/mockApiResponses';
import CoursesTab from '.';
const mockDispatch = jest.fn();
const onClickNewCourse = jest.fn();
const isShowProcessing = false;
const isLoading = false;
@@ -23,7 +21,7 @@ const showNewCourseContainer = true;
const renderComponent = (overrideProps = {}, studioHomeState = {}) => {
// Generate a custom initial state based on studioHomeCoursesRequestParams
const customInitialState = {
const customInitialState: any = { // TODO: remove 'any' once our redux state has proper types
...initialState,
studioHome: {
...initialState.studioHome,
@@ -38,7 +36,6 @@ const renderComponent = (overrideProps = {}, studioHomeState = {}) => {
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CoursesTab
dispatch={mockDispatch}
coursesDataItems={studioHomeMock.courses}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Icon,
@@ -26,18 +25,39 @@ import AlertMessage from '../../../generic/alert-message';
import messages from '../messages';
import './index.scss';
const CoursesTab = ({
interface Props {
coursesDataItems: {
courseKey: string;
displayName: string;
lmsLink: string | null;
number: string;
org: string;
rerunLink: string | null;
run: string;
url: string;
}[];
showNewCourseContainer: boolean;
onClickNewCourse: () => void;
isShowProcessing: boolean;
isLoading: boolean;
isFailed: boolean;
numPages: number;
coursesCount: number;
isEnabledPagination?: boolean;
}
const CoursesTab: React.FC<Props> = ({
coursesDataItems,
showNewCourseContainer,
onClickNewCourse,
isShowProcessing,
isLoading,
isFailed,
dispatch,
numPages,
coursesCount,
isEnabledPagination,
numPages = 0,
coursesCount = 0,
isEnabledPagination = false,
}) => {
const dispatch = useDispatch();
const intl = useIntl();
const location = useLocation();
const {
@@ -136,7 +156,6 @@ const CoursesTab = ({
number,
run,
url,
cmsLink,
}) => (
<CardItem
key={courseKey}
@@ -148,7 +167,6 @@ const CoursesTab = ({
number={number}
run={run}
url={url}
cmsLink={cmsLink}
isPaginated={isEnabledPagination}
/>
),
@@ -197,34 +215,4 @@ const CoursesTab = ({
);
};
CoursesTab.defaultProps = {
numPages: 0,
coursesCount: 0,
isEnabledPagination: false,
};
CoursesTab.propTypes = {
coursesDataItems: PropTypes.arrayOf(
PropTypes.shape({
courseKey: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
lmsLink: PropTypes.string.isRequired,
number: PropTypes.string.isRequired,
org: PropTypes.string.isRequired,
rerunLink: PropTypes.string.isRequired,
run: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
}),
).isRequired,
showNewCourseContainer: PropTypes.bool.isRequired,
onClickNewCourse: PropTypes.func.isRequired,
isShowProcessing: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
isFailed: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
numPages: PropTypes.number,
coursesCount: PropTypes.number,
isEnabledPagination: PropTypes.bool,
};
export default CoursesTab;

View File

@@ -1,9 +1,9 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { Tab, Tabs } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate, useLocation } from 'react-router-dom';
import { getLoadingStatuses, getStudioHomeData } from '../data/selectors';
@@ -17,13 +17,13 @@ import { fetchLibraryData } from '../data/thunks';
import { isMixedOrV1LibrariesMode, isMixedOrV2LibrariesMode } from './utils';
const TabsSection = ({
intl,
showNewCourseContainer,
onClickNewCourse,
isShowProcessing,
dispatch,
isPaginationCoursesEnabled,
}) => {
const dispatch = useDispatch();
const intl = useIntl();
const navigate = useNavigate();
const { pathname } = useLocation();
const libMode = getConfig().LIBRARY_MODE;
@@ -33,7 +33,7 @@ const TabsSection = ({
legacyLibraries: 'legacyLibraries',
archived: 'archived',
taxonomies: 'taxonomies',
};
} as const;
const initTabKeyState = (pname) => {
if (pname.includes('/libraries-v1')) {
@@ -80,7 +80,7 @@ const TabsSection = ({
// Controlling the visibility of tabs when using conditional rendering is necessary for
// the correct operation of iterating over child elements inside the Paragon Tabs component.
const visibleTabs = useMemo(() => {
const tabs = [];
const tabs: JSX.Element[] = [];
tabs.push(
<Tab
key={TABS_LIST.courses}
@@ -94,7 +94,6 @@ const TabsSection = ({
isShowProcessing={isShowProcessing}
isLoading={isLoadingCourses}
isFailed={isFailedCoursesPage}
dispatch={dispatch}
numPages={numPages}
coursesCount={coursesCount}
isEnabledPagination={isPaginationCoursesEnabled}
@@ -199,12 +198,10 @@ TabsSection.defaultProps = {
};
TabsSection.propTypes = {
intl: intlShape.isRequired,
showNewCourseContainer: PropTypes.bool.isRequired,
onClickNewCourse: PropTypes.func.isRequired,
isShowProcessing: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
isPaginationCoursesEnabled: PropTypes.bool,
};
export default injectIntl(TabsSection);
export default TabsSection;

View File

@@ -150,11 +150,14 @@ const defaultUser = {
*
* Returns the new `axiosMock` in case you need to mock out axios requests.
*/
export function initializeMocks({ user = defaultUser } = {}) {
export function initializeMocks({ user = defaultUser, initialState = undefined }: {
user?: { userId: number, username: string },
initialState?: Record<string, any>, // TODO: proper typing for our redux state
} = {}) {
initializeMockApp({
authenticatedUser: user,
});
reduxStore = initializeReduxStore();
reduxStore = initializeReduxStore(initialState as any);
queryClient = new QueryClient({
defaultOptions: {
queries: {