From 6255768c97de8507ce3e9fee6a4b10ab677e828b Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 10 Sep 2024 13:34:22 -0700 Subject: [PATCH] test: refactor and fix flakiness of LibraryAuthoringTest (#1263) --- .eslintrc.js | 2 + src/header/Header.tsx | 1 - .../LibraryAuthoringPage.test.tsx | 403 ++++++------------ .../LibraryAuthoringPage.tsx | 5 +- .../__mocks__/{index.js => index.ts} | 0 .../__mocks__/library-search.json | 273 ++++++++++++ ...onentsMock.js => libraryComponentsMock.ts} | 8 +- src/library-authoring/common/context.tsx | 1 - .../ComponentInfoHeader.test.tsx | 1 - .../components/LibrarySection.tsx | 1 - src/library-authoring/data/api.mocks.ts | 129 ++++++ src/library-authoring/data/api.test.ts | 89 ++-- src/library-authoring/data/api.ts | 16 +- src/library-authoring/data/apiHooks.ts | 5 +- src/search-manager/ClearFiltersButton.tsx | 1 - src/search-manager/FilterByTags.tsx | 1 - src/search-manager/SearchKeywordsField.tsx | 1 - src/search-manager/SearchManager.ts | 1 - src/search-modal/SearchModal.tsx | 1 - src/search-modal/SearchUI.tsx | 1 - src/studio-home/card-item/index.jsx | 1 - .../libraries-v2-filters/index.tsx | 1 - ...omyMenu.test.jsx => TaxonomyMenu.test.tsx} | 78 ++-- src/testUtils.tsx | 182 ++++++++ 24 files changed, 800 insertions(+), 402 deletions(-) rename src/library-authoring/__mocks__/{index.js => index.ts} (100%) create mode 100644 src/library-authoring/__mocks__/library-search.json rename src/library-authoring/__mocks__/{libraryComponentsMock.js => libraryComponentsMock.ts} (83%) create mode 100644 src/library-authoring/data/api.mocks.ts rename src/taxonomy/taxonomy-menu/{TaxonomyMenu.test.jsx => TaxonomyMenu.test.tsx} (84%) create mode 100644 src/testUtils.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 06e92c1d56..6fc94024bf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,8 @@ module.exports = createConfig( 'template-curly-spacing': 'off', 'react-hooks/exhaustive-deps': 'off', 'no-restricted-exports': 'off', + // There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers + 'no-restricted-syntax': 'off', }, settings: { // Import URLs should be resolved using aliases diff --git a/src/header/Header.tsx b/src/header/Header.tsx index 42dc5f0469..ca109018f0 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index f7b6544355..6a5cac0912 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -1,44 +1,24 @@ -import React from 'react'; -import MockAdapter from 'axios-mock-adapter'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import fetchMock from 'fetch-mock-jest'; import { fireEvent, + initializeMocks, render, - waitFor, screen, + waitFor, within, -} from '@testing-library/react'; -import fetchMock from 'fetch-mock-jest'; -import initializeStore from '../store'; +} from '../testUtils'; import { getContentSearchConfigUrl } from '../search-manager/data/api'; -import mockResult from '../search-modal/__mocks__/search-result.json'; +import mockResult from './__mocks__/library-search.json'; import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; -import { getContentLibraryApiUrl, getXBlockFieldsApiUrl, type ContentLibrary } from './data/api'; +import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './data/api.mocks'; import { LibraryLayout } from '.'; -let store; -const mockUseParams = jest.fn(); -let axiosMock; - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts - useParams: () => mockUseParams(), -})); +mockContentLibrary.applyMock(); +mockLibraryBlockTypes.applyMock(); +mockXBlockFields.applyMock(); const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - /** * Returns 0 components from the search query. */ @@ -75,37 +55,6 @@ const returnLowNumberResults = (_url, req) => { return newMockResult; }; -const libraryData: ContentLibrary = { - id: 'lib:org1:lib1', - type: 'complex', - org: 'org1', - slug: 'lib1', - title: 'lib1', - description: 'lib1', - numBlocks: 2, - version: 0, - lastPublished: null, - lastDraftCreated: '2024-07-22', - publishedBy: 'staff', - lastDraftCreatedBy: 'staff', - allowLti: false, - allowPublicLearning: false, - allowPublicRead: false, - hasUnpublishedChanges: true, - hasUnpublishedDeletes: false, - canEditLibrary: true, - license: '', - created: '2024-06-26', - updated: '2024-07-20', -}; - -const xBlockFields = { - display_name: 'Test HTML Block', - metadata: { - display_name: 'Test HTML Block', - }, -}; - const clipboardBroadcastChannelMock = { postMessage: jest.fn(), close: jest.fn(), @@ -113,32 +62,14 @@ const clipboardBroadcastChannelMock = { (global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); -const RootWrapper = () => ( - - - - - - - -); +const path = '/library/:libraryId/*'; +const libraryTitle = mockContentLibrary.libraryData.title; describe('', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - mockUseParams.mockReturnValue({ libraryId: '1' }); + const { axiosMock } = initializeMocks(); // 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', @@ -162,179 +93,124 @@ describe('', () => { afterEach(() => { jest.clearAllMocks(); - axiosMock.restore(); fetchMock.mockReset(); - queryClient.clear(); }); const renderLibraryPage = async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); - - const result = render(); + render(, { path, params: { libraryId: mockContentLibrary.libraryId } }); // Ensure the search endpoint is called: // Call 1: To fetch searchable/filterable/sortable library data await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - - return result; }; it('shows the spinner before the query is complete', () => { - mockUseParams.mockReturnValue({ libraryId: '1' }); - // @ts-ignore Use unresolved promise to keep the Loading visible - axiosMock.onGet(getContentLibraryApiUrl('1')).reply(() => new Promise()); - const { getByRole } = render(); - const spinner = getByRole('status'); + // This mock will never return data about the library (it loads forever): + const libraryId = mockContentLibrary.libraryIdThatNeverLoads; + render(, { path, params: { libraryId } }); + const spinner = screen.getByRole('status'); expect(spinner.textContent).toEqual('Loading...'); }); it('shows an error component if no library returned', async () => { - mockUseParams.mockReturnValue({ libraryId: 'invalid' }); - axiosMock.onGet(getContentLibraryApiUrl('invalid')).reply(400); - - const { findByTestId } = render(); - - expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); + // This mock will simulate a 404 error: + const libraryId = mockContentLibrary.library404; + render(, { path, params: { libraryId } }); + expect(await screen.findByTestId('notFoundAlert')).toBeInTheDocument(); }); - it('shows an error component if no library param', async () => { - mockUseParams.mockReturnValue({ libraryId: '' }); - - const { findByTestId } = render(); - - expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); - }); - - it('show library data', async () => { - const { - getByRole, getAllByText, getByText, queryByText, findByText, findAllByText, - } = await renderLibraryPage(); - - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + it('shows library data', async () => { + await renderLibraryPage(); - expect(await findByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); - expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); // "Recently Modified" header + sort shown - expect(getAllByText('Recently Modified').length).toEqual(2); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); - expect((await findAllByText('Test HTML Block'))[0]).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); + expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument(); // Navigate to the components tab - fireEvent.click(getByRole('tab', { name: 'Components' })); + fireEvent.click(screen.getByRole('tab', { name: 'Components' })); // "Recently Modified" default sort shown - expect(getAllByText('Recently Modified').length).toEqual(1); - expect(queryByText('Collections (0)')).not.toBeInTheDocument(); - expect(queryByText('Components (6)')).not.toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); // Navigate to the collections tab - fireEvent.click(getByRole('tab', { name: 'Collections' })); + fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); // "Recently Modified" default sort shown - expect(getAllByText('Recently Modified').length).toEqual(1); - expect(queryByText('Collections (0)')).not.toBeInTheDocument(); - expect(queryByText('Components (6)')).not.toBeInTheDocument(); - expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument(); - expect(getByText('Coming soon!')).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); + expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument(); + expect(screen.getByText('Coming soon!')).toBeInTheDocument(); // Go back to Home tab // This step is necessary to avoid the url change leak to other tests - fireEvent.click(getByRole('tab', { name: 'Home' })); + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); // "Recently Modified" header + sort shown - expect(getAllByText('Recently Modified').length).toEqual(2); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); - }); - - it('show library without components', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); - fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - - const { findByText, getByText, findAllByText } = render(); - - expect(await findByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); - - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - - expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); }); - it('show library without components without permission', async () => { - const data = { - ...libraryData, - canEditLibrary: false, - }; - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, data); + it('shows a library without components', async () => { fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - - render(); + await renderLibraryPage(); expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument(); }); - it('show new content button', async () => { + it('shows the new content button', async () => { await renderLibraryPage(); expect(await screen.findByRole('heading')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); + expect(screen.queryByText('Read Only')).not.toBeInTheDocument(); }); - it('read only state of library', async () => { - const data = { - ...libraryData, - canEditLibrary: false, - }; - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, data); - - render(); - expect(await screen.findByRole('heading')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument(); + it('shows an empty read-only library, without a "create component" button', async () => { + // Use a library mock that is read-only: + const libraryId = mockContentLibrary.libraryIdReadOnly; + // Update search mock so it returns no results: + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + render(, { path, params: { libraryId } }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument(); expect(screen.getByText('Read Only')).toBeInTheDocument(); }); - it('show library without search results', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + it('show a library without search results', async () => { + // Update search mock so it returns no results: fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + await renderLibraryPage(); - const { - findByText, - getByRole, - getByText, - findAllByText, - } = render(); - - expect(await findByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'noresults' } }); // Ensure the search endpoint is called again, only once more since the recently modified call // should not be impacted by the search await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); - expect(getByText('No matching components found in this library.')).toBeInTheDocument(); + expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument(); // Navigate to the components tab - fireEvent.click(getByRole('tab', { name: 'Components' })); - expect(getByText('No matching components found in this library.')).toBeInTheDocument(); - - // Go back to Home tab - // This step is necessary to avoid the url change leak to other tests - fireEvent.click(getByRole('tab', { name: 'Home' })); + fireEvent.click(screen.getByRole('tab', { name: 'Components' })); + expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument(); }); it('should open and close new content sidebar', async () => { @@ -358,15 +234,18 @@ describe('', () => { await renderLibraryPage(); expect(await screen.findByText('Content library')).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[1]).toBeInTheDocument(); expect(screen.getByText('Draft')).toBeInTheDocument(); expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + // Draft saved on date: expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); - expect(screen.getByText('staff')).toBeInTheDocument(); - expect(screen.getByText(libraryData.org)).toBeInTheDocument(); + + expect(screen.getByText(mockContentLibrary.libraryData.org)).toBeInTheDocument(); + // Updated: expect(screen.getByText('July 20, 2024')).toBeInTheDocument(); + // Created: expect(screen.getByText('June 26, 2024')).toBeInTheDocument(); }); @@ -374,8 +253,8 @@ describe('', () => { await renderLibraryPage(); expect(await screen.findByText('Content library')).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); - expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[1]).toBeInTheDocument(); // Open by default; close the library info sidebar const closeButton = screen.getByRole('button', { name: /close/i }); @@ -389,90 +268,79 @@ describe('', () => { expect(screen.getByText('Draft')).toBeInTheDocument(); expect(screen.getByText('(Never Published)')).toBeInTheDocument(); - // CLose library info sidebar with 'Library info' button + // Close library info sidebar with 'Library info' button fireEvent.click(libraryInfoButton); expect(screen.queryByText('Draft')).not.toBeInTheDocument(); expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument(); }); it('show the "View All" button when viewing library with many components', async () => { - const { - getByRole, getByText, queryByText, getAllByText, findAllByText, - } = await renderLibraryPage(); + await renderLibraryPage(); - expect(getByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect(screen.getByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); // "Recently Modified" header + sort shown - await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); }); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); - expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); - expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); }); + expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); + expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); + expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); // There should only be one "View All" button, since the Components count // are above the preview limit (4) - expect(getByText('View All')).toBeInTheDocument(); + expect(screen.getByText('View All')).toBeInTheDocument(); // Clicking on "View All" button should navigate to the Components tab - fireEvent.click(getByText('View All')); + fireEvent.click(screen.getByText('View All')); // "Recently Modified" default sort shown - expect(getAllByText('Recently Modified').length).toEqual(1); - expect(queryByText('Collections (0)')).not.toBeInTheDocument(); - expect(queryByText('Components (6)')).not.toBeInTheDocument(); - expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); + expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); // Go back to Home tab // This step is necessary to avoid the url change leak to other tests - fireEvent.click(getByRole('tab', { name: 'Home' })); + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); // "Recently Modified" header + sort shown - expect(getAllByText('Recently Modified').length).toEqual(2); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (6)')).toBeInTheDocument(); + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Components (10)')).toBeInTheDocument(); }); it('should not show the "View All" button when viewing library with low number of components', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true }); + await renderLibraryPage(); - const { - getByText, queryByText, getAllByText, findAllByText, - } = render(); - - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - - expect(getByText('Content library')).toBeInTheDocument(); - expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect(screen.getByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); // "Recently Modified" header + sort shown - await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); }); - expect(getByText('Collections (0)')).toBeInTheDocument(); - expect(getByText('Components (2)')).toBeInTheDocument(); - expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); - expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); }); + expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Components (2)')).toBeInTheDocument(); + expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); + expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); // There should not be any "View All" button on page since Components count // is less than the preview limit (4) - expect(queryByText('View All')).not.toBeInTheDocument(); + expect(screen.queryByText('View All')).not.toBeInTheDocument(); }); - it('sort library components', async () => { - const { - findByTitle, getAllByText, getByRole, getByTitle, - } = await renderLibraryPage(); + it('sorts library components', async () => { + await renderLibraryPage(); - expect(await findByTitle('Sort search results')).toBeInTheDocument(); + expect(await screen.findByTitle('Sort search results')).toBeInTheDocument(); const testSortOption = (async (optionText, sortBy, isDefault) => { // Open the drop-down menu - fireEvent.click(getByTitle('Sort search results')); + fireEvent.click(screen.getByTitle('Sort search results')); // Click the option with the given text // Since the sort drop-down also shows the selected sort // option in its toggle button, we need to make sure we're // clicking on the last one found. - const options = getAllByText(optionText); + const options = screen.getAllByText(optionText); expect(options.length).toBeGreaterThan(0); fireEvent.click(options[options.length - 1]); @@ -487,12 +355,13 @@ describe('', () => { }); // Is the sort option stored in the query string? - const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`; - expect(window.location.search).toEqual(searchText); + // Note: we can't easily check this at the moment with + // const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`; + // expect(window.location.href).toEqual(searchText); // Is the selected sort option shown in the toggle button (if not default) // as well as in the drop-down menu? - expect(getAllByText(optionText).length).toEqual(isDefault ? 1 : 2); + expect(screen.getAllByText(optionText).length).toEqual(isDefault ? 1 : 2); }); await testSortOption('Title, A-Z', 'display_name:asc', false); @@ -512,14 +381,14 @@ describe('', () => { // Re-selecting the previous sort option resets sort to default "Recently Modified" await testSortOption('Recently Published', 'modified:desc', true); - expect(getAllByText('Recently Modified').length).toEqual(3); + expect(screen.getAllByText('Recently Modified').length).toEqual(3); // Enter a keyword into the search box - const searchBox = getByRole('searchbox'); + const searchBox = screen.getByRole('searchbox'); fireEvent.change(searchBox, { target: { value: 'words to find' } }); // Default sort option changes to "Most Relevant" - expect(getAllByText('Most Relevant').length).toEqual(2); + expect(screen.getAllByText('Most Relevant').length).toEqual(2); await waitFor(() => { expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { body: expect.stringContaining('"sort":[]'), @@ -530,30 +399,28 @@ describe('', () => { }); it('should open and close the component sidebar', async () => { - const usageKey = mockResult.results[0].hits[0].usage_key; - const { getAllByText, queryByTestId, queryByText } = await renderLibraryPage(); - axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields); + const mockResult0 = mockResult.results[0].hits[0]; + const displayName = 'Introduction to Testing'; + expect(mockResult0.display_name).toStrictEqual(displayName); + await renderLibraryPage(); // Click on the first component - waitFor(() => expect(queryByText('Test HTML Block')).toBeInTheDocument()); - fireEvent.click(getAllByText('Test HTML Block')[0]); + waitFor(() => expect(screen.queryByText(displayName)).toBeInTheDocument()); + fireEvent.click(screen.getAllByText(displayName)[0]); const sidebar = screen.getByTestId('library-sidebar'); const { getByRole, getByText } = within(sidebar); - await waitFor(() => expect(getByText('Test HTML Block')).toBeInTheDocument()); + await waitFor(() => expect(getByText(displayName)).toBeInTheDocument()); const closeButton = getByRole('button', { name: /close/i }); fireEvent.click(closeButton); - await waitFor(() => expect(queryByTestId('library-sidebar')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); - it('filter by capa problem type', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); - + it('can filter by capa problem type', async () => { const problemTypes = { 'Multiple Choice': 'choiceresponse', Checkboxes: 'multiplechoiceresponse', @@ -562,15 +429,18 @@ describe('', () => { 'Text Input': 'stringresponse', }; - render(); + await renderLibraryPage(); // Ensure the search endpoint is called await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); const filterButton = screen.getByRole('button', { name: /type/i }); fireEvent.click(filterButton); - const openProblemItem = screen.getByTestId('open-problem-item-button'); - fireEvent.click(openProblemItem); + const problemFilterCheckbox = screen.getByRole('checkbox', { name: /problem/i }); + const problemFilterMenuItem = problemFilterCheckbox.parentElement; // div.pgn__menu-item + const showProbTypesSubmenuBtn = problemFilterMenuItem!.querySelector('button[aria-label="Open problem types filters"]'); + expect(showProbTypesSubmenuBtn).not.toBeNull(); + fireEvent.click(showProbTypesSubmenuBtn!); const validateSubmenu = async (submenuText : string) => { const submenu = screen.getByText(submenuText); @@ -639,14 +509,9 @@ describe('', () => { }); }); - it('empty type filter', async () => { - mockUseParams.mockReturnValue({ libraryId: libraryData.id }); - axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + it('has an empty type filter when there are no results', async () => { fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - - render(); - - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + await renderLibraryPage(); const filterButton = screen.getByRole('button', { name: /type/i }); fireEvent.click(filterButton); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 5f8c144d34..7ec5e63a8a 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -119,6 +119,9 @@ const LibraryAuthoringPage = () => { const navigate = useNavigate(); const { libraryId } = useParams(); + if (!libraryId) { + throw new Error('Rendered without libraryId URL parameter'); + } const { data: libraryData, isLoading } = useContentLibrary(libraryId); const currentPath = location.pathname.split('/').pop(); @@ -138,7 +141,7 @@ const LibraryAuthoringPage = () => { return ; } - if (!libraryId || !libraryData) { + if (!libraryData) { return ; } diff --git a/src/library-authoring/__mocks__/index.js b/src/library-authoring/__mocks__/index.ts similarity index 100% rename from src/library-authoring/__mocks__/index.js rename to src/library-authoring/__mocks__/index.ts diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json new file mode 100644 index 0000000000..c800f368ad --- /dev/null +++ b/src/library-authoring/__mocks__/library-search.json @@ -0,0 +1,273 @@ +{ + "comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts", + "note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.", + "results": [ + { + "indexUid": "studio_content", + "hits": [ + { + "id": "lbaximtesthtml571fe018-f3ce-45c9-8f53-5dafcb422fdd-273ebd90", + "display_name": "Introduction to Testing", + "block_id": "571fe018-f3ce-45c9-8f53-5dafcb422fdd", + "content": { + "html_content": "This is a text component which uses HTML." + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1721857069.042984, + "modified": 1725398676.078056, + "last_published": 1725035862.450613, + "usage_key": "lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2", + "display_name": "Second Text Component", + "block_id": "73a22298-bcd9-4f4c-ae34-0bc2b0612480", + "content": { + "html_content": "Preview of the second text component here" + }, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1724879593.066427, + "modified": 1725034981.663482, + "last_published": 1725035862.450613, + "usage_key": "lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95", + "display_name": "Third Text component", + "block_id": "be5b5db9-26ba-4fac-86af-654538c70b5e", + "content": { + "html_content": "This is a text component that I've edited within the library. " + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1721857034.455737, + "modified": 1722551300.377488, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:html:be5b5db9-26ba-4fac-86af-654538c70b5e", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2", + "display_name": "Text 4", + "block_id": "e59e8c73-4056-4894-bca4-062781fb3f68", + "content": { + "html_content": "" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774228.49832, + "modified": 1720774228.49832, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:html:e59e8c73-4056-4894-bca4-062781fb3f68", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115", + "display_name": "Blank Problem", + "block_id": "f16116c9-516e-4bb9-b99e-103599f62417", + "content": { + "problem_types": [], + "capa_content": " " + }, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1724725821.973896, + "modified": 1724725821.973896, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:f16116c9-516e-4bb9-b99e-103599f62417", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblem2ace6b9b-6620-413c-a66f-19c797527f34-3a7973b7", + "display_name": "Multiple Choice Problem", + "block_id": "2ace6b9b-6620-413c-a66f-19c797527f34", + "content": { + "problem_types": ["multiplechoiceresponse"], + "capa_content": "What is the gradient of an inverted hyperspace manifold?cos (x) ey ln(z) i + sin(x)ey ln(z)j + sin(x) ey(1/z)k " + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:2ace6b9b-6620-413c-a66f-19c797527f34", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblem7d7e98ba-3ac9-4aa8-8946-159129b39a28-3a7973b7", + "display_name": "Single Choice Problem", + "block_id": "7d7e98ba-3ac9-4aa8-8946-159129b39a28", + "content": { + "problem_types": ["choiceresponse"], + "capa_content": "Blah blah?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:7d7e98ba-3ac9-4aa8-8946-159129b39a28", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblem4e1a72f9-ac93-42aa-a61c-ab5f9698c398-3a7973b7", + "display_name": "Numerical Response Problem", + "block_id": "4e1a72f9-ac93-42aa-a61c-ab5f9698c398", + "content": { + "problem_types": ["numericalresponse"], + "capa_content": "What is 1 + 1?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:4e1a72f9-ac93-42aa-a61c-ab5f9698c398", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblemad483625-ade2-4712-88d8-c9743abbd291-3a7973b7", + "display_name": "Option Response Problem", + "block_id": "ad483625-ade2-4712-88d8-c9743abbd291", + "content": { + "problem_types": ["optionresponse"], + "capa_content": "What is foobar?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:ad483625-ade2-4712-88d8-c9743abbd291", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblemb4c859cb-de70-421a-917b-e6e01ce44bd8-3a7973b7", + "display_name": "String Response Problem", + "block_id": "b4c859cb-de70-421a-917b-e6e01ce44bd8", + "content": { + "problem_types": ["stringresponse"], + "capa_content": "What is your name?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:b4c859cb-de70-421a-917b-e6e01ce44bd8", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + } + ], + "query": "", + "processingTimeMs": 1, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 10 + }, + { + "indexUid": "studio_content", + "hits": [], + "query": "", + "processingTimeMs": 0, + "limit": 0, + "offset": 0, + "estimatedTotalHits": 10, + "facetDistribution": { + "block_type": { + "html": 4, + "problem": 6 + }, + "content.problem_types": { + "multiplechoiceresponse": 1, + "choiceresponse": 1, + "numericalresponse": 1, + "optionresponse": 1, + "stringresponse": 1 + } + }, + "facetStats": {} + } + ] +} \ No newline at end of file diff --git a/src/library-authoring/__mocks__/libraryComponentsMock.js b/src/library-authoring/__mocks__/libraryComponentsMock.ts similarity index 83% rename from src/library-authoring/__mocks__/libraryComponentsMock.js rename to src/library-authoring/__mocks__/libraryComponentsMock.ts index 8f3dfa2a7f..6e1db6a719 100644 --- a/src/library-authoring/__mocks__/libraryComponentsMock.js +++ b/src/library-authoring/__mocks__/libraryComponentsMock.ts @@ -1,6 +1,7 @@ -module.exports = [ +export default [ { id: '1', + usageKey: 'lb:org:lib:html:1', displayName: 'Text', formatted: { content: { @@ -14,6 +15,7 @@ module.exports = [ }, { id: '2', + usageKey: 'lb:org:lib:html:2', displayName: 'Text', formatted: { content: { @@ -27,6 +29,7 @@ module.exports = [ }, { id: '3', + usageKey: 'lb:org:lib:video:3', displayName: 'Video', formatted: { content: { @@ -40,6 +43,7 @@ module.exports = [ }, { id: '4', + usageKey: 'lb:org:lib:video:4', displayName: 'Video', formatted: { content: { @@ -53,6 +57,7 @@ module.exports = [ }, { id: '5', + usageKey: 'lb:org:lib:problem:5', displayName: 'Problem', formatted: { content: { @@ -63,6 +68,7 @@ module.exports = [ }, { id: '6', + usageKey: 'lb:org:lib:problem:6', displayName: 'Problem', formatted: { content: { diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index bf7f98e982..8b11d61d1b 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; export enum SidebarBodyComponentId { diff --git a/src/library-authoring/component-info/ComponentInfoHeader.test.tsx b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx index a66b57b56e..b0033f32ae 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.test.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import MockAdapter from 'axios-mock-adapter'; import { IntlProvider } from '@edx/frontend-platform/i18n'; diff --git a/src/library-authoring/components/LibrarySection.tsx b/src/library-authoring/components/LibrarySection.tsx index 66fe604ac6..14b6f8c592 100644 --- a/src/library-authoring/components/LibrarySection.tsx +++ b/src/library-authoring/components/LibrarySection.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { Card, ActionRow, Button } from '@openedx/paragon'; diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts new file mode 100644 index 0000000000..863d25607c --- /dev/null +++ b/src/library-authoring/data/api.mocks.ts @@ -0,0 +1,129 @@ +/* istanbul ignore file */ +import { createAxiosError } from '../../testUtils'; +import * as api from './api'; + +/** + * Mock for `getLibraryBlockTypes()` + */ +export async function mockLibraryBlockTypes(): Promise { + return [ + { blockType: 'about', displayName: 'overview' }, + { blockType: 'annotatable', displayName: 'Annotation' }, + { blockType: 'chapter', displayName: 'Section' }, + { blockType: 'conditional', displayName: 'Conditional' }, + { blockType: 'course', displayName: 'Empty' }, + { blockType: 'course_info', displayName: 'Text' }, + { blockType: 'discussion', displayName: 'Discussion' }, + { blockType: 'done', displayName: 'Completion' }, + { blockType: 'drag-and-drop-v2', displayName: 'Drag and Drop' }, + { blockType: 'edx_sga', displayName: 'Staff Graded Assignment' }, + { blockType: 'google-calendar', displayName: 'Google Calendar' }, + { blockType: 'google-document', displayName: 'Google Document' }, + { blockType: 'html', displayName: 'Text' }, + { blockType: 'library', displayName: 'Library' }, + { blockType: 'library_content', displayName: 'Randomized Content Block' }, + { blockType: 'lti', displayName: 'LTI' }, + { blockType: 'lti_consumer', displayName: 'LTI Consumer' }, + { blockType: 'openassessment', displayName: 'Open Response Assessment' }, + { blockType: 'poll', displayName: 'Poll' }, + { blockType: 'problem', displayName: 'Problem' }, + { blockType: 'scorm', displayName: 'Scorm module' }, + { blockType: 'sequential', displayName: 'Subsection' }, + { blockType: 'split_test', displayName: 'Content Experiment' }, + { blockType: 'staffgradedxblock', displayName: 'Staff Graded Points' }, + { blockType: 'static_tab', displayName: 'Empty' }, + { blockType: 'survey', displayName: 'Survey' }, + { blockType: 'thumbs', displayName: 'Thumbs' }, + { blockType: 'unit', displayName: 'Unit' }, + { blockType: 'vertical', displayName: 'Unit' }, + { blockType: 'video', displayName: 'Video' }, + { blockType: 'videoalpha', displayName: 'Video' }, + { blockType: 'word_cloud', displayName: 'Word cloud' }, + ]; +} +mockLibraryBlockTypes.applyMock = () => { + jest.spyOn(api, 'getLibraryBlockTypes').mockImplementation(mockLibraryBlockTypes); +}; + +/** + * Mock for `getContentLibrary()` + * + * This mock returns different data/responses depending on the ID of the library + * that you request. + */ +export async function mockContentLibrary(libraryId: string): Promise { + // This mock has many different behaviors, depending on the library ID: + switch (libraryId) { + case mockContentLibrary.libraryIdThatNeverLoads: + // Return a promise that never resolves, to simulate never loading: + return new Promise(() => {}); + case mockContentLibrary.library404: + throw createAxiosError({ code: 400, message: 'Not found.', path: api.getContentLibraryApiUrl(libraryId) }); + case mockContentLibrary.library500: + throw createAxiosError({ code: 500, message: 'Internal Error.', path: api.getContentLibraryApiUrl(libraryId) }); + case mockContentLibrary.libraryId: + return mockContentLibrary.libraryData; + case mockContentLibrary.libraryIdReadOnly: + return { + ...mockContentLibrary.libraryData, + id: mockContentLibrary.libraryIdReadOnly, + slug: 'readOnly', + allowPublicRead: true, + canEditLibrary: false, + }; + default: + throw new Error(`mockContentLibrary: unknown library ID "${libraryId}"`); + } +} +mockContentLibrary.libraryId = 'lib:Axim:TEST'; +mockContentLibrary.libraryData = { + // This is captured from a real API response: + id: mockContentLibrary.libraryId, + type: 'complex', // 'type' is a deprecated field; don't use it. + org: 'Axim', + slug: 'TEST', + title: 'Test Library', + description: 'A library for testing', + numBlocks: 10, + version: 18, + lastPublished: null, // or e.g. '2024-08-30T16:37:42Z', + publishedBy: null, // or e.g. 'test_author', + lastDraftCreated: '2024-07-22T21:37:49Z', + lastDraftCreatedBy: null, + allowLti: false, + allowPublicLearning: false, + allowPublicRead: false, + hasUnpublishedChanges: true, + hasUnpublishedDeletes: false, + license: '', + canEditLibrary: true, + created: '2024-06-26T14:19:59Z', + updated: '2024-07-20T17:36:51Z', +} satisfies api.ContentLibrary; +mockContentLibrary.libraryIdReadOnly = 'lib:Axim:readOnly'; +mockContentLibrary.libraryIdThatNeverLoads = 'lib:Axim:infiniteLoading'; +mockContentLibrary.library404 = 'lib:Axim:error404'; +mockContentLibrary.library500 = 'lib:Axim:error500'; +mockContentLibrary.applyMock = () => { jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary); }; + +/** + * Mock for `getXBlockFields()` + * + * This mock returns different data/responses depending on the ID of the block + * that you request. Use `mockXBlockFields.applyMock()` to apply it to the whole + * test suite. + */ +export async function mockXBlockFields(usageKey: string): Promise { + const thisMock = mockXBlockFields; + switch (usageKey) { + case thisMock.usageKeyHtml: return thisMock.dataHtml; + default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`); + } +} +mockXBlockFields.usageKeyHtml = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'; +mockXBlockFields.dataHtml = { + displayName: 'Introduction to Testing', + data: '

This is a text component which uses HTML.

', + metadata: { displayName: 'Introduction to Testing' }, +} satisfies api.XBlockFields; +mockXBlockFields.applyMock = () => { jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields); }; diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index 557488900d..36200ff91c 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -1,65 +1,46 @@ -import MockAdapter from 'axios-mock-adapter'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { - commitLibraryChanges, - createLibraryBlock, - getCommitLibraryChangesUrl, - getCreateLibraryBlockUrl, - revertLibraryChanges, -} from './api'; - -let axiosMock; - -describe('library api calls', () => { - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, +import { initializeMocks } from '../../testUtils'; +import * as api from './api'; + +describe('library data API', () => { + describe('createLibraryBlock', () => { + it('should create library block', async () => { + const { axiosMock } = initializeMocks(); + const libraryId = 'lib:org:1'; + const url = api.getCreateLibraryBlockUrl(libraryId); + axiosMock.onPost(url).reply(200); + await api.createLibraryBlock({ + libraryId, + blockType: 'html', + definitionId: '1', + }); + + expect(axiosMock.history.post[0].url).toEqual(url); }); - - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); - afterEach(() => { - jest.clearAllMocks(); - axiosMock.restore(); - }); + describe('commitLibraryChanges', () => { + it('should commit library changes', async () => { + const { axiosMock } = initializeMocks(); + const libraryId = 'lib:org:1'; + const url = api.getCommitLibraryChangesUrl(libraryId); + axiosMock.onPost(url).reply(200); - it('should create library block', async () => { - const libraryId = 'lib:org:1'; - const url = getCreateLibraryBlockUrl(libraryId); - axiosMock.onPost(url).reply(200); - await createLibraryBlock({ - libraryId, - blockType: 'html', - definitionId: '1', - }); + await api.commitLibraryChanges(libraryId); - expect(axiosMock.history.post[0].url).toEqual(url); - }); - - it('should commit library changes', async () => { - const libraryId = 'lib:org:1'; - const url = getCommitLibraryChangesUrl(libraryId); - axiosMock.onPost(url).reply(200); - - await commitLibraryChanges(libraryId); - - expect(axiosMock.history.post[0].url).toEqual(url); + expect(axiosMock.history.post[0].url).toEqual(url); + }); }); - it('should revert library changes', async () => { - const libraryId = 'lib:org:1'; - const url = getCommitLibraryChangesUrl(libraryId); - axiosMock.onDelete(url).reply(200); + describe('revertLibraryChanges', () => { + it('should revert library changes', async () => { + const { axiosMock } = initializeMocks(); + const libraryId = 'lib:org:1'; + const url = api.getCommitLibraryChangesUrl(libraryId); + axiosMock.onDelete(url).reply(200); - await revertLibraryChanges(libraryId); + await api.revertLibraryChanges(libraryId); - expect(axiosMock.history.delete[0].url).toEqual(url); + expect(axiosMock.history.delete[0].url).toEqual(url); + }); }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index e622e6addf..323f57de93 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -8,7 +8,7 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; */ export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`; /** - * Get the URL for get block types of library. + * Get the URL for getting block types of a library (what types can be created). */ export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/block_types/`; /** @@ -128,13 +128,9 @@ export interface UpdateXBlockFieldsRequest { } /** - * Fetch block types of a library + * Fetch the list of XBlock types that can be added to this library */ -export async function getLibraryBlockTypes(libraryId?: string): Promise { - if (!libraryId) { - throw new Error('libraryId is required'); - } - +export async function getLibraryBlockTypes(libraryId: string): Promise { const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId)); return camelCaseObject(data); } @@ -142,11 +138,7 @@ export async function getLibraryBlockTypes(libraryId?: string): Promise { - if (!libraryId) { - throw new Error('libraryId is required'); - } - +export async function getContentLibrary(libraryId: string): Promise { const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId)); return camelCaseObject(data); } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 2ebed19ff9..a48832554f 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -64,10 +64,11 @@ export const libraryAuthoringQueryKeys = { /** * Hook to fetch a content library by its ID. */ -export const useContentLibrary = (libraryId?: string) => ( +export const useContentLibrary = (libraryId: string | undefined) => ( useQuery({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId), - queryFn: () => getContentLibrary(libraryId), + queryFn: () => getContentLibrary(libraryId!), + enabled: libraryId !== undefined, }) ); diff --git a/src/search-manager/ClearFiltersButton.tsx b/src/search-manager/ClearFiltersButton.tsx index 0aca013741..0328d38616 100644 --- a/src/search-manager/ClearFiltersButton.tsx +++ b/src/search-manager/ClearFiltersButton.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; diff --git a/src/search-manager/FilterByTags.tsx b/src/search-manager/FilterByTags.tsx index 8535ab4485..9559ff0f28 100644 --- a/src/search-manager/FilterByTags.tsx +++ b/src/search-manager/FilterByTags.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { diff --git a/src/search-manager/SearchKeywordsField.tsx b/src/search-manager/SearchKeywordsField.tsx index 78bb3d9cd6..953cd7799d 100644 --- a/src/search-manager/SearchKeywordsField.tsx +++ b/src/search-manager/SearchKeywordsField.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { SearchField } from '@openedx/paragon'; diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index 76361c0924..d980a851d5 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ /** * This is a search manager that provides search functionality similar to the * Instantsearch library. We use it because Instantsearch doesn't support diff --git a/src/search-modal/SearchModal.tsx b/src/search-modal/SearchModal.tsx index ca143df51f..2e552fb6e8 100644 --- a/src/search-modal/SearchModal.tsx +++ b/src/search-modal/SearchModal.tsx @@ -5,7 +5,6 @@ import { ModalDialog } from '@openedx/paragon'; import messages from './messages'; import SearchUI from './SearchUI'; -// eslint-disable-next-line react/require-default-props const SearchModal: React.FC<{ courseId?: string, isOpen: boolean, onClose: () => void }> = ({ courseId, ...props }) => { const intl = useIntl(); const title = intl.formatMessage(messages.title); diff --git a/src/search-modal/SearchUI.tsx b/src/search-modal/SearchUI.tsx index 8318b84d58..2df074111c 100644 --- a/src/search-modal/SearchUI.tsx +++ b/src/search-modal/SearchUI.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { MenuItem, diff --git a/src/studio-home/card-item/index.jsx b/src/studio-home/card-item/index.jsx index 0ed8b178b2..11f9327375 100644 --- a/src/studio-home/card-item/index.jsx +++ b/src/studio-home/card-item/index.jsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx index 0af40ddf92..c071c59efa 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import React, { useState, useCallback, useEffect } from 'react'; import { SearchField } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.tsx similarity index 84% rename from src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx rename to src/taxonomy/taxonomy-menu/TaxonomyMenu.test.tsx index 6272c12da5..4c6f3d31fc 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.tsx @@ -1,19 +1,15 @@ -// @ts-check -/* eslint-disable react/prop-types */ -// ^ eslint doesn't 'see' JSDoc types; remove this lint directive when converting this to .tsx import React, { useMemo } from 'react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { + fireEvent, + initializeMocks, + render, + waitFor, +} from '../../testUtils'; import { TaxonomyContext } from '../common/context'; -import initializeStore from '../../store'; import { deleteTaxonomy, getTaxonomy, getTaxonomyExportFile } from '../data/api'; import { TaxonomyMenu } from '.'; -let store; const taxonomyId = 1; const taxonomyName = 'Taxonomy 1'; @@ -24,19 +20,17 @@ jest.mock('../data/api', () => ({ getTaxonomy: jest.fn(), })); -const queryClient = new QueryClient(); - const mockSetToastMessage = jest.fn(); /** - * @type {React.FC<{ - * iconMenu: boolean, - * systemDefined?: boolean, - * canChangeTaxonomy?: boolean, - * canDeleteTaxonomy?: boolean, - * }>} + * @type {} */ -const TaxonomyMenuComponent = ({ +const TaxonomyMenuComponent: React.FC<{ + iconMenu: boolean, + systemDefined?: boolean, + canChangeTaxonomy?: boolean, + canDeleteTaxonomy?: boolean, +}> = ({ iconMenu, systemDefined = false, canChangeTaxonomy = true, @@ -50,43 +44,25 @@ const TaxonomyMenuComponent = ({ }), []); return ( - - - - - - - - - + + + ); }; describe.each([true, false])('', (iconMenu) => { beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - }); - - afterEach(() => { - jest.clearAllMocks(); + initializeMocks(); }); test('should open and close menu on button click', () => { diff --git a/src/testUtils.tsx b/src/testUtils.tsx new file mode 100644 index 0000000000..d306dfb1a7 --- /dev/null +++ b/src/testUtils.tsx @@ -0,0 +1,182 @@ +/* istanbul ignore file */ +/* eslint-disable react/prop-types */ +/* eslint-disable import/no-extraneous-dependencies */ +/** + * Helper functions for writing tests. + */ +import React from 'react'; +import { AxiosError } from 'axios'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, type RenderResult } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import { + MemoryRouter, + MemoryRouterProps, + Route, + Routes, +} from 'react-router-dom'; + +import initializeReduxStore from './store'; + +/** @deprecated Use React Query and/or regular React Context instead of redux */ +let reduxStore; +let queryClient; +let axiosMock: MockAdapter; + +export interface RouteOptions { + /** The URL path, like '/libraries/:libraryId' */ + path?: string; + /** The URL parameters, like {libraryId: 'lib:org:123'} */ + params?: Record; + /** and/or instead of specifying path and params, specify MemoryRouterProps */ + routerProps?: MemoryRouterProps; +} + +/** + * This component works together with the custom `render()` method we have in + * this file to provide whatever react-router context you need for your + * component. + * + * In the simplest case, you don't need to worry about the router at all, so + * just render your component using `render()`. + * + * The next simplest way to use it is to specify `path` (the route matching rule + * that is normally used to determine when to show the component or its parent + * page) and `params` like this: + * + * ``` + * render(, { path: '/library/:libraryId/*', params: { libraryId: 'lib:Axim:testlib' } }); + * ``` + * + * In this case, components that use the `useParams` hook will get the right + * library ID, and we don't even have to mock anything. + * + * In other cases, such as when you have routes inside routes, you'll need to + * set the router's `initialEntries` (URL history) prop yourself, like this: + * + * ``` + * render(, { + * path: '/library/:libraryId/*', + * // The root component is mounted on the above path, as it is in the "real" + * // MFE. But to access the 'settings' sub-route/component for this test, we + * // need tospecify the URL like this: + * routerProps: { initialEntries: [`/library/${libraryId}/settings`] }, + * }); + * ``` + */ +const RouterAndRoute: React.FC = ({ + children, + path = '/', + params = {}, + routerProps = {}, +}) => { + if (Object.entries(params).length > 0 || path !== '/') { + const newRouterProps = { ...routerProps }; + if (!routerProps.initialEntries) { + // Substitute the params into the URL so '/library/:libraryId' becomes '/library/lib:org:123' + let pathWithParams = path; + for (const [key, value] of Object.entries(params)) { + pathWithParams = pathWithParams.replaceAll(`:${key}`, value); + } + if (pathWithParams.endsWith('/*')) { + // Some routes (that contain child routes) need to end with /* in the but not in the router + pathWithParams = pathWithParams.substring(0, pathWithParams.length - 1); + } + newRouterProps.initialEntries = [pathWithParams]; + } + return ( + + + + + + ); + } + return ( + {children} + ); +}; + +function makeWrapper({ ...routeArgs }: RouteOptions) { + const AllTheProviders = ({ children }) => ( + + + + + {children} + + + + + ); + return AllTheProviders; +} + +/** + * 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 { + return render(ui, { wrapper: makeWrapper(options) }); +} + +const defaultUser = { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], +} as const; + +/** + * Initialize common mocks that many of our React components will require. + * + * This should be called within each test case, or in `beforeEach()`. + * + * Returns the new `axiosMock` in case you need to mock out axios requests. + */ +export function initializeMocks({ user = defaultUser } = {}) { + initializeMockApp({ + authenticatedUser: user, + }); + reduxStore = initializeReduxStore(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + + return { + reduxStore, + axiosMock, + }; +} + +export * from '@testing-library/react'; +export { customRender as render }; + +/** Simulate a real Axios error (such as we'd see in response to a 404) */ +export function createAxiosError({ code, message, path }: { code: number, message: string, path: string }) { + const request = { path }; + const config = {}; + const error = new AxiosError( + `Mocked request failed with status code ${code}`, + AxiosError.ERR_BAD_RESPONSE, + config, + request, + { + status: code, + data: { detail: message }, + statusText: 'error', + config, + headers: {}, + }, + ); + return error; +}