Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hacky Prototype search UI using Instantsearch + Meilisearch [FC-0040] #868

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-dom": "^6.40.4",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.16.0",
Expand All @@ -104,6 +107,7 @@
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.2.1",
"@types/react-instantsearch-dom": "^6.12.7",
"axios": "^0.27.2",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
Expand Down
43 changes: 27 additions & 16 deletions src/header/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
// @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 { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
import messages from './messages';
import SearchModal from '../search-modal/SearchModal';
import { useKeyHandler } from '../hooks';

const Header = ({
courseId,
courseOrg,
courseNumber,
courseTitle,
isHiddenMainMenu,
// injected
intl,
}) => {
const intl = useIntl();
const [showSearchModal, setShowSearchModal] = React.useState(false);
const toggleModal = React.useCallback(() => setShowSearchModal(x => !x), []);
useKeyHandler({ handler: toggleModal, keyName: '/' });

const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const mainMenuDropdowns = [
{
Expand All @@ -36,16 +42,23 @@ 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,
mainMenuDropdowns,
outlineLink,
}}
/>
<SearchModal
isOpen={showSearchModal}
courseId={courseId}
onClose={toggleModal}
/>
</>
);
};

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

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

export default injectIntl(Header);
export default Header;
5 changes: 5 additions & 0 deletions src/header/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ const messages = defineMessages({
defaultMessage: 'Back to course outline in Studio',
description: 'The aria label for the link back to the Studio Course Outline',
},
courseSearchTitle: {
id: 'courseSearch.title',
defaultMessage: 'Search Course(s)',
description: 'Title for the course search dialog',
},
});

export default messages;
16 changes: 16 additions & 0 deletions src/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,19 @@ export const useEscapeClick = ({ onEscape, dependency }) => {
};
}, [dependency]);
};

export const useKeyHandler = ({ keyName, handler, dependencies = [] }) => {
useEffect(() => {
const checkKey = (event) => {
if (event.key === keyName) {
handler();
}
};

window.addEventListener('keydown', checkKey);

return () => {
window.removeEventListener('keydown', checkKey);
};
}, dependencies);
};
75 changes: 75 additions & 0 deletions src/search-modal/SearchModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* 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 { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useQuery } from '@tanstack/react-query';

import messages from './messages';
import { LoadingSpinner } from '../generic/Loading';
import SearchUI from './SearchUI';

// 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,
} = useQuery({
queryKey: ['content_search'],
queryFn: async () => {
const url = new URL('api/content_search/v2/studio/', getConfig().STUDIO_BASE_URL).href;
const response = await getAuthenticatedHttpClient().get(url);
return {
url: response.data.url,
indexName: response.data.index_name,
apiKey: response.data.api_key,
};
},
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.
});

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;
32 changes: 32 additions & 0 deletions src/search-modal/SearchResult.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import {
Highlight,

} from 'react-instantsearch-dom';

/** @type {React.FC<{hit: import('react-instantsearch-core').Hit<{
* id: string,
* breadcrumbs: {display_name: string}[]}>,
* }>} */
const SearchResult = ({ hit }) => (
<div key={hit.id}>
<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">
<Highlight attribute="content.html_content" hit={hit} />
<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>
</div>
);

export default SearchResult;
44 changes: 44 additions & 0 deletions src/search-modal/SearchUI.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import {
HierarchicalMenu,
InfiniteHits,
InstantSearch,
RefinementList,
SearchBox,
Stats,
} from 'react-instantsearch-dom';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import 'instantsearch.css/themes/algolia-min.css';

import SearchResult from './SearchResult';

/** @type {React.FC<{url: string, apiKey: string, indexName: string}>} */
const SearchUI = (props) => {
const { searchClient } = React.useMemo(() => instantMeiliSearch(props.url, props.apiKey), [props.url, props.apiKey]);

return (
<div 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;
15 changes: 15 additions & 0 deletions src/search-modal/messages.js
Original file line number Diff line number Diff line change
@@ -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;
Loading