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:
Yusuf Musleh
2024-06-20 15:00:57 +03:00
committed by GitHub
parent e2ed3bc7a7
commit 088a01d716
24 changed files with 755 additions and 58 deletions

1
.env
View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
**********

View File

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

View File

@@ -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}`);
}
/**

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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')}`,
);
});
});

View File

@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as studioHomeMock } from './studioHomeMock';
export { default as listStudioHomeV2LibrariesMock } from './listStudioHomeV2LibrariesMock';

View 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: '',
},
],
};

View File

@@ -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>

View File

@@ -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

View File

@@ -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);

View 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;

View File

@@ -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',

View 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;

View File

@@ -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);
});

View File

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

View 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;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;
};

View File

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