-
Notifications
You must be signed in to change notification settings - Fork 78
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add content search modal [FC-0040] (#928)
* feat: Prototype search UI using Instantsearch + Meilisearch --------- Co-authored-by: Braden MacDonald <braden@opencraft.com>
- Loading branch information
1 parent
5634e9e
commit 2adff6e
Showing
11 changed files
with
2,274 additions
and
1,200 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
/* eslint-disable react/prop-types */ | ||
// @ts-check | ||
import React from 'react'; | ||
import { | ||
ModalDialog, | ||
} from '@openedx/paragon'; | ||
import { ErrorAlert } from '@edx/frontend-lib-content-components'; | ||
import { useIntl } from '@edx/frontend-platform/i18n'; | ||
|
||
import { LoadingSpinner } from '../generic/Loading'; | ||
import SearchUI from './SearchUI'; | ||
import { useContentSearch } from './data/apiHooks'; | ||
import messages from './messages'; | ||
|
||
// Using TypeScript here is blocked until we have frontend-build 14: | ||
// interface Props { | ||
// courseId: string; | ||
// isOpen: boolean; | ||
// onClose: () => void; | ||
// } | ||
|
||
/** @type {React.FC<{courseId: string, isOpen: boolean, onClose: () => void}>} */ | ||
const SearchModal = ({ courseId, ...props }) => { | ||
const intl = useIntl(); | ||
|
||
// Load the Meilisearch connection details from the LMS: the URL to use, the index name, and an API key specific | ||
// to us (to the current user) that allows us to search all content we have permission to view. | ||
const { | ||
data: searchEndpointData, | ||
isLoading, | ||
error, | ||
} = useContentSearch(); | ||
|
||
const title = intl.formatMessage(messages['courseSearch.title']); | ||
let body; | ||
if (searchEndpointData) { | ||
body = <SearchUI {...searchEndpointData} />; | ||
} else if (isLoading) { | ||
body = <LoadingSpinner />; | ||
} else { | ||
// @ts-ignore | ||
body = <ErrorAlert isError>{error?.message ?? String(error)}</ErrorAlert>; | ||
} | ||
|
||
return ( | ||
<ModalDialog | ||
title={title} | ||
size="xl" | ||
isOpen={props.isOpen} | ||
onClose={props.onClose} | ||
hasCloseButton | ||
isFullscreenOnMobile | ||
> | ||
<ModalDialog.Header><ModalDialog.Title>{title}</ModalDialog.Title></ModalDialog.Header> | ||
<ModalDialog.Body>{body}</ModalDialog.Body> | ||
</ModalDialog> | ||
); | ||
}; | ||
|
||
export default SearchModal; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import React from 'react'; | ||
|
||
import { initializeMockApp } from '@edx/frontend-platform'; | ||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; | ||
import { IntlProvider } from '@edx/frontend-platform/i18n'; | ||
import { AppProvider } from '@edx/frontend-platform/react'; | ||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
import { render } from '@testing-library/react'; | ||
import MockAdapter from 'axios-mock-adapter'; | ||
|
||
import initializeStore from '../store'; | ||
import SearchModal from './SearchModal'; | ||
import { getContentSearchConfigUrl } from './data/api'; | ||
|
||
let store; | ||
let axiosMock; | ||
|
||
const queryClient = new QueryClient({ | ||
defaultOptions: { | ||
queries: { | ||
retry: false, | ||
}, | ||
}, | ||
}); | ||
|
||
const RootWrapper = () => ( | ||
<AppProvider store={store}> | ||
<IntlProvider locale="en" messages={{}}> | ||
<QueryClientProvider client={queryClient}> | ||
<SearchModal isOpen onClose={() => undefined} /> | ||
</QueryClientProvider> | ||
</IntlProvider> | ||
</AppProvider> | ||
); | ||
|
||
describe('<SearchModal />', () => { | ||
beforeEach(() => { | ||
initializeMockApp({ | ||
authenticatedUser: { | ||
userId: 3, | ||
username: 'abc123', | ||
administrator: true, | ||
roles: [], | ||
}, | ||
}); | ||
store = initializeStore(); | ||
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
queryClient.clear(); | ||
}); | ||
|
||
it('should render the search ui if the config is loaded', async () => { | ||
axiosMock.onGet(getContentSearchConfigUrl()).replyOnce(200, { | ||
url: 'https://meilisearch.example.com', | ||
index: 'test-index', | ||
apiKey: 'test-api-key', | ||
}); | ||
const { findByTestId } = render(<RootWrapper />); | ||
expect(await findByTestId('search-ui')).toBeInTheDocument(); | ||
}); | ||
|
||
it('should render the spinner while the config is loading', () => { | ||
axiosMock.onGet(getContentSearchConfigUrl()).replyOnce(200, new Promise(() => {})); // never resolves | ||
const { getByRole } = render(<RootWrapper />); | ||
|
||
const spinner = getByRole('status'); | ||
expect(spinner.textContent).toEqual('Loading...'); | ||
}); | ||
|
||
it('should render the error message if the api call throws', async () => { | ||
axiosMock.onGet(getContentSearchConfigUrl()).networkError(); | ||
const { findByText } = render(<RootWrapper />); | ||
expect(await findByText('Network Error')).toBeInTheDocument(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
/* eslint-disable react/prop-types */ | ||
// @ts-check | ||
import React from 'react'; | ||
import { Highlight } from 'react-instantsearch'; | ||
|
||
/* This component will be replaced by a new search UI component that will be developed in the future. | ||
* See: | ||
* - https://github.com/openedx/modular-learning/issues/200 | ||
* - https://github.com/openedx/modular-learning/issues/201 | ||
*/ | ||
/* istanbul ignore next */ | ||
/** @type {React.FC<{hit: import('instantsearch.js').Hit<{ | ||
* id: string, | ||
* display_name: string, | ||
* block_type: string, | ||
* content: { | ||
* html_content: string, | ||
* capa_content: string | ||
* }, | ||
* breadcrumbs: {display_name: string}[]}>, | ||
* }>} */ | ||
const SearchResult = ({ hit }) => ( | ||
<> | ||
<div className="hit-name"> | ||
<strong><Highlight attribute="display_name" hit={hit} /></strong> | ||
</div> | ||
<p className="hit-block_type"><em><Highlight attribute="block_type" hit={hit} /></em></p> | ||
<div className="hit-description"> | ||
{ /* @ts-ignore Wrong type definition upstream */ } | ||
<Highlight attribute="content.html_content" hit={hit} /> | ||
{ /* @ts-ignore Wrong type definition upstream */ } | ||
<Highlight attribute="content.capa_content" hit={hit} /> | ||
</div> | ||
<div style={{ fontSize: '8px' }}> | ||
{hit.breadcrumbs.map((bc, i) => ( | ||
// eslint-disable-next-line react/no-array-index-key | ||
<span key={i}>{bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '>' : ''} </span> | ||
))} | ||
</div> | ||
</> | ||
); | ||
|
||
export default SearchResult; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
/* eslint-disable react/prop-types */ | ||
// @ts-check | ||
import React from 'react'; | ||
import { | ||
HierarchicalMenu, | ||
InfiniteHits, | ||
InstantSearch, | ||
RefinementList, | ||
SearchBox, | ||
Stats, | ||
} from 'react-instantsearch'; | ||
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'; | ||
import 'instantsearch.css/themes/algolia-min.css'; | ||
|
||
import SearchResult from './SearchResult'; | ||
|
||
/* This component will be replaced by a new search UI component that will be developed in the future. | ||
* See: | ||
* - https://github.com/openedx/modular-learning/issues/200 | ||
* - https://github.com/openedx/modular-learning/issues/201 | ||
*/ | ||
/* istanbul ignore next */ | ||
/** @type {React.FC<{url: string, apiKey: string, indexName: string}>} */ | ||
const SearchUI = (props) => { | ||
const { searchClient } = React.useMemo( | ||
() => instantMeiliSearch(props.url, props.apiKey, { primaryKey: 'id' }), | ||
[props.url, props.apiKey], | ||
); | ||
|
||
return ( | ||
<div data-testid="search-ui" className="ais-InstantSearch"> | ||
<InstantSearch indexName={props.indexName} searchClient={searchClient}> | ||
<Stats /> | ||
<SearchBox /> | ||
<strong>Refine by component type:</strong> | ||
<RefinementList attribute="block_type" /> | ||
<strong>Refine by tag:</strong> | ||
<HierarchicalMenu | ||
attributes={[ | ||
'tags.taxonomy', | ||
'tags.level0', | ||
'tags.level1', | ||
'tags.level2', | ||
'tags.level3', | ||
]} | ||
/> | ||
<InfiniteHits hitComponent={SearchResult} /> | ||
</InstantSearch> | ||
</div> | ||
); | ||
}; | ||
|
||
export default SearchUI; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
// @ts-check | ||
import { getConfig } from '@edx/frontend-platform'; | ||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; | ||
|
||
export const getContentSearchConfigUrl = () => new URL( | ||
'api/content_search/v2/studio/', | ||
getConfig().STUDIO_BASE_URL, | ||
).href; | ||
|
||
/** | ||
* Get the content search configuration from the CMS. | ||
* | ||
* @returns {Promise<{url: string, indexName: string, apiKey: string}>} | ||
*/ | ||
export const getContentSearchConfig = async () => { | ||
const url = getContentSearchConfigUrl(); | ||
const response = await getAuthenticatedHttpClient().get(url); | ||
return { | ||
url: response.data.url, | ||
indexName: response.data.index_name, | ||
apiKey: response.data.api_key, | ||
}; | ||
}; |
Oops, something went wrong.