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;