diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx
index eea5ed732..9470f0ad5 100644
--- a/src/library-authoring/EmptyStates.tsx
+++ b/src/library-authoring/EmptyStates.tsx
@@ -1,4 +1,3 @@
-import { useParams } from 'react-router';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import type { MessageDescriptor } from 'react-intl';
import {
@@ -8,6 +7,7 @@ import { Add } from '@openedx/paragon/icons';
import { ClearFiltersButton } from '../search-manager';
import messages from './messages';
import { useContentLibrary } from './data/apiHooks';
+import { useLibraryContext } from './common/context';
export const NoComponents = ({
infoText = messages.noComponents,
@@ -18,7 +18,7 @@ export const NoComponents = ({
addBtnText?: MessageDescriptor;
handleBtnClick: () => void;
}) => {
- const { libraryId } = useParams();
+ const { libraryId } = useLibraryContext();
const { data: libraryData } = useContentLibrary(libraryId);
const canEditLibrary = libraryData?.canEditLibrary ?? false;
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index edcfb5f15..816f13b1c 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -1,4 +1,4 @@
-import React, { useContext, useEffect } from 'react';
+import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import classNames from 'classnames';
import { StudioFooter } from '@edx/frontend-component-footer';
@@ -13,7 +13,7 @@ import {
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import {
- Routes, Route, useLocation, useNavigate, useParams, useSearchParams,
+ Routes, Route, useLocation, useNavigate, useSearchParams,
} from 'react-router-dom';
import Loading from '../generic/Loading';
@@ -33,7 +33,7 @@ import LibraryCollections from './collections/LibraryCollections';
import LibraryHome from './LibraryHome';
import { useContentLibrary } from './data/apiHooks';
import { LibrarySidebar } from './library-sidebar';
-import { LibraryContext, SidebarBodyComponentId } from './common/context';
+import { SidebarBodyComponentId, useLibraryContext } from './common/context';
import messages from './messages';
enum TabList {
@@ -53,7 +53,7 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
openInfoSidebar,
closeLibrarySidebar,
sidebarBodyComponent,
- } = useContext(LibraryContext);
+ } = useLibraryContext();
if (!canEditLibrary) {
return null;
@@ -119,11 +119,7 @@ const LibraryAuthoringPage = () => {
const location = useLocation();
const navigate = useNavigate();
- const { libraryId } = useParams();
- if (!libraryId) {
- // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
- throw new Error('Rendered without libraryId URL parameter');
- }
+ const { libraryId } = useLibraryContext();
const { data: libraryData, isLoading } = useContentLibrary(libraryId);
const currentPath = location.pathname.split('/').pop();
@@ -131,7 +127,7 @@ const LibraryAuthoringPage = () => {
const {
sidebarBodyComponent,
openInfoSidebar,
- } = useContext(LibraryContext);
+ } = useLibraryContext();
useEffect(() => {
openInfoSidebar();
@@ -199,16 +195,12 @@ const LibraryAuthoringPage = () => {
+
)}
/>
}
+ element={}
/>
void,
};
-const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) => {
+const LibraryHome = ({ tabList, handleTabChange } : LibraryHomeProps) => {
const intl = useIntl();
const {
totalHits: componentCount,
totalCollectionHits: collectionCount,
isFiltered,
} = useSearchContext();
- const { openAddContentSidebar } = useContext(LibraryContext);
+ const { openAddContentSidebar } = useLibraryContext();
const renderEmptyState = () => {
if (componentCount === 0 && collectionCount === 0) {
@@ -35,7 +33,7 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps)
return (
-
+
{
renderEmptyState()
|| (
@@ -52,7 +50,7 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps)
contentCount={componentCount}
viewAllAction={() => handleTabChange(tabList.components)}
>
-
+
>
)
diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx
index abd2cf8ee..56718cc5e 100644
--- a/src/library-authoring/LibraryLayout.tsx
+++ b/src/library-authoring/LibraryLayout.tsx
@@ -50,7 +50,7 @@ const LibraryLayout = () => {
}, [goBack]);
return (
-
+
{/*
TODO: we should be opening this editor as a modal, not making it a separate page/URL.
diff --git a/src/library-authoring/LibraryRecentlyModified.tsx b/src/library-authoring/LibraryRecentlyModified.tsx
index 4d5696ec0..4d66cb880 100644
--- a/src/library-authoring/LibraryRecentlyModified.tsx
+++ b/src/library-authoring/LibraryRecentlyModified.tsx
@@ -9,8 +9,9 @@ import messages from './messages';
import ComponentCard from './components/ComponentCard';
import { useLibraryBlockTypes } from './data/apiHooks';
import CollectionCard from './components/CollectionCard';
+import { useLibraryContext } from './common/context';
-const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => {
+const RecentlyModified: React.FC> = () => {
const intl = useIntl();
const {
hits,
@@ -18,6 +19,7 @@ const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => {
totalHits,
totalCollectionHits,
} = useSearchContext();
+ const { libraryId } = useLibraryContext();
const componentCount = totalHits + totalCollectionHits;
// Since we only display a fixed number of items in preview,
@@ -68,13 +70,16 @@ const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => {
: null;
};
-const LibraryRecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => (
-
-
-
-);
+const LibraryRecentlyModified: React.FC> = () => {
+ const { libraryId } = useLibraryContext();
+ return (
+
+
+
+ );
+};
export default LibraryRecentlyModified;
diff --git a/src/library-authoring/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx
index f0f721c39..a67c4f995 100644
--- a/src/library-authoring/add-content/AddContentContainer.test.tsx
+++ b/src/library-authoring/add-content/AddContentContainer.test.tsx
@@ -1,6 +1,6 @@
import {
fireEvent,
- render,
+ render as baseRender,
screen,
waitFor,
initializeMocks,
@@ -8,18 +8,23 @@ import {
import { mockContentLibrary } from '../data/api.mocks';
import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api';
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
+import { LibraryProvider } from '../common/context';
import AddContentContainer from './AddContentContainer';
mockBroadcastChannel();
const { libraryId } = mockContentLibrary;
-const renderOpts = { path: '/library/:libraryId/*', params: { libraryId } };
+const render = () => baseRender(, {
+ path: '/library/:libraryId/*',
+ params: { libraryId },
+ extraWrapper: ({ children }) => { children },
+});
describe('', () => {
it('should render content buttons', () => {
initializeMocks();
mockClipboardEmpty.applyMock();
- render();
+ render();
expect(screen.getByRole('button', { name: /collection/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /problem/i })).toBeInTheDocument();
@@ -36,7 +41,7 @@ describe('', () => {
const url = getCreateLibraryBlockUrl(libraryId);
axiosMock.onPost(url).reply(200);
- render(, renderOpts);
+ render();
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
@@ -48,9 +53,9 @@ describe('', () => {
initializeMocks();
// Simulate having an HTML block in the clipboard:
const getClipboardSpy = mockClipboardHtml.applyMock();
- const doc = render(, renderOpts);
+ render();
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called three times! Refactor to use react-query.
- await waitFor(() => expect(doc.queryByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument());
+ await waitFor(() => expect(screen.queryByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument());
});
it('should paste content', async () => {
@@ -61,7 +66,7 @@ describe('', () => {
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(200);
- render(, renderOpts);
+ render();
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
@@ -79,7 +84,7 @@ describe('', () => {
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(400);
- render(, renderOpts);
+ render();
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx
index 1d46aaacb..fe88ba0cd 100644
--- a/src/library-authoring/add-content/AddContentContainer.tsx
+++ b/src/library-authoring/add-content/AddContentContainer.tsx
@@ -23,9 +23,9 @@ import { useCopyToClipboard } from '../../generic/clipboard';
import { getCanEdit } from '../../course-unit/data/selectors';
import { useCreateLibraryBlock, useLibraryPasteClipboard, useUpdateCollectionComponents } from '../data/apiHooks';
import { getEditUrl } from '../components/utils';
+import { useLibraryContext } from '../common/context';
import messages from './messages';
-import { LibraryContext } from '../common/context';
type ContentType = {
name: string,
@@ -73,7 +73,7 @@ const AddContentContainer = () => {
const { showPasteXBlock } = useCopyToClipboard(canEdit);
const {
openCreateCollectionModal,
- } = React.useContext(LibraryContext);
+ } = useLibraryContext();
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
diff --git a/src/library-authoring/collections/LibraryCollectionComponents.tsx b/src/library-authoring/collections/LibraryCollectionComponents.tsx
index 5d870645c..27ffd5639 100644
--- a/src/library-authoring/collections/LibraryCollectionComponents.tsx
+++ b/src/library-authoring/collections/LibraryCollectionComponents.tsx
@@ -1,14 +1,13 @@
-import { useContext } from 'react';
import { Stack } from '@openedx/paragon';
import { NoComponents, NoSearchResults } from '../EmptyStates';
import { useSearchContext } from '../../search-manager';
import { LibraryComponents } from '../components';
import messages from './messages';
-import { LibraryContext } from '../common/context';
+import { useLibraryContext } from '../common/context';
-const LibraryCollectionComponents = ({ libraryId }: { libraryId: string }) => {
+const LibraryCollectionComponents = () => {
const { totalHits: componentCount, isFiltered } = useSearchContext();
- const { openAddContentSidebar } = useContext(LibraryContext);
+ const { openAddContentSidebar } = useLibraryContext();
if (componentCount === 0) {
return isFiltered
@@ -25,7 +24,7 @@ const LibraryCollectionComponents = ({ libraryId }: { libraryId: string }) => {
return (
Content ({componentCount})
-
+
);
};
diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx
index efcf74999..a96ed49cb 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.tsx
@@ -1,4 +1,4 @@
-import { useContext, useEffect } from 'react';
+import { useEffect } from 'react';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -27,7 +27,7 @@ import {
SearchSortWidget,
} from '../../search-manager';
import { useCollection, useContentLibrary } from '../data/apiHooks';
-import { LibraryContext } from '../common/context';
+import { useLibraryContext } from '../common/context';
import messages from './messages';
import { LibrarySidebar } from '../library-sidebar';
import LibraryCollectionComponents from './LibraryCollectionComponents';
@@ -36,7 +36,7 @@ const HeaderActions = ({ canEditLibrary }: { canEditLibrary: boolean; }) => {
const intl = useIntl();
const {
openAddContentSidebar,
- } = useContext(LibraryContext);
+ } = useLibraryContext();
if (!canEditLibrary) {
return null;
@@ -104,7 +104,7 @@ const LibraryCollectionPage = () => {
const {
sidebarBodyComponent,
openCollectionInfoSidebar,
- } = useContext(LibraryContext);
+ } = useLibraryContext();
const {
data: collectionData,
@@ -189,7 +189,7 @@ const LibraryCollectionPage = () => {
-
+
diff --git a/src/library-authoring/collections/LibraryCollections.tsx b/src/library-authoring/collections/LibraryCollections.tsx
index 97d194f4a..d68e2b16c 100644
--- a/src/library-authoring/collections/LibraryCollections.tsx
+++ b/src/library-authoring/collections/LibraryCollections.tsx
@@ -1,12 +1,10 @@
-import { useContext } from 'react';
-
import { useLoadOnScroll } from '../../hooks';
import { useSearchContext } from '../../search-manager';
import { NoComponents, NoSearchResults } from '../EmptyStates';
import CollectionCard from '../components/CollectionCard';
import { LIBRARY_SECTION_PREVIEW_LIMIT } from '../components/LibrarySection';
import messages from './messages';
-import { LibraryContext } from '../common/context';
+import { useLibraryContext } from '../common/context';
type LibraryCollectionsProps = {
variant: 'full' | 'preview',
@@ -29,7 +27,7 @@ const LibraryCollections = ({ variant }: LibraryCollectionsProps) => {
isFiltered,
} = useSearchContext();
- const { openCreateCollectionModal } = useContext(LibraryContext);
+ const { openCreateCollectionModal } = useLibraryContext();
const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits;
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index 86b862e96..772eaf17f 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -9,36 +9,38 @@ export enum SidebarBodyComponentId {
}
export interface LibraryContextData {
+ /** The ID of the current library */
+ libraryId: string;
+ // Sidebar stuff - only one sidebar is active at any given time:
sidebarBodyComponent: SidebarBodyComponentId | null;
closeLibrarySidebar: () => void;
openAddContentSidebar: () => void;
openInfoSidebar: () => void;
openComponentInfoSidebar: (usageKey: string) => void;
currentComponentUsageKey?: string;
+ // "Create New Collection" modal
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
closeCreateCollectionModal: () => void;
+ // Current collection
openCollectionInfoSidebar: (collectionId: string) => void;
currentCollectionId?: string;
}
-export const LibraryContext = React.createContext({
- sidebarBodyComponent: null,
- closeLibrarySidebar: () => {},
- openAddContentSidebar: () => {},
- openInfoSidebar: () => {},
- openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
- isCreateCollectionModalOpen: false,
- openCreateCollectionModal: () => {},
- closeCreateCollectionModal: () => {},
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- openCollectionInfoSidebar: (_collectionId: string) => {},
-} as LibraryContextData);
+/**
+ * Library Context.
+ * Always available when we're in the context of a single library.
+ *
+ * Get this using `useLibraryContext()`
+ *
+ * Not used on the "library list" on Studio home.
+ */
+const LibraryContext = React.createContext(undefined);
/**
* React component to provide `LibraryContext`
*/
-export const LibraryProvider = (props: { children?: React.ReactNode }) => {
+export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: string }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState();
const [currentCollectionId, setcurrentCollectionId] = React.useState();
@@ -76,7 +78,8 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
setSidebarBodyComponent(SidebarBodyComponentId.CollectionInfo);
}, []);
- const context = React.useMemo(() => ({
+ const context = React.useMemo(() => ({
+ libraryId: props.libraryId,
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
@@ -89,6 +92,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
openCollectionInfoSidebar,
currentCollectionId,
}), [
+ props.libraryId,
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
@@ -108,3 +112,12 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
);
};
+
+export function useLibraryContext(): LibraryContextData {
+ const ctx = React.useContext(LibraryContext);
+ if (ctx === undefined) {
+ /* istanbul ignore next */
+ throw new Error('useLibraryContext() was used in a component without a ancestor.');
+ }
+ return ctx;
+}
diff --git a/src/library-authoring/components/CollectionCard.test.tsx b/src/library-authoring/components/CollectionCard.test.tsx
index 92f2c8d98..b2c7242f2 100644
--- a/src/library-authoring/components/CollectionCard.test.tsx
+++ b/src/library-authoring/components/CollectionCard.test.tsx
@@ -1,10 +1,12 @@
+import React from 'react';
import {
initializeMocks,
fireEvent,
- render,
+ render as baseRender,
screen,
} from '../../testUtils';
+import { LibraryProvider } from '../common/context';
import { type CollectionHit } from '../../search-manager';
import CollectionCard from './CollectionCard';
@@ -28,6 +30,10 @@ const CollectionHitSample: CollectionHit = {
tags: {},
};
+const render = (ui: React.ReactElement) => baseRender(ui, {
+ extraWrapper: ({ children }) => { children },
+});
+
describe('', () => {
beforeEach(() => {
initializeMocks();
diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx
index c8114ec7e..c10661230 100644
--- a/src/library-authoring/components/CollectionCard.tsx
+++ b/src/library-authoring/components/CollectionCard.tsx
@@ -6,11 +6,10 @@ import {
IconButton,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
-import { useContext } from 'react';
import { Link } from 'react-router-dom';
import { type CollectionHit } from '../../search-manager';
-import { LibraryContext } from '../common/context';
+import { useLibraryContext } from '../common/context';
import BaseComponentCard from './BaseComponentCard';
import messages from './messages';
@@ -48,7 +47,7 @@ const CollectionCard = ({ collectionHit }: CollectionCardProps) => {
const intl = useIntl();
const {
openCollectionInfoSidebar,
- } = useContext(LibraryContext);
+ } = useLibraryContext();
const {
type,
diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx
index dae278eba..1196c1820 100644
--- a/src/library-authoring/components/ComponentCard.test.tsx
+++ b/src/library-authoring/components/ComponentCard.test.tsx
@@ -1,27 +1,21 @@
-import React from 'react';
-import { AppProvider } from '@edx/frontend-platform/react';
-import { initializeMockApp } from '@edx/frontend-platform';
-import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
-import { render, fireEvent, waitFor } from '@testing-library/react';
-import MockAdapter from 'axios-mock-adapter';
-import type { Store } from 'redux';
-
-import { ToastProvider } from '../../generic/toast-context';
+import {
+ fireEvent,
+ render as baseRender,
+ screen,
+ waitFor,
+ initializeMocks,
+} from '../../testUtils';
+import { LibraryProvider } from '../common/context';
import { getClipboardUrl } from '../../generic/data/api';
import { ContentHit } from '../../search-manager';
-import initializeStore from '../../store';
import ComponentCard from './ComponentCard';
-let store: Store;
-let axiosMock: MockAdapter;
-
const contentHit: ContentHit = {
id: '1',
usageKey: 'lb:org1:demolib:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d',
type: 'library_block',
blockId: 'a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d',
- contextKey: 'lb:org1:Demo_Course',
+ contextKey: 'lib:org1:Demo_Course',
org: 'org1',
breadcrumbs: [{ displayName: 'Demo Lib' }],
displayName: 'Text Display Name',
@@ -47,57 +41,32 @@ const clipboardBroadcastChannelMock = {
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
-const RootWrapper = () => (
-
-
-
-
-
-
-
-);
+const libraryId = 'lib:org1:Demo_Course';
+const render = () => baseRender(, {
+ extraWrapper: ({ children }) => { children },
+});
describe('', () => {
- beforeEach(() => {
- initializeMockApp({
- authenticatedUser: {
- userId: 3,
- username: 'abc123',
- administrator: true,
- roles: [],
- },
- });
- store = initializeStore();
-
- axiosMock = new MockAdapter(getAuthenticatedHttpClient());
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- axiosMock.restore();
- });
-
it('should render the card with title and description', () => {
- const { getByText } = render();
+ initializeMocks();
+ render();
- expect(getByText('Text Display Formated Name')).toBeInTheDocument();
- expect(getByText('This is a text: ID=1')).toBeInTheDocument();
+ expect(screen.getByText('Text Display Formated Name')).toBeInTheDocument();
+ expect(screen.getByText('This is a text: ID=1')).toBeInTheDocument();
});
it('should call the updateClipboard function when the copy button is clicked', async () => {
+ const { axiosMock, mockShowToast } = initializeMocks();
axiosMock.onPost(getClipboardUrl()).reply(200, {});
- const { getByRole, getByTestId, getByText } = render();
+ render();
// Open menu
- expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument();
- fireEvent.click(getByTestId('component-card-menu-toggle'));
+ expect(screen.getByTestId('component-card-menu-toggle')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('component-card-menu-toggle'));
// Click copy to clipboard
- expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
- fireEvent.click(getByRole('button', { name: 'Copy to clipboard' }));
+ expect(screen.getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' }));
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
@@ -105,21 +74,22 @@ describe('', () => {
);
await waitFor(() => {
- expect(getByText('Component copied to clipboard')).toBeInTheDocument();
+ expect(mockShowToast).toHaveBeenCalledWith('Component copied to clipboard');
});
});
it('should show error message if the api call fails', async () => {
+ const { axiosMock, mockShowToast } = initializeMocks();
axiosMock.onPost(getClipboardUrl()).reply(400);
- const { getByRole, getByTestId, getByText } = render();
+ render();
// Open menu
- expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument();
- fireEvent.click(getByTestId('component-card-menu-toggle'));
+ expect(screen.getByTestId('component-card-menu-toggle')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('component-card-menu-toggle'));
// Click copy to clipboard
- expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
- fireEvent.click(getByRole('button', { name: 'Copy to clipboard' }));
+ expect(screen.getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' }));
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
@@ -127,7 +97,7 @@ describe('', () => {
);
await waitFor(() => {
- expect(getByText('Failed to copy component to clipboard')).toBeInTheDocument();
+ expect(mockShowToast).toHaveBeenCalledWith('Failed to copy component to clipboard');
});
});
});
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index 39a23926f..a1314544d 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -12,7 +12,7 @@ import { Link } from 'react-router-dom';
import { updateClipboard } from '../../generic/data/api';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit } from '../../search-manager';
-import { LibraryContext } from '../common/context';
+import { useLibraryContext } from '../common/context';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import { getEditUrl } from './utils';
@@ -66,7 +66,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
const {
openComponentInfoSidebar,
- } = useContext(LibraryContext);
+ } = useLibraryContext();
const {
blockType,
diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx
index ed490b92d..84391ab2a 100644
--- a/src/library-authoring/components/LibraryComponents.test.tsx
+++ b/src/library-authoring/components/LibraryComponents.test.tsx
@@ -1,26 +1,24 @@
-import { AppProvider } from '@edx/frontend-platform/react';
-import { initializeMockApp } from '@edx/frontend-platform';
-import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { render, screen, fireEvent } from '@testing-library/react';
-import MockAdapter from 'axios-mock-adapter';
import fetchMock from 'fetch-mock-jest';
-import type { Store } from 'redux';
+import {
+ fireEvent,
+ render,
+ screen,
+ initializeMocks,
+} from '../../testUtils';
import { getContentSearchConfigUrl } from '../../search-manager/data/api';
-import { SearchContextProvider } from '../../search-manager/SearchManager';
+import { mockLibraryBlockTypes, mockContentLibrary } from '../data/api.mocks';
import mockEmptyResult from '../../search-modal/__mocks__/empty-search-result.json';
-import initializeStore from '../../store';
+import { LibraryProvider } from '../common/context';
import { libraryComponentsMock } from '../__mocks__';
import LibraryComponents from './LibraryComponents';
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
-const mockUseLibraryBlockTypes = jest.fn();
+mockLibraryBlockTypes.applyMock();
+mockContentLibrary.applyMock();
const mockFetchNextPage = jest.fn();
const mockUseSearchContext = jest.fn();
-const mockUseContentLibrary = jest.fn();
const data = {
totalHits: 1,
@@ -33,17 +31,6 @@ const data = {
isFiltered: false,
};
-let store: Store;
-let axiosMock: MockAdapter;
-
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
-});
-
const returnEmptyResult = (_url: string, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
@@ -56,28 +43,6 @@ const returnEmptyResult = (_url: string, req) => {
return mockEmptyResult;
};
-const blockTypeData = {
- data: [
- {
- blockType: 'html',
- displayName: 'Text',
- },
- {
- blockType: 'video',
- displayName: 'Video',
- },
- {
- blockType: 'problem',
- displayName: 'Problem',
- },
- ],
-};
-
-jest.mock('../data/apiHooks', () => ({
- useLibraryBlockTypes: () => mockUseLibraryBlockTypes(),
- useContentLibrary: () => mockUseContentLibrary(),
-}));
-
jest.mock('../../search-manager', () => ({
...jest.requireActual('../../search-manager'),
useSearchContext: () => mockUseSearchContext(),
@@ -90,36 +55,19 @@ const clipboardBroadcastChannelMock = {
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
-const RootWrapper = (props) => (
-
-
-
-
-
-
-
-
-
-);
+const withLibraryId = (libraryId: string) => ({
+ extraWrapper: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+});
describe('', () => {
beforeEach(() => {
- initializeMockApp({
- authenticatedUser: {
- userId: 3,
- username: 'abc123',
- administrator: true,
- roles: [],
- },
- });
- store = initializeStore();
- mockUseLibraryBlockTypes.mockReturnValue(blockTypeData);
- mockUseSearchContext.mockReturnValue(data);
+ const { axiosMock } = initializeMocks();
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
// The API method to get the Meilisearch connection details uses Axios:
- axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(getContentSearchConfigUrl()).reply(200, {
url: 'http://mock.meilisearch.local',
index_name: 'studio',
@@ -128,7 +76,8 @@ describe('', () => {
});
afterEach(() => {
- jest.resetAllMocks();
+ fetchMock.reset();
+ mockFetchNextPage.mockReset();
});
it('should render empty state', async () => {
@@ -136,15 +85,10 @@ describe('', () => {
...data,
totalHits: 0,
});
- mockUseContentLibrary.mockReturnValue({
- data: {
- canEditLibrary: true,
- },
- });
- render();
+ render(, withLibraryId(mockContentLibrary.libraryId));
expect(await screen.findByText(/you have not added any content to this library yet\./i));
- expect(screen.getByRole('button', { name: /add component/i })).toBeInTheDocument();
+ expect(await screen.findByRole('button', { name: /add component/i })).toBeInTheDocument();
});
it('should render empty state without add content button', async () => {
@@ -152,13 +96,8 @@ describe('', () => {
...data,
totalHits: 0,
});
- mockUseContentLibrary.mockReturnValue({
- data: {
- canEditLibrary: false,
- },
- });
- render();
+ render(, withLibraryId(mockContentLibrary.libraryIdReadOnly));
expect(await screen.findByText(/you have not added any content to this library yet\./i));
expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument();
});
@@ -169,7 +108,7 @@ describe('', () => {
hits: libraryComponentsMock,
isFetching: false,
});
- render();
+ render(, withLibraryId(mockContentLibrary.libraryId));
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
@@ -185,7 +124,7 @@ describe('', () => {
hits: libraryComponentsMock,
isFetching: false,
});
- render();
+ render(, withLibraryId(mockContentLibrary.libraryId));
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
@@ -203,7 +142,7 @@ describe('', () => {
hasNextPage: true,
});
- render();
+ render(, withLibraryId(mockContentLibrary.libraryId));
Object.defineProperty(window, 'innerHeight', { value: 800 });
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
@@ -221,7 +160,7 @@ describe('', () => {
hasNextPage: true,
});
- render();
+ render(, withLibraryId(mockContentLibrary.libraryId));
Object.defineProperty(window, 'innerHeight', { value: 800 });
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx
index c91dbad55..7d5280663 100644
--- a/src/library-authoring/components/LibraryComponents.tsx
+++ b/src/library-authoring/components/LibraryComponents.tsx
@@ -1,4 +1,4 @@
-import React, { useContext, useMemo } from 'react';
+import React, { useMemo } from 'react';
import { useLoadOnScroll } from '../../hooks';
import { useSearchContext } from '../../search-manager';
@@ -6,10 +6,9 @@ import { NoComponents, NoSearchResults } from '../EmptyStates';
import { useLibraryBlockTypes } from '../data/apiHooks';
import ComponentCard from './ComponentCard';
import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection';
-import { LibraryContext } from '../common/context';
+import { useLibraryContext } from '../common/context';
type LibraryComponentsProps = {
- libraryId: string,
variant: 'full' | 'preview',
};
@@ -20,7 +19,7 @@ type LibraryComponentsProps = {
* - 'full': Show all components with Infinite scroll pagination.
* - 'preview': Show first 4 components without pagination.
*/
-const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
+const LibraryComponents = ({ variant }: LibraryComponentsProps) => {
const {
hits,
totalHits: componentCount,
@@ -29,11 +28,11 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
fetchNextPage,
isFiltered,
} = useSearchContext();
- const { openAddContentSidebar } = useContext(LibraryContext);
+ const { libraryId, openAddContentSidebar } = useLibraryContext();
const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits;
- // TODO add this to LibraryContext
+ // TODO get rid of "useLibraryBlockTypes". Use instead.
const { data: blockTypesData } = useLibraryBlockTypes(libraryId);
const blockTypes = useMemo(() => {
const result = {};
diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx
index cc611b3a9..1b160ab0e 100644
--- a/src/library-authoring/create-collection/CreateCollectionModal.tsx
+++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx
@@ -5,12 +5,12 @@ import {
Form,
ModalDialog,
} from '@openedx/paragon';
-import { useNavigate, useParams } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik } from 'formik';
import * as Yup from 'yup';
import FormikControl from '../../generic/FormikControl';
-import { LibraryContext } from '../common/context';
+import { useLibraryContext } from '../common/context';
import messages from './messages';
import { useCreateLibraryCollection } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
@@ -18,15 +18,12 @@ import { ToastContext } from '../../generic/toast-context';
const CreateCollectionModal = () => {
const intl = useIntl();
const navigate = useNavigate();
- const { libraryId } = useParams();
- if (!libraryId) {
- throw new Error('Rendered without libraryId URL parameter');
- }
- const create = useCreateLibraryCollection(libraryId!);
const {
+ libraryId,
isCreateCollectionModalOpen,
closeCreateCollectionModal,
- } = React.useContext(LibraryContext);
+ } = useLibraryContext();
+ const create = useCreateLibraryCollection(libraryId);
const { showToast } = React.useContext(ToastContext);
const handleCreate = React.useCallback((values) => {
diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
index a7ce2b5b5..729484a07 100644
--- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx
+++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
@@ -1,4 +1,4 @@
-import React, { useContext } from 'react';
+import React from 'react';
import {
Stack,
Icon,
@@ -10,7 +10,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { AddContentContainer, AddContentHeader } from '../add-content';
import { CollectionInfo, CollectionInfoHeader } from '../collections';
import { ContentLibrary } from '../data/api';
-import { LibraryContext, SidebarBodyComponentId } from '../common/context';
+import { SidebarBodyComponentId, useLibraryContext } from '../common/context';
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
import messages from '../messages';
@@ -35,7 +35,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
closeLibrarySidebar,
currentComponentUsageKey,
currentCollectionId,
- } = useContext(LibraryContext);
+ } = useLibraryContext();
const bodyComponentMap = {
[SidebarBodyComponentId.AddContent]: ,
diff --git a/src/testUtils.tsx b/src/testUtils.tsx
index a6cc43e64..73b2518f1 100644
--- a/src/testUtils.tsx
+++ b/src/testUtils.tsx
@@ -46,6 +46,10 @@ export interface RouteOptions {
routerProps?: MemoryRouterProps;
}
+export interface WrapperOptions {
+ extraWrapper?: React.FunctionComponent<{ children: React.ReactNode; }>;
+}
+
/**
* This component works together with the custom `render()` method we have in
* this file to provide whatever react-router context you need for your
@@ -111,14 +115,14 @@ const RouterAndRoute: React.FC = ({
);
};
-function makeWrapper({ ...routeArgs }: RouteOptions) {
+function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions) {
const AllTheProviders = ({ children }) => (
- {children}
+ {extraWrapper ? React.createElement(extraWrapper, undefined, children) : children}
@@ -132,7 +136,7 @@ function makeWrapper({ ...routeArgs }: RouteOptions) {
* Same as render() from `@testing-library/react` but this one provides all the
* wrappers our React components need to render properly.
*/
-function customRender(ui: React.ReactElement, options: RouteOptions = {}): RenderResult {
+function customRender(ui: React.ReactElement, options: WrapperOptions & RouteOptions = {}): RenderResult {
return render(ui, { wrapper: makeWrapper(options) });
}