diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx
index c4281a8c13..eaa16c49c2 100644
--- a/src/CourseAuthoringPage.jsx
+++ b/src/CourseAuthoringPage.jsx
@@ -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,
-}) => (
-
-);
-
-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 &&
: (!isEditor && (
-
)
)}
diff --git a/src/generic/toast-context/index.jsx b/src/generic/toast-context/index.jsx
new file mode 100644
index 0000000000..0c68fec7b9
--- /dev/null
+++ b/src/generic/toast-context/index.jsx
@@ -0,0 +1,50 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import { Toast } from '@openedx/paragon';
+import React from 'react';
+
+/**
+ * Global context to keep track of popup message(s) that appears to user after
+ * they take an action like creating or deleting something.
+ */
+export const ToastContext = React.createContext({
+ toastMessage: /** @type{null|string} */ (null),
+ showToast: /** @type{function} */ (() => {}),
+ closeToast: /** @type{function} */ (() => {}),
+});
+
+/**
+ * React component to provide `ToastContext` to the app
+ * @param {{children?: React.ReactNode}} props The components to wrap
+ */
+export const ToastProvider = (props) => {
+ // TODO, We can convert this to a queue of messages,
+ // see: https://github.com/open-craft/frontend-app-course-authoring/pull/38#discussion_r1638990647
+
+ const [toastMessage, setToastMessage] = React.useState(/** @type{null|string} */ (null));
+
+ React.useEffect(() => () => {
+ // Cleanup function to avoid updating state on unmounted component
+ setToastMessage(null);
+ }, []);
+
+ const showToast = React.useCallback((message) => setToastMessage(message), [setToastMessage]);
+ const closeToast = React.useCallback(() => setToastMessage(null), [setToastMessage]);
+
+ const context = React.useMemo(() => ({
+ toastMessage,
+ showToast,
+ closeToast,
+ }), [toastMessage, showToast, closeToast]);
+
+ return (
+
+ {props.children}
+ { toastMessage && (
+
+ {toastMessage}
+
+ )}
+
+ );
+};
diff --git a/src/header/Header.jsx b/src/header/Header.jsx
index 7cc1adcb08..e5ba1a4b3c 100644
--- a/src/header/Header.jsx
+++ b/src/header/Header.jsx
@@ -6,16 +6,17 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { StudioHeader } from '@edx/frontend-component-header';
import { useToggle } from '@openedx/paragon';
-import SearchModal from '../search-modal/SearchModal';
+import { SearchModal } from '../search-modal';
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
import messages from './messages';
const Header = ({
- courseId,
- courseOrg,
- courseNumber,
- courseTitle,
+ contentId,
+ org,
+ number,
+ title,
isHiddenMainMenu,
+ isLibrary,
}) => {
const intl = useIntl();
@@ -23,40 +24,40 @@ const Header = ({
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: contentId, 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: contentId, 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: contentId, intl }),
},
- ];
- const outlineLink = `${studioBaseUrl}/course/${courseId}`;
+ ] : [];
+ const outlineLink = !isLibrary ? `${studioBaseUrl}/course/${contentId}` : `/course-authoring/library/${contentId}`;
return (
<>
{ meiliSearchEnabled && (
)}
@@ -65,19 +66,21 @@ const Header = ({
};
Header.propTypes = {
- courseId: PropTypes.string,
- courseNumber: PropTypes.string,
- courseOrg: PropTypes.string,
- courseTitle: PropTypes.string,
+ contentId: PropTypes.string,
+ number: PropTypes.string,
+ org: PropTypes.string,
+ title: PropTypes.string,
isHiddenMainMenu: PropTypes.bool,
+ isLibrary: PropTypes.bool,
};
Header.defaultProps = {
- courseId: '',
- courseNumber: '',
- courseOrg: '',
- courseTitle: '',
+ contentId: '',
+ number: '',
+ org: '',
+ title: '',
isHiddenMainMenu: false,
+ isLibrary: false,
};
export default Header;
diff --git a/src/index.jsx b/src/index.jsx
index f881441df9..e714ff5e3a 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -19,15 +19,16 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { logError } from '@edx/frontend-platform/logging';
import messages from './i18n';
+import { CreateLibrary, LibraryLayout } 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';
import AccessibilityPage from './accessibility-page';
+import { ToastProvider } from './generic/toast-context';
import 'react-datepicker/dist/react-datepicker.css';
import './index.scss';
@@ -55,7 +56,8 @@ const App = () => {
} />
} />
} />
- } />
+ } />
+ } />
} />
} />
{getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && (
@@ -84,10 +86,12 @@ const App = () => {
return (
-
-
-
-
+
+
+
+
+
+
);
};
diff --git a/src/library-authoring/CreateLibrary.tsx b/src/library-authoring/CreateLibrary.tsx
new file mode 100644
index 0000000000..227f14dbe5
--- /dev/null
+++ b/src/library-authoring/CreateLibrary.tsx
@@ -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 = () => (
+ <>
+
+
+ }
+ />
+
+
+
+
+ >
+);
+
+export default CreateLibrary;
diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx
new file mode 100644
index 0000000000..7582aec60d
--- /dev/null
+++ b/src/library-authoring/EmptyStates.tsx
@@ -0,0 +1,27 @@
+import React, { useContext } 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';
+import { LibraryContext } from './common/context';
+
+export const NoComponents = () => {
+ const { openAddContentSidebar } = useContext(LibraryContext);
+
+ return (
+
+
+
+
+ );
+};
+
+export const NoSearchResults = () => (
+
+
+
+);
diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
new file mode 100644
index 0000000000..946660f026
--- /dev/null
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -0,0 +1,275 @@
+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,
+ screen,
+} 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 } from './data/api';
+import { LibraryProvider } from './common/context';
+
+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 ... 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 = {
+ id: 'lib:org1:lib1',
+ type: 'complex',
+ org: 'org1',
+ slug: 'lib1',
+ title: 'lib1',
+ description: 'lib1',
+ numBlocks: 2,
+ version: 0,
+ lastPublished: null,
+ allowLti: false,
+ allowPublic_learning: false,
+ allowPublic_read: false,
+ hasUnpublished_changes: true,
+ hasUnpublished_deletes: false,
+ canEditLibrary: true,
+ license: '',
+};
+
+const RootWrapper = () => (
+
+
+
+
+
+
+
+
+
+);
+
+describe('', () => {
+ 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 ... 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();
+ 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();
+
+ expect(await findByTestId('notFoundAlert')).toBeInTheDocument();
+ });
+
+ it('shows an error component if no library param', async () => {
+ mockUseParams.mockReturnValue({ libraryId: '' });
+
+ const { findByTestId } = render();
+
+ 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();
+
+ // 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();
+
+ 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 new content button', async () => {
+ mockUseParams.mockReturnValue({ libraryId: libraryData.id });
+ axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
+
+ render();
+
+ expect(await screen.findByRole('heading')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /new/i })).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();
+
+ 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' }));
+ });
+
+ it('should open and close new content sidebar', async () => {
+ mockUseParams.mockReturnValue({ libraryId: libraryData.id });
+ axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
+
+ render();
+
+ expect(await screen.findByRole('heading')).toBeInTheDocument();
+ expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
+
+ const newButton = screen.getByRole('button', { name: /new/i });
+ fireEvent.click(newButton);
+
+ expect(screen.getByText(/add content/i)).toBeInTheDocument();
+
+ const closeButton = screen.getByRole('button', { name: /close/i });
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
new file mode 100644
index 0000000000..a699317719
--- /dev/null
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -0,0 +1,162 @@
+import React, { useEffect, useContext, useState } from 'react';
+import { StudioFooter } from '@edx/frontend-component-footer';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Button,
+ Container,
+ Icon,
+ IconButton,
+ SearchField,
+ Tab,
+ Tabs,
+ Row,
+ Col,
+} from '@openedx/paragon';
+import { Add, 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';
+import { LibrarySidebar } from './library-sidebar';
+import { LibraryContext } from './common/context';
+
+const TAB_LIST = {
+ home: '',
+ components: 'components',
+ collections: 'collections',
+};
+
+const SubHeaderTitle = ({ title }: { title: string }) => {
+ const intl = useIntl();
+ return (
+ <>
+ {title}
+
+ >
+ );
+};
+
+const LibraryAuthoringPage = () => {
+ const intl = useIntl();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const [tabKey, setTabKey] = useState(TAB_LIST.home);
+ const [searchKeywords, setSearchKeywords] = useState('');
+
+ const { libraryId } = useParams();
+
+ const { data: libraryData, isLoading } = useContentLibrary(libraryId);
+
+ const { sidebarBodyComponent, openAddContentSidebar } = useContext(LibraryContext);
+
+ useEffect(() => {
+ const currentPath = location.pathname.split('/').pop();
+ if (currentPath && Object.values(TAB_LIST).includes(currentPath)) {
+ setTabKey(currentPath);
+ } else {
+ setTabKey(TAB_LIST.home);
+ }
+ }, [location]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!libraryId || !libraryData) {
+ return ;
+ }
+
+ const handleTabChange = (key: string) => {
+ setTabKey(key);
+ navigate(key);
+ };
+
+ return (
+
+
+
+
+
+ }
+ subtitle={intl.formatMessage(messages.headingSubtitle)}
+ headerActions={[
+ ,
+ ]}
+ />
+ setSearchKeywords(value)}
+ onSubmit={() => {}}
+ className="w-50"
+ />
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+ { sidebarBodyComponent !== null && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default LibraryAuthoringPage;
diff --git a/src/library-authoring/LibraryCollections.tsx b/src/library-authoring/LibraryCollections.tsx
new file mode 100644
index 0000000000..2f1eb8951f
--- /dev/null
+++ b/src/library-authoring/LibraryCollections.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+
+const LibraryCollections = () => (
+
+
+
+);
+
+export default LibraryCollections;
diff --git a/src/library-authoring/LibraryComponents.tsx b/src/library-authoring/LibraryComponents.tsx
new file mode 100644
index 0000000000..fee8cb3502
--- /dev/null
+++ b/src/library-authoring/LibraryComponents.tsx
@@ -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 === '' ? : ;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default LibraryComponents;
diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx
new file mode 100644
index 0000000000..1201e8a848
--- /dev/null
+++ b/src/library-authoring/LibraryHome.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { FormattedMessage } 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 }) => (
+
+
+
+ {children}
+
+
+);
+
+type LibraryHomeProps = {
+ libraryId: string,
+ filter: {
+ searchKeywords: string,
+ },
+};
+
+const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => {
+ const { searchKeywords } = filter;
+ const { componentCount, collectionCount } = useLibraryComponentCount(libraryId, searchKeywords);
+
+ if (componentCount === 0) {
+ return searchKeywords === '' ? : ;
+ }
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default LibraryHome;
diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx
new file mode 100644
index 0000000000..95d829606f
--- /dev/null
+++ b/src/library-authoring/LibraryLayout.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import LibraryAuthoringPage from './LibraryAuthoringPage';
+import { LibraryProvider } from './common/context';
+
+const LibraryLayout = () => (
+
+
+
+);
+
+export default LibraryLayout;
diff --git a/src/library-authoring/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx
new file mode 100644
index 0000000000..51b07843c9
--- /dev/null
+++ b/src/library-authoring/add-content/AddContentContainer.test.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ render, screen, fireEvent, waitFor,
+} from '@testing-library/react';
+import { AppProvider } from '@edx/frontend-platform/react';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import AddContentContainer from './AddContentContainer';
+import initializeStore from '../../store';
+import { getCreateLibraryBlockUrl } from '../data/api';
+
+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 libraryId = '1';
+let store;
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+});
+
+const RootWrapper = () => (
+
+
+
+
+
+
+
+);
+
+describe('', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ mockUseParams.mockReturnValue({ libraryId });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render content buttons', () => {
+ render();
+ expect(screen.getByRole('button', { name: /collection/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /problem/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /open reponse/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /drag drop/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument();
+ });
+
+ it('should create a content', async () => {
+ const url = getCreateLibraryBlockUrl(libraryId);
+ axiosMock.onPost(url).reply(200);
+
+ render();
+
+ const textButton = screen.getByRole('button', { name: /text/i });
+ fireEvent.click(textButton);
+
+ await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
+ });
+});
diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx
new file mode 100644
index 0000000000..3313e5ae54
--- /dev/null
+++ b/src/library-authoring/add-content/AddContentContainer.tsx
@@ -0,0 +1,108 @@
+import React, { useContext } from 'react';
+import {
+ Stack,
+ Button,
+} from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Article,
+ AutoAwesome,
+ BookOpen,
+ Create,
+ ThumbUpOutline,
+ Question,
+ VideoCamera,
+} from '@openedx/paragon/icons';
+import { v4 as uuid4 } from 'uuid';
+import { useParams } from 'react-router-dom';
+import { ToastContext } from '../../generic/toast-context';
+import { useCreateLibraryBlock } from '../data/apiHook';
+import messages from './messages';
+
+const AddContentContainer = () => {
+ const intl = useIntl();
+ const { libraryId } = useParams();
+ const createBlockMutation = useCreateLibraryBlock();
+ const { showToast } = useContext(ToastContext);
+
+ const contentTypes = [
+ {
+ name: intl.formatMessage(messages.textTypeButton),
+ disabled: false,
+ icon: Article,
+ blockType: 'html',
+ },
+ {
+ name: intl.formatMessage(messages.problemTypeButton),
+ disabled: false,
+ icon: Question,
+ blockType: 'problem',
+ },
+ {
+ name: intl.formatMessage(messages.openResponseTypeButton),
+ disabled: false,
+ icon: Create,
+ blockType: 'openassessment',
+ },
+ {
+ name: intl.formatMessage(messages.dragDropTypeButton),
+ disabled: false,
+ icon: ThumbUpOutline,
+ blockType: 'drag-and-drop-v2',
+ },
+ {
+ name: intl.formatMessage(messages.videoTypeButton),
+ disabled: false,
+ icon: VideoCamera,
+ blockType: 'video',
+ },
+ {
+ name: intl.formatMessage(messages.otherTypeButton),
+ disabled: true,
+ icon: AutoAwesome,
+ blockType: 'other', // This block doesn't exist yet.
+ },
+ ];
+
+ const onCreateContent = (blockType: string) => {
+ if (libraryId) {
+ createBlockMutation.mutateAsync({
+ libraryId,
+ blockType,
+ definitionId: `${uuid4()}`,
+ }).then(() => {
+ showToast(intl.formatMessage(messages.successCreateMessage));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.errorCreateMessage));
+ });
+ }
+ };
+
+ return (
+
+
+
+ {contentTypes.map((contentType) => (
+
+ ))}
+
+ );
+};
+
+export default AddContentContainer;
diff --git a/src/library-authoring/add-content/index.ts b/src/library-authoring/add-content/index.ts
new file mode 100644
index 0000000000..876828e16f
--- /dev/null
+++ b/src/library-authoring/add-content/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as AddContentContainer } from './AddContentContainer';
diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts
new file mode 100644
index 0000000000..e76ed7317f
--- /dev/null
+++ b/src/library-authoring/add-content/messages.ts
@@ -0,0 +1,51 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ collectionButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.collection',
+ defaultMessage: 'Collection',
+ description: 'Content of button to create a Collection.',
+ },
+ textTypeButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.types.text',
+ defaultMessage: 'Text',
+ description: 'Content of button to create a Text component.',
+ },
+ problemTypeButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.types.problem',
+ defaultMessage: 'Problem',
+ description: 'Content of button to create a Problem component.',
+ },
+ openResponseTypeButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.types.open-response',
+ defaultMessage: 'Open Reponse',
+ description: 'Content of button to create a Open Response component.',
+ },
+ dragDropTypeButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.types.drag-drop',
+ defaultMessage: 'Drag Drop',
+ description: 'Content of button to create a Drag Drod component.',
+ },
+ videoTypeButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.types.video',
+ defaultMessage: 'Video',
+ description: 'Content of button to create a Video component.',
+ },
+ otherTypeButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.types.other',
+ defaultMessage: 'Advanced / Other',
+ description: 'Content of button to create a Advanced / Other component.',
+ },
+ successCreateMessage: {
+ id: 'course-authoring.library-authoring.add-content.success.text',
+ defaultMessage: 'Content created successfully.',
+ description: 'Message when creation of content in library is success',
+ },
+ errorCreateMessage: {
+ id: 'course-authoring.library-authoring.add-content.error.text',
+ defaultMessage: 'There was an error creating the content.',
+ description: 'Message when creation of content in library is on error',
+ },
+});
+
+export default messages;
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
new file mode 100644
index 0000000000..b12df68e7e
--- /dev/null
+++ b/src/library-authoring/common/context.tsx
@@ -0,0 +1,37 @@
+/* eslint-disable react/prop-types */
+/* eslint-disable react/require-default-props */
+import React from 'react';
+
+export interface LibraryContextData {
+ sidebarBodyComponent: string | null;
+ closeLibrarySidebar: Function;
+ openAddContentSidebar: Function
+}
+
+export const LibraryContext = React.createContext({
+ sidebarBodyComponent: null,
+ closeLibrarySidebar: () => {},
+ openAddContentSidebar: () => {},
+} as LibraryContextData);
+
+/**
+ * React component to provide `LibraryContext`
+ */
+export const LibraryProvider = (props: { children?: React.ReactNode }) => {
+ const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null);
+
+ const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
+ const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent('add-content'), []);
+
+ const context = React.useMemo(() => ({
+ sidebarBodyComponent,
+ closeLibrarySidebar,
+ openAddContentSidebar,
+ }), [sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar]);
+
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/src/library-authoring/data/api.test.js b/src/library-authoring/data/api.test.js
new file mode 100644
index 0000000000..72d2584d08
--- /dev/null
+++ b/src/library-authoring/data/api.test.js
@@ -0,0 +1,39 @@
+// @ts-check
+import MockAdapter from 'axios-mock-adapter';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { createLibraryBlock, getCreateLibraryBlockUrl } from './api';
+
+let axiosMock;
+
+describe('library api calls', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should create library block', async () => {
+ const libraryId = 'lib:org:1';
+ const url = getCreateLibraryBlockUrl(libraryId);
+ axiosMock.onPost(url).reply(200);
+ await createLibraryBlock({
+ libraryId,
+ blockType: 'html',
+ definitionId: '1',
+ });
+
+ expect(axiosMock.history.post[0].url).toEqual(url);
+ });
+});
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
new file mode 100644
index 0000000000..c95dba74b5
--- /dev/null
+++ b/src/library-authoring/data/api.ts
@@ -0,0 +1,75 @@
+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}/`;
+/**
+ * Get the URL for create content in library.
+ */
+export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
+
+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;
+ canEditLibrary: boolean;
+ license: string;
+}
+
+export interface CreateBlockDataRequest {
+ libraryId: string;
+ blockType: string;
+ definitionId: string;
+}
+
+export interface CreateBlockDataResponse {
+ id: string;
+ blockType: string;
+ defKey: string | null;
+ displayName: string;
+ hasUnpublishedChanges: boolean;
+ tagsCount: number;
+}
+
+/**
+ * Fetch a content library by its ID.
+ */
+export async function getContentLibrary(libraryId?: string): Promise {
+ if (!libraryId) {
+ throw new Error('libraryId is required');
+ }
+
+ const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId));
+ return camelCaseObject(data);
+}
+
+export async function createLibraryBlock({
+ libraryId,
+ blockType,
+ definitionId,
+}: CreateBlockDataRequest): Promise {
+ const client = getAuthenticatedHttpClient();
+ const { data } = await client.post(
+ getCreateLibraryBlockUrl(libraryId),
+ {
+ block_type: blockType,
+ definition_id: definitionId,
+ },
+ );
+
+ return camelCaseObject(data);
+}
diff --git a/src/library-authoring/data/apiHook.test.tsx b/src/library-authoring/data/apiHook.test.tsx
new file mode 100644
index 0000000000..841581ff58
--- /dev/null
+++ b/src/library-authoring/data/apiHook.test.tsx
@@ -0,0 +1,54 @@
+// @ts-check
+import React from 'react';
+
+import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { renderHook } from '@testing-library/react-hooks';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import MockAdapter from 'axios-mock-adapter';
+import { getCreateLibraryBlockUrl } from './api';
+import { useCreateLibraryBlock } from './apiHook';
+
+let axiosMock;
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+});
+
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+describe('library api hooks', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+
+ it('should create library block', async () => {
+ const libraryId = 'lib:org:1';
+ const url = getCreateLibraryBlockUrl(libraryId);
+ axiosMock.onPost(url).reply(200);
+ const { result } = renderHook(() => useCreateLibraryBlock(), { wrapper });
+ await result.current.mutateAsync({
+ libraryId,
+ blockType: 'html',
+ definitionId: '1',
+ });
+
+ expect(axiosMock.history.post[0].url).toEqual(url);
+ });
+});
diff --git a/src/library-authoring/data/apiHook.ts b/src/library-authoring/data/apiHook.ts
new file mode 100644
index 0000000000..bafd52e10d
--- /dev/null
+++ b/src/library-authoring/data/apiHook.ts
@@ -0,0 +1,73 @@
+import React from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { MeiliSearch } from 'meilisearch';
+
+import { useContentSearchConnection, useContentSearchResults } from '../../search-modal';
+import { createLibraryBlock, getContentLibrary } from './api';
+
+export const libraryQueryKeys = {
+ /**
+ * Used in all query keys.
+ * You can use these key to invalidate all queries.
+ */
+ all: ['contentLibrary'],
+ contentLibrary: (libraryId) => [
+ libraryQueryKeys.all, libraryId,
+ ],
+};
+
+/**
+ * Hook to fetch a content library by its ID.
+ */
+export const useContentLibrary = (libraryId?: string) => (
+ useQuery({
+ queryKey: ['contentLibrary', libraryId],
+ queryFn: () => getContentLibrary(libraryId),
+ })
+);
+
+/**
+ * Use this mutation to create a block in a library
+ */
+export const useCreateLibraryBlock = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: createLibraryBlock,
+ onSettled: (_data, _error, variables) => {
+ queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(variables.libraryId) });
+ queryClient.invalidateQueries({ queryKey: ['content_search'] });
+ },
+ });
+};
+
+/**
+ * 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,
+ };
+};
diff --git a/src/library-authoring/index.ts b/src/library-authoring/index.ts
new file mode 100644
index 0000000000..95abe50c17
--- /dev/null
+++ b/src/library-authoring/index.ts
@@ -0,0 +1,2 @@
+export { default as LibraryLayout } from './LibraryLayout';
+export { default as CreateLibrary } from './CreateLibrary';
diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
new file mode 100644
index 0000000000..734379f518
--- /dev/null
+++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
@@ -0,0 +1,52 @@
+import React, { useContext } from 'react';
+import {
+ Stack,
+ Icon,
+ IconButton,
+} from '@openedx/paragon';
+import { Close } from '@openedx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from '../messages';
+import { AddContentContainer } from '../add-content';
+import { LibraryContext } from '../common/context';
+
+/**
+ * Sidebar container for library pages.
+ *
+ * It's designed to "squash" the page when open.
+ * Uses `sidebarBodyComponent` of the `store` to
+ * choose which component is rendered.
+ * You can add more components in `bodyComponentMap`.
+ * Use the slice actions to open and close this sidebar.
+ */
+const LibrarySidebar = () => {
+ const intl = useIntl();
+ const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext);
+
+ const bodyComponentMap = {
+ 'add-content': ,
+ unknown: null,
+ };
+
+ const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown'];
+
+ return (
+
+
+
+ {intl.formatMessage(messages.addContentTitle)}
+
+
+
+ {buildBody()}
+
+ );
+};
+
+export default LibrarySidebar;
diff --git a/src/library-authoring/library-sidebar/index.ts b/src/library-authoring/library-sidebar/index.ts
new file mode 100644
index 0000000000..087b1e1d9d
--- /dev/null
+++ b/src/library-authoring/library-sidebar/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as LibrarySidebar } from './LibrarySidebar';
diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts
new file mode 100644
index 0000000000..60497e2106
--- /dev/null
+++ b/src/library-authoring/messages.ts
@@ -0,0 +1,76 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+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',
+ },
+ 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.',
+ },
+ addContentTitle: {
+ id: 'course-authoring.library-authoring.drawer.title.add-content',
+ defaultMessage: 'Add Content',
+ description: 'Title of add content in library container.',
+ },
+ newContentButton: {
+ id: 'course-authoring.library-authoring.buttons.new-content.text',
+ defaultMessage: 'New',
+ description: 'Text of button to open "Add content drawer"',
+ },
+ closeButtonAlt: {
+ id: 'course-authoring.library-authoring.buttons.close.alt',
+ defaultMessage: 'Close',
+ description: 'Alt text of close button',
+ },
+});
+
+export default messages;
diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx
index 77f806be4a..ada7cdd862 100644
--- a/src/search-modal/SearchResult.tsx
+++ b/src/search-modal/SearchResult.tsx
@@ -35,9 +35,9 @@ function getItemIcon(blockType: string): React.ReactElement {
/**
* 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 (
@@ -213,7 +215,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
diff --git a/src/search-modal/SearchUI.test.tsx b/src/search-modal/SearchUI.test.tsx
index 92f0f244d3..7a62193e6d 100644
--- a/src/search-modal/SearchUI.test.tsx
+++ b/src/search-modal/SearchUI.test.tsx
@@ -342,9 +342,10 @@ describe('', () => {
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('', () => {
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',
+ );
});
});
diff --git a/src/search-modal/data/apiHooks.ts b/src/search-modal/data/apiHooks.ts
index cfc4454d5f..fe77482285 100644
--- a/src/search-modal/data/apiHooks.ts
+++ b/src/search-modal/data/apiHooks.ts
@@ -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,
diff --git a/src/search-modal/index.js b/src/search-modal/index.js
new file mode 100644
index 0000000000..190635618d
--- /dev/null
+++ b/src/search-modal/index.js
@@ -0,0 +1,3 @@
+// @ts-check
+export { default as SearchModal } from './SearchModal';
+export { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.jsx
index 4953c6f3ae..4400e54517 100644
--- a/src/studio-home/StudioHome.jsx
+++ b/src/studio-home/StudioHome.jsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import {
Button,
Container,
@@ -11,6 +11,7 @@ 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 { useLocation } 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,8 @@ import { useStudioHome } from './hooks';
import AlertMessage from '../generic/alert-message';
const StudioHome = ({ intl }) => {
+ const location = useLocation();
+
const isPaginationCoursesEnabled = getConfig().ENABLE_HOME_PAGE_COURSE_API_V2;
const {
isLoadingPage,
@@ -47,6 +50,8 @@ const StudioHome = ({ intl }) => {
const libMode = getConfig().LIBRARY_MODE;
+ const v1LibraryTab = isMixedOrV1LibrariesMode(libMode) && location?.pathname.split('/').pop() === 'libraries-v1';
+
const {
userIsActive,
studioShortName,
@@ -55,7 +60,7 @@ const StudioHome = ({ intl }) => {
redirectToLibraryAuthoringMfe,
} = studioHomeData;
- function getHeaderButtons() {
+ const getHeaderButtons = useCallback(() => {
const headerButtons = [];
if (isFailedLoadingPage || !userIsActive) {
@@ -83,7 +88,7 @@ const StudioHome = ({ intl }) => {
}
let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`;
- if (isMixedOrV2LibrariesMode(libMode)) {
+ if (isMixedOrV2LibrariesMode(libMode) && !v1LibraryTab) {
libraryHref = libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe
? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create')
// Redirection to the placeholder is done in the MFE rather than
@@ -106,7 +111,7 @@ const StudioHome = ({ intl }) => {
);
return headerButtons;
- }
+ }, [location]);
const headerButtons = userIsActive ? getHeaderButtons() : [];
if (isLoadingPage && !isFiltered) {
diff --git a/src/studio-home/tabs-section/LibraryV2Placeholder.jsx b/src/studio-home/tabs-section/LibraryV2Placeholder.jsx
deleted file mode 100644
index 6b13853a2c..0000000000
--- a/src/studio-home/tabs-section/LibraryV2Placeholder.jsx
+++ /dev/null
@@ -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 (
- <>
-
-
-
-
-
-
-
- {intl.formatMessage(messages.libraryV2PlaceholderBody)}
-
-
-
-
- >
- );
-};
-
-export default LibraryV2Placeholder;