feat: library home page ("bare bones") (#1076)
This commit is contained in:
@@ -15,29 +15,6 @@ import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
}) => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
AppHeader.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string,
|
||||
courseOrg: PropTypes.string,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AppHeader.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -74,11 +51,11 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
<Header
|
||||
number={courseNumber}
|
||||
org={courseOrg}
|
||||
title={courseTitle}
|
||||
contextId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1,62 +1,75 @@
|
||||
// @ts-check
|
||||
/* eslint-disable react/require-default-props */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { StudioHeader } from '@edx/frontend-component-header';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { generatePath, useHref } from 'react-router-dom';
|
||||
|
||||
import SearchModal from '../search-modal/SearchModal';
|
||||
import { SearchModal } from '../search-modal';
|
||||
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
|
||||
import messages from './messages';
|
||||
|
||||
interface HeaderProps {
|
||||
contextId?: string,
|
||||
number?: string,
|
||||
org?: string,
|
||||
title?: string,
|
||||
isHiddenMainMenu?: boolean,
|
||||
isLibrary?: boolean,
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
courseId,
|
||||
courseOrg,
|
||||
courseNumber,
|
||||
courseTitle,
|
||||
isHiddenMainMenu,
|
||||
}) => {
|
||||
contextId = '',
|
||||
org = '',
|
||||
number = '',
|
||||
title = '',
|
||||
isHiddenMainMenu = false,
|
||||
isLibrary = false,
|
||||
}: HeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const libraryHref = useHref('/library/:libraryId');
|
||||
|
||||
const [isShowSearchModalOpen, openSearchModal, closeSearchModal] = useToggle(false);
|
||||
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const meiliSearchEnabled = [true, 'true'].includes(getConfig().MEILISEARCH_ENABLED);
|
||||
const mainMenuDropdowns = [
|
||||
const mainMenuDropdowns = !isLibrary ? [
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.content']),
|
||||
items: getContentMenuItems({ studioBaseUrl, courseId, intl }),
|
||||
items: getContentMenuItems({ studioBaseUrl, courseId: contextId, intl }),
|
||||
},
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.settings']),
|
||||
items: getSettingMenuItems({ studioBaseUrl, courseId, intl }),
|
||||
items: getSettingMenuItems({ studioBaseUrl, courseId: contextId, intl }),
|
||||
},
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.tools']),
|
||||
items: getToolsMenuItems({ studioBaseUrl, courseId, intl }),
|
||||
items: getToolsMenuItems({ studioBaseUrl, courseId: contextId, intl }),
|
||||
},
|
||||
];
|
||||
const outlineLink = `${studioBaseUrl}/course/${courseId}`;
|
||||
] : [];
|
||||
const outlineLink = !isLibrary
|
||||
? `${studioBaseUrl}/course/${contextId}`
|
||||
: generatePath(libraryHref, { libraryId: contextId });
|
||||
|
||||
return (
|
||||
<>
|
||||
<StudioHeader
|
||||
org={courseOrg}
|
||||
number={courseNumber}
|
||||
title={courseTitle}
|
||||
org={org}
|
||||
number={number}
|
||||
title={title}
|
||||
isHiddenMainMenu={isHiddenMainMenu}
|
||||
mainMenuDropdowns={mainMenuDropdowns}
|
||||
outlineLink={outlineLink}
|
||||
searchButtonAction={meiliSearchEnabled && openSearchModal}
|
||||
searchButtonAction={meiliSearchEnabled ? openSearchModal : undefined}
|
||||
/>
|
||||
{ meiliSearchEnabled && (
|
||||
<SearchModal
|
||||
isOpen={isShowSearchModalOpen}
|
||||
courseId={courseId}
|
||||
courseId={isLibrary ? undefined : contextId}
|
||||
onClose={closeSearchModal}
|
||||
/>
|
||||
)}
|
||||
@@ -64,20 +77,4 @@ const Header = ({
|
||||
);
|
||||
};
|
||||
|
||||
Header.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseOrg: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
isHiddenMainMenu: PropTypes.bool,
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
courseId: '',
|
||||
courseNumber: '',
|
||||
courseOrg: '',
|
||||
courseTitle: '',
|
||||
isHiddenMainMenu: false,
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -16,11 +16,11 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import messages from './i18n';
|
||||
|
||||
import { CreateLibrary, LibraryAuthoringPage } from './library-authoring';
|
||||
import initializeStore from './store';
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import Head from './head/Head';
|
||||
import { StudioHome } from './studio-home';
|
||||
import LibraryV2Placeholder from './studio-home/tabs-section/LibraryV2Placeholder';
|
||||
import CourseRerun from './course-rerun';
|
||||
import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy';
|
||||
import { ContentTagsDrawer } from './content-tags-drawer';
|
||||
@@ -52,7 +52,8 @@ const App = () => {
|
||||
<Route path="/home" element={<StudioHome />} />
|
||||
<Route path="/libraries" element={<StudioHome />} />
|
||||
<Route path="/libraries-v1" element={<StudioHome />} />
|
||||
<Route path="/library/:libraryId" element={<LibraryV2Placeholder />} />
|
||||
<Route path="/library/create" element={<CreateLibrary />} />
|
||||
<Route path="/library/:libraryId/*" element={<LibraryAuthoringPage />} />
|
||||
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
|
||||
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
|
||||
{getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && (
|
||||
|
||||
27
src/library-authoring/CreateLibrary.tsx
Normal file
27
src/library-authoring/CreateLibrary.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Container } from '@openedx/paragon';
|
||||
|
||||
import Header from '../header';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
/* istanbul ignore next This is only a placeholder component */
|
||||
const CreateLibrary = () => (
|
||||
<>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<SubHeader
|
||||
title={<FormattedMessage {...messages.createLibrary} />}
|
||||
/>
|
||||
<div className="d-flex my-6 justify-content-center">
|
||||
<FormattedMessage
|
||||
{...messages.createLibraryTempPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
export default CreateLibrary;
|
||||
23
src/library-authoring/EmptyStates.tsx
Normal file
23
src/library-authoring/EmptyStates.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const NoComponents = () => (
|
||||
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
|
||||
<FormattedMessage {...messages.noComponents} />
|
||||
<Button iconBefore={Add}>
|
||||
<FormattedMessage {...messages.addComponent} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const NoSearchResults = () => (
|
||||
<div className="d-flex mt-6 justify-content-center">
|
||||
<FormattedMessage {...messages.noSearchResults} />
|
||||
</div>
|
||||
);
|
||||
236
src/library-authoring/LibraryAuthoringPage.test.tsx
Normal file
236
src/library-authoring/LibraryAuthoringPage.test.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { getContentSearchConfigUrl } from '../search-modal/data/api';
|
||||
import mockResult from '../search-modal/__mocks__/search-result.json';
|
||||
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
|
||||
import LibraryAuthoringPage from './LibraryAuthoringPage';
|
||||
import { getContentLibraryApiUrl, type ContentLibrary } from './data/api';
|
||||
|
||||
let store;
|
||||
const mockUseParams = jest.fn();
|
||||
let axiosMock;
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
|
||||
useParams: () => mockUseParams(),
|
||||
}));
|
||||
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const returnEmptyResult = (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
|
||||
mockEmptyResult.results[0].query = query;
|
||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return mockEmptyResult;
|
||||
};
|
||||
|
||||
const libraryData: ContentLibrary = {
|
||||
id: 'lib:org1:lib1',
|
||||
type: 'complex',
|
||||
org: 'org1',
|
||||
slug: 'lib1',
|
||||
title: 'lib1',
|
||||
description: 'lib1',
|
||||
numBlocks: 2,
|
||||
version: 0,
|
||||
lastPublished: null,
|
||||
allowLti: false,
|
||||
allowPublicLearning: false,
|
||||
allowPublicRead: false,
|
||||
hasUnpublishedChanges: true,
|
||||
hasUnpublishedDeletes: false,
|
||||
license: '',
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LibraryAuthoringPage />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<LibraryAuthoringPage />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
mockUseParams.mockReturnValue({ libraryId: '1' });
|
||||
|
||||
// The API method to get the Meilisearch connection details uses Axios:
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(getContentSearchConfigUrl()).reply(200, {
|
||||
url: 'http://mock.meilisearch.local',
|
||||
index_name: 'studio',
|
||||
api_key: 'test-key',
|
||||
});
|
||||
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
// because otherwise Instantsearch will update the UI and change the query,
|
||||
// leading to unexpected results in the test cases.
|
||||
mockResult.results[0].query = query;
|
||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return mockResult;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
axiosMock.restore();
|
||||
fetchMock.mockReset();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('shows the spinner before the query is complete', () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: '1' });
|
||||
// @ts-ignore Use unresolved promise to keep the Loading visible
|
||||
axiosMock.onGet(getContentLibraryApiUrl('1')).reply(() => new Promise());
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
|
||||
it('shows an error component if no library returned', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: 'invalid' });
|
||||
axiosMock.onGet(getContentLibraryApiUrl('invalid')).reply(400);
|
||||
|
||||
const { findByTestId } = render(<RootWrapper />);
|
||||
|
||||
expect(await findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error component if no library param', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: '' });
|
||||
|
||||
const { findByTestId } = render(<RootWrapper />);
|
||||
|
||||
expect(await findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show library data', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
|
||||
const {
|
||||
getByRole, getByText, queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Ensure the search endpoint is called
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('Content library')).toBeInTheDocument();
|
||||
expect(getByText(libraryData.title)).toBeInTheDocument();
|
||||
|
||||
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
||||
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
expect(getByText('There are 6 components in this library')).toBeInTheDocument();
|
||||
|
||||
// Navigate to the components tab
|
||||
fireEvent.click(getByRole('tab', { name: 'Components' }));
|
||||
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
|
||||
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
|
||||
expect(queryByText('Components (6)')).not.toBeInTheDocument();
|
||||
expect(getByText('There are 6 components in this library')).toBeInTheDocument();
|
||||
|
||||
// Navigate to the collections tab
|
||||
fireEvent.click(getByRole('tab', { name: 'Collections' }));
|
||||
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
|
||||
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
|
||||
expect(queryByText('Components (6)')).not.toBeInTheDocument();
|
||||
expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument();
|
||||
expect(getByText('Coming soon!')).toBeInTheDocument();
|
||||
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(getByRole('tab', { name: 'Home' }));
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
expect(getByText('There are 6 components in this library')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show library without components', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
|
||||
|
||||
const { findByText, getByText } = render(<RootWrapper />);
|
||||
|
||||
expect(await findByText('Content library')).toBeInTheDocument();
|
||||
expect(await findByText(libraryData.title)).toBeInTheDocument();
|
||||
|
||||
// Ensure the search endpoint is called
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show library without search results', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
|
||||
|
||||
const { findByText, getByRole, getByText } = render(<RootWrapper />);
|
||||
|
||||
expect(await findByText('Content library')).toBeInTheDocument();
|
||||
expect(await findByText(libraryData.title)).toBeInTheDocument();
|
||||
|
||||
// Ensure the search endpoint is called
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } });
|
||||
|
||||
// Ensure the search endpoint is called again
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('No matching components found in this library.')).toBeInTheDocument();
|
||||
|
||||
// Navigate to the components tab
|
||||
fireEvent.click(getByRole('tab', { name: 'Components' }));
|
||||
expect(getByText('No matching components found in this library.')).toBeInTheDocument();
|
||||
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(getByRole('tab', { name: 'Home' }));
|
||||
});
|
||||
});
|
||||
124
src/library-authoring/LibraryAuthoringPage.tsx
Normal file
124
src/library-authoring/LibraryAuthoringPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Container, Icon, IconButton, SearchField, Tab, Tabs,
|
||||
} from '@openedx/paragon';
|
||||
import { InfoOutline } from '@openedx/paragon/icons';
|
||||
import {
|
||||
Routes, Route, useLocation, useNavigate, useParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Loading from '../generic/Loading';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import Header from '../header';
|
||||
import NotFoundAlert from '../generic/NotFoundAlert';
|
||||
import LibraryComponents from './LibraryComponents';
|
||||
import LibraryCollections from './LibraryCollections';
|
||||
import LibraryHome from './LibraryHome';
|
||||
import { useContentLibrary } from './data/apiHook';
|
||||
import messages from './messages';
|
||||
|
||||
enum TabList {
|
||||
home = '',
|
||||
components = 'components',
|
||||
collections = 'collections',
|
||||
}
|
||||
|
||||
const SubHeaderTitle = ({ title }: { title: string }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<IconButton
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.headingInfoAlt)}
|
||||
className="mr-2"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryAuthoringPage = () => {
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchKeywords, setSearchKeywords] = useState('');
|
||||
|
||||
const { libraryId } = useParams();
|
||||
|
||||
const { data: libraryData, isLoading } = useContentLibrary(libraryId);
|
||||
|
||||
const currentPath = location.pathname.split('/').pop();
|
||||
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!libraryId || !libraryData) {
|
||||
return <NotFoundAlert />;
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
// setTabKey(key);
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
isLibrary
|
||||
/>
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<SubHeader
|
||||
title={<SubHeaderTitle title={libraryData.title} />}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
/>
|
||||
<SearchField
|
||||
value={searchKeywords}
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onChange={(value: string) => setSearchKeywords(value)}
|
||||
onSubmit={() => {}}
|
||||
className="w-50"
|
||||
/>
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
activeKey={activeKey}
|
||||
onSelect={handleTabChange}
|
||||
className="my-3"
|
||||
>
|
||||
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
|
||||
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
|
||||
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
|
||||
</Tabs>
|
||||
<Routes>
|
||||
<Route
|
||||
path={TabList.home}
|
||||
element={<LibraryHome libraryId={libraryId} filter={{ searchKeywords }} />}
|
||||
/>
|
||||
<Route
|
||||
path={TabList.components}
|
||||
element={<LibraryComponents libraryId={libraryId} filter={{ searchKeywords }} />}
|
||||
/>
|
||||
<Route
|
||||
path={TabList.collections}
|
||||
element={<LibraryCollections />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<NotFoundAlert />}
|
||||
/>
|
||||
</Routes>
|
||||
</Container>
|
||||
<StudioFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryAuthoringPage;
|
||||
14
src/library-authoring/LibraryCollections.tsx
Normal file
14
src/library-authoring/LibraryCollections.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LibraryCollections = () => (
|
||||
<div className="d-flex my-6 justify-content-center">
|
||||
<FormattedMessage
|
||||
{...messages.collectionsTempPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LibraryCollections;
|
||||
32
src/library-authoring/LibraryComponents.tsx
Normal file
32
src/library-authoring/LibraryComponents.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import { useLibraryComponentCount } from './data/apiHook';
|
||||
import messages from './messages';
|
||||
|
||||
type LibraryComponentsProps = {
|
||||
libraryId: string;
|
||||
filter: {
|
||||
searchKeywords: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LibraryComponents = ({ libraryId, filter: { searchKeywords } }: LibraryComponentsProps) => {
|
||||
const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords);
|
||||
|
||||
if (componentCount === 0) {
|
||||
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex my-6 justify-content-center">
|
||||
<FormattedMessage
|
||||
{...messages.componentsTempPlaceholder}
|
||||
values={{ componentCount }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryComponents;
|
||||
55
src/library-authoring/LibraryHome.tsx
Normal file
55
src/library-authoring/LibraryHome.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Card, Stack,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import LibraryCollections from './LibraryCollections';
|
||||
import LibraryComponents from './LibraryComponents';
|
||||
import { useLibraryComponentCount } from './data/apiHook';
|
||||
import messages from './messages';
|
||||
|
||||
const Section = ({ title, children } : { title: string, children: React.ReactNode }) => (
|
||||
<Card>
|
||||
<Card.Header
|
||||
title={title}
|
||||
/>
|
||||
<Card.Section>
|
||||
{children}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
|
||||
type LibraryHomeProps = {
|
||||
libraryId: string,
|
||||
filter: {
|
||||
searchKeywords: string,
|
||||
},
|
||||
};
|
||||
|
||||
const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => {
|
||||
const intl = useIntl();
|
||||
const { searchKeywords } = filter;
|
||||
const { componentCount, collectionCount } = useLibraryComponentCount(libraryId, searchKeywords);
|
||||
|
||||
if (componentCount === 0) {
|
||||
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<Section title={intl.formatMessage(messages.recentlyModifiedTitle)}>
|
||||
{ intl.formatMessage(messages.recentComponentsTempPlaceholder) }
|
||||
</Section>
|
||||
<Section title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}>
|
||||
<LibraryCollections />
|
||||
</Section>
|
||||
<Section title={intl.formatMessage(messages.componentsTitle, { componentCount })}>
|
||||
<LibraryComponents libraryId={libraryId} filter={filter} />
|
||||
</Section>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryHome;
|
||||
38
src/library-authoring/data/api.ts
Normal file
38
src/library-authoring/data/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
/**
|
||||
* Get the URL for the content library API.
|
||||
*/
|
||||
export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
type: string;
|
||||
org: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
numBlocks: number;
|
||||
version: number;
|
||||
lastPublished: Date | null;
|
||||
allowLti: boolean;
|
||||
allowPublicLearning: boolean;
|
||||
allowPublicRead: boolean;
|
||||
hasUnpublishedChanges: boolean;
|
||||
hasUnpublishedDeletes: boolean;
|
||||
license: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a content library by its ID.
|
||||
*/
|
||||
export async function getContentLibrary(libraryId?: string): Promise<ContentLibrary> {
|
||||
if (!libraryId) {
|
||||
throw new Error('libraryId is required');
|
||||
}
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
48
src/library-authoring/data/apiHook.ts
Normal file
48
src/library-authoring/data/apiHook.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
|
||||
import { useContentSearchConnection, useContentSearchResults } from '../../search-modal';
|
||||
import { getContentLibrary } from './api';
|
||||
|
||||
/**
|
||||
* Hook to fetch a content library by its ID.
|
||||
*/
|
||||
export const useContentLibrary = (libraryId?: string) => (
|
||||
useQuery({
|
||||
queryKey: ['contentLibrary', libraryId],
|
||||
queryFn: () => getContentLibrary(libraryId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to fetch the count of components and collections in a library.
|
||||
*/
|
||||
export const useLibraryComponentCount = (libraryId: string, searchKeywords: string) => {
|
||||
// Meilisearch code to get Collection and Component counts
|
||||
const { data: connectionDetails } = useContentSearchConnection();
|
||||
|
||||
const indexName = connectionDetails?.indexName;
|
||||
const client = React.useMemo(() => {
|
||||
if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
|
||||
}, [connectionDetails?.apiKey, connectionDetails?.url]);
|
||||
|
||||
const libFilter = `context_key = "${libraryId}"`;
|
||||
|
||||
const { totalHits: componentCount } = useContentSearchResults({
|
||||
client,
|
||||
indexName,
|
||||
searchKeywords,
|
||||
extraFilter: [libFilter], // ToDo: Add filter for components when collection is implemented
|
||||
});
|
||||
|
||||
const collectionCount = 0; // ToDo: Implement collections count
|
||||
|
||||
return {
|
||||
componentCount,
|
||||
collectionCount,
|
||||
};
|
||||
};
|
||||
2
src/library-authoring/index.ts
Normal file
2
src/library-authoring/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LibraryAuthoringPage } from './LibraryAuthoringPage';
|
||||
export { default as CreateLibrary } from './CreateLibrary';
|
||||
95
src/library-authoring/messages.ts
Normal file
95
src/library-authoring/messages.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
|
||||
import type { defineMessages as defineMessagesType } from 'react-intl';
|
||||
|
||||
// frontend-platform currently doesn't provide types... do it ourselves.
|
||||
const defineMessages = _defineMessages as typeof defineMessagesType;
|
||||
|
||||
const messages = defineMessages({
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.library-authoring.heading-subtitle',
|
||||
defaultMessage: 'Content library',
|
||||
description: 'The page heading for the library page.',
|
||||
},
|
||||
headingInfoAlt: {
|
||||
id: 'course-authoring.library-authoring.heading-info-alt',
|
||||
defaultMessage: 'Info',
|
||||
description: 'Alt text for the info icon next to the page heading.',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.search',
|
||||
defaultMessage: 'Search...',
|
||||
description: 'Placeholder for search field',
|
||||
},
|
||||
noSearchResults: {
|
||||
id: 'course-authoring.library-authoring.no-search-results',
|
||||
defaultMessage: 'No matching components found in this library.',
|
||||
description: 'Message displayed when no search results are found',
|
||||
},
|
||||
noComponents: {
|
||||
id: 'course-authoring.library-authoring.no-components',
|
||||
defaultMessage: 'You have not added any content to this library yet.',
|
||||
description: 'Message displayed when the library is empty',
|
||||
},
|
||||
addComponent: {
|
||||
id: 'course-authoring.library-authoring.add-component',
|
||||
defaultMessage: 'Add component',
|
||||
description: 'Button text to add a new component',
|
||||
},
|
||||
homeTab: {
|
||||
id: 'course-authoring.library-authoring.home-tab',
|
||||
defaultMessage: 'Home',
|
||||
description: 'Tab label for the home tab',
|
||||
},
|
||||
componentsTab: {
|
||||
id: 'course-authoring.library-authoring.components-tab',
|
||||
defaultMessage: 'Components',
|
||||
description: 'Tab label for the components tab',
|
||||
},
|
||||
collectionsTab: {
|
||||
id: 'course-authoring.library-authoring.collections-tab',
|
||||
defaultMessage: 'Collections',
|
||||
description: 'Tab label for the collections tab',
|
||||
},
|
||||
componentsTempPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.components-temp-placeholder',
|
||||
defaultMessage: 'There are {componentCount} components in this library',
|
||||
description: 'Temp placeholder for the component container. This will be replaced with the actual component list.',
|
||||
},
|
||||
collectionsTempPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.collections-temp-placeholder',
|
||||
defaultMessage: 'Coming soon!',
|
||||
description: 'Temp placeholder for the collections container. This will be replaced with the actual collection list.',
|
||||
},
|
||||
recentComponentsTempPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.recent-components-temp-placeholder',
|
||||
defaultMessage: 'Recently modified components and collections will be displayed here.',
|
||||
description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.',
|
||||
},
|
||||
createLibrary: {
|
||||
id: 'course-authoring.library-authoring.create-library',
|
||||
defaultMessage: 'Create library',
|
||||
description: 'Header for the create library form',
|
||||
},
|
||||
createLibraryTempPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.create-library-temp-placeholder',
|
||||
defaultMessage: 'This is a placeholder for the create library form. This will be replaced with the actual form.',
|
||||
description: 'Temp placeholder for the create library container. This will be replaced with the new library form.',
|
||||
},
|
||||
recentlyModifiedTitle: {
|
||||
id: 'course-authoring.library-authoring.recently-modified-title',
|
||||
defaultMessage: 'Recently Modified',
|
||||
description: 'Title for the recently modified components and collections container',
|
||||
},
|
||||
collectionsTitle: {
|
||||
id: 'course-authoring.library-authoring.collections-title',
|
||||
defaultMessage: 'Collections ({collectionCount})',
|
||||
description: 'Title for the collections container',
|
||||
},
|
||||
componentsTitle: {
|
||||
id: 'course-authoring.library-authoring.components-title',
|
||||
defaultMessage: 'Components ({componentCount})',
|
||||
description: 'Title for the components container',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -5,7 +5,8 @@ import { ModalDialog } from '@openedx/paragon';
|
||||
import messages from './messages';
|
||||
import SearchUI from './SearchUI';
|
||||
|
||||
const SearchModal: React.FC<{ courseId: string, isOpen: boolean, onClose: () => void }> = ({ courseId, ...props }) => {
|
||||
// eslint-disable-next-line react/require-default-props
|
||||
const SearchModal: React.FC<{ courseId?: string, isOpen: boolean, onClose: () => void }> = ({ courseId, ...props }) => {
|
||||
const intl = useIntl();
|
||||
const title = intl.formatMessage(messages.title);
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ function getItemIcon(blockType: string): React.ComponentType {
|
||||
/**
|
||||
* Returns the URL Suffix for library/library component hit
|
||||
*/
|
||||
function getLibraryHitUrl(hit: ContentHit, libraryAuthoringMfeUrl: string): string {
|
||||
function getLibraryComponentUrlSuffix(hit: ContentHit) {
|
||||
const { contextKey } = hit;
|
||||
return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${contextKey}`);
|
||||
return `library/${contextKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,10 +117,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
|
||||
const { closeSearchModal } = useSearchContext();
|
||||
const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe } = useSelector(getStudioHomeData);
|
||||
|
||||
const { usageKey } = hit;
|
||||
|
||||
const noRedirectUrl = usageKey.startsWith('lb:') && !redirectToLibraryAuthoringMfe;
|
||||
|
||||
/**
|
||||
* Returns the URL for the context of the hit
|
||||
*/
|
||||
@@ -136,13 +132,19 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
|
||||
return `/${urlSuffix}`;
|
||||
}
|
||||
|
||||
if (usageKey.startsWith('lb:')) {
|
||||
if (redirectToLibraryAuthoringMfe) {
|
||||
return getLibraryHitUrl(hit, libraryAuthoringMfeUrl);
|
||||
if (contextKey.startsWith('lib:')) {
|
||||
const urlSuffix = getLibraryComponentUrlSuffix(hit);
|
||||
if (redirectToLibraryAuthoringMfe && libraryAuthoringMfeUrl) {
|
||||
return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, urlSuffix);
|
||||
}
|
||||
|
||||
if (newWindow) {
|
||||
return `${getPath(getConfig().PUBLIC_PATH)}${urlSuffix}`;
|
||||
}
|
||||
return `/${urlSuffix}`;
|
||||
}
|
||||
|
||||
// No context URL for this hit (e.g. a library without library authoring mfe)
|
||||
// istanbul ignore next - This case should never be reached
|
||||
return undefined;
|
||||
}, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, hit]);
|
||||
|
||||
@@ -189,12 +191,12 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={`border-bottom search-result p-2 align-items-start ${noRedirectUrl ? 'text-muted' : ''}`}
|
||||
className="border-bottom search-result p-2 align-items-start"
|
||||
direction="horizontal"
|
||||
gap={3}
|
||||
onClick={navigateToContext}
|
||||
onKeyDown={navigateToContext}
|
||||
tabIndex={noRedirectUrl ? undefined : 0}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<Icon className="text-muted" src={getItemIcon(hit.blockType)} />
|
||||
@@ -213,7 +215,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
|
||||
<IconButton
|
||||
src={OpenInNew}
|
||||
iconAs={Icon}
|
||||
disabled={noRedirectUrl ? true : undefined}
|
||||
onClick={openContextInNewWindow}
|
||||
alt={intl.formatMessage(messages.openInNewWindow)}
|
||||
/>
|
||||
|
||||
@@ -342,9 +342,10 @@ describe('<SearchUI />', () => {
|
||||
window.location = location;
|
||||
});
|
||||
|
||||
test('click lib component result doesnt navigates to the context withou libraryAuthoringMfe', async () => {
|
||||
test('click lib component result navigates to course-authoring/library without libraryAuthoringMfe', async () => {
|
||||
const data = generateGetStudioHomeDataApiResponse();
|
||||
data.redirectToLibraryAuthoringMfe = false;
|
||||
data.libraryAuthoringMfeUrl = '';
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
|
||||
|
||||
await executeThunk(fetchStudioHomeData(), store.dispatch);
|
||||
@@ -354,18 +355,21 @@ describe('<SearchUI />', () => {
|
||||
const resultItem = await findByRole('button', { name: /Library Content/ });
|
||||
|
||||
// Clicking the "Open in new window" button should open the result in a new window:
|
||||
const { open, location } = window;
|
||||
const { open } = window;
|
||||
window.open = jest.fn();
|
||||
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'/library/lib:org1:libafter1',
|
||||
'_blank',
|
||||
);
|
||||
window.open = open;
|
||||
|
||||
// @ts-ignore
|
||||
window.location = { href: '' };
|
||||
// Clicking in the result should navigate to the result's URL:
|
||||
fireEvent.click(resultItem);
|
||||
expect(window.location.href === location.href);
|
||||
window.location = location;
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'/library/lib:org1:libafter1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import Stats from './Stats';
|
||||
import { SearchContextProvider } from './manager/SearchManager';
|
||||
import messages from './messages';
|
||||
|
||||
const SearchUI: React.FC<{ courseId: string, closeSearchModal?: () => void }> = (props) => {
|
||||
const SearchUI: React.FC<{ courseId?: string, closeSearchModal?: () => void }> = (props) => {
|
||||
const hasCourseId = Boolean(props.courseId);
|
||||
const [searchThisCourseEnabled, setSearchThisCourse] = React.useState(hasCourseId);
|
||||
const switchToThisCourse = React.useCallback(() => setSearchThisCourse(true), []);
|
||||
|
||||
@@ -35,8 +35,8 @@ export const useContentSearchResults = ({
|
||||
indexName,
|
||||
extraFilter,
|
||||
searchKeywords,
|
||||
blockTypesFilter,
|
||||
tagsFilter,
|
||||
blockTypesFilter = [],
|
||||
tagsFilter = [],
|
||||
}: {
|
||||
/** The Meilisearch API client */
|
||||
client?: MeiliSearch;
|
||||
@@ -47,9 +47,9 @@ export const useContentSearchResults = ({
|
||||
/** The keywords that the user is searching for, if any */
|
||||
searchKeywords: string;
|
||||
/** Only search for these block types (e.g. `["html", "problem"]`) */
|
||||
blockTypesFilter: string[];
|
||||
blockTypesFilter?: string[];
|
||||
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
|
||||
tagsFilter: string[];
|
||||
tagsFilter?: string[];
|
||||
}) => {
|
||||
const query = useInfiniteQuery({
|
||||
enabled: client !== undefined && indexName !== undefined,
|
||||
|
||||
2
src/search-modal/index.ts
Normal file
2
src/search-modal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SearchModal } from './SearchModal';
|
||||
export { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Add as AddIcon, Error } from '@openedx/paragon/icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { getConfig, getPath } from '@edx/frontend-platform';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { constructLibraryAuthoringURL } from '../utils';
|
||||
import Loading from '../generic/Loading';
|
||||
@@ -19,7 +20,7 @@ import Header from '../header';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import HomeSidebar from './home-sidebar';
|
||||
import TabsSection from './tabs-section';
|
||||
import { isMixedOrV2LibrariesMode } from './tabs-section/utils';
|
||||
import { isMixedOrV1LibrariesMode, isMixedOrV2LibrariesMode } from './tabs-section/utils';
|
||||
import OrganizationSection from './organization-section';
|
||||
import VerifyEmailLayout from './verify-email-layout';
|
||||
import CreateNewCourseForm from './create-new-course-form';
|
||||
@@ -28,6 +29,9 @@ import { useStudioHome } from './hooks';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
|
||||
const StudioHome = ({ intl }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isPaginationCoursesEnabled = getConfig().ENABLE_HOME_PAGE_COURSE_API_V2;
|
||||
const {
|
||||
isLoadingPage,
|
||||
@@ -47,6 +51,8 @@ const StudioHome = ({ intl }) => {
|
||||
|
||||
const libMode = getConfig().LIBRARY_MODE;
|
||||
|
||||
const v1LibraryTab = isMixedOrV1LibrariesMode(libMode) && location?.pathname.split('/').pop() === 'libraries-v1';
|
||||
|
||||
const {
|
||||
userIsActive,
|
||||
studioShortName,
|
||||
@@ -55,7 +61,7 @@ const StudioHome = ({ intl }) => {
|
||||
redirectToLibraryAuthoringMfe,
|
||||
} = studioHomeData;
|
||||
|
||||
function getHeaderButtons() {
|
||||
const getHeaderButtons = useCallback(() => {
|
||||
const headerButtons = [];
|
||||
|
||||
if (isFailedLoadingPage || !userIsActive) {
|
||||
@@ -82,15 +88,20 @@ const StudioHome = ({ intl }) => {
|
||||
);
|
||||
}
|
||||
|
||||
let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`;
|
||||
if (isMixedOrV2LibrariesMode(libMode)) {
|
||||
libraryHref = libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe
|
||||
? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create')
|
||||
// Redirection to the placeholder is done in the MFE rather than
|
||||
// through the backend i.e. redirection from cms, because this this will probably change,
|
||||
// hence why we use the MFE's origin
|
||||
: `${window.location.origin}${getPath(getConfig().PUBLIC_PATH)}library/create`;
|
||||
}
|
||||
const newLibraryClick = () => {
|
||||
if (isMixedOrV2LibrariesMode(libMode) && !v1LibraryTab) {
|
||||
if (libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe) {
|
||||
// Library authoring MFE
|
||||
window.open(constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create'));
|
||||
} else {
|
||||
// Use course-authoring route
|
||||
navigate('/library/create');
|
||||
}
|
||||
} else {
|
||||
// Studio home library for legacy libraries
|
||||
window.open(`${getConfig().STUDIO_BASE_URL}/home_library`);
|
||||
}
|
||||
};
|
||||
|
||||
headerButtons.push(
|
||||
<Button
|
||||
@@ -98,7 +109,7 @@ const StudioHome = ({ intl }) => {
|
||||
iconBefore={AddIcon}
|
||||
size="sm"
|
||||
disabled={showNewCourseContainer}
|
||||
href={libraryHref}
|
||||
onClick={newLibraryClick}
|
||||
data-testid="new-library-button"
|
||||
>
|
||||
{intl.formatMessage(messages.addNewLibraryBtnText)}
|
||||
@@ -106,7 +117,7 @@ const StudioHome = ({ intl }) => {
|
||||
);
|
||||
|
||||
return headerButtons;
|
||||
}
|
||||
}, [location, userIsActive, isFailedLoadingPage]);
|
||||
|
||||
const headerButtons = userIsActive ? getHeaderButtons() : [];
|
||||
if (isLoadingPage && !isFiltered) {
|
||||
|
||||
@@ -35,6 +35,13 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = () => (
|
||||
@@ -165,7 +172,7 @@ describe('<StudioHome />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('href should include home_library when in "v1 only" lib mode', async () => {
|
||||
it('should navigate to home_library when in "v1 only" lib mode', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
LIBRARY_MODE: 'v1 only',
|
||||
@@ -178,9 +185,15 @@ describe('<StudioHome />', () => {
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const createNewLibraryButton = getByTestId('new-library-button');
|
||||
expect(createNewLibraryButton.getAttribute('href')).toBe(`${studioBaseUrl}/home_library`);
|
||||
|
||||
const { open } = window;
|
||||
window.open = jest.fn();
|
||||
fireEvent.click(createNewLibraryButton);
|
||||
expect(window.open).toHaveBeenCalledWith(`${studioBaseUrl}/home_library`);
|
||||
window.open = open;
|
||||
});
|
||||
it('href should include create', async () => {
|
||||
|
||||
it('should navigate to the library authoring mfe', () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
|
||||
@@ -191,9 +204,27 @@ describe('<StudioHome />', () => {
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const createNewLibraryButton = getByTestId('new-library-button');
|
||||
expect(createNewLibraryButton.getAttribute('href')).toBe(
|
||||
|
||||
const { open } = window;
|
||||
window.open = jest.fn();
|
||||
fireEvent.click(createNewLibraryButton);
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
`${constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create')}`,
|
||||
);
|
||||
window.open = open;
|
||||
});
|
||||
|
||||
it('should navigate to the library authoring page in course authoring', () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
LIBRARY_MODE: 'v2 only',
|
||||
});
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const createNewLibraryButton = getByTestId('new-library-button');
|
||||
|
||||
fireEvent.click(createNewLibraryButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/library/create');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Header from '../../header';
|
||||
import SubHeader from '../../generic/sub-header/SubHeader';
|
||||
import messages from './messages';
|
||||
|
||||
/* istanbul ignore next */
|
||||
const LibraryV2Placeholder = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<section className="mb-4">
|
||||
<article className="studio-home-sub-header">
|
||||
<section>
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.libraryV2PlaceholderTitle)}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
<section>
|
||||
<p>{intl.formatMessage(messages.libraryV2PlaceholderBody)}</p>
|
||||
</section>
|
||||
</section>
|
||||
</Container>
|
||||
<StudioFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryV2Placeholder;
|
||||
Reference in New Issue
Block a user