Skip to content

Commit

Permalink
feat: add content search modal [FC-0040] (#928)
Browse files Browse the repository at this point in the history
* feat: Prototype search UI using Instantsearch + Meilisearch
---------

Co-authored-by: Braden MacDonald <braden@opencraft.com>
  • Loading branch information
rpenido and bradenmacdonald authored Apr 8, 2024
1 parent 5634e9e commit 2adff6e
Show file tree
Hide file tree
Showing 11 changed files with 2,274 additions and 1,200 deletions.
3,129 changes: 1,948 additions & 1,181 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-ai-translations": "^2.0.0",
"@edx/frontend-component-footer": "^13.0.2",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-component-header": "^5.1.0",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-lib-content-components": "^2.1.5",
"@edx/frontend-platform": "7.0.1",
Expand All @@ -53,6 +53,7 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@meilisearch/instant-meilisearch": "^0.16.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
Expand All @@ -72,6 +73,7 @@
"email-validator": "2.0.4",
"file-saver": "^2.0.5",
"formik": "2.2.6",
"instantsearch.css": "^8.1.0",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"moment": "2.29.4",
Expand All @@ -80,6 +82,7 @@
"react-datepicker": "^4.13.0",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-instantsearch": "^7.7.1",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.16.0",
Expand Down
47 changes: 30 additions & 17 deletions src/header/Header.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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`,
Expand All @@ -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}
/>
)}
</>
);
};

Expand All @@ -55,8 +70,6 @@ Header.propTypes = {
courseOrg: PropTypes.string,
courseTitle: PropTypes.string,
isHiddenMainMenu: PropTypes.bool,
// injected
intl: intlShape.isRequired,
};

Header.defaultProps = {
Expand All @@ -67,4 +80,4 @@ Header.defaultProps = {
isHiddenMainMenu: false,
};

export default injectIntl(Header);
export default Header;
1 change: 0 additions & 1 deletion src/hooks.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
60 changes: 60 additions & 0 deletions src/search-modal/SearchModal.jsx
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;
78 changes: 78 additions & 0 deletions src/search-modal/SearchModal.test.jsx
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();
});
});
43 changes: 43 additions & 0 deletions src/search-modal/SearchResult.jsx
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;
53 changes: 53 additions & 0 deletions src/search-modal/SearchUI.jsx
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;
23 changes: 23 additions & 0 deletions src/search-modal/data/api.js
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,
};
};
Loading

0 comments on commit 2adff6e

Please sign in to comment.