refactor: Improve LibraryContext, convert tests to testUtils (#1345)

This commit is contained in:
Braden MacDonald
2024-10-03 19:35:43 -07:00
committed by GitHub
parent dc6ede4d80
commit 652af9f6a5
20 changed files with 170 additions and 246 deletions

View File

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

View File

@@ -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 = () => {
<Route
path={TabList.home}
element={(
<LibraryHome
libraryId={libraryId}
tabList={TabList}
handleTabChange={handleTabChange}
/>
<LibraryHome tabList={TabList} handleTabChange={handleTabChange} />
)}
/>
<Route
path={TabList.components}
element={<LibraryComponents libraryId={libraryId} variant="full" />}
element={<LibraryComponents variant="full" />}
/>
<Route
path={TabList.collections}

View File

@@ -1,4 +1,3 @@
import React, { useContext } from 'react';
import { Stack } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -9,22 +8,21 @@ import { LibraryComponents } from './components';
import LibrarySection from './components/LibrarySection';
import LibraryRecentlyModified from './LibraryRecentlyModified';
import messages from './messages';
import { LibraryContext } from './common/context';
import { useLibraryContext } from './common/context';
type LibraryHomeProps = {
libraryId: string,
tabList: { home: string, components: string, collections: string },
handleTabChange: (key: string) => 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 (
<Stack gap={3}>
<LibraryRecentlyModified libraryId={libraryId} />
<LibraryRecentlyModified />
{
renderEmptyState()
|| (
@@ -52,7 +50,7 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps)
contentCount={componentCount}
viewAllAction={() => handleTabChange(tabList.components)}
>
<LibraryComponents libraryId={libraryId} variant="preview" />
<LibraryComponents variant="preview" />
</LibrarySection>
</>
)

View File

@@ -50,7 +50,7 @@ const LibraryLayout = () => {
}, [goBack]);
return (
<LibraryProvider>
<LibraryProvider libraryId={libraryId}>
<Routes>
{/*
TODO: we should be opening this editor as a modal, not making it a separate page/URL.

View File

@@ -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<Record<never, never>> = () => {
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 }) => (
<SearchContextProvider
extraFilter={`context_key = "${libraryId}"`}
overrideSearchSortOrder={SearchSortOption.RECENTLY_MODIFIED}
>
<RecentlyModified libraryId={libraryId} />
</SearchContextProvider>
);
const LibraryRecentlyModified: React.FC<Record<never, never>> = () => {
const { libraryId } = useLibraryContext();
return (
<SearchContextProvider
extraFilter={`context_key = "${libraryId}"`}
overrideSearchSortOrder={SearchSortOption.RECENTLY_MODIFIED}
>
<RecentlyModified />
</SearchContextProvider>
);
};
export default LibraryRecentlyModified;

View File

@@ -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(<AddContentContainer />, {
path: '/library/:libraryId/*',
params: { libraryId },
extraWrapper: ({ children }) => <LibraryProvider libraryId={libraryId}>{ children }</LibraryProvider>,
});
describe('<AddContentContainer />', () => {
it('should render content buttons', () => {
initializeMocks();
mockClipboardEmpty.applyMock();
render(<AddContentContainer />);
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('<AddContentContainer />', () => {
const url = getCreateLibraryBlockUrl(libraryId);
axiosMock.onPost(url).reply(200);
render(<AddContentContainer />, renderOpts);
render();
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
@@ -48,9 +53,9 @@ describe('<AddContentContainer />', () => {
initializeMocks();
// Simulate having an HTML block in the clipboard:
const getClipboardSpy = mockClipboardHtml.applyMock();
const doc = render(<AddContentContainer />, 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('<AddContentContainer />', () => {
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(200);
render(<AddContentContainer />, renderOpts);
render();
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
@@ -79,7 +84,7 @@ describe('<AddContentContainer />', () => {
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(400);
render(<AddContentContainer />, renderOpts);
render();
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);

View File

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

View File

@@ -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 (
<Stack direction="vertical" gap={3}>
<h3 className="text-gray">Content ({componentCount})</h3>
<LibraryComponents libraryId={libraryId} variant="full" />
<LibraryComponents variant="full" />
</Stack>
);
};

View File

@@ -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 = () => {
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<LibraryCollectionComponents libraryId={libraryId} />
<LibraryCollectionComponents />
</SearchContextProvider>
</Container>
<StudioFooter />

View File

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

View File

@@ -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<LibraryContextData | undefined>(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<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
const [currentCollectionId, setcurrentCollectionId] = React.useState<string>();
@@ -76,7 +78,8 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
setSidebarBodyComponent(SidebarBodyComponentId.CollectionInfo);
}, []);
const context = React.useMemo(() => ({
const context = React.useMemo<LibraryContextData>(() => ({
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 }) => {
</LibraryContext.Provider>
);
};
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 <LibraryProvider> ancestor.');
}
return ctx;
}

View File

@@ -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 }) => <LibraryProvider libraryId="lib:Axim:TEST">{ children }</LibraryProvider>,
});
describe('<CollectionCard />', () => {
beforeEach(() => {
initializeMocks();

View File

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

View File

@@ -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 = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<ToastProvider>
<ComponentCard
contentHit={contentHit}
blockTypeDisplayName="text"
/>
</ToastProvider>
</IntlProvider>
</AppProvider>
);
const libraryId = 'lib:org1:Demo_Course';
const render = () => baseRender(<ComponentCard contentHit={contentHit} blockTypeDisplayName="text" />, {
extraWrapper: ({ children }) => <LibraryProvider libraryId={libraryId}>{ children }</LibraryProvider>,
});
describe('<ComponentCard />', () => {
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(<RootWrapper />);
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(<RootWrapper />);
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('<ComponentCard />', () => {
);
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(<RootWrapper />);
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('<ComponentCard />', () => {
);
await waitFor(() => {
expect(getByText('Failed to copy component to clipboard')).toBeInTheDocument();
expect(mockShowToast).toHaveBeenCalledWith('Failed to copy component to clipboard');
});
});
});

View File

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

View File

@@ -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) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<SearchContextProvider>
<LibraryComponents libraryId="1" {...props} />
</SearchContextProvider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
const withLibraryId = (libraryId: string) => ({
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
),
});
describe('<LibraryComponents />', () => {
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('<LibraryComponents />', () => {
});
afterEach(() => {
jest.resetAllMocks();
fetchMock.reset();
mockFetchNextPage.mockReset();
});
it('should render empty state', async () => {
@@ -136,15 +85,10 @@ describe('<LibraryComponents />', () => {
...data,
totalHits: 0,
});
mockUseContentLibrary.mockReturnValue({
data: {
canEditLibrary: true,
},
});
render(<RootWrapper />);
render(<LibraryComponents variant="full" />, 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('<LibraryComponents />', () => {
...data,
totalHits: 0,
});
mockUseContentLibrary.mockReturnValue({
data: {
canEditLibrary: false,
},
});
render(<RootWrapper />);
render(<LibraryComponents variant="full" />, 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('<LibraryComponents />', () => {
hits: libraryComponentsMock,
isFetching: false,
});
render(<RootWrapper variant="full" />);
render(<LibraryComponents variant="full" />, 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('<LibraryComponents />', () => {
hits: libraryComponentsMock,
isFetching: false,
});
render(<RootWrapper variant="preview" />);
render(<LibraryComponents variant="preview" />, 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('<LibraryComponents />', () => {
hasNextPage: true,
});
render(<RootWrapper variant="full" />);
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
Object.defineProperty(window, 'innerHeight', { value: 800 });
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
@@ -221,7 +160,7 @@ describe('<LibraryComponents />', () => {
hasNextPage: true,
});
render(<RootWrapper variant="preview" />);
render(<LibraryComponents variant="preview" />, withLibraryId(mockContentLibrary.libraryId));
Object.defineProperty(window, 'innerHeight', { value: 800 });
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });

View File

@@ -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 <BlockTypeLabel> instead.
const { data: blockTypesData } = useLibraryBlockTypes(libraryId);
const blockTypes = useMemo(() => {
const result = {};

View File

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

View File

@@ -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]: <AddContentContainer />,

View File

@@ -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<RouteOptions> = ({
);
};
function makeWrapper({ ...routeArgs }: RouteOptions) {
function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions) {
const AllTheProviders = ({ children }) => (
<AppProvider store={reduxStore} wrapWithRouter={false}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastContext.Provider value={mockToastContext}>
<RouterAndRoute {...routeArgs}>
{children}
{extraWrapper ? React.createElement(extraWrapper, undefined, children) : children}
</RouterAndRoute>
</ToastContext.Provider>
</QueryClientProvider>
@@ -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) });
}