From 652af9f6a54795acbf2c8f195802b6650291b8fa Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 3 Oct 2024 19:35:43 -0700 Subject: [PATCH] refactor: Improve LibraryContext, convert tests to testUtils (#1345) --- src/library-authoring/EmptyStates.tsx | 4 +- .../LibraryAuthoringPage.tsx | 24 ++-- src/library-authoring/LibraryHome.tsx | 12 +- src/library-authoring/LibraryLayout.tsx | 2 +- .../LibraryRecentlyModified.tsx | 23 ++-- .../add-content/AddContentContainer.test.tsx | 21 ++-- .../add-content/AddContentContainer.tsx | 4 +- .../LibraryCollectionComponents.tsx | 9 +- .../collections/LibraryCollectionPage.tsx | 10 +- .../collections/LibraryCollections.tsx | 6 +- src/library-authoring/common/context.tsx | 41 ++++--- .../components/CollectionCard.test.tsx | 8 +- .../components/CollectionCard.tsx | 5 +- .../components/ComponentCard.test.tsx | 92 +++++---------- .../components/ComponentCard.tsx | 4 +- .../components/LibraryComponents.test.tsx | 111 ++++-------------- .../components/LibraryComponents.tsx | 11 +- .../CreateCollectionModal.tsx | 13 +- .../library-sidebar/LibrarySidebar.tsx | 6 +- src/testUtils.tsx | 10 +- 20 files changed, 170 insertions(+), 246 deletions(-) diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index eea5ed732a..9470f0ad5c 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 edcfb5f150..816f13b1c4 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 abd2cf8ee6..56718cc5ef 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 4d5696ec05..4d66cb8801 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 f0f721c393..a67c4f9959 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 1d46aaacbb..fe88ba0cdc 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 5d870645c9..27ffd5639c 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 efcf749999..a96ed49cb6 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 97d194f4a3..d68e2b16cf 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 86b862e96a..772eaf17fb 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 92f2c8d981..b2c7242f22 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 c8114ec7e7..c10661230d 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 dae278eba7..1196c18207 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 39a23926fc..a1314544d7 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 ed490b92d6..84391ab2a3 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 c91dbad55a..7d5280663f 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 cc611b3a96..1b160ab0ee 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 a7ce2b5b5b..729484a071 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 a6cc43e647..73b2518f16 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) }); }