fix: Show spinner while loading library components (#1331)
This commit is contained in:
@@ -19,7 +19,7 @@ exports[`SolutionWidget render snapshot: renders correct default 1`] = `
|
||||
<FormattedMessage
|
||||
defaultMessage="Provide an explanation for the correct answer"
|
||||
description="Description of the solution widget"
|
||||
id="authoring.problemEditor.solutionwidget.solutionDescriptionText"
|
||||
id="authoring.problemEditor.explanationwidget.solutionDescriptionText"
|
||||
/>
|
||||
</div>
|
||||
<TinyMceWidget
|
||||
|
||||
@@ -8,12 +8,12 @@ const messages = defineMessages({
|
||||
description: 'Explanation Title',
|
||||
},
|
||||
solutionDescriptionText: {
|
||||
id: 'authoring.problemEditor.solutionwidget.solutionDescriptionText',
|
||||
id: 'authoring.problemEditor.explanationwidget.solutionDescriptionText',
|
||||
defaultMessage: 'Provide an explanation for the correct answer',
|
||||
description: 'Description of the solution widget',
|
||||
},
|
||||
placeholder: {
|
||||
id: 'authoring.problemEditor.questionwidget.placeholder',
|
||||
id: 'authoring.problemEditor.explanationwidget.placeholder',
|
||||
defaultMessage: 'Enter your explanation',
|
||||
description: 'Placeholder text for tinyMCE editor',
|
||||
},
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
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 });
|
||||
|
||||
@@ -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 <LoadingSpinner />;
|
||||
}
|
||||
if (componentCount === 0 && collectionCount === 0) {
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
||||
}
|
||||
|
||||
@@ -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('<LibraryCollectionPage />', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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 <mark>...</mark> 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 }) => (
|
||||
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe('<LibraryCollections />', () => {
|
||||
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(<LibraryCollections variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (totalCollectionHits === 0) {
|
||||
return isFiltered
|
||||
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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('<LibraryComponents />', () => {
|
||||
expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a spinner while loading', async () => {
|
||||
mockUseSearchContext.mockReturnValue({
|
||||
...data,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render components in full variant', async () => {
|
||||
mockUseSearchContext.mockReturnValue({
|
||||
...data,
|
||||
hits: libraryComponentsMock,
|
||||
isFetching: false,
|
||||
});
|
||||
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
||||
|
||||
@@ -122,7 +131,6 @@ describe('<LibraryComponents />', () => {
|
||||
mockUseSearchContext.mockReturnValue({
|
||||
...data,
|
||||
hits: libraryComponentsMock,
|
||||
isFetching: false,
|
||||
});
|
||||
render(<LibraryComponents variant="preview" />, withLibraryId(mockContentLibrary.libraryId));
|
||||
|
||||
@@ -138,7 +146,6 @@ describe('<LibraryComponents />', () => {
|
||||
mockUseSearchContext.mockReturnValue({
|
||||
...data,
|
||||
hits: libraryComponentsMock,
|
||||
isFetching: false,
|
||||
hasNextPage: true,
|
||||
});
|
||||
|
||||
@@ -152,11 +159,10 @@ describe('<LibraryComponents />', () => {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (componentCount === 0) {
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
||||
}
|
||||
|
||||
@@ -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<api.Collection> {
|
||||
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',
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface SearchContextData {
|
||||
defaultSearchSortOrder: SearchSortOption;
|
||||
hits: ContentHit[];
|
||||
totalHits: number;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean | undefined;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user