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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
60
src/search-modal/SearchModal.jsx
Normal file
60
src/search-modal/SearchModal.jsx
Normal 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;
|
||||
78
src/search-modal/SearchModal.test.jsx
Normal file
78
src/search-modal/SearchModal.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
43
src/search-modal/SearchResult.jsx
Normal file
43
src/search-modal/SearchResult.jsx
Normal 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;
|
||||
53
src/search-modal/SearchUI.jsx
Normal file
53
src/search-modal/SearchUI.jsx
Normal 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;
|
||||
23
src/search-modal/data/api.js
Normal file
23
src/search-modal/data/api.js
Normal 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,
|
||||
};
|
||||
};
|
||||
20
src/search-modal/data/apiHooks.js
Normal file
20
src/search-modal/data/apiHooks.js
Normal 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.
|
||||
})
|
||||
);
|
||||
15
src/search-modal/messages.js
Normal file
15
src/search-modal/messages.js
Normal 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;
|
||||
Reference in New Issue
Block a user