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.
This commit is contained in:
1
.env
1
.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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
14
README.rst
14
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
|
||||
**********
|
||||
|
||||
|
||||
@@ -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(
|
||||
<Route>
|
||||
<Route path="/home" element={<StudioHome />} />
|
||||
<Route path="/libraries" element={<StudioHome />} />
|
||||
<Route path="/libraries-v1" element={<StudioHome />} />
|
||||
<Route path="/library/:libraryId" element={<LibraryV2Placeholder />} />
|
||||
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
|
||||
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
|
||||
{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');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = () => (
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<StudioHome intl={injectIntl} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/home']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/home"
|
||||
element={<StudioHome intl={injectIntl} />}
|
||||
/>
|
||||
<Route
|
||||
path="/libraries"
|
||||
element={<StudioHome intl={injectIntl} />}
|
||||
/>
|
||||
<Route
|
||||
path="/libraries-v1"
|
||||
element={<StudioHome intl={injectIntl} />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -145,7 +158,18 @@ describe('<StudioHome />', () => {
|
||||
});
|
||||
|
||||
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('<StudioHome />', () => {
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const createNewLibraryButton = getByTestId('new-library-button');
|
||||
expect(createNewLibraryButton.getAttribute('href')).toBe(`${libraryAuthoringMfeUrl}/create`);
|
||||
expect(createNewLibraryButton.getAttribute('href')).toBe(
|
||||
`${constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as studioHomeMock } from './studioHomeMock';
|
||||
export { default as listStudioHomeV2LibrariesMock } from './listStudioHomeV2LibrariesMock';
|
||||
|
||||
44
src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js
Normal file
44
src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js
Normal file
@@ -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: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 ? (
|
||||
<Hyperlink
|
||||
className="card-item-title"
|
||||
destination={courseUrl().toString()}
|
||||
destination={destinationUrl().toString()}
|
||||
>
|
||||
{hasDisplayName}
|
||||
</Hyperlink>
|
||||
|
||||
@@ -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<Object>} - 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
15
src/studio-home/data/apiHooks.js
Normal file
15
src/studio-home/data/apiHooks.js
Normal file
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
36
src/studio-home/tabs-section/LibraryV2Placeholder.jsx
Normal file
36
src/studio-home/tabs-section/LibraryV2Placeholder.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<section className="mb-4">
|
||||
<article className="studio-home-sub-header">
|
||||
<section>
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.libraryV2PlaceholderTitle)}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
<section>
|
||||
<p>{intl.formatMessage(messages.libraryV2PlaceholderBody)}</p>
|
||||
</section>
|
||||
</section>
|
||||
</Container>
|
||||
<StudioFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryV2Placeholder;
|
||||
@@ -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) => (
|
||||
<TabsSection
|
||||
intl={{ formatMessage: jest.fn() }}
|
||||
dispatch={mockDispatch}
|
||||
isPaginationCoursesEnabled={false}
|
||||
{...overrideProps}
|
||||
/>
|
||||
);
|
||||
|
||||
const RootWrapper = (overrideProps) => (
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TabsSection
|
||||
intl={{ formatMessage: jest.fn() }}
|
||||
dispatch={mockDispatch}
|
||||
isPaginationCoursesEnabled={false}
|
||||
{...overrideProps}
|
||||
/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/home']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/home"
|
||||
element={tabSectionComponent(overrideProps)}
|
||||
/>
|
||||
<Route
|
||||
path="/libraries"
|
||||
element={tabSectionComponent(overrideProps)}
|
||||
/>
|
||||
<Route
|
||||
path="/libraries-v1"
|
||||
element={tabSectionComponent(overrideProps)}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -59,6 +104,10 @@ describe('<TabsSection />', () => {
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
LIBRARY_MODE: 'mixed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all tabs correctly', async () => {
|
||||
@@ -82,9 +131,53 @@ describe('<TabsSection />', () => {
|
||||
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
@@ -156,6 +249,46 @@ describe('<TabsSection />', () => {
|
||||
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(<RootWrapper />);
|
||||
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('<TabsSection />', () => {
|
||||
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
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('<TabsSection />', () => {
|
||||
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(<RootWrapper />);
|
||||
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('<TabsSection />', () => {
|
||||
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('<TabsSection />', () => {
|
||||
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('<TabsSection />', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<Tab
|
||||
key={TABS_LIST.libraries}
|
||||
eventKey={TABS_LIST.libraries}
|
||||
title={intl.formatMessage(messages.librariesTabTitle)}
|
||||
>
|
||||
{!redirectToLibraryAuthoringMfe && (
|
||||
if (isMixedOrV2LibrariesMode(libMode)) {
|
||||
tabs.push(
|
||||
<Tab
|
||||
key={TABS_LIST.libraries}
|
||||
eventKey={TABS_LIST.libraries}
|
||||
title={intl.formatMessage(messages.librariesTabTitle)}
|
||||
>
|
||||
<LibrariesV2Tab
|
||||
libraryAuthoringMfeUrl={libraryAuthoringMfeUrl}
|
||||
redirectToLibraryAuthoringMfe={redirectToLibraryAuthoringMfe}
|
||||
/>
|
||||
</Tab>,
|
||||
);
|
||||
}
|
||||
|
||||
if (isMixedOrV1LibrariesMode(libMode)) {
|
||||
tabs.push(
|
||||
<Tab
|
||||
key={TABS_LIST.legacyLibraries}
|
||||
eventKey={TABS_LIST.legacyLibraries}
|
||||
title={intl.formatMessage(
|
||||
libMode === 'v1 only'
|
||||
? messages.librariesTabTitle
|
||||
: messages.legacyLibrariesTabTitle,
|
||||
)}
|
||||
>
|
||||
<LibrariesTab
|
||||
libraries={libraries}
|
||||
isLoading={isLoadingLibraries}
|
||||
isFailed={isFailedLibrariesPage}
|
||||
/>
|
||||
)}
|
||||
</Tab>,
|
||||
);
|
||||
</Tab>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
111
src/studio-home/tabs-section/libraries-v2-tab/index.jsx
Normal file
111
src/studio-home/tabs-section/libraries-v2-tab/index.jsx
Normal file
@@ -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 (
|
||||
<Row className="m-0 mt-4 justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.librariesTabErrorMessage)}
|
||||
variant="danger"
|
||||
description={(
|
||||
<Row className="m-0 align-items-center">
|
||||
<Icon src={Error} className="text-danger-500 mr-1" />
|
||||
<span>{intl.formatMessage(messages.librariesTabErrorMessage)}</span>
|
||||
</Row>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="courses-tab-container">
|
||||
<div className="d-flex flex-row justify-content-between my-4">
|
||||
{/* Temporary div to add spacing. This will be replaced with lib search/filters */}
|
||||
<div className="d-flex" />
|
||||
<p data-testid="pagination-info">
|
||||
{intl.formatMessage(messages.coursesPaginationInfo, {
|
||||
length: data.results.length,
|
||||
total: data.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{
|
||||
data.results.map(({
|
||||
id, org, slug, title,
|
||||
}) => (
|
||||
<CardItem
|
||||
key={`${org}+${slug}`}
|
||||
isLibraries
|
||||
displayName={title}
|
||||
org={org}
|
||||
number={slug}
|
||||
url={libURL(id)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
data.numPages > 1
|
||||
&& (
|
||||
<Pagination
|
||||
className="d-flex justify-content-center"
|
||||
paginationLabel="pagination navigation"
|
||||
pageCount={data.numPages}
|
||||
currentPage={currentPage}
|
||||
onPageSelect={handlePageSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
LibrariesV2Tab.propTypes = {
|
||||
libraryAuthoringMfeUrl: PropTypes.string.isRequired,
|
||||
redirectToLibraryAuthoringMfe: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default LibrariesV2Tab;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
24
src/utils.js
24
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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user