diff --git a/.env b/.env index ce17454708..4235461134 100644 --- a/.env +++ b/.env @@ -43,3 +43,4 @@ AI_TRANSLATIONS_BASE_URL='' ENABLE_HOME_PAGE_COURSE_API_V2=false ENABLE_CHECKLIST_QUALITY='' ENABLE_GRADING_METHOD_IN_PROBLEMS=false +LIBRARY_MODE="v1 only" diff --git a/.env.development b/.env.development index 983ce9674f..5547e8ffec 100644 --- a/.env.development +++ b/.env.development @@ -46,3 +46,4 @@ AI_TRANSLATIONS_BASE_URL='http://localhost:18760' ENABLE_HOME_PAGE_COURSE_API_V2=false ENABLE_CHECKLIST_QUALITY=true ENABLE_GRADING_METHOD_IN_PROBLEMS=false +LIBRARY_MODE="mixed" diff --git a/.env.test b/.env.test index 28240ad2ff..0f73517968 100644 --- a/.env.test +++ b/.env.test @@ -37,3 +37,4 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com" ENABLE_HOME_PAGE_COURSE_API_V2=true ENABLE_CHECKLIST_QUALITY=true ENABLE_GRADING_METHOD_IN_PROBLEMS=false +LIBRARY_MODE="mixed" diff --git a/README.rst b/README.rst index 3847453ea3..d0680a32e5 100644 --- a/README.rst +++ b/README.rst @@ -264,6 +264,20 @@ In additional to the standard settings, the following local configuration items Tagging/Taxonomy functionality. +Feature: Libraries V2/Legacy Tabs +================================= + +Configuration +------------- + +In additional to the standard settings, the following local configurations can be set to switch between different library modes: + +* ``LIBRARY_MODE``: can be set to ``mixed`` (default for development), ``v1 only`` (default for production) and ``v2 only``. + + * ``mixed``: Shows 2 tabs, "Libraries" that lists the v2 libraries and "Legacy Libraries" that lists the v1 libraries. When creating a new library in this mode it will create a new v2 library. + * ``v1 only``: Shows only 1 tab, "Libraries" that lists v1 libraries only. When creating a new library in this mode it will create a new v1 library. + * ``v2 only``: Shows only 1 tab, "Libraries" that lists v2 libraries only. When creating a new library in this mode it will create a new v2 library. + Developing ********** 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/header/Header.jsx b/src/header/Header.jsx index 7cc1adcb08..6865a3db96 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}` : `${studioBaseUrl}/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 889063acd7..bf7ee9c423 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -19,6 +19,7 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar'; import { logError } from '@edx/frontend-platform/logging'; import messages from './i18n'; +import { CreateLibrary, LibraryAuthoringPage } from './library-authoring'; import initializeStore from './store'; import CourseAuthoringRoutes from './CourseAuthoringRoutes'; import Head from './head/Head'; @@ -52,6 +53,10 @@ const App = () => { createRoutesFromElements( } /> + } /> + } /> + } /> + } /> } /> } /> {getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && ( @@ -125,6 +130,7 @@ initialize({ ENABLE_HOME_PAGE_COURSE_API_V2: process.env.ENABLE_HOME_PAGE_COURSE_API_V2 === 'true', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true', + LIBRARY_MODE: process.env.LIBRARY_MODE || 'v1 only', }, 'CourseAuthoringConfig'); }, }, diff --git a/src/library-authoring/CreateLibrary.jsx b/src/library-authoring/CreateLibrary.jsx new file mode 100644 index 0000000000..738e9fb769 --- /dev/null +++ b/src/library-authoring/CreateLibrary.jsx @@ -0,0 +1,29 @@ +// @ts-check +/* eslint-disable react/prop-types */ +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.jsx b/src/library-authoring/EmptyStates.jsx new file mode 100644 index 0000000000..54e3b8019d --- /dev/null +++ b/src/library-authoring/EmptyStates.jsx @@ -0,0 +1,24 @@ +// @ts-check +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + Button, Stack, +} from '@openedx/paragon'; +import { Add } from '@openedx/paragon/icons'; + +import messages from './messages'; + +export const NoComponents = () => ( + + + + +); + +export const NoSearchResults = () => ( +
+ +
+); diff --git a/src/library-authoring/LibraryAuthoringPage.jsx b/src/library-authoring/LibraryAuthoringPage.jsx new file mode 100644 index 0000000000..782b8dd664 --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.jsx @@ -0,0 +1,139 @@ +// @ts-check +/* eslint-disable react/prop-types */ +import React, { useEffect } from 'react'; +import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, Icon, IconButton, SearchField, Tab, Tabs, +} from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import { + Routes, Route, useLocation, useNavigate, useParams, +} from 'react-router-dom'; + +import Loading from '../generic/Loading'; +import SubHeader from '../generic/sub-header/SubHeader'; +import Header from '../header'; +import NotFoundAlert from '../generic/NotFoundAlert'; +import LibraryComponents from './LibraryComponents'; +import LibraryCollections from './LibraryCollections'; +import LibraryHome from './LibraryHome'; +import { useContentLibrary } from './data/apiHook'; +import messages from './messages'; + +const TAB_LIST = { + home: '', + components: 'components', + collections: 'collections', +}; + +const SubHeaderTitle = ({ title }) => { + const intl = useIntl(); + return ( + <> + {title} + + + ); +}; + +/** + * @type {React.FC} + */ +const LibraryAuthoringPage = () => { + const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const [tabKey, setTabKey] = React.useState(TAB_LIST.home); + const [searchKeywords, setSearchKeywords] = React.useState(''); + + const { libraryId } = useParams(); + + const { data: libraryData, isLoading } = useContentLibrary(libraryId); + + 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 ; + } + + /** Handle tab change + * @param {string} key + */ + const handleTabChange = (key) => { + setTabKey(key); + navigate(key); + }; + + return ( + <> +
+ + } + subtitle={intl.formatMessage(messages.headingSubtitle)} + /> + setSearchKeywords(value)} + onSubmit={() => {}} + className="w-50" + /> + + + + + + + } + /> + } + /> + } + /> + } + /> + + + + + ); +}; + +export default LibraryAuthoringPage; diff --git a/src/library-authoring/LibraryAuthoringPage.test.jsx b/src/library-authoring/LibraryAuthoringPage.test.jsx new file mode 100644 index 0000000000..15b0d8200c --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.test.jsx @@ -0,0 +1,237 @@ +// @ts-check +import React from 'react'; +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock-jest'; + +import initializeStore from '../store'; +import { getContentSearchConfigUrl } from '../search-modal/data/api'; +import mockResult from '../search-modal/__mocks__/search-result.json'; +import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; +import LibraryAuthoringPage from './LibraryAuthoringPage'; +import { getContentLibraryApiUrl } from './data/api'; + +let store; +const mockUseParams = jest.fn(); +let axiosMock; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useParams: () => mockUseParams(), +})); + +const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const returnEmptyResult = (_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise we may have an inconsistent state that causes more queries and unexpected results. + mockEmptyResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... 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, + 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 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' })); + }); +}); diff --git a/src/library-authoring/LibraryCollections.jsx b/src/library-authoring/LibraryCollections.jsx new file mode 100644 index 0000000000..5292b10f40 --- /dev/null +++ b/src/library-authoring/LibraryCollections.jsx @@ -0,0 +1,19 @@ +// @ts-check +/* eslint-disable react/prop-types */ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +/** + * @type {React.FC} + */ +const LibraryCollections = () => ( +
+ +
+); + +export default LibraryCollections; diff --git a/src/library-authoring/LibraryComponents.jsx b/src/library-authoring/LibraryComponents.jsx new file mode 100644 index 0000000000..3ce00c4fda --- /dev/null +++ b/src/library-authoring/LibraryComponents.jsx @@ -0,0 +1,35 @@ +// @ts-check +/* eslint-disable react/prop-types */ +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 {React.FC<{ + * libraryId: string, + * filter: { + * searchKeywords: string, + * }, + * }>} + */ +const LibraryComponents = ({ libraryId, filter: { searchKeywords } }) => { + const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords); + + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + + return ( +
+ +
+ ); +}; + +export default LibraryComponents; diff --git a/src/library-authoring/LibraryHome.jsx b/src/library-authoring/LibraryHome.jsx new file mode 100644 index 0000000000..4331a0b54d --- /dev/null +++ b/src/library-authoring/LibraryHome.jsx @@ -0,0 +1,63 @@ +// @ts-check +/* eslint-disable react/prop-types */ +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'; + +/** + * @type {React.FC<{ + * title: string, + * children: React.ReactNode, + * }>} + */ +const Section = ({ title, children }) => ( + + + + {children} + + +); + +/** + * @type {React.FC<{ + * libraryId: string, + * filter: { + * searchKeywords: string, + * }, + * }>} + */ +const LibraryHome = ({ libraryId, filter }) => { + const { searchKeywords } = filter; + const { componentCount, collectionCount } = useLibraryComponentCount(libraryId, searchKeywords); + + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + + return ( + +
+ +
+
+ +
+
+ +
+
+ ); +}; + +export default LibraryHome; diff --git a/src/library-authoring/data/api.js b/src/library-authoring/data/api.js new file mode 100644 index 0000000000..9fae35d947 --- /dev/null +++ b/src/library-authoring/data/api.js @@ -0,0 +1,24 @@ +// @ts-check +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. + * @param {string} libraryId - The ID of the library to fetch. + */ +export const getContentLibraryApiUrl = (libraryId) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`; + +/** + * Fetch a content library by its ID. + * @param {string} [libraryId] - The ID of the library to fetch. + * @returns {Promise} + */ +export async function getContentLibrary(libraryId) { + if (!libraryId) { + throw new Error('libraryId is required'); + } + + const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId)); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHook.js b/src/library-authoring/data/apiHook.js new file mode 100644 index 0000000000..8f11e49a4e --- /dev/null +++ b/src/library-authoring/data/apiHook.js @@ -0,0 +1,52 @@ +// @ts-check +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { MeiliSearch } from 'meilisearch'; + +import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; +import { getContentLibrary } from './api'; + +/** + * Hook to fetch a content library by its ID. + * @param {string} [libraryId] - The ID of the library to fetch. + */ +export const useContentLibrary = (libraryId) => ( + useQuery({ + queryKey: ['contentLibrary', libraryId], + queryFn: () => getContentLibrary(libraryId), + }) +); + +/** + * Hook to fetch the count of components and collections in a library. + * @param {string} libraryId - The ID of the library to fetch. + * @param {string} searchKeywords - Keywords to search for. + */ +export const useLibraryComponentCount = (libraryId, searchKeywords) => { + // 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/data/types.mjs b/src/library-authoring/data/types.mjs new file mode 100644 index 0000000000..34486525d7 --- /dev/null +++ b/src/library-authoring/data/types.mjs @@ -0,0 +1,18 @@ +/** + * @typedef {Object} ContentLibrary + * @property {string} id + * @property {string} type + * @property {string} org + * @property {string} slug + * @property {string} title + * @property {string} description + * @property {number} numBlocks + * @property {number} version + * @property {Date | null} lastPublished + * @property {boolean} allowLti + * @property {boolean} allowPublicLearning + * @property {boolean} allowPublicRead + * @property {boolean} hasUnpublishedChanges + * @property {boolean} hasUnpublishedDeletes + * @property {string} license + */ diff --git a/src/library-authoring/index.js b/src/library-authoring/index.js new file mode 100644 index 0000000000..69831c4ed9 --- /dev/null +++ b/src/library-authoring/index.js @@ -0,0 +1,4 @@ +// @ts-check +// eslint-disable-next-line import/prefer-default-export +export { default as LibraryAuthoringPage } from './LibraryAuthoringPage'; +export { default as CreateLibrary } from './CreateLibrary'; diff --git a/src/library-authoring/messages.js b/src/library-authoring/messages.js new file mode 100644 index 0000000000..ff985aa62c --- /dev/null +++ b/src/library-authoring/messages.js @@ -0,0 +1,62 @@ +// @ts-check +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.', + }, +}); + +export default messages; diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 634c12d016..09fa2f4a19 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -16,6 +16,7 @@ import { import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import { constructLibraryAuthoringURL } from '../utils'; import { COMPONENT_TYPE_ICON_MAP, TYPE_ICONS_MAP } from '../course-unit/constants'; import { getStudioHomeData } from '../studio-home/data/selectors'; import { useSearchContext } from './manager/SearchManager'; @@ -36,12 +37,11 @@ function getItemIcon(blockType) { /** * Returns the URL Suffix for library/library component hit * @param {import('./data/api').ContentHit} hit - * @param {string} libraryAuthoringMfeUrl * @returns string */ -function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) { +function getLibraryComponentUrlSuffix(hit) { const { contextKey } = hit; - return `${libraryAuthoringMfeUrl}library/${contextKey}`; + return `library/${contextKey}`; } /** @@ -130,10 +130,6 @@ const SearchResult = ({ 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 */ @@ -149,13 +145,19 @@ const SearchResult = ({ 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]); @@ -206,12 +208,12 @@ const SearchResult = ({ hit }) => { return ( @@ -230,7 +232,6 @@ const SearchResult = ({ hit }) => { diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx index c653807dfb..0c46908c08 100644 --- a/src/search-modal/SearchUI.test.jsx +++ b/src/search-modal/SearchUI.test.jsx @@ -344,9 +344,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); @@ -356,18 +357,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.js b/src/search-modal/data/apiHooks.js index 02488635da..eb64696fc8 100644 --- a/src/search-modal/data/apiHooks.js +++ b/src/search-modal/data/apiHooks.js @@ -34,16 +34,16 @@ export const useContentSearchConnection = () => ( * @param {string} [context.indexName] Which search index contains the content data * @param {import('meilisearch').Filter} [context.extraFilter] Other filters to apply to the search, e.g. course ID * @param {string} context.searchKeywords The keywords that the user is searching for, if any - * @param {string[]} context.blockTypesFilter Only search for these block types (e.g. ["html", "problem"]) - * @param {string[]} context.tagsFilter Required tags (all must match), e.g. ["Difficulty > Hard", "Subject > Math"] + * @param {string[]} [context.blockTypesFilter] Only search for these block types (e.g. ["html", "problem"]) + * @param {string[]} [context.tagsFilter] Required tags (all must match), e.g. ["Difficulty > Hard", "Subject > Math"] */ export const useContentSearchResults = ({ client, indexName, extraFilter, searchKeywords, - blockTypesFilter, - tagsFilter, + blockTypesFilter = [], + tagsFilter = [], }) => { 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/setupTest.js b/src/setupTest.js index 35b1c9ebe2..f0f7f6a435 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -48,6 +48,7 @@ mergeConfig({ ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null, + LIBRARY_MODE: process.env.LIBRARY_MODE || 'v1 only', }, 'CourseAuthoringConfig'); class ResizeObserver { diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.jsx index 8348aaca34..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, @@ -10,14 +10,17 @@ import { import { Add as AddIcon, Error } from '@openedx/paragon/icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { StudioFooter } from '@edx/frontend-component-footer'; -import { getConfig } from '@edx/frontend-platform'; +import { getConfig, getPath } from '@edx/frontend-platform'; +import { useLocation } from 'react-router-dom'; +import { constructLibraryAuthoringURL } from '../utils'; import Loading from '../generic/Loading'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import Header from '../header'; import SubHeader from '../generic/sub-header/SubHeader'; import HomeSidebar from './home-sidebar'; import TabsSection from './tabs-section'; +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'; @@ -26,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, @@ -43,6 +48,10 @@ const StudioHome = ({ intl }) => { dispatch, } = useStudioHome(isPaginationCoursesEnabled); + const libMode = getConfig().LIBRARY_MODE; + + const v1LibraryTab = isMixedOrV1LibrariesMode(libMode) && location?.pathname.split('/').pop() === 'libraries-v1'; + const { userIsActive, studioShortName, @@ -51,7 +60,7 @@ const StudioHome = ({ intl }) => { redirectToLibraryAuthoringMfe, } = studioHomeData; - function getHeaderButtons() { + const getHeaderButtons = useCallback(() => { const headerButtons = []; if (isFailedLoadingPage || !userIsActive) { @@ -79,8 +88,13 @@ const StudioHome = ({ intl }) => { } let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`; - if (redirectToLibraryAuthoringMfe) { - libraryHref = `${libraryAuthoringMfeUrl}/create`; + if (isMixedOrV2LibrariesMode(libMode) && !v1LibraryTab) { + libraryHref = libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe + ? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create') + // Redirection to the placeholder is done in the MFE rather than + // through the backend i.e. redirection from cms, because this this will probably change, + // hence why we use the MFE's origin + : `${window.location.origin}${getPath(getConfig().PUBLIC_PATH)}library/create`; } headerButtons.push( @@ -97,7 +111,7 @@ const StudioHome = ({ intl }) => { ); return headerButtons; - } + }, [location]); const headerButtons = userIsActive ? getHeaderButtons() : []; if (isLoadingPage && !isFiltered) { diff --git a/src/studio-home/StudioHome.test.jsx b/src/studio-home/StudioHome.test.jsx index 0d9a339b35..c053808944 100644 --- a/src/studio-home/StudioHome.test.jsx +++ b/src/studio-home/StudioHome.test.jsx @@ -1,6 +1,8 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { initializeMockApp } from '@edx/frontend-platform'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { initializeMockApp, getConfig, setConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -12,7 +14,7 @@ import MockAdapter from 'axios-mock-adapter'; import initializeStore from '../store'; import { RequestStatus } from '../data/constants'; import { COURSE_CREATOR_STATES } from '../constants'; -import { executeThunk } from '../utils'; +import { executeThunk, constructLibraryAuthoringURL } from '../utils'; import { studioHomeMock } from './__mocks__'; import { getStudioHomeApiUrl } from './data/api'; import { fetchStudioHomeData } from './data/thunks'; @@ -23,7 +25,6 @@ import { StudioHome } from '.'; let axiosMock; let store; -const mockPathname = '/foo-bar'; const { studioShortName, studioRequestEmail, @@ -34,17 +35,29 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: () => ({ - pathname: mockPathname, - }), -})); +const queryClient = new QueryClient(); const RootWrapper = () => ( - + - + + + + } + /> + } + /> + } + /> + + + ); @@ -145,7 +158,18 @@ describe('', () => { }); describe('render new library button', () => { - it('href should include home_library', async () => { + beforeEach(() => { + setConfig({ + ...getConfig(), + LIBRARY_MODE: 'mixed', + }); + }); + + it('href should include home_library when in "v1 only" lib mode', async () => { + setConfig({ + ...getConfig(), + LIBRARY_MODE: 'v1 only', + }); useSelector.mockReturnValue({ ...studioHomeMock, courseCreatorStatus: COURSE_CREATOR_STATES.granted, @@ -167,7 +191,9 @@ describe('', () => { const { getByTestId } = render(); const createNewLibraryButton = getByTestId('new-library-button'); - expect(createNewLibraryButton.getAttribute('href')).toBe(`${libraryAuthoringMfeUrl}/create`); + expect(createNewLibraryButton.getAttribute('href')).toBe( + `${constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create')}`, + ); }); }); diff --git a/src/studio-home/__mocks__/index.js b/src/studio-home/__mocks__/index.js index 92461eb0bb..af2a85b390 100644 --- a/src/studio-home/__mocks__/index.js +++ b/src/studio-home/__mocks__/index.js @@ -1,2 +1,2 @@ -// eslint-disable-next-line import/prefer-default-export export { default as studioHomeMock } from './studioHomeMock'; +export { default as listStudioHomeV2LibrariesMock } from './listStudioHomeV2LibrariesMock'; diff --git a/src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js b/src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js new file mode 100644 index 0000000000..02257a9744 --- /dev/null +++ b/src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js @@ -0,0 +1,44 @@ +module.exports = { + next: null, + previous: null, + count: 2, + num_pages: 1, + current_page: 1, + start: 0, + results: [ + { + id: 'lib:SampleTaxonomyOrg1:AL1', + type: 'complex', + org: 'SampleTaxonomyOrg1', + slug: 'AL1', + title: 'Another Library 2', + description: '', + num_blocks: 0, + version: 0, + last_published: null, + allow_lti: false, + allow_public_learning: false, + allow_public_read: false, + has_unpublished_changes: false, + has_unpublished_deletes: false, + license: '', + }, + { + id: 'lib:SampleTaxonomyOrg1:TL1', + type: 'complex', + org: 'SampleTaxonomyOrg1', + slug: 'TL1', + title: 'Test Library 1', + description: '', + num_blocks: 0, + version: 0, + last_published: null, + allow_lti: false, + allow_public_learning: false, + allow_public_read: false, + has_unpublished_changes: false, + has_unpublished_deletes: false, + license: '', + }, + ], +}; diff --git a/src/studio-home/card-item/index.jsx b/src/studio-home/card-item/index.jsx index 2bc9c3e16f..ed84bb2cc0 100644 --- a/src/studio-home/card-item/index.jsx +++ b/src/studio-home/card-item/index.jsx @@ -35,7 +35,7 @@ const CardItem = ({ courseCreatorStatus, rerunCreatorStatus, } = useSelector(getStudioHomeData); - const courseUrl = () => new URL(url, getConfig().STUDIO_BASE_URL); + const destinationUrl = () => new URL(url, getConfig().STUDIO_BASE_URL); const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`; const readOnlyItem = !(lmsLink || rerunLink || url); const showActions = !(readOnlyItem || isLibraries); @@ -51,7 +51,7 @@ const CardItem = ({ title={!readOnlyItem ? ( {hasDisplayName} diff --git a/src/studio-home/data/api.js b/src/studio-home/data/api.js index 1fefe2981a..2124f6fed7 100644 --- a/src/studio-home/data/api.js +++ b/src/studio-home/data/api.js @@ -40,6 +40,28 @@ export async function getStudioHomeLibraries() { return camelCaseObject(data); } +/** + * Get's studio home v2 Libraries. + * @param {object} customParams - Additional custom paramaters for the API request. + * @param {string} [customParams.type] - (optional) Library type, default `complex` + * @param {number} [customParams.page] - (optional) Page number of results + * @param {number} [customParams.pageSize] - (optional) The number of results on each page, default `50` + * @param {boolean} [customParams.pagination] - (optional) Whether pagination is supported, default `true` + * @returns {Promise} - A Promise that resolves to the response data container the studio home v2 libraries. + */ +export async function getStudioHomeLibrariesV2(customParams) { + // Set default params if not passed in + const customParamsDefaults = { + type: customParams.type || 'complex', + page: customParams.page || 1, + pageSize: customParams.pageSize || 50, + pagination: customParams.pagination !== undefined ? customParams.pagination : true, + }; + const customParamsFormat = snakeCaseObject(customParamsDefaults); + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/libraries/v2/`, { params: customParamsFormat }); + return camelCaseObject(data); +} + /** * Handle course notification requests. * @param {string} url diff --git a/src/studio-home/data/api.test.js b/src/studio-home/data/api.test.js index 593a2730de..66f6ee279f 100644 --- a/src/studio-home/data/api.test.js +++ b/src/studio-home/data/api.test.js @@ -13,8 +13,14 @@ import { getStudioHomeCourses, getStudioHomeCoursesV2, getStudioHomeLibraries, + getStudioHomeLibrariesV2, } from './api'; -import { generateGetStudioCoursesApiResponse, generateGetStudioHomeDataApiResponse, generateGetStuioHomeLibrariesApiResponse } from '../factories/mockApiResponses'; +import { + generateGetStudioCoursesApiResponse, + generateGetStudioHomeDataApiResponse, + generateGetStudioHomeLibrariesApiResponse, + generateGetStudioHomeLibrariesV2ApiResponse, +} from '../factories/mockApiResponses'; let axiosMock; @@ -64,11 +70,21 @@ describe('studio-home api calls', () => { expect(result).toEqual(expected); }); - it('should get studio libraries data', async () => { + it('should get studio v1 libraries data', async () => { const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`; - axiosMock.onGet(apiLink).reply(200, generateGetStuioHomeLibrariesApiResponse()); + axiosMock.onGet(apiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); const result = await getStudioHomeLibraries(); - const expected = generateGetStuioHomeLibrariesApiResponse(); + const expected = generateGetStudioHomeLibrariesApiResponse(); + + expect(axiosMock.history.get[0].url).toEqual(apiLink); + expect(result).toEqual(expected); + }); + + it('should get studio v2 libraries data', async () => { + const apiLink = `${getApiBaseUrl()}/api/libraries/v2/`; + axiosMock.onGet(apiLink).reply(200, generateGetStudioHomeLibrariesV2ApiResponse()); + const result = await getStudioHomeLibrariesV2({}); + const expected = generateGetStudioHomeLibrariesV2ApiResponse(); expect(axiosMock.history.get[0].url).toEqual(apiLink); expect(result).toEqual(expected); diff --git a/src/studio-home/data/apiHooks.js b/src/studio-home/data/apiHooks.js new file mode 100644 index 0000000000..92575bf717 --- /dev/null +++ b/src/studio-home/data/apiHooks.js @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getStudioHomeLibrariesV2 } from './api'; + +/** + * Builds the query to fetch list of V2 Libraries + */ +const useListStudioHomeV2Libraries = (customParams) => ( + useQuery({ + queryKey: ['listV2Libraries', customParams], + queryFn: () => getStudioHomeLibrariesV2(customParams), + }) +); + +export default useListStudioHomeV2Libraries; diff --git a/src/studio-home/factories/mockApiResponses.jsx b/src/studio-home/factories/mockApiResponses.jsx index 30615ba8d5..5d75f9f592 100644 --- a/src/studio-home/factories/mockApiResponses.jsx +++ b/src/studio-home/factories/mockApiResponses.jsx @@ -112,7 +112,7 @@ export const generateGetStudioCoursesApiResponseV2 = () => ({ }, }); -export const generateGetStuioHomeLibrariesApiResponse = () => ({ +export const generateGetStudioHomeLibrariesApiResponse = () => ({ libraries: [ { displayName: 'MBA', @@ -125,6 +125,51 @@ export const generateGetStuioHomeLibrariesApiResponse = () => ({ ], }); +export const generateGetStudioHomeLibrariesV2ApiResponse = () => ({ + next: null, + previous: null, + count: 2, + numPages: 1, + currentPage: 1, + start: 0, + results: [ + { + id: 'lib:SampleTaxonomyOrg1:AL1', + type: 'complex', + org: 'SampleTaxonomyOrg1', + slug: 'AL1', + title: 'Another Library 2', + description: '', + numBlocks: 0, + version: 0, + lastPublished: null, + allowLti: false, + allowPublicLearning: false, + allowpublicRead: false, + hasUnpublishedChanges: false, + hasUnpublishedDeletes: false, + license: '', + }, + { + id: 'lib:SampleTaxonomyOrg1:TL1', + type: 'complex', + org: 'SampleTaxonomyOrg1', + slug: 'TL1', + title: 'Test Library 1', + description: '', + numBlocks: 0, + version: 0, + lastPublished: null, + allowLti: false, + allowPublicLearning: false, + allowPublicRead: false, + hasUnpublishedChanges: false, + hasUnpublishedDeletes: false, + license: '', + }, + ], +}); + export const generateNewVideoApiResponse = () => ({ files: [{ edx_video_id: 'mOckID4', diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.jsx index fdc955d8df..90f47d5e85 100644 --- a/src/studio-home/tabs-section/TabsSection.test.jsx +++ b/src/studio-home/tabs-section/TabsSection.test.jsx @@ -1,4 +1,6 @@ import React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getConfig, initializeMockApp, setConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { @@ -9,7 +11,7 @@ import { AppProvider } from '@edx/frontend-platform/react'; import MockAdapter from 'axios-mock-adapter'; import initializeStore from '../../store'; -import { studioHomeMock } from '../__mocks__'; +import { studioHomeMock, listStudioHomeV2LibrariesMock } from '../__mocks__'; import messages from '../messages'; import tabMessages from './messages'; import TabsSection from '.'; @@ -18,12 +20,32 @@ import { generateGetStudioHomeDataApiResponse, generateGetStudioCoursesApiResponse, generateGetStudioCoursesApiResponseV2, - generateGetStuioHomeLibrariesApiResponse, + generateGetStudioHomeLibrariesApiResponse, } from '../factories/mockApiResponses'; import { getApiBaseUrl, getStudioHomeApiUrl } from '../data/api'; import { executeThunk } from '../../utils'; import { fetchLibraryData, fetchStudioHomeData } from '../data/thunks'; +import useListStudioHomeV2Libraries from '../data/apiHooks'; + +jest.mock('../data/apiHooks', () => ({ + // Since only useListStudioHomeV2Libraries is exported as default + __esModule: true, + default: jest.fn(() => ({ + data: { + next: null, + previous: null, + count: 2, + num_pages: 1, + current_page: 1, + start: 0, + results: [], + }, + isLoading: false, + isError: false, + })), +})); + const { studioShortName } = studioHomeMock; let axiosMock; @@ -34,15 +56,38 @@ const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`; const mockDispatch = jest.fn(); +const queryClient = new QueryClient(); + +const tabSectionComponent = (overrideProps) => ( + +); + const RootWrapper = (overrideProps) => ( - + - + + + + + + + + + ); @@ -59,6 +104,10 @@ describe('', () => { }); store = initializeStore(initialState); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + setConfig({ + ...getConfig(), + LIBRARY_MODE: 'mixed', + }); }); it('should render all tabs correctly', async () => { @@ -82,9 +131,53 @@ describe('', () => { expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(tabMessages.archivedTabTitle.defaultMessage)).toBeInTheDocument(); }); + it('should render only 1 library tab when "v1 only" lib mode', async () => { + setConfig({ + ...getConfig(), + LIBRARY_MODE: 'v1 only', + }); + + const data = generateGetStudioHomeDataApiResponse(); + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); + const librariesTab = screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); + expect(librariesTab).toBeInTheDocument(); + // Check Tab.eventKey + expect(librariesTab).toHaveAttribute('data-rb-event-key', 'legacyLibraries'); + + expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).not.toBeInTheDocument(); + }); + + it('should render only 1 library tab when "v2 only" lib mode', async () => { + setConfig({ + ...getConfig(), + LIBRARY_MODE: 'v2 only', + }); + + const data = generateGetStudioHomeDataApiResponse(); + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); + const librariesTab = screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); + expect(librariesTab).toBeInTheDocument(); + // Check Tab.eventKey + expect(librariesTab).toHaveAttribute('data-rb-event-key', 'libraries'); + + expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).not.toBeInTheDocument(); + }); + describe('course tab', () => { it('should render specific course details', async () => { render(); @@ -156,6 +249,46 @@ describe('', () => { const pagination = screen.queryByRole('navigation'); expect(pagination).not.toBeInTheDocument(); }); + + it('should set the url path to "/home" when switching away then back to courses tab', async () => { + const data = generateGetStudioCoursesApiResponseV2(); + data.results.courses = []; + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(courseApiLinkV2).reply(200, data); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + // confirm the url path is initially /home + waitFor(() => { + expect(window.location.href).toContain('/home'); + }); + + // switch to libraries tab + axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); + await executeThunk(fetchLibraryData(), store.dispatch); + const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); + await act(async () => { + fireEvent.click(librariesTab); + }); + + // confirm that the url path has changed + expect(librariesTab).toHaveClass('active'); + waitFor(() => { + expect(window.location.href).toContain('/libraries-v1'); + }); + + // switch back to courses tab + const coursesTab = screen.getByText(tabMessages.coursesTabTitle.defaultMessage); + await act(async () => { + fireEvent.click(coursesTab); + }); + + // confirm that the url path is /home + expect(coursesTab).toHaveClass('active'); + waitFor(() => { + expect(window.location.href).toContain('/home'); + }); + }); }); describe('taxonomies tab', () => { @@ -224,15 +357,72 @@ describe('', () => { expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(tabMessages.archivedTabTitle.defaultMessage)).toBeNull(); }); }); describe('library tab', () => { - it('should switch to Libraries tab and render specific library details', async () => { + it('should switch to Legacy Libraries tab and render specific v1 library details', async () => { render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - axiosMock.onGet(libraryApiLink).reply(200, generateGetStuioHomeLibrariesApiResponse()); + axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + await executeThunk(fetchLibraryData(), store.dispatch); + + const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); + await act(async () => { + fireEvent.click(librariesTab); + }); + + expect(librariesTab).toHaveClass('active'); + + expect(screen.getByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); + + expect(screen.getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible(); + }); + + it('should switch to Libraries tab and render specific v2 library details', async () => { + useListStudioHomeV2Libraries.mockReturnValue({ + data: listStudioHomeV2LibrariesMock, + isLoading: false, + isError: false, + }); + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); + await act(async () => { + fireEvent.click(librariesTab); + }); + + expect(librariesTab).toHaveClass('active'); + + expect(screen.getByText('Showing 2 of 2')).toBeVisible(); + + expect(screen.getByText(listStudioHomeV2LibrariesMock.results[0].title)).toBeVisible(); + expect(screen.getByText( + `${listStudioHomeV2LibrariesMock.results[0].org} / ${listStudioHomeV2LibrariesMock.results[0].slug}`, + )).toBeVisible(); + + expect(screen.getByText(listStudioHomeV2LibrariesMock.results[1].title)).toBeVisible(); + expect(screen.getByText( + `${listStudioHomeV2LibrariesMock.results[1].org} / ${listStudioHomeV2LibrariesMock.results[1].slug}`, + )).toBeVisible(); + }); + + it('should switch to Libraries tab and render specific v1 library details ("v1 only" mode)', async () => { + setConfig({ + ...getConfig(), + LIBRARY_MODE: 'v1 only', + }); + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); await executeThunk(fetchLibraryData(), store.dispatch); @@ -248,6 +438,42 @@ describe('', () => { expect(screen.getByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`)).toBeVisible(); }); + it('should switch to Libraries tab and render specific v2 library details ("v2 only" mode)', async () => { + setConfig({ + ...getConfig(), + LIBRARY_MODE: 'v2 only', + }); + + useListStudioHomeV2Libraries.mockReturnValue({ + data: listStudioHomeV2LibrariesMock, + isLoading: false, + isError: false, + }); + + render(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); + await act(async () => { + fireEvent.click(librariesTab); + }); + + expect(librariesTab).toHaveClass('active'); + + expect(screen.getByText('Showing 2 of 2')).toBeVisible(); + + expect(screen.getByText(listStudioHomeV2LibrariesMock.results[0].title)).toBeVisible(); + expect(screen.getByText( + `${listStudioHomeV2LibrariesMock.results[0].org} / ${listStudioHomeV2LibrariesMock.results[0].slug}`, + )).toBeVisible(); + + expect(screen.getByText(listStudioHomeV2LibrariesMock.results[1].title)).toBeVisible(); + expect(screen.getByText( + `${listStudioHomeV2LibrariesMock.results[1].org} / ${listStudioHomeV2LibrariesMock.results[1].slug}`, + )).toBeVisible(); + }); + it('should hide Libraries tab when libraries are disabled', async () => { const data = generateGetStudioHomeDataApiResponse(); data.librariesEnabled = false; @@ -257,7 +483,7 @@ describe('', () => { await executeThunk(fetchStudioHomeData(), store.dispatch); expect(screen.getByText(tabMessages.coursesTabTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.queryByText(tabMessages.librariesTabTitle.defaultMessage)).toBeNull(); + expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeNull(); }); it('should redirect to library authoring mfe', async () => { @@ -268,7 +494,7 @@ describe('', () => { axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); + const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); fireEvent.click(librariesTab); waitFor(() => { @@ -283,7 +509,7 @@ describe('', () => { await executeThunk(fetchStudioHomeData(), store.dispatch); await executeThunk(fetchLibraryData(), store.dispatch); - const librariesTab = screen.getByText(tabMessages.librariesTabTitle.defaultMessage); + const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); await act(async () => { fireEvent.click(librariesTab); }); diff --git a/src/studio-home/tabs-section/index.jsx b/src/studio-home/tabs-section/index.jsx index 1409766c47..75a3ae12ba 100644 --- a/src/studio-home/tabs-section/index.jsx +++ b/src/studio-home/tabs-section/index.jsx @@ -1,18 +1,20 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { Tab, Tabs } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { getLoadingStatuses, getStudioHomeData } from '../data/selectors'; import messages from './messages'; import LibrariesTab from './libraries-tab'; +import LibrariesV2Tab from './libraries-v2-tab/index'; import ArchivedTab from './archived-tab'; import CoursesTab from './courses-tab'; import { RequestStatus } from '../../data/constants'; import { fetchLibraryData } from '../data/thunks'; +import { isMixedOrV1LibrariesMode, isMixedOrV2LibrariesMode } from './utils'; const TabsSection = ({ intl, @@ -23,13 +25,43 @@ const TabsSection = ({ isPaginationCoursesEnabled, }) => { const navigate = useNavigate(); + const { pathname } = useLocation(); + const libMode = getConfig().LIBRARY_MODE; const TABS_LIST = { courses: 'courses', libraries: 'libraries', + legacyLibraries: 'legacyLibraries', archived: 'archived', taxonomies: 'taxonomies', }; - const [tabKey, setTabKey] = useState(TABS_LIST.courses); + + const initTabKeyState = (pname) => { + if (pname.includes('/libraries-v1')) { + return TABS_LIST.legacyLibraries; + } + + if (pname.includes('/libraries')) { + return isMixedOrV2LibrariesMode(libMode) + ? TABS_LIST.libraries + : TABS_LIST.legacyLibraries; + } + + // Default to courses tab + return TABS_LIST.courses; + }; + + const [tabKey, setTabKey] = useState(initTabKeyState(pathname)); + + // This is needed to handle navigating using the back/forward buttons in the browser + useEffect(() => { + // Handle special case when navigating directly to /libraries-v1 + // we need to call dispatch to fetch library data + if (pathname.includes('/libraries-v1')) { + dispatch(fetchLibraryData()); + } + setTabKey(initTabKeyState(pathname)); + }, [pathname]); + const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, @@ -87,21 +119,40 @@ const TabsSection = ({ } if (librariesEnabled) { - tabs.push( - - {!redirectToLibraryAuthoringMfe && ( + if (isMixedOrV2LibrariesMode(libMode)) { + tabs.push( + + + , + ); + } + + if (isMixedOrV1LibrariesMode(libMode)) { + tabs.push( + - )} - , - ); + , + ); + } } if (getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true') { @@ -118,10 +169,13 @@ const TabsSection = ({ }, [archivedCourses, librariesEnabled, showNewCourseContainer, isLoadingCourses, isLoadingLibraries]); const handleSelectTab = (tab) => { - if (tab === TABS_LIST.libraries && redirectToLibraryAuthoringMfe) { - window.location.assign(libraryAuthoringMfeUrl); - } else if (tab === TABS_LIST.libraries && !redirectToLibraryAuthoringMfe) { + if (tab === TABS_LIST.courses) { + navigate('/home'); + } else if (tab === TABS_LIST.legacyLibraries) { dispatch(fetchLibraryData()); + navigate('/libraries-v1'); + } else if (tab === TABS_LIST.libraries) { + navigate('/libraries'); } else if (tab === TABS_LIST.taxonomies) { navigate('/taxonomies'); } diff --git a/src/studio-home/tabs-section/libraries-v2-tab/index.jsx b/src/studio-home/tabs-section/libraries-v2-tab/index.jsx new file mode 100644 index 0000000000..c3b58df554 --- /dev/null +++ b/src/studio-home/tabs-section/libraries-v2-tab/index.jsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Icon, Row, Pagination } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig, getPath } from '@edx/frontend-platform'; + +import { constructLibraryAuthoringURL } from '../../../utils'; +import useListStudioHomeV2Libraries from '../../data/apiHooks'; +import { LoadingSpinner } from '../../../generic/Loading'; +import AlertMessage from '../../../generic/alert-message'; +import CardItem from '../../card-item'; +import messages from '../messages'; + +const LibrariesV2Tab = ({ + libraryAuthoringMfeUrl, + redirectToLibraryAuthoringMfe, +}) => { + const intl = useIntl(); + + const [currentPage, setCurrentPage] = useState(1); + + const handlePageSelect = (page) => { + setCurrentPage(page); + }; + + const { + data, + isLoading, + isError, + } = useListStudioHomeV2Libraries({ page: currentPage }); + + if (isLoading) { + return ( + + + + ); + } + + const libURL = (id) => ( + libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe + ? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${id}`) + // Redirection to the placeholder is done in the MFE rather than + // through the backend i.e. redirection from cms, because this this will probably change, + // hence why we use the MFE's origin + : `${window.location.origin}${getPath(getConfig().PUBLIC_PATH)}library/${id}` + ); + + return ( + isError ? ( + + + {intl.formatMessage(messages.librariesTabErrorMessage)} + + )} + /> + ) : ( +
+
+ {/* Temporary div to add spacing. This will be replaced with lib search/filters */} +
+

+ {intl.formatMessage(messages.coursesPaginationInfo, { + length: data.results.length, + total: data.count, + })} +

+
+ + { + data.results.map(({ + id, org, slug, title, + }) => ( + + )) + } + + { + data.numPages > 1 + && ( + + ) + } +
+ ) + ); +}; + +LibrariesV2Tab.propTypes = { + libraryAuthoringMfeUrl: PropTypes.string.isRequired, + redirectToLibraryAuthoringMfe: PropTypes.bool.isRequired, +}; + +export default LibrariesV2Tab; diff --git a/src/studio-home/tabs-section/messages.js b/src/studio-home/tabs-section/messages.js index 5ae2e139b2..0ed614f55a 100644 --- a/src/studio-home/tabs-section/messages.js +++ b/src/studio-home/tabs-section/messages.js @@ -21,6 +21,10 @@ const messages = defineMessages({ id: 'course-authoring.studio-home.libraries.tab.title', defaultMessage: 'Libraries', }, + legacyLibrariesTabTitle: { + id: 'course-authoring.studio-home.legacy.libraries.tab.title', + defaultMessage: 'Legacy Libraries', + }, archivedTabTitle: { id: 'course-authoring.studio-home.archived.tab.title', defaultMessage: 'Archived courses', @@ -46,6 +50,14 @@ const messages = defineMessages({ defaultMessage: 'Taxonomies', description: 'Title of Taxonomies tab on the home page', }, + libraryV2PlaceholderTitle: { + id: 'course-authoring.studio-home.libraries.placeholder.title', + defaultMessage: 'Library V2 Placeholder', + }, + libraryV2PlaceholderBody: { + id: 'course-authoring.studio-home.libraries.placeholder.body', + defaultMessage: 'This is a placeholder page, as the Library Authoring MFE is not enabled.', + }, }); export default messages; diff --git a/src/studio-home/tabs-section/utils.js b/src/studio-home/tabs-section/utils.js index dff32dc95a..ec4fb9303e 100644 --- a/src/studio-home/tabs-section/utils.js +++ b/src/studio-home/tabs-section/utils.js @@ -11,5 +11,11 @@ const sortAlphabeticallyArray = (arr) => [...arr] return firstDisplayName.localeCompare(secondDisplayName); }); -// eslint-disable-next-line import/prefer-default-export -export { sortAlphabeticallyArray }; +const isMixedOrV1LibrariesMode = (libMode) => ['mixed', 'v1 only'].includes(libMode); +const isMixedOrV2LibrariesMode = (libMode) => ['mixed', 'v2 only'].includes(libMode); + +export { + sortAlphabeticallyArray, + isMixedOrV1LibrariesMode, + isMixedOrV2LibrariesMode, +}; diff --git a/src/utils.js b/src/utils.js index d4bc8f6ff3..2abb63e5be 100644 --- a/src/utils.js +++ b/src/utils.js @@ -301,3 +301,27 @@ export const getFileSizeToClosestByte = (fileSize) => { const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2); return `${fileSizeFixedDecimal} ${units[divides]}`; }; + +/** + * Constructs library authoring MFE URL with correct slashes + * @param {string} libraryAuthoringMfeUrl - the base library authoring MFE url + * @param {string} path - the library authoring MFE url path + * @returns {string} - the correct internal route path + */ +export const constructLibraryAuthoringURL = (libraryAuthoringMfeUrl, path) => { + // Remove '/' at the beginning of path if any + const trimmedPath = path.startsWith('/') + ? path.slice(1, path.length) + : path; + + let constructedUrl = libraryAuthoringMfeUrl; + // Remove trailing `/` from base if found + if (libraryAuthoringMfeUrl.endsWith('/')) { + constructedUrl = constructedUrl.slice(0, -1); + } + + // Add the `/` and path to url + constructedUrl = `${constructedUrl}/${trimmedPath}`; + + return constructedUrl; +}; diff --git a/src/utils.test.js b/src/utils.test.js index e4aada849f..a5b12d6c37 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -1,6 +1,6 @@ import { getConfig, getPath } from '@edx/frontend-platform'; -import { getFileSizeToClosestByte, createCorrectInternalRoute } from './utils'; +import { getFileSizeToClosestByte, createCorrectInternalRoute, constructLibraryAuthoringURL } from './utils'; jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(), @@ -78,3 +78,30 @@ describe('FilesAndUploads utils', () => { }); }); }); + +describe('constructLibraryAuthoringURL', () => { + it('should construct URL given no trailing `/` in base and no starting `/` in path', () => { + const libraryAuthoringMfeUrl = 'http://localhost:3001'; + const path = 'example'; + const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path); + expect(constructedURL).toEqual('http://localhost:3001/example'); + }); + it('should construct URL given a trailing `/` in base and no starting `/` in path', () => { + const libraryAuthoringMfeUrl = 'http://localhost:3001/'; + const path = 'example'; + const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path); + expect(constructedURL).toEqual('http://localhost:3001/example'); + }); + it('should construct URL with no trailing `/` in base and a starting `/` in path', () => { + const libraryAuthoringMfeUrl = 'http://localhost:3001'; + const path = '/example'; + const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path); + expect(constructedURL).toEqual('http://localhost:3001/example'); + }); + it('should construct URL with a trailing `/` in base and a starting `/` in path', () => { + const libraryAuthoringMfeUrl = 'http://localhost:3001/'; + const path = '/example'; + const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path); + expect(constructedURL).toEqual('http://localhost:3001/example'); + }); +});