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