feat: library home page ("bare bones") (#1076)

This commit is contained in:
Rômulo Penido
2024-07-10 14:20:00 +02:00
committed by GitHub
parent 117b4f10e7
commit f60ddb579e
23 changed files with 830 additions and 147 deletions

View File

@@ -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}
/>
)
)}

View File

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

View File

@@ -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' && (

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

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

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

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

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

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

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

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

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

View File

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

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

View File

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

View File

@@ -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)}
/>

View File

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

View File

@@ -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), []);

View File

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

View File

@@ -0,0 +1,2 @@
export { default as SearchModal } from './SearchModal';
export { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';

View File

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

View File

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

View File

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