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/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/common/context.tsx b/src/library-authoring/common/context.tsx index ea3b2207fe..4027abd33c 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -1,6 +1,5 @@ import { useToggle } from '@openedx/paragon'; import React from 'react'; -import { useParams } from 'react-router-dom'; export enum SidebarBodyComponentId { AddContent = 'add-content', @@ -41,13 +40,7 @@ const LibraryContext = React.createContext(undef /** * React component to provide `LibraryContext` */ -export const LibraryProvider = (props: { children?: React.ReactNode }) => { - const { libraryId } = useParams(); - - if (libraryId === undefined) { - // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. - throw new Error('Error: route is missing libraryId.'); - } +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(); @@ -86,7 +79,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { }, []); const context = React.useMemo(() => ({ - libraryId, + libraryId: props.libraryId, sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar, @@ -99,7 +92,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { openCollectionInfoSidebar, currentCollectionId, }), [ - libraryId, + props.libraryId, sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar, 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/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/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/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) }); }