diff --git a/src/index.scss b/src/index.scss
index 381ca17082..db1b1d8ac6 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -26,7 +26,8 @@
@import "textbooks/Textbooks";
@import "content-tags-drawer/ContentTagsDropDownSelector";
@import "content-tags-drawer/ContentTagsCollapsible";
-@import "search-modal/SearchModal";
+@import "search-modal";
+@import "search-manager";
@import "certificates/scss/Certificates";
@import "group-configurations/GroupConfigurations";
@import "library-authoring";
diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index 0e90e222f6..0012858829 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -12,9 +12,8 @@ import {
screen,
} from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';
-
import initializeStore from '../store';
-import { getContentSearchConfigUrl } from '../search-modal/data/api';
+import { getContentSearchConfigUrl } from '../search-manager/data/api';
import mockResult from '../search-modal/__mocks__/search-result.json';
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
import { getContentLibraryApiUrl, type ContentLibrary } from './data/api';
@@ -159,8 +158,7 @@ describe('', () => {
} = render();
// Ensure the search endpoint is called
- // One called for LibraryComponents and another called for components count
- await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
expect(getByText('Content library')).toBeInTheDocument();
expect(getByText(libraryData.title)).toBeInTheDocument();
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index 8d5e2f7313..f3afb5555f 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -3,14 +3,13 @@ import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
+ Col,
Container,
Icon,
IconButton,
- SearchField,
+ Row,
Tab,
Tabs,
- Row,
- Col,
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import {
@@ -21,13 +20,20 @@ import Loading from '../generic/Loading';
import SubHeader from '../generic/sub-header/SubHeader';
import Header from '../header';
import NotFoundAlert from '../generic/NotFoundAlert';
+import {
+ ClearFiltersButton,
+ FilterByBlockType,
+ FilterByTags,
+ SearchContextProvider,
+ SearchKeywordsField,
+} from '../search-manager';
import LibraryComponents from './components/LibraryComponents';
import LibraryCollections from './LibraryCollections';
import LibraryHome from './LibraryHome';
import { useContentLibrary } from './data/apiHooks';
-import messages from './messages';
import { LibrarySidebar } from './library-sidebar';
import { LibraryContext } from './common/context';
+import messages from './messages';
enum TabList {
home = '',
@@ -54,7 +60,6 @@ const LibraryAuthoringPage = () => {
const intl = useIntl();
const location = useLocation();
const navigate = useNavigate();
- const [searchKeywords, setSearchKeywords] = React.useState('');
const { libraryId } = useParams();
@@ -87,57 +92,61 @@ const LibraryAuthoringPage = () => {
contextId={libraryId}
isLibrary
/>
-
- }
- subtitle={intl.formatMessage(messages.headingSubtitle)}
- headerActions={[
- ,
- ]}
- />
- setSearchKeywords(value)}
- onSubmit={() => {}}
- className="w-50"
- />
-
-
-
-
-
-
- }
- />
- }
- />
- }
- />
- }
+
+
+ }
+ subtitle={intl.formatMessage(messages.headingSubtitle)}
+ headerActions={[
+ ,
+ ]}
/>
-
-
+
+
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
{ sidebarBodyComponent !== null && (
diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx
index 0c202b2cdc..60544b1645 100644
--- a/src/library-authoring/LibraryHome.tsx
+++ b/src/library-authoring/LibraryHome.tsx
@@ -4,11 +4,11 @@ import {
Card, Stack,
} from '@openedx/paragon';
+import { useSearchContext } from '../search-manager';
import { NoComponents, NoSearchResults } from './EmptyStates';
import LibraryCollections from './LibraryCollections';
-import { useLibraryComponentCount } from './data/apiHooks';
-import messages from './messages';
import { LibraryComponents } from './components';
+import messages from './messages';
const Section = ({ title, children } : { title: string, children: React.ReactNode }) => (
@@ -23,15 +23,17 @@ const Section = ({ title, children } : { title: string, children: React.ReactNod
type LibraryHomeProps = {
libraryId: string,
- filter: {
- searchKeywords: string,
- },
};
-const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => {
+const LibraryHome = ({ libraryId } : LibraryHomeProps) => {
const intl = useIntl();
- const { searchKeywords } = filter;
- const { componentCount, collectionCount } = useLibraryComponentCount(libraryId, searchKeywords);
+
+ const {
+ totalHits: componentCount,
+ searchKeywords,
+ } = useSearchContext();
+
+ const collectionCount = 0;
if (componentCount === 0) {
return searchKeywords === '' ? : ;
@@ -46,7 +48,7 @@ const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => {
);
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index 0789354491..ce9ef594ab 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -10,11 +10,11 @@ import {
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
-import messages from './messages';
-import TagCount from '../../generic/tag-count';
+
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
-import { ContentHit } from '../../search-modal/data/api';
-import Highlight from '../../search-modal/Highlight';
+import TagCount from '../../generic/tag-count';
+import { type ContentHit, Highlight } from '../../search-manager';
+import messages from './messages';
type ComponentCardProps = {
contentHit: ContentHit,
diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx
index 13687a2c09..716f07ec83 100644
--- a/src/library-authoring/components/LibraryComponents.test.tsx
+++ b/src/library-authoring/components/LibraryComponents.test.tsx
@@ -1,38 +1,60 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, fireEvent } from '@testing-library/react';
-import LibraryComponents from './LibraryComponents';
+import MockAdapter from 'axios-mock-adapter';
+import fetchMock from 'fetch-mock-jest';
+import type { Store } from 'redux';
+import { getContentSearchConfigUrl } from '../../search-manager/data/api';
+import { SearchContextProvider } from '../../search-manager/SearchManager';
+import mockEmptyResult from '../../search-modal/__mocks__/empty-search-result.json';
import initializeStore from '../../store';
import { libraryComponentsMock } from '../__mocks__';
+import LibraryComponents from './LibraryComponents';
+
+const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
-const mockUseLibraryComponents = jest.fn();
-const mockUseLibraryComponentCount = jest.fn();
const mockUseLibraryBlockTypes = jest.fn();
const mockFetchNextPage = jest.fn();
-let store;
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
-});
+const mockUseSearchContext = jest.fn();
const data = {
+ totalHits: 1,
hits: [],
isFetching: true,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: mockFetchNextPage,
+ searchKeywords: '',
};
-const countData = {
- componentCount: 1,
- collectionCount: 0,
+
+let store: Store;
+let axiosMock: MockAdapter;
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 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;
};
+
const blockTypeData = {
data: [
{
@@ -51,16 +73,21 @@ const blockTypeData = {
};
jest.mock('../data/apiHooks', () => ({
- useLibraryComponents: () => mockUseLibraryComponents(),
- useLibraryComponentCount: () => mockUseLibraryComponentCount(),
useLibraryBlockTypes: () => mockUseLibraryBlockTypes(),
}));
+jest.mock('../../search-manager', () => ({
+ ...jest.requireActual('../../search-manager'),
+ useSearchContext: () => mockUseSearchContext(),
+}));
+
const RootWrapper = (props) => (
-
+
+
+
@@ -77,9 +104,18 @@ describe('', () => {
},
});
store = initializeStore();
- mockUseLibraryComponents.mockReturnValue(data);
- mockUseLibraryComponentCount.mockReturnValue(countData);
mockUseLibraryBlockTypes.mockReturnValue(blockTypeData);
+ mockUseSearchContext.mockReturnValue(data);
+
+ fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
+
+ // The API method to get the Meilisearch connection details uses Axios:
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onGet(getContentSearchConfigUrl()).reply(200, {
+ url: 'http://mock.meilisearch.local',
+ index_name: 'studio',
+ api_key: 'test-key',
+ });
});
afterEach(() => {
@@ -87,16 +123,17 @@ describe('', () => {
});
it('should render empty state', async () => {
- mockUseLibraryComponentCount.mockReturnValueOnce({
- ...countData,
- componentCount: 0,
+ mockUseSearchContext.mockReturnValue({
+ ...data,
+ totalHits: 0,
});
+
render();
expect(await screen.findByText(/you have not added any content to this library yet\./i));
});
it('should render components in full variant', async () => {
- mockUseLibraryComponents.mockReturnValue({
+ mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
isFetching: false,
@@ -112,7 +149,7 @@ describe('', () => {
});
it('should render components in preview variant', async () => {
- mockUseLibraryComponents.mockReturnValue({
+ mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
isFetching: false,
@@ -128,7 +165,7 @@ describe('', () => {
});
it('should call `fetchNextPage` on scroll to bottom in full variant', async () => {
- mockUseLibraryComponents.mockReturnValue({
+ mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
isFetching: false,
@@ -146,7 +183,7 @@ describe('', () => {
});
it('should not call `fetchNextPage` on croll to bottom in preview variant', async () => {
- mockUseLibraryComponents.mockReturnValue({
+ mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
isFetching: false,
diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx
index b2e7ed68b1..a4742194c0 100644
--- a/src/library-authoring/components/LibraryComponents.tsx
+++ b/src/library-authoring/components/LibraryComponents.tsx
@@ -1,16 +1,14 @@
import React, { useEffect, useMemo } from 'react';
-
import { CardGrid } from '@openedx/paragon';
+
+import { useSearchContext } from '../../search-manager';
import { NoComponents, NoSearchResults } from '../EmptyStates';
-import { useLibraryBlockTypes, useLibraryComponentCount, useLibraryComponents } from '../data/apiHooks';
+import { useLibraryBlockTypes } from '../data/apiHooks';
import ComponentCard from './ComponentCard';
type LibraryComponentsProps = {
libraryId: string,
- filter: {
- searchKeywords: string,
- },
- variant: string,
+ variant: 'full' | 'preview',
};
/**
@@ -22,16 +20,16 @@ type LibraryComponentsProps = {
*/
const LibraryComponents = ({
libraryId,
- filter: { searchKeywords },
variant,
}: LibraryComponentsProps) => {
- const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords);
const {
hits,
+ totalHits: componentCount,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
- } = useLibraryComponents(libraryId, searchKeywords);
+ searchKeywords,
+ } = useSearchContext();
const componentList = variant === 'preview' ? hits.slice(0, 4) : hits;
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index a0129b5c16..7171808649 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -52,6 +52,7 @@ export async function getLibraryBlockTypes(libraryId?: string): Promise (
/**
* Hook to fetch block types of a library.
*/
-export const useLibraryBlockTypes = (libraryId) => (
+export const useLibraryBlockTypes = (libraryId: string) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.contentLibraryBlockTypes(libraryId),
queryFn: () => getLibraryBlockTypes(libraryId),
})
);
-/**
- * Hook to fetch components in a library.
- */
-export const useLibraryComponents = (libraryId: string, searchKeywords: string) => {
- const { data: connectionDetails } = useContentSearchConnection();
-
- const indexName = connectionDetails?.indexName;
- const client = React.useMemo(() => {
- if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
- return undefined;
- }
- return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
- }, [connectionDetails?.apiKey, connectionDetails?.url]);
-
- const libFilter = `context_key = "${libraryId}"`;
-
- return useContentSearchResults({
- client,
- indexName,
- searchKeywords,
- extraFilter: [libFilter],
- });
-};
-
/**
* Use this mutation to create a block in a library
*/
@@ -88,38 +61,6 @@ export const useCreateLibraryBlock = () => {
});
};
-/**
- * Hook to fetch the count of components and collections in a library.
- */
-export const useLibraryComponentCount = (libraryId: string, searchKeywords: string) => {
- // Meilisearch code to get Collection and Component counts
- const { data: connectionDetails } = useContentSearchConnection();
-
- const indexName = connectionDetails?.indexName;
- const client = React.useMemo(() => {
- if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
- return undefined;
- }
- return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
- }, [connectionDetails?.apiKey, connectionDetails?.url]);
-
- const libFilter = `context_key = "${libraryId}"`;
-
- const { totalHits: componentCount } = useContentSearchResults({
- client,
- indexName,
- searchKeywords,
- extraFilter: [libFilter], // ToDo: Add filter for components when collection is implemented
- });
-
- const collectionCount = 0; // ToDo: Implement collections count
-
- return {
- componentCount,
- collectionCount,
- };
-};
-
/**
* Builds the query to fetch list of V2 Libraries
*/
diff --git a/src/library-authoring/index.ts b/src/library-authoring/index.tsx
similarity index 100%
rename from src/library-authoring/index.ts
rename to src/library-authoring/index.tsx
diff --git a/src/search-modal/BlockTypeLabel.tsx b/src/search-manager/BlockTypeLabel.tsx
similarity index 100%
rename from src/search-modal/BlockTypeLabel.tsx
rename to src/search-manager/BlockTypeLabel.tsx
diff --git a/src/search-modal/ClearFiltersButton.tsx b/src/search-manager/ClearFiltersButton.tsx
similarity index 91%
rename from src/search-modal/ClearFiltersButton.tsx
rename to src/search-manager/ClearFiltersButton.tsx
index 7a29e51722..eeae127381 100644
--- a/src/search-modal/ClearFiltersButton.tsx
+++ b/src/search-manager/ClearFiltersButton.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import messages from './messages';
-import { useSearchContext } from './manager/SearchManager';
+import { useSearchContext } from './SearchManager';
/**
* A button that appears when at least one filter is active, and will clear the filters when clicked.
diff --git a/src/search-manager/FilterBy.scss b/src/search-manager/FilterBy.scss
new file mode 100644
index 0000000000..3caccac691
--- /dev/null
+++ b/src/search-manager/FilterBy.scss
@@ -0,0 +1,11 @@
+// Options for the "filter by tag/block type" menu
+.pgn__menu.filter-by-refinement-menu {
+ .pgn__menu-item {
+ // Make the "filter by tag/block type" menu expand to fit the tags hierarchy and longer block type names
+ width: 100%;
+ }
+}
+
+.clear-filter-button:hover {
+ color: $info-900 !important;
+}
diff --git a/src/search-modal/FilterByBlockType.tsx b/src/search-manager/FilterByBlockType.tsx
similarity index 94%
rename from src/search-modal/FilterByBlockType.tsx
rename to src/search-manager/FilterByBlockType.tsx
index 5aba1bc7df..dc65c7ca86 100644
--- a/src/search-modal/FilterByBlockType.tsx
+++ b/src/search-manager/FilterByBlockType.tsx
@@ -6,10 +6,11 @@ import {
Menu,
MenuItem,
} from '@openedx/paragon';
+import { FilterList } from '@openedx/paragon/icons';
import SearchFilterWidget from './SearchFilterWidget';
import messages from './messages';
import BlockTypeLabel from './BlockTypeLabel';
-import { useSearchContext } from './manager/SearchManager';
+import { useSearchContext } from './SearchManager';
/**
* A button with a dropdown that allows filtering the current search by component type (XBlock type)
@@ -69,8 +70,10 @@ const FilterByBlockType: React.FC> = () => {
({ label: }))}
label={}
+ clearFilter={() => setBlockTypesFilter([])}
+ icon={FilterList}
>
-
+
-