feat: simplify Library Home Page (v2) (#1443)

This commit is contained in:
Daniel Valenzuela
2024-11-07 14:48:19 -03:00
committed by GitHub
parent d99e3f0f62
commit 979c69b48e
28 changed files with 440 additions and 1094 deletions

View File

@@ -42,40 +42,12 @@ const returnEmptyResult = (_url, req) => {
// 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;
mockEmptyResult.results[2].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) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockEmptyResult;
};
/**
* Returns 2 components from the search query.
* This lets us test that the StudioHome "View All" button is hidden when a
* low number of search results are shown (<=4 by default).
*/
const returnLowNumberResults = (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
const newMockResult = { ...mockResult };
// 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.
newMockResult.results[0].query = query;
// Limit number of results to just 2
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2);
newMockResult.results[0].estimatedTotalHits = 2;
newMockResult.results[2].estimatedTotalHits = 2;
// 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
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResult;
};
const path = '/library/:libraryId/*';
const libraryTitle = mockContentLibrary.libraryData.title;
@@ -136,35 +108,25 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();
// Navigate to the components tab
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
// Navigate to the collections tab
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument();
expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument();
// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
});
it('shows a library without components and collections', async () => {
@@ -188,7 +150,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(cancelButton);
expect(collectionModalHeading).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
const addComponentButton = screen.getByRole('button', { name: /add component/i });
@@ -246,7 +208,7 @@ describe('<LibraryAuthoringPage />', () => {
// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
});
it('should open and close new content sidebar', async () => {
@@ -328,68 +290,6 @@ describe('<LibraryAuthoringPage />', () => {
expect(manageAccess).not.toBeInTheDocument();
});
it('show the "View All" button when viewing library with many components', async () => {
await renderLibraryPage();
expect(screen.getByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
// "Recently Modified" header + sort shown
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
expect(screen.getByText('Collections (6)')).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 be two "View All" button, since the Components and Collections count
// are above the preview limit (4)
expect(screen.getAllByText('View All').length).toEqual(2);
// Clicking on first "View All" button should navigate to the Collections tab
fireEvent.click(screen.getAllByText('View All')[0]);
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.getByText('Collection 1')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// Clicking on second "View All" button should navigate to the Components tab
fireEvent.click(screen.getAllByText('View All')[1]);
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (6)')).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(screen.getByRole('tab', { name: 'Home' }));
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
});
it('should not show the "View All" button when viewing library with low number of components', async () => {
fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true });
await renderLibraryPage();
expect(screen.getByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
// "Recently Modified" header + sort shown
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
expect(screen.getByText('Collections (2)')).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(screen.queryByText('View All')).not.toBeInTheDocument();
});
it('sorts library components', async () => {
await renderLibraryPage();
@@ -444,7 +344,7 @@ describe('<LibraryAuthoringPage />', () => {
// Re-selecting the previous sort option resets sort to default "Recently Modified"
await testSortOption('Recently Published', 'modified:desc', true);
expect(screen.getAllByText('Recently Modified').length).toEqual(3);
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
// Enter a keyword into the search box
const searchBox = screen.getByRole('searchbox');
@@ -467,7 +367,6 @@ describe('<LibraryAuthoringPage />', () => {
expect(mockResult0.display_name).toStrictEqual(displayName);
await renderLibraryPage();
// Click on the first component. It should appear twice, in both "Recently Modified" and "Components"
fireEvent.click((await screen.findAllByText(displayName))[0]);
const sidebar = screen.getByTestId('library-sidebar');
@@ -579,7 +478,7 @@ describe('<LibraryAuthoringPage />', () => {
}
// Validate click on Problem type
const problemMenu = screen.getByText('Problem');
const problemMenu = screen.getAllByText('Problem')[0];
expect(problemMenu).toBeInTheDocument();
fireEvent.click(problemMenu);
await waitFor(() => {
@@ -647,7 +546,8 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
const sidebar = screen.getByTestId('library-sidebar');
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();
@@ -691,7 +591,8 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
const sidebar = screen.getByTestId('library-sidebar');
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();
@@ -724,7 +625,8 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
const sidebar = screen.getByTestId('library-sidebar');
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();
@@ -739,22 +641,6 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(createButton);
});
it('shows both components and collections in recently modified section', async () => {
await renderLibraryPage();
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement;
expect(recentModifiedContainer).toBeTruthy();
const container = within(recentModifiedContainer!);
expect(container.queryAllByText('Text').length).toBeGreaterThan(0);
expect(container.queryAllByText('Collection').length).toBeGreaterThan(0);
});
it('shows a single block when usageKey query param is set', async () => {
render(<LibraryLayout />, {
path,

View File

@@ -35,35 +35,11 @@ import {
SearchKeywordsField,
SearchSortWidget,
} from '../search-manager';
import LibraryComponents from './components/LibraryComponents';
import LibraryCollections from './collections/LibraryCollections';
import LibraryHome from './LibraryHome';
import LibraryContent, { ContentType } from './LibraryContent';
import { LibrarySidebar } from './library-sidebar';
import { SidebarBodyComponentId, useLibraryContext } from './common/context';
import messages from './messages';
enum TabList {
home = '',
components = 'components',
collections = 'collections',
}
interface TabContentProps {
eventKey: string;
handleTabChange: (key: string) => void;
}
const TabContent = ({ eventKey, handleTabChange }: TabContentProps) => {
switch (eventKey) {
case TabList.components:
return <LibraryComponents variant="full" />;
case TabList.collections:
return <LibraryCollections variant="full" />;
default:
return <LibraryHome tabList={TabList} handleTabChange={handleTabChange} />;
}
};
const HeaderActions = () => {
const intl = useIntl();
const {
@@ -162,15 +138,15 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
openInfoSidebar,
} = useLibraryContext();
const [activeKey, setActiveKey] = useState<string | undefined>('');
const [activeKey, setActiveKey] = useState<ContentType | undefined>(ContentType.home);
useEffect(() => {
const currentPath = location.pathname.split('/').pop();
if (componentPickerMode || currentPath === libraryId || currentPath === '') {
setActiveKey(TabList.home);
} else if (currentPath && currentPath in TabList) {
setActiveKey(TabList[currentPath]);
setActiveKey(ContentType.home);
} else if (currentPath && currentPath in ContentType) {
setActiveKey(ContentType[currentPath]);
}
}, [location.pathname]);
@@ -203,7 +179,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
return <NotFoundAlert />;
}
const handleTabChange = (key: string) => {
const handleTabChange = (key: ContentType) => {
setActiveKey(key);
if (!componentPickerMode) {
navigate({
@@ -235,6 +211,14 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
extraFilter.push('last_published IS NOT NULL');
}
const activeTypeFilters = {
components: 'NOT type = "collection"',
collections: 'type = "collection"',
};
if (activeKey !== ContentType.home) {
extraFilter.push(activeTypeFilters[activeKey]);
}
return (
<div className="d-flex">
<div className="flex-grow-1">
@@ -275,11 +259,11 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
onSelect={handleTabChange}
className="my-3"
>
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
<Tab eventKey={ContentType.home} title={intl.formatMessage(messages.homeTab)} />
<Tab eventKey={ContentType.components} title={intl.formatMessage(messages.componentsTab)} />
<Tab eventKey={ContentType.collections} title={intl.formatMessage(messages.collectionsTab)} />
</Tabs>
<TabContent eventKey={activeKey} handleTabChange={handleTabChange} />
<LibraryContent contentType={activeKey} />
</SearchContextProvider>
</Container>
{!componentPickerMode && <StudioFooter containerProps={{ size: undefined }} />}

View File

@@ -1,15 +1,17 @@
import fetchMock from 'fetch-mock-jest';
import {
fireEvent,
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';
} 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 LibraryContent from './LibraryContent';
import { libraryComponentsMock } from './__mocks__';
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
@@ -18,8 +20,8 @@ const mockFetchNextPage = jest.fn();
const mockUseSearchContext = jest.fn();
const data = {
totalHits: 1,
hits: [],
totalContentAndCollectionHits: 0,
contentAndCollectionHits: [],
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: mockFetchNextPage,
@@ -40,8 +42,8 @@ const returnEmptyResult = (_url: string, req) => {
return mockEmptyResult;
};
jest.mock('../../search-manager', () => ({
...jest.requireActual('../../search-manager'),
jest.mock('../search-manager', () => ({
...jest.requireActual('../search-manager'),
useSearchContext: () => mockUseSearchContext(),
}));
@@ -58,7 +60,7 @@ const withLibraryId = (libraryId: string) => ({
),
});
describe('<LibraryCollections />', () => {
describe('<LibraryHome />', () => {
beforeEach(() => {
const { axiosMock } = initializeMocks();
@@ -83,7 +85,31 @@ describe('<LibraryCollections />', () => {
isLoading: true,
});
render(<LibraryCollections variant="full" />, withLibraryId(mockContentLibrary.libraryId));
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should render an empty state when there are no results', async () => {
mockUseSearchContext.mockReturnValue({
...data,
totalHits: 0,
});
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
});
it('should load more results when the user scrolls to the bottom', async () => {
mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
hasNextPage: true,
});
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
Object.defineProperty(window, 'innerHeight', { value: 800 });
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
fireEvent.scroll(window, { target: { scrollY: 1000 } });
expect(mockFetchNextPage).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,92 @@
import { useEffect } from 'react';
import { LoadingSpinner } from '../generic/Loading';
import { useSearchContext } from '../search-manager';
import { NoComponents, NoSearchResults } from './EmptyStates';
import { useLibraryContext } from './common/context';
import CollectionCard from './components/CollectionCard';
import ComponentCard from './components/ComponentCard';
import { useLoadOnScroll } from '../hooks';
import messages from './collections/messages';
export enum ContentType {
home = '',
components = 'components',
collections = 'collections',
}
/**
* Library Content to show content grid
*
* Use content to:
* - 'collections': Suggest to create a collection on empty state.
* - Anything else to suggest to add content on empty state.
*/
type LibraryContentProps = {
contentType?: ContentType;
};
const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) => {
const {
hits,
totalHits,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isLoading,
isFiltered,
usageKey,
} = useSearchContext();
const { openAddContentSidebar, openComponentInfoSidebar, openCreateCollectionModal } = useLibraryContext();
useEffect(() => {
if (usageKey) {
openComponentInfoSidebar(usageKey);
}
}, [usageKey]);
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
true,
);
if (isLoading) {
return <LoadingSpinner />;
}
if (totalHits === 0) {
if (contentType === ContentType.collections) {
return isFiltered
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
: (
<NoComponents
infoText={messages.noCollections}
addBtnText={messages.addCollection}
handleBtnClick={openCreateCollectionModal}
/>
);
}
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
}
return (
<div className="library-cards-grid">
{hits.map((contentHit) => (
contentHit.type === 'collection' ? (
<CollectionCard
key={contentHit.id}
collectionHit={contentHit}
/>
) : (
<ComponentCard
key={contentHit.id}
contentHit={contentHit}
/>
)
))}
</div>
);
};
export default LibraryContent;

View File

@@ -1,67 +0,0 @@
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';
import { LibraryComponents } from './components';
import LibrarySection from './components/LibrarySection';
import LibraryRecentlyModified from './LibraryRecentlyModified';
import messages from './messages';
import { useLibraryContext } from './common/context';
type LibraryHomeProps = {
tabList: { home: string, components: string, collections: string },
handleTabChange: (key: string) => void,
};
const LibraryHome = ({ tabList, handleTabChange } : LibraryHomeProps) => {
const intl = useIntl();
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} />;
}
return null;
};
return (
<Stack gap={3}>
<LibraryRecentlyModified />
{
renderEmptyState()
|| (
<>
<LibrarySection
title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}
contentCount={collectionCount}
viewAllAction={() => handleTabChange(tabList.collections)}
>
<LibraryCollections variant="preview" />
</LibrarySection>
<LibrarySection
title={intl.formatMessage(messages.componentsTitle, { componentCount })}
contentCount={componentCount}
viewAllAction={() => handleTabChange(tabList.components)}
>
<LibraryComponents variant="preview" />
</LibrarySection>
</>
)
}
</Stack>
);
};
export default LibraryHome;

View File

@@ -1,80 +0,0 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { orderBy } from 'lodash';
import { SearchContextProvider, useSearchContext } from '../search-manager';
import { type CollectionHit, type ContentHit, SearchSortOption } from '../search-manager/data/api';
import LibrarySection, { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection';
import messages from './messages';
import ComponentCard from './components/ComponentCard';
import CollectionCard from './components/CollectionCard';
import { useLibraryContext } from './common/context';
const RecentlyModified: React.FC<Record<never, never>> = () => {
const intl = useIntl();
const {
hits,
collectionHits,
totalHits,
totalCollectionHits,
} = useSearchContext();
const componentCount = totalHits + totalCollectionHits;
// Since we only display a fixed number of items in preview,
// only these number of items are use in sort step below
const componentList = hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
const collectionList = collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
// Sort them by `modified` field in reverse and display them
const recentItems = orderBy([
...componentList,
...collectionList,
], ['modified'], ['desc']).slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
return componentCount > 0
? (
<LibrarySection
title={intl.formatMessage(messages.recentlyModifiedTitle)}
contentCount={componentCount}
>
<div className="library-cards-grid">
{recentItems.map((contentHit) => (
contentHit.type === 'collection' ? (
<CollectionCard
key={contentHit.id}
collectionHit={contentHit as CollectionHit}
/>
) : (
<ComponentCard
key={contentHit.id}
contentHit={contentHit as ContentHit}
/>
)
))}
</div>
</LibrarySection>
)
: null;
};
const LibraryRecentlyModified: React.FC<Record<never, never>> = () => {
const { libraryId, showOnlyPublished } = useLibraryContext();
const extraFilter = [`context_key = "${libraryId}"`];
if (showOnlyPublished) {
extraFilter.push('last_published IS NOT NULL');
}
return (
<SearchContextProvider
extraFilter={extraFilter}
overrideSearchSortOrder={
showOnlyPublished
? SearchSortOption.RECENTLY_PUBLISHED
: SearchSortOption.RECENTLY_MODIFIED
}
>
<RecentlyModified />
</SearchContextProvider>
);
};
export default LibraryRecentlyModified;

View File

@@ -180,34 +180,7 @@
"display_name": "Blank Problem",
"description": "Problem"
}
}
],
"query": "",
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 5
},
{
"indexUid": "studio_content",
"hits": [],
"query": "",
"processingTimeMs": 0,
"limit": 0,
"offset": 0,
"estimatedTotalHits": 5,
"facetDistribution": {
"block_type": {
"html": 4,
"problem": 1
},
"content.problem_types": {}
},
"facetStats": {}
},
{
"indexUid": "studio_content",
"hits": [
{
"display_name": "My first collection",
"block_id": "my-first-collection",
@@ -246,12 +219,30 @@
"access_id": 16,
"num_children": 1
}
],
"query": "",
"processingTimeMs": 0,
"limit": 1,
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 1
"estimatedTotalHits": 5
},
{
"indexUid": "studio_content",
"hits": [],
"query": "",
"processingTimeMs": 0,
"limit": 0,
"offset": 0,
"estimatedTotalHits": 5,
"facetDistribution": {
"block_type": {
"html": 4,
"problem": 1
},
"content.problem_types": {}
},
"facetStats": {}
}
]
}

View File

@@ -32,6 +32,199 @@
"description": "Testing"
}
},
{
"display_name": "Collection 1",
"block_id": "col1",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.",
"id": 1,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.628254,
"modified": 1725878053.420395,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 1",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "1",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.628254",
"modified": "1725534795.628266",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 2",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
"id": 2,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.619101,
"modified": 1725534795.619113,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 2",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "2",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.619101",
"modified": "1725534795.619113",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 3",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
"id": 3,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.609781,
"modified": 1725534795.609794,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 3",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "3",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.609781",
"modified": "1725534795.609794",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 4",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
"id": 4,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.596287,
"modified": 1725534795.5963,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 4",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "4",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.596287",
"modified": "1725534795.5963",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 5",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
"id": 5,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.583068,
"modified": 1725534795.583082,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 5",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "5",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.583068",
"modified": "1725534795.583082",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 6",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
"id": 6,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.573794,
"modified": 1725534795.573808,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 6",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "6",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.573794",
"modified": "1725534795.573808",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
"display_name": "Second Text Component",
@@ -318,209 +511,6 @@
}
},
"facetStats": {}
},
{
"indexUid": "studio",
"hits": [
{
"display_name": "Collection 1",
"block_id": "col1",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.",
"id": 1,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.628254,
"modified": 1725878053.420395,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 1",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "1",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.628254",
"modified": "1725534795.628266",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 2",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
"id": 2,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.619101,
"modified": 1725534795.619113,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 2",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "2",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.619101",
"modified": "1725534795.619113",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 3",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
"id": 3,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.609781,
"modified": 1725534795.609794,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 3",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "3",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.609781",
"modified": "1725534795.609794",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 4",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
"id": 4,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.596287,
"modified": 1725534795.5963,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 4",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "4",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.596287",
"modified": "1725534795.5963",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 5",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
"id": 5,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.583068,
"modified": 1725534795.583082,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 5",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "5",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.583068",
"modified": "1725534795.583082",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 6",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
"id": 6,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.573794,
"modified": 1725534795.573808,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 6",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "6",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.573794",
"modified": "1725534795.573808",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
}
],
"query": "learn",
"processingTimeMs": 1,
"limit": 6,
"offset": 0,
"estimatedTotalHits": 6
}
]
}

View File

@@ -1,9 +1,9 @@
import { Stack } from '@openedx/paragon';
import { NoComponents, NoSearchResults } from '../EmptyStates';
import { useSearchContext } from '../../search-manager';
import { LibraryComponents } from '../components';
import messages from './messages';
import { useLibraryContext } from '../common/context';
import LibraryContent, { ContentType } from '../LibraryContent';
const LibraryCollectionComponents = () => {
const { totalHits: componentCount, isFiltered } = useSearchContext();
@@ -24,7 +24,7 @@ const LibraryCollectionComponents = () => {
return (
<Stack direction="vertical" gap={3}>
<h3 className="text-gray">Content ({componentCount})</h3>
<LibraryComponents variant="full" />
<LibraryContent contentType={ContentType.collections} />
</Stack>
);
};

View File

@@ -37,7 +37,7 @@ const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
const path = '/library/:libraryId/*';
const libraryTitle = mockContentLibrary.libraryData.title;
const mockCollection = {
collectionId: mockResult.results[2].hits[0].block_id,
collectionId: mockResult.results[0].hits[5].block_id,
collectionNeverLoads: mockGetCollectionMetadata.collectionIdLoading,
collectionNoComponents: 'collection-no-components',
collectionEmpty: mockGetCollectionMetadata.collectionIdError,
@@ -62,23 +62,21 @@ describe('<LibraryCollectionPage />', () => {
// because otherwise Instantsearch will update the UI and change the query,
// leading to unexpected results in the test cases.
mockResultCopy.results[0].query = query;
mockResultCopy.results[2].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
mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
const collectionQueryId = requestData?.queries[0]?.filter?.[3]?.split('collections.key = "')[1].split('"')[0];
const collectionQueryId = requestData?.queries[0]?.filter?.[2]?.split('collections.key = "')[1].split('"')[0];
switch (collectionQueryId) {
case mockCollection.collectionNeverLoads:
return new Promise<any>(() => {});
case mockCollection.collectionEmpty:
mockResultCopy.results[2].hits = [];
mockResultCopy.results[2].estimatedTotalHits = 0;
mockResultCopy.results[0].hits = [];
mockResultCopy.results[0].totalHits = 0;
break;
case mockCollection.collectionNoComponents:
mockResultCopy.results[0].hits = [];
mockResultCopy.results[0].estimatedTotalHits = 0;
mockResultCopy.results[0].totalHits = 0;
mockResultCopy.results[1].facetDistribution.block_type = {};
mockResultCopy.results[2].hits[0].num_children = 0;
break;
default:
break;
@@ -181,7 +179,7 @@ describe('<LibraryCollectionPage />', () => {
// should not be impacted by the search
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
expect(screen.queryByText('No matching components found in this collections.')).toBeInTheDocument();
expect(screen.queryByText('No matching components found in this collection.')).toBeInTheDocument();
});
it('should open and close new content sidebar', async () => {

View File

@@ -196,7 +196,6 @@ const LibraryCollectionPage = () => {
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
<SearchContextProvider
extraFilter={extraFilter}
overrideQueries={{ collections: { limit: 0 } }}
>
<SubHeader
title={(

View File

@@ -1,71 +0,0 @@
import { LoadingSpinner } from '../../generic/Loading';
import { useLoadOnScroll } from '../../hooks';
import { useSearchContext } from '../../search-manager';
import { NoComponents, NoSearchResults } from '../EmptyStates';
import CollectionCard from '../components/CollectionCard';
import { LIBRARY_SECTION_PREVIEW_LIMIT } from '../components/LibrarySection';
import messages from './messages';
import { useLibraryContext } from '../common/context';
type LibraryCollectionsProps = {
variant: 'full' | 'preview',
};
/**
* Library Collections to show collections grid
*
* Use style to:
* - 'full': Show all collections with Infinite scroll pagination.
* - 'preview': Show first 4 collections without pagination.
*/
const LibraryCollections = ({ variant }: LibraryCollectionsProps) => {
const {
collectionHits,
totalCollectionHits,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isLoading,
isFiltered,
} = useSearchContext();
const { openCreateCollectionModal } = useLibraryContext();
const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits;
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
variant === 'full',
);
if (isLoading) {
return <LoadingSpinner />;
}
if (totalCollectionHits === 0) {
return isFiltered
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
: (
<NoComponents
infoText={messages.noCollections}
addBtnText={messages.addCollection}
handleBtnClick={openCreateCollectionModal}
/>
);
}
return (
<div className="library-cards-grid">
{collectionList.map((collectionHit) => (
<CollectionCard
key={collectionHit.id}
collectionHit={collectionHit}
/>
))}
</div>
);
};
export default LibraryCollections;

View File

@@ -63,7 +63,7 @@ const messages = defineMessages({
},
noSearchResultsInCollection: {
id: 'course-authoring.library-authoring.collections-pag.no-search-results.text',
defaultMessage: 'No matching components found in this collections.',
defaultMessage: 'No matching components found in this collection.',
description: 'Message displayed when no matching components are found in collection',
},
newContentButton: {

View File

@@ -12,7 +12,7 @@ import {
} from '@openedx/paragon/icons';
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
import { ComponentMenu } from '../components';
import ComponentMenu from '../components';
import { canEditComponent } from '../components/ComponentEditorModal';
import ComponentDetails from './ComponentDetails';
import ComponentManagement from './ComponentManagement';

View File

@@ -39,14 +39,14 @@ describe('<ManageCollections />', () => {
fetchMock.mockReset();
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[2]?.q ?? '';
const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise Instantsearch will update the UI and change the query,
// leading to unexpected results in the test cases.
mockCollectionsResults.results[2].query = query;
mockCollectionsResults.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
mockCollectionsResults.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
mockCollectionsResults.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockCollectionsResults;
});
});

View File

@@ -29,7 +29,7 @@ interface CollectionsDrawerProps extends ManageCollectionsProps {
const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
const type = 'checkbox';
const intl = useIntl();
const { collectionHits } = useSearchContext();
const { hits } = useSearchContext();
const { showToast } = useContext(ToastContext);
const collectionKeys = collections.map((collection) => collection.key);
const [selectedCollections, {
@@ -67,7 +67,7 @@ const CollectionsSelectableBox = ({ usageKey, collections, onClose }: Collection
columns={1}
ariaLabelledby={intl.formatMessage(messages.manageCollectionsSelectionLabel)}
>
{collectionHits.map((collectionHit) => (
{hits.map((collectionHit) => (
<SelectableBox
className="d-inline-flex align-items-center shadow-none border border-gray-100"
value={collectionHit.blockId}
@@ -112,12 +112,9 @@ const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsD
return (
<SearchContextProvider
overrideQueries={{
components: { limit: 0 },
blockTypes: { limit: 0 },
}}
extraFilter={`context_key = "${libraryId}"`}
extraFilter={[`context_key = "${libraryId}"`, 'type = "collection"']}
skipUrlUpdate
skipBlockTypeFetch
>
<Stack className="mt-2" gap={3}>
<FormattedMessage

View File

@@ -208,9 +208,8 @@ describe('<ComponentPicker />', () => {
onChange.mockClear();
// Select another component (the second "Select" button is the same component as the first,
// but in the "Components" section instead of the "Recently Changed" section)
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[2]);
// Select another component
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[1]);
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
{
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',

View File

@@ -1,176 +0,0 @@
import fetchMock from 'fetch-mock-jest';
import {
fireEvent,
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 { libraryComponentsMock } from '../__mocks__';
import LibraryComponents from './LibraryComponents';
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('<LibraryComponents />', () => {
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();
});
it('should render empty state', async () => {
mockUseSearchContext.mockReturnValue({
...data,
totalHits: 0,
});
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
expect(await screen.findByText(/you have not added any content to this library yet\./i));
expect(await screen.findByRole('button', { name: /add component/i })).toBeInTheDocument();
});
it('should render empty state without add content button', async () => {
mockUseSearchContext.mockReturnValue({
...data,
totalHits: 0,
});
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryIdReadOnly));
expect(await screen.findByText(/you have not added any content to this library yet\./i));
expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument();
});
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,
});
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
expect(screen.getByText('Video Component 3')).toBeInTheDocument();
expect(screen.getByText('Video Component 4')).toBeInTheDocument();
expect(screen.getByText('This is a problem: ID=5')).toBeInTheDocument();
expect(screen.getByText('This is a problem: ID=6')).toBeInTheDocument();
});
it('should render components in preview variant', async () => {
mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
});
render(<LibraryComponents variant="preview" />, withLibraryId(mockContentLibrary.libraryId));
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
expect(screen.getByText('Video Component 3')).toBeInTheDocument();
expect(screen.getByText('Video Component 4')).toBeInTheDocument();
expect(screen.queryByText('This is a problem: ID=5')).not.toBeInTheDocument();
expect(screen.queryByText('This is a problem: ID=6')).not.toBeInTheDocument();
});
it('should call `fetchNextPage` on scroll to bottom in full variant', async () => {
mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
hasNextPage: true,
});
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
Object.defineProperty(window, 'innerHeight', { value: 800 });
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
fireEvent.scroll(window, { target: { scrollY: 1000 } });
expect(mockFetchNextPage).toHaveBeenCalled();
});
it('should not call `fetchNextPage` on scroll to bottom in preview variant', async () => {
mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
hasNextPage: true,
});
render(<LibraryComponents variant="preview" />, withLibraryId(mockContentLibrary.libraryId));
Object.defineProperty(window, 'innerHeight', { value: 800 });
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
fireEvent.scroll(window, { target: { scrollY: 1000 } });
expect(mockFetchNextPage).not.toHaveBeenCalled();
});
});

View File

@@ -1,70 +0,0 @@
import { useEffect } from 'react';
import { LoadingSpinner } from '../../generic/Loading';
import { useLoadOnScroll } from '../../hooks';
import { useSearchContext } from '../../search-manager';
import { NoComponents, NoSearchResults } from '../EmptyStates';
import ComponentCard from './ComponentCard';
import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection';
import { useLibraryContext } from '../common/context';
type LibraryComponentsProps = {
variant: 'full' | 'preview',
};
/**
* Library Components to show components grid
*
* Use style to:
* - 'full': Show all components with Infinite scroll pagination.
* - 'preview': Show first 4 components without pagination.
*/
const LibraryComponents = ({ variant }: LibraryComponentsProps) => {
const {
hits,
totalHits: componentCount,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isLoading,
isFiltered,
usageKey,
} = useSearchContext();
const { openAddContentSidebar, openComponentInfoSidebar } = useLibraryContext();
useEffect(() => {
if (usageKey) {
openComponentInfoSidebar(usageKey);
}
}, [usageKey]);
const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits;
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
variant === 'full',
);
if (isLoading) {
return <LoadingSpinner />;
}
if (componentCount === 0) {
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
}
return (
<div className="library-cards-grid">
{ componentList.map((contentHit) => (
<ComponentCard
key={contentHit.id}
contentHit={contentHit}
/>
)) }
</div>
);
};
export default LibraryComponents;

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { Card, ActionRow, Button } from '@openedx/paragon';
export const LIBRARY_SECTION_PREVIEW_LIMIT = 4;
const LibrarySection: React.FC<{
title: string,
viewAllAction?: () => void,
contentCount: number,
previewLimit?: number,
children: React.ReactNode,
}> = ({
title,
viewAllAction,
contentCount,
previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT,
children,
}) => (
<Card>
<Card.Header
title={title}
actions={
viewAllAction
&& contentCount > previewLimit
&& (
<ActionRow>
<Button variant="tertiary" onClick={viewAllAction}>View All</Button>
</ActionRow>
)
}
/>
<Card.Section>
{children}
</Card.Section>
</Card>
);
export default LibrarySection;

View File

@@ -1,2 +1 @@
export { default as LibraryComponents } from './LibraryComponents';
export { ComponentMenu } from './ComponentCard';
export { ComponentMenu as default } from './ComponentCard';

View File

@@ -33,7 +33,7 @@ const messages = defineMessages({
},
homeTab: {
id: 'course-authoring.library-authoring.home-tab',
defaultMessage: 'Home',
defaultMessage: 'All Content',
description: 'Tab label for the home tab',
},
componentsTab: {

View File

@@ -10,7 +10,7 @@ import { MeiliSearch, type Filter } from 'meilisearch';
import { union } from 'lodash';
import {
CollectionHit, ContentHit, SearchSortOption, forceArray, OverrideQueries,
CollectionHit, ContentHit, SearchSortOption, forceArray,
} from './data/api';
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
@@ -34,7 +34,7 @@ export interface SearchContextData {
searchSortOrder: SearchSortOption;
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
defaultSearchSortOrder: SearchSortOption;
hits: ContentHit[];
hits: (ContentHit | CollectionHit)[];
totalHits: number;
isLoading: boolean;
hasNextPage: boolean | undefined;
@@ -42,8 +42,6 @@ export interface SearchContextData {
fetchNextPage: () => void;
closeSearchModal: () => void;
hasError: boolean;
collectionHits: CollectionHit[];
totalCollectionHits: number;
usageKey: string;
}
@@ -93,10 +91,10 @@ export const SearchContextProvider: React.FC<{
overrideSearchSortOrder?: SearchSortOption
children: React.ReactNode,
closeSearchModal?: () => void,
overrideQueries?: OverrideQueries,
skipBlockTypeFetch?: boolean,
skipUrlUpdate?: boolean,
}> = ({
overrideSearchSortOrder, overrideQueries, skipUrlUpdate, ...props
overrideSearchSortOrder, skipBlockTypeFetch, skipUrlUpdate, ...props
}) => {
const [searchKeywords, setSearchKeywords] = React.useState('');
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
@@ -165,7 +163,7 @@ export const SearchContextProvider: React.FC<{
problemTypesFilter,
tagsFilter,
sort,
overrideQueries,
skipBlockTypeFetch,
});
return React.createElement(SearchContext.Provider, {

View File

@@ -1,7 +1,7 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type {
Filter, MeiliSearch, MultiSearchQuery, SearchParams,
Filter, MeiliSearch, MultiSearchQuery,
} from 'meilisearch';
export const getContentSearchConfigUrl = () => new URL(
@@ -126,6 +126,7 @@ export interface ContentHit extends BaseContentHit {
* - First one is the name of the course/library itself.
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
*/
type: 'course_block' | 'library_block';
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
description?: string;
content?: ContentDetails;
@@ -149,6 +150,7 @@ export interface ContentPublishedData {
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
*/
export interface CollectionHit extends BaseContentHit {
type: 'collection';
description: string;
numChildren?: number;
}
@@ -169,29 +171,6 @@ export function formatSearchHit(hit: Record<string, any>): ContentHit | Collecti
return camelCaseObject(newHit);
}
export interface OverrideQueries {
components?: SearchParams,
blockTypes?: SearchParams,
collections?: SearchParams,
}
function applyOverrideQueries(
queries: MultiSearchQuery[],
overrideQueries?: OverrideQueries,
): MultiSearchQuery[] {
const newQueries = [...queries];
if (overrideQueries?.components) {
newQueries[0] = { ...overrideQueries.components, indexUid: queries[0].indexUid };
}
if (overrideQueries?.blockTypes) {
newQueries[1] = { ...overrideQueries.blockTypes, indexUid: queries[1].indexUid };
}
if (overrideQueries?.collections) {
newQueries[2] = { ...overrideQueries.collections, indexUid: queries[2].indexUid };
}
return newQueries;
}
interface FetchSearchParams {
client: MeiliSearch,
indexName: string,
@@ -204,7 +183,7 @@ interface FetchSearchParams {
sort?: SearchSortOption[],
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
offset?: number,
overrideQueries?: OverrideQueries,
skipBlockTypeFetch?: boolean,
}
export async function fetchSearchResults({
@@ -216,18 +195,16 @@ export async function fetchSearchResults({
tagsFilter,
extraFilter,
sort,
overrideQueries,
offset = 0,
skipBlockTypeFetch = false,
}: FetchSearchParams): Promise<{
hits: ContentHit[],
hits: (ContentHit | CollectionHit)[],
nextOffset: number | undefined,
totalHits: number,
blockTypes: Record<string, number>,
problemTypes: Record<string, number>,
collectionHits: CollectionHit[],
totalCollectionHits: number,
}> {
let queries: MultiSearchQuery[] = [];
const queries: MultiSearchQuery[] = [];
// Convert 'extraFilter' into an array
const extraFilterFormatted = forceArray(extraFilter);
@@ -246,8 +223,6 @@ export async function fetchSearchResults({
...problemTypesFilterFormatted,
].flat()];
const collectionsFilter = 'type = "collection"';
// First query is always to get the hits, with all the filters applied.
queries.push({
indexUid: indexName,
@@ -255,7 +230,6 @@ export async function fetchSearchResults({
filter: [
// top-level entries in the array are AND conditions and must all match
// Inner arrays are OR conditions, where only one needs to match.
`NOT ${collectionsFilter}`, // exclude collections
...typeFilters,
...extraFilterFormatted,
...tagsFilterFormatted,
@@ -270,52 +244,27 @@ export async function fetchSearchResults({
});
// The second query is to get the possible values for the "block types" filter
queries.push({
indexUid: indexName,
q: searchKeywords,
facets: ['block_type', 'content.problem_types'],
filter: [
...extraFilterFormatted,
// We exclude the block type filter here so we get all the other available options for it.
...tagsFilterFormatted,
],
limit: 0, // We don't need any "hits" for this - just the facetDistribution
});
// Third query is to get the hits for collections, with all the filters applied.
queries.push({
indexUid: indexName,
q: searchKeywords,
filter: [
// top-level entries in the array are AND conditions and must all match
// Inner arrays are OR conditions, where only one needs to match.
collectionsFilter, // include only collections
...extraFilterFormatted,
// We exclude the block type filter as collections are only of 1 type i.e. collection.
...tagsFilterFormatted,
],
attributesToHighlight: ['display_name', 'description'],
highlightPreTag: HIGHLIGHT_PRE_TAG,
highlightPostTag: HIGHLIGHT_POST_TAG,
attributesToCrop: ['description'],
sort,
offset,
limit,
});
queries = applyOverrideQueries(queries, overrideQueries);
if (!skipBlockTypeFetch) {
queries.push({
indexUid: indexName,
facets: ['block_type', 'content.problem_types'],
filter: [
...extraFilterFormatted,
// We exclude the block type filter here so we get all the other available options for it.
...tagsFilterFormatted,
],
limit: 0, // We don't need any "hits" for this - just the facetDistribution
});
}
const { results } = await client.multiSearch(({ queries }));
const componentHitLength = results[0].hits.length;
const collectionHitLength = results[2].hits.length;
const hitLength = results[0].hits.length;
return {
hits: results[0].hits.map(formatSearchHit) as ContentHit[],
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? componentHitLength,
blockTypes: results[1].facetDistribution?.block_type ?? {},
problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {},
nextOffset: componentHitLength === limit || collectionHitLength === limit ? offset + limit : undefined,
collectionHits: results[2].hits.map(formatSearchHit) as CollectionHit[],
totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? collectionHitLength,
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? hitLength,
blockTypes: results[1]?.facetDistribution?.block_type ?? {},
problemTypes: results[1]?.facetDistribution?.['content.problem_types'] ?? {},
nextOffset: hitLength === limit ? offset + limit : undefined,
};
}
@@ -553,19 +502,3 @@ export async function fetchTagsThatMatchKeyword({
return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit };
}
/**
* Fetch single document by its id
*/
/* istanbul ignore next */
export async function fetchDocumentById({ client, indexName, id } : {
/** The Meilisearch client instance */
client: MeiliSearch;
/** Which index to search */
indexName: string;
/** document id */
id: string | number;
}): Promise<ContentHit | CollectionHit> {
const doc = await client.index(indexName).getDocument(id);
return formatSearchHit(doc);
}

View File

@@ -9,9 +9,7 @@ import {
fetchSearchResults,
fetchTagsThatMatchKeyword,
getContentSearchConfig,
fetchDocumentById,
fetchBlockTypes,
OverrideQueries,
} from './api';
/**
@@ -57,7 +55,7 @@ export const useContentSearchResults = ({
problemTypesFilter = [],
tagsFilter = [],
sort = [],
overrideQueries,
skipBlockTypeFetch = false,
}: {
/** The Meilisearch API client */
client?: MeiliSearch;
@@ -75,8 +73,8 @@ export const useContentSearchResults = ({
tagsFilter?: string[];
/** Sort search results using these options */
sort?: SearchSortOption[];
/** Set true to fetch collections along with components */
overrideQueries?: OverrideQueries,
/** If true, don't fetch the block types from the server */
skipBlockTypeFetch?: boolean;
}) => {
const query = useInfiniteQuery({
enabled: client !== undefined && indexName !== undefined,
@@ -92,9 +90,9 @@ export const useContentSearchResults = ({
problemTypesFilter,
tagsFilter,
sort,
overrideQueries,
],
queryFn: ({ pageParam = 0 }) => {
// istanbul ignore if: this should never happen
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
@@ -110,7 +108,7 @@ export const useContentSearchResults = ({
// For infinite pagination of results, we can retrieve additional pages if requested.
// Note that if there are 20 results per page, the "second page" has offset=20, not 2.
offset: pageParam,
overrideQueries,
skipBlockTypeFetch,
});
},
getNextPageParam: (lastPage) => lastPage.nextOffset,
@@ -125,14 +123,8 @@ export const useContentSearchResults = ({
[pages],
);
const collectionHits = React.useMemo(
() => pages?.reduce((allHits, page) => [...allHits, ...page.collectionHits], []) ?? [],
[pages],
);
return {
hits,
collectionHits,
// The distribution of block type filter options
blockTypes: pages?.[0]?.blockTypes ?? {},
problemTypes: pages?.[0]?.problemTypes ?? {},
@@ -147,7 +139,6 @@ export const useContentSearchResults = ({
hasNextPage: query.hasNextPage,
// The last page has the most accurate count of total hits
totalHits: pages?.[pages.length - 1]?.totalHits ?? 0,
totalCollectionHits: pages?.[pages.length - 1]?.totalCollectionHits ?? 0,
};
};
@@ -186,6 +177,7 @@ export const useTagFilterOptions = (args: {
],
queryFn: () => {
const { client, indexName } = args;
// istanbul ignore if: this should never happen
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
@@ -210,6 +202,7 @@ export const useTagFilterOptions = (args: {
],
queryFn: () => {
const { client, indexName } = args;
// istanbul ignore if: this should never happen
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
@@ -259,27 +252,3 @@ export const useGetBlockTypes = (extraFilters: Filter) => {
queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
});
};
/* istanbul ignore next */
export const useGetSingleDocument = ({ client, indexName, id }: {
client?: MeiliSearch;
indexName?: string;
id: string | number;
}) => (
useQuery({
enabled: client !== undefined && indexName !== undefined,
queryKey: [
'content_search',
client?.config.apiKey,
client?.config.host,
indexName,
id,
],
queryFn: () => {
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
return fetchDocumentById({ client, indexName, id });
},
})
);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { StatefulButton } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchContext } from '../search-manager';
import { ContentHit, useSearchContext } from '../search-manager';
import SearchResult from './SearchResult';
import messages from './messages';
@@ -28,7 +28,9 @@ const SearchResults: React.FC<Record<never, never>> = () => {
return (
<>
{hits.map((hit) => <SearchResult key={hit.id} hit={hit} />)}
{hits.filter((hit): hit is ContentHit => hit.type !== 'collection').map(
(hit) => <SearchResult key={hit.id} hit={hit} />,
)}
{hasNextPage
? (
<StatefulButton

View File

@@ -96,12 +96,9 @@ describe('<SearchUI />', () => {
// because otherwise Instantsearch will update the UI and change the query,
// leading to unexpected results in the test cases.
mockResult.results[0].query = query;
mockResult.results[2].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
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockResult;
});
fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult);
@@ -173,8 +170,8 @@ describe('<SearchUI />', () => {
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return requestedFilter?.[2] === 'type = "course_block"'
&& requestedFilter?.[3] === 'context_key = "course-v1:org+test+123"';
return requestedFilter?.[1] === 'type = "course_block"'
&& requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"';
});
// Now we should see the results:
expect(queryByText('Enter a keyword')).toBeNull();
@@ -362,8 +359,8 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
// the filter is:
// ['NOT type == "collection"', '', 'type = "course_block"', 'context_key = "course-v1:org+test+123"']
return (requestedFilter?.length === 4);
// ['', 'type = "course_block"', 'context_key = "course-v1:org+test+123"']
return (requestedFilter?.length === 3);
});
// Now we should see the results:
expect(getByText('6 results found')).toBeInTheDocument();
@@ -389,7 +386,6 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
'NOT type = "collection"',
[
'block_type = problem',
'content.problem_types = choiceresponse',
@@ -423,7 +419,6 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
'NOT type = "collection"',
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
@@ -459,7 +454,6 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
'NOT type = "collection"',
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',

View File

@@ -22,15 +22,6 @@
"block_type": {}
},
"facetStats": {}
},
{
"indexUid": "studio",
"hits": [],
"query": "noresult",
"processingTimeMs": 0,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 0
}
]
}