Skip to content

Commit

Permalink
feat: Add lib v2/legacy tabs in studio home (#1050)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
yusuf-musleh authored Jun 20, 2024
1 parent e2ed3bc commit 088a01d
Show file tree
Hide file tree
Showing 24 changed files with 755 additions and 58 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ INVITE_STUDENTS_EMAIL_TO="[email protected]"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_MODE="mixed"
14 changes: 14 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
**********

Expand Down
5 changes: 5 additions & 0 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' && (
Expand Down Expand Up @@ -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');
},
},
Expand Down
3 changes: 2 additions & 1 deletion src/search-modal/SearchResult.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,7 +42,7 @@ function getItemIcon(blockType) {
*/
function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) {
const { contextKey } = hit;
return `${libraryAuthoringMfeUrl}library/${contextKey}`;
return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${contextKey}`);
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/setupTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 12 additions & 3 deletions src/studio-home/StudioHome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,6 +45,8 @@ const StudioHome = ({ intl }) => {
dispatch,
} = useStudioHome(isPaginationCoursesEnabled);

const libMode = getConfig().LIBRARY_MODE;

const {
userIsActive,
studioShortName,
Expand Down Expand Up @@ -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(
Expand Down
52 changes: 39 additions & 13 deletions src/studio-home/StudioHome.test.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -23,7 +25,6 @@ import { StudioHome } from '.';

let axiosMock;
let store;
const mockPathname = '/foo-bar';
const {
studioShortName,
studioRequestEmail,
Expand All @@ -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>
);
Expand Down Expand Up @@ -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,
Expand All @@ -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')}`,
);
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/studio-home/__mocks__/index.js
Original file line number Diff line number Diff line change
@@ -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 changes: 44 additions & 0 deletions src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js
Original file line number Diff line number Diff line change
@@ -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: '',
},
],
};
4 changes: 2 additions & 2 deletions src/studio-home/card-item/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -51,7 +51,7 @@ const CardItem = ({
title={!readOnlyItem ? (
<Hyperlink
className="card-item-title"
destination={courseUrl().toString()}
destination={destinationUrl().toString()}
>
{hasDisplayName}
</Hyperlink>
Expand Down
22 changes: 22 additions & 0 deletions src/studio-home/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions src/studio-home/data/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/studio-home/data/apiHooks.js
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 088a01d

Please sign in to comment.