From 088a01d71615891658d97844741ecd6b3e361819 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Thu, 20 Jun 2024 15:00:57 +0300 Subject: [PATCH] feat: Add lib v2/legacy tabs in studio home (#1050) This PR adds a new configuration flag that shows/hides tabs in studio home along with some new functionality around to V1 and V2 Libraries. When the new LIBRARY_MODE flag is set to "mixed" (default in dev) it will show "Libraries" and "Legacy Libraries" tabs that correspond to v1 and v2 tabs respectively. When the new LIBRARY_MODE flag is set to "v1 only" (default in production) or "v2 only", only one tab "Libraries" is shown and only the respective libraries are fetched when the tab is clicked. In addition to the above changes, the URL/route now updates when clicking on the tabs, and navigating to it directly would open up that tab as well as a new placeholder page that you will be redirected to when clicking on a v2 library if the library authoring MFE is not enabled. --- .env | 1 + .env.development | 1 + .env.test | 1 + README.rst | 14 + src/index.jsx | 5 + src/search-modal/SearchResult.jsx | 3 +- src/setupTest.js | 1 + src/studio-home/StudioHome.jsx | 15 +- src/studio-home/StudioHome.test.jsx | 52 +++- src/studio-home/__mocks__/index.js | 2 +- .../listStudioHomeV2LibrariesMock.js | 44 +++ src/studio-home/card-item/index.jsx | 4 +- src/studio-home/data/api.js | 22 ++ src/studio-home/data/api.test.js | 24 +- src/studio-home/data/apiHooks.js | 15 ++ .../factories/mockApiResponses.jsx | 47 +++- .../tabs-section/LibraryV2Placeholder.jsx | 36 +++ .../tabs-section/TabsSection.test.jsx | 254 +++++++++++++++++- src/studio-home/tabs-section/index.jsx | 86 ++++-- .../tabs-section/libraries-v2-tab/index.jsx | 111 ++++++++ src/studio-home/tabs-section/messages.js | 12 + src/studio-home/tabs-section/utils.js | 10 +- src/utils.js | 24 ++ src/utils.test.js | 29 +- 24 files changed, 755 insertions(+), 58 deletions(-) create mode 100644 src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js create mode 100644 src/studio-home/data/apiHooks.js create mode 100644 src/studio-home/tabs-section/LibraryV2Placeholder.jsx create mode 100644 src/studio-home/tabs-section/libraries-v2-tab/index.jsx diff --git a/.env b/.env index ce1745470..423546113 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 983ce9674..5547e8ffe 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 28240ad2f..0f7351796 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 3847453ea..d0680a32e 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/index.jsx b/src/index.jsx index 889063acd..f881441df 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -23,6 +23,7 @@ import initializeStore from './store'; import CourseAuthoringRoutes from './CourseAuthoringRoutes'; import Head from './head/Head'; import { StudioHome } from './studio-home'; +import LibraryV2Placeholder from './studio-home/tabs-section/LibraryV2Placeholder'; import CourseRerun from './course-rerun'; import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy'; import { ContentTagsDrawer } from './content-tags-drawer'; @@ -52,6 +53,9 @@ const App = () => { createRoutesFromElements( } /> + } /> + } /> + } /> } /> } /> {getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && ( @@ -125,6 +129,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/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 634c12d01..939043ef7 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'; @@ -41,7 +42,7 @@ function getItemIcon(blockType) { */ function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) { const { contextKey } = hit; - return `${libraryAuthoringMfeUrl}library/${contextKey}`; + return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${contextKey}`); } /** diff --git a/src/setupTest.js b/src/setupTest.js index 35b1c9ebe..f0f7f6a43 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 8348aaca3..4953c6f3a 100644 --- a/src/studio-home/StudioHome.jsx +++ b/src/studio-home/StudioHome.jsx @@ -10,14 +10,16 @@ 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 { 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 { isMixedOrV2LibrariesMode } from './tabs-section/utils'; import OrganizationSection from './organization-section'; import VerifyEmailLayout from './verify-email-layout'; import CreateNewCourseForm from './create-new-course-form'; @@ -43,6 +45,8 @@ const StudioHome = ({ intl }) => { dispatch, } = useStudioHome(isPaginationCoursesEnabled); + const libMode = getConfig().LIBRARY_MODE; + const { userIsActive, studioShortName, @@ -79,8 +83,13 @@ const StudioHome = ({ intl }) => { } let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`; - if (redirectToLibraryAuthoringMfe) { - libraryHref = `${libraryAuthoringMfeUrl}/create`; + if (isMixedOrV2LibrariesMode(libMode)) { + libraryHref = libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe + ? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create') + // Redirection to the placeholder is done in the MFE rather than + // through the backend i.e. redirection from cms, because this this will probably change, + // hence why we use the MFE's origin + : `${window.location.origin}${getPath(getConfig().PUBLIC_PATH)}library/create`; } headerButtons.push( diff --git a/src/studio-home/StudioHome.test.jsx b/src/studio-home/StudioHome.test.jsx index 0d9a339b3..c05380894 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 92461eb0b..af2a85b39 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 000000000..02257a974 --- /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 2bc9c3e16..ed84bb2cc 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 1fefe2981..2124f6fed 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 593a2730d..66f6ee279 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 000000000..92575bf71 --- /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 30615ba8d..5d75f9f59 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/LibraryV2Placeholder.jsx b/src/studio-home/tabs-section/LibraryV2Placeholder.jsx new file mode 100644 index 000000000..6b13853a2 --- /dev/null +++ b/src/studio-home/tabs-section/LibraryV2Placeholder.jsx @@ -0,0 +1,36 @@ +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; diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.jsx index fdc955d8d..90f47d5e8 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 1409766c4..75a3ae12b 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 000000000..c3b58df55 --- /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 5ae2e139b..0ed614f55 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 dff32dc95..ec4fb9303 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 d4bc8f6ff..2abb63e5b 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 e4aada849..a5b12d6c3 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'); + }); +});