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