feat: add content search modal [FC-0040] (#928)

* feat: Prototype search UI using Instantsearch + Meilisearch
---------

Co-authored-by: Braden MacDonald <braden@opencraft.com>
This commit is contained in:
Rômulo Penido
2024-04-08 13:09:15 -03:00
committed by GitHub
parent 5634e9e507
commit 2adff6e51d
11 changed files with 2270 additions and 1196 deletions

View File

@@ -1,9 +1,12 @@
// @ts-check
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StudioHeader } from '@edx/frontend-component-header';
import { useToggle } from '@openedx/paragon';
import SearchModal from '../search-modal/SearchModal';
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
import messages from './messages';
@@ -13,10 +16,13 @@ const Header = ({
courseNumber,
courseTitle,
isHiddenMainMenu,
// injected
intl,
}) => {
const intl = useIntl();
const [isShowSearchModalOpen, openSearchModal, closeSearchModal] = useToggle(false);
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const meiliSearchEnabled = getConfig().MEILISEARCH_ENABLED || null;
const mainMenuDropdowns = [
{
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
@@ -35,17 +41,26 @@ const Header = ({
},
];
const outlineLink = `${studioBaseUrl}/course/${courseId}`;
return (
<StudioHeader
{...{
org: courseOrg,
number: courseNumber,
title: courseTitle,
isHiddenMainMenu,
mainMenuDropdowns,
outlineLink,
}}
/>
<>
<StudioHeader
org={courseOrg}
number={courseNumber}
title={courseTitle}
isHiddenMainMenu={isHiddenMainMenu}
mainMenuDropdowns={mainMenuDropdowns}
outlineLink={outlineLink}
searchButtonAction={meiliSearchEnabled && openSearchModal}
/>
{ meiliSearchEnabled && (
<SearchModal
isOpen={isShowSearchModalOpen}
courseId={courseId}
onClose={closeSearchModal}
/>
)}
</>
);
};
@@ -55,8 +70,6 @@ Header.propTypes = {
courseOrg: PropTypes.string,
courseTitle: PropTypes.string,
isHiddenMainMenu: PropTypes.bool,
// injected
intl: intlShape.isRequired,
};
Header.defaultProps = {
@@ -67,4 +80,4 @@ Header.defaultProps = {
isHiddenMainMenu: false,
};
export default injectIntl(Header);
export default Header;

View File

@@ -1,7 +1,6 @@
import { useEffect } from 'react';
import { history } from '@edx/frontend-platform';
// eslint-disable-next-line import/prefer-default-export
export const useScrollToHashElement = ({ isLoading }) => {
useEffect(() => {
const currentHash = window.location.hash;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -0,0 +1,20 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { getContentSearchConfig } from './api';
/**
* Load the Meilisearch connection details from the CMS: the URL to use, the index name, and an API key specific
* to the current user that allows it to search all content he have permission to view.
*
*/
/* eslint-disable import/prefer-default-export */
export const useContentSearch = () => (
useQuery({
queryKey: ['content_search'],
queryFn: getContentSearchConfig,
staleTime: 60 * 60, // If cache is up to one hour old, no need to re-fetch
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
})
);

View File

@@ -0,0 +1,15 @@
// @ts-check
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
// frontend-platform currently doesn't provide types... do it ourselves.
const defineMessages = /** @type {<T>(x: T) => x} */(_defineMessages);
const messages = defineMessages({
'courseSearch.title': {
id: 'courseSearch.title',
defaultMessage: 'Search',
description: 'Title for the course search dialog',
},
});
export default messages;