diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap index 3cb07a6ce..55074ed30 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/__snapshots__/index.test.jsx.snap @@ -19,7 +19,7 @@ exports[`SolutionWidget render snapshot: renders correct default 1`] = ` ', () => { expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); - expect(screen.getByText('You have not added any collection to this library yet.')).toBeInTheDocument(); + expect(screen.getByText('You have not added any collections to this library yet.')).toBeInTheDocument(); // Open Create collection modal const addCollectionButton = screen.getByRole('button', { name: /add collection/i }); diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx index e5a56c679..170c12d3d 100644 --- a/src/library-authoring/LibraryHome.tsx +++ b/src/library-authoring/LibraryHome.tsx @@ -1,6 +1,7 @@ import { Stack } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { LoadingSpinner } from '../generic/Loading'; import { useSearchContext } from '../search-manager'; import { NoComponents, NoSearchResults } from './EmptyStates'; import LibraryCollections from './collections/LibraryCollections'; @@ -20,11 +21,15 @@ const LibraryHome = ({ tabList, handleTabChange } : LibraryHomeProps) => { const { totalHits: componentCount, totalCollectionHits: collectionCount, + isLoading, isFiltered, } = useSearchContext(); const { openAddContentSidebar } = useLibraryContext(); const renderEmptyState = () => { + if (isLoading) { + return ; + } if (componentCount === 0 && collectionCount === 0) { return isFiltered ? : ; } diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index af9f794a8..67e4eea08 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -33,7 +33,7 @@ const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; const mockCollection = { collectionId: mockResult.results[2].hits[0].block_id, - collectionNeverLoads: 'collection-always-loading', + collectionNeverLoads: mockGetCollectionMetadata.collectionIdLoading, collectionNoComponents: 'collection-no-components', collectionEmpty: mockGetCollectionMetadata.collectionIdError, }; @@ -108,7 +108,7 @@ describe('', () => { it('shows an error component if no collection returned', async () => { // This mock will simulate incorrect collection id await renderLibraryCollectionPage(mockCollection.collectionEmpty); - expect(await screen.findByText(/Mocked request failed with status code 400./)).toBeInTheDocument(); + expect(await screen.findByText(/Mocked request failed with status code 404./)).toBeInTheDocument(); }); it('shows collection data', async () => { diff --git a/src/library-authoring/collections/LibraryCollections.test.tsx b/src/library-authoring/collections/LibraryCollections.test.tsx new file mode 100644 index 000000000..43d7324cc --- /dev/null +++ b/src/library-authoring/collections/LibraryCollections.test.tsx @@ -0,0 +1,89 @@ +import fetchMock from 'fetch-mock-jest'; + +import { + render, + screen, + initializeMocks, +} from '../../testUtils'; +import { getContentSearchConfigUrl } from '../../search-manager/data/api'; +import { mockContentLibrary } from '../data/api.mocks'; +import mockEmptyResult from '../../search-modal/__mocks__/empty-search-result.json'; +import { LibraryProvider } from '../common/context'; +import LibraryCollections from './LibraryCollections'; + +const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; + +mockContentLibrary.applyMock(); +const mockFetchNextPage = jest.fn(); +const mockUseSearchContext = jest.fn(); + +const data = { + totalHits: 1, + hits: [], + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + searchKeywords: '', + isFiltered: false, + isLoading: false, +}; + +const returnEmptyResult = (_url: string, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise we may have an inconsistent state that causes more queries and unexpected results. + mockEmptyResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockEmptyResult.results[0]?.hits.forEach((hit: any) => { hit._formatted = { ...hit }; }); + return mockEmptyResult; +}; + +jest.mock('../../search-manager', () => ({ + ...jest.requireActual('../../search-manager'), + useSearchContext: () => mockUseSearchContext(), +})); + +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + +const withLibraryId = (libraryId: string) => ({ + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +}); + +describe('', () => { + beforeEach(() => { + const { axiosMock } = initializeMocks(); + + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + // The API method to get the Meilisearch connection details uses Axios: + axiosMock.onGet(getContentSearchConfigUrl()).reply(200, { + url: 'http://mock.meilisearch.local', + index_name: 'studio', + api_key: 'test-key', + }); + }); + + afterEach(() => { + fetchMock.reset(); + mockFetchNextPage.mockReset(); + }); + + it('should render a spinner while loading', async () => { + mockUseSearchContext.mockReturnValue({ + ...data, + isLoading: true, + }); + + render(, withLibraryId(mockContentLibrary.libraryId)); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/collections/LibraryCollections.tsx b/src/library-authoring/collections/LibraryCollections.tsx index d68e2b16c..84b868a55 100644 --- a/src/library-authoring/collections/LibraryCollections.tsx +++ b/src/library-authoring/collections/LibraryCollections.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from '../../generic/Loading'; import { useLoadOnScroll } from '../../hooks'; import { useSearchContext } from '../../search-manager'; import { NoComponents, NoSearchResults } from '../EmptyStates'; @@ -24,6 +25,7 @@ const LibraryCollections = ({ variant }: LibraryCollectionsProps) => { isFetchingNextPage, hasNextPage, fetchNextPage, + isLoading, isFiltered, } = useSearchContext(); @@ -38,6 +40,10 @@ const LibraryCollections = ({ variant }: LibraryCollectionsProps) => { variant === 'full', ); + if (isLoading) { + return ; + } + if (totalCollectionHits === 0) { return isFiltered ? diff --git a/src/library-authoring/collections/messages.ts b/src/library-authoring/collections/messages.ts index 03ddff1af..5c5e0c03a 100644 --- a/src/library-authoring/collections/messages.ts +++ b/src/library-authoring/collections/messages.ts @@ -103,7 +103,7 @@ const messages = defineMessages({ }, noCollections: { id: 'course-authoring.library-authoring.no-collections', - defaultMessage: 'You have not added any collection to this library yet.', + defaultMessage: 'You have not added any collections to this library yet.', description: 'Message displayed when the library has no collections', }, addCollection: { diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx index 84391ab2a..0bf198063 100644 --- a/src/library-authoring/components/LibraryComponents.test.tsx +++ b/src/library-authoring/components/LibraryComponents.test.tsx @@ -23,12 +23,12 @@ const mockUseSearchContext = jest.fn(); const data = { totalHits: 1, hits: [], - isFetching: true, isFetchingNextPage: false, hasNextPage: false, fetchNextPage: mockFetchNextPage, searchKeywords: '', isFiltered: false, + isLoading: false, }; const returnEmptyResult = (_url: string, req) => { @@ -102,11 +102,20 @@ describe('', () => { expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument(); }); + it('should render a spinner while loading', async () => { + mockUseSearchContext.mockReturnValue({ + ...data, + isLoading: true, + }); + + render(, withLibraryId(mockContentLibrary.libraryId)); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + it('should render components in full variant', async () => { mockUseSearchContext.mockReturnValue({ ...data, hits: libraryComponentsMock, - isFetching: false, }); render(, withLibraryId(mockContentLibrary.libraryId)); @@ -122,7 +131,6 @@ describe('', () => { mockUseSearchContext.mockReturnValue({ ...data, hits: libraryComponentsMock, - isFetching: false, }); render(, withLibraryId(mockContentLibrary.libraryId)); @@ -138,7 +146,6 @@ describe('', () => { mockUseSearchContext.mockReturnValue({ ...data, hits: libraryComponentsMock, - isFetching: false, hasNextPage: true, }); @@ -152,11 +159,10 @@ describe('', () => { expect(mockFetchNextPage).toHaveBeenCalled(); }); - it('should not call `fetchNextPage` on croll to bottom in preview variant', async () => { + it('should not call `fetchNextPage` on scroll to bottom in preview variant', async () => { mockUseSearchContext.mockReturnValue({ ...data, hits: libraryComponentsMock, - isFetching: false, hasNextPage: true, }); diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index 7d5280663..8f44e7762 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; +import { LoadingSpinner } from '../../generic/Loading'; import { useLoadOnScroll } from '../../hooks'; import { useSearchContext } from '../../search-manager'; import { NoComponents, NoSearchResults } from '../EmptyStates'; @@ -26,6 +27,7 @@ const LibraryComponents = ({ variant }: LibraryComponentsProps) => { isFetchingNextPage, hasNextPage, fetchNextPage, + isLoading, isFiltered, } = useSearchContext(); const { libraryId, openAddContentSidebar } = useLibraryContext(); @@ -51,6 +53,10 @@ const LibraryComponents = ({ variant }: LibraryComponentsProps) => { variant === 'full', ); + if (isLoading) { + return ; + } + if (componentCount === 0) { return isFiltered ? : ; } diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 0a4b82411..220c7576d 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -281,13 +281,22 @@ mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetad * This mock returns a fixed response for the collection ID *collection_1*. */ export async function mockGetCollectionMetadata(libraryId: string, collectionId: string): Promise { - if (collectionId === mockGetCollectionMetadata.collectionIdError) { - throw createAxiosError({ code: 400, message: 'Not found.', path: api.getLibraryCollectionApiUrl(libraryId, collectionId) }); + switch (collectionId) { + case mockGetCollectionMetadata.collectionIdError: + throw createAxiosError({ + code: 404, + message: 'Not found.', + path: api.getLibraryCollectionApiUrl(libraryId, collectionId), + }); + case mockGetCollectionMetadata.collectionIdLoading: + return new Promise(() => {}); + default: + return Promise.resolve(mockGetCollectionMetadata.collectionData); } - return Promise.resolve(mockGetCollectionMetadata.collectionData); } mockGetCollectionMetadata.collectionId = 'collection_1'; mockGetCollectionMetadata.collectionIdError = 'collection_error'; +mockGetCollectionMetadata.collectionIdLoading = 'collection_loading'; mockGetCollectionMetadata.collectionData = { id: 1, key: 'collection_1', diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index cb1314b6b..1726e10d9 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -36,7 +36,7 @@ export interface SearchContextData { defaultSearchSortOrder: SearchSortOption; hits: ContentHit[]; totalHits: number; - isFetching: boolean; + isLoading: boolean; hasNextPage: boolean | undefined; isFetchingNextPage: boolean; fetchNextPage: () => void; diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index c22a00426..cd63bbb34 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -137,7 +137,7 @@ export const useContentSearchResults = ({ blockTypes: pages?.[0]?.blockTypes ?? {}, problemTypes: pages?.[0]?.problemTypes ?? {}, status: query.status, - isFetching: query.isFetching, + isLoading: query.isLoading, isError: query.isError, isFetchingNextPage: query.isFetchingNextPage, // Call this to load more pages. We include some "safety" features recommended by the docs: this should never be