From 317c3e78ff4c718bd094a72441c189951d497059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 23 Jul 2024 14:42:23 -0300 Subject: [PATCH] feat: adds filter by tags and contentType to library home Refactor to add search-manager feature. Co-authored-by: Yusuf Musleh --- src/index.scss | 3 +- .../LibraryAuthoringPage.test.tsx | 6 +- .../LibraryAuthoringPage.tsx | 119 ++++++++------- src/library-authoring/LibraryHome.tsx | 20 +-- .../components/ComponentCard.tsx | 8 +- .../components/LibraryComponents.test.tsx | 89 ++++++++---- .../components/LibraryComponents.tsx | 16 +-- src/library-authoring/data/api.ts | 1 + src/library-authoring/data/apiHooks.ts | 61 +------- src/library-authoring/{index.ts => index.tsx} | 0 .../BlockTypeLabel.tsx | 0 .../ClearFiltersButton.tsx | 2 +- src/search-manager/FilterBy.scss | 11 ++ .../FilterByBlockType.tsx | 7 +- .../FilterByTags.tsx | 19 ++- .../Highlight.tsx | 8 +- .../SearchFilterWidget.tsx | 27 ++++ .../SearchKeywordsField.tsx | 2 +- .../SearchManager.ts | 8 +- .../Stats.tsx | 2 +- .../data/api.ts | 10 +- .../data/apiHooks.ts | 0 src/search-manager/index.scss | 1 + src/search-manager/index.ts | 10 ++ src/search-manager/messages.ts | 135 ++++++++++++++++++ src/search-modal/EmptyStates.tsx | 2 +- src/search-modal/SearchModal.scss | 16 --- src/search-modal/SearchModal.test.tsx | 2 +- src/search-modal/SearchResult.tsx | 12 +- src/search-modal/SearchResults.tsx | 2 +- src/search-modal/SearchUI.test.tsx | 6 +- src/search-modal/SearchUI.tsx | 14 +- src/search-modal/index.scss | 1 + src/search-modal/index.ts | 2 +- src/search-modal/messages.ts | 120 ---------------- 35 files changed, 395 insertions(+), 347 deletions(-) rename src/library-authoring/{index.ts => index.tsx} (100%) rename src/{search-modal => search-manager}/BlockTypeLabel.tsx (100%) rename src/{search-modal => search-manager}/ClearFiltersButton.tsx (91%) create mode 100644 src/search-manager/FilterBy.scss rename src/{search-modal => search-manager}/FilterByBlockType.tsx (94%) rename src/{search-modal => search-manager}/FilterByTags.tsx (93%) rename src/{search-modal => search-manager}/Highlight.tsx (72%) rename src/{search-modal => search-manager}/SearchFilterWidget.tsx (69%) rename src/{search-modal => search-manager}/SearchKeywordsField.tsx (94%) rename src/{search-modal/manager => search-manager}/SearchManager.ts (94%) rename src/{search-modal => search-manager}/Stats.tsx (90%) rename src/{search-modal => search-manager}/data/api.ts (98%) rename src/{search-modal => search-manager}/data/apiHooks.ts (100%) create mode 100644 src/search-manager/index.scss create mode 100644 src/search-manager/index.ts create mode 100644 src/search-manager/messages.ts create mode 100644 src/search-modal/index.scss 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} > - + -