Skip to content

Commit

Permalink
feat: improve search modal results
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Apr 10, 2024
1 parent 881c8ec commit cca5d59
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 35 deletions.
6 changes: 3 additions & 3 deletions src/search-modal/SearchEndpointLoader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { useContentSearch } from './data/apiHooks';
import SearchUI from './SearchUI';
import messages from './messages';

/** @type {React.FC<{courseId: string}>} */
const SearchEndpointLoader = ({ courseId }) => {
/** @type {React.FC<{courseId: string, closeSearch: () => void}>} */
const SearchEndpointLoader = ({ courseId, closeSearch }) => {
const intl = useIntl();

// Load the Meilisearch connection details from the LMS: the URL to use, the index name, and an API key specific
Expand All @@ -25,7 +25,7 @@ const SearchEndpointLoader = ({ courseId }) => {
const title = intl.formatMessage(messages.title);

if (searchEndpointData) {
return <SearchUI {...searchEndpointData} courseId={courseId} />;
return <SearchUI {...searchEndpointData} courseId={courseId} closeSearch={closeSearch}/>;

Check failure on line 28 in src/search-modal/SearchEndpointLoader.jsx

View workflow job for this annotation

GitHub Actions / tests

A space is required before closing bracket
}
return (
<>
Expand Down
4 changes: 2 additions & 2 deletions src/search-modal/SearchModal.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { ModalDialog } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ModalDialog } from '@openedx/paragon';

import SearchEndpointLoader from './SearchEndpointLoader';
import messages from './messages';
Expand All @@ -24,7 +24,7 @@ const SearchModal = ({ courseId, ...props }) => {
isFullscreenOnMobile
className="courseware-search-modal"
>
<SearchEndpointLoader courseId={courseId} />
<SearchEndpointLoader courseId={courseId} closeSearch={props.onClose} />
</ModalDialog>
);
};
Expand Down
6 changes: 6 additions & 0 deletions src/search-modal/SearchModal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@
.ais-InfiniteHits-loadMore--disabled {
display: none; // temporary; remove this once we implement our own <Hits>/<InfiniteHits> component.
}

.search-result {
&:hover {
background-color: $gray-100 !important;
}
}
}
169 changes: 142 additions & 27 deletions src/search-modal/SearchResult.jsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,152 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { Highlight } from 'react-instantsearch';
import BlockTypeLabel from './BlockTypeLabel';
import { getConfig, getPath } from '@edx/frontend-platform';
import {
Icon,
IconButton,
Stack,
} from '@openedx/paragon';
import {
Article,
Folder,
OpenInNew,
Question,
TextFields,
Videocam,
} from '@openedx/paragon/icons';
import {
Highlight,
Snippet,
} from 'react-instantsearch';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';

import {
getLoadingStatuses,

Check failure on line 26 in src/search-modal/SearchResult.jsx

View workflow job for this annotation

GitHub Actions / tests

'getLoadingStatuses' is defined but never used
getSavingStatuses,

Check failure on line 27 in src/search-modal/SearchResult.jsx

View workflow job for this annotation

GitHub Actions / tests

'getSavingStatuses' is defined but never used
getStudioHomeData,
} from '../studio-home/data/selectors';

/**
* A single search result (row), usually represents an XBlock/Component
* @type {React.FC<{hit: import('instantsearch.js').Hit<{
* id: string,
* display_name: string,
* block_type: string,
* 'content.html_content'?: string,
* 'content.capa_content'?: string,
* breadcrumbs: {display_name: string}[]}>,
* @typedef {import('instantsearch.js').Hit<{
* id: string,
* usage_key: string,
* context_key: string,
* display_name: string,
* block_type: string,
* 'content.html_content'?: string,
* 'content.capa_content'?: string,
* breadcrumbs: {display_name: string}[]
* breadcrumbsNames: string[],
* }>} CustomHit
*/

/**
* Custom Highlight component that uses the <b> tag for highlighting
* @type {React.FC<{
* attribute: keyof CustomHit | string[],
* hit: CustomHit,
* separator?: string,
* }>}
*/
const SearchResult = ({ hit }) => (
<div className="my-2 pb-2 border-bottom">
<div className="hit-name small">
<strong><Highlight attribute="display_name" hit={hit} /></strong>{' '}
(<BlockTypeLabel type={hit.block_type} />)
</div>
<div className="hit-description x-small text-truncate">
<Highlight attribute="content.html_content" hit={hit} />
<Highlight attribute="content.capa_content" hit={hit} />
</div>
<div className="text-muted x-small">
{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>
const CustomHighlight = ({ attribute, hit, separator }) => (
<Highlight
attribute={attribute}
hit={hit}
separator={separator}
highlightedTagName="b"
/>
);

const ItemIcon = {
vertical: Folder,
sequential: Folder,
chapter: Folder,
problem: Question,
video: Videocam,
html: TextFields,
};

/**
* Returns the URL for the context of the hit
* @param {CustomHit} hit
* @param {boolean} newWindow
* @param {string} libraryAuthoringMfeUrl
* @returns {string}
*/
const getContextUrl = (hit, newWindow, libraryAuthoringMfeUrl) => {
const { context_key: contextKey, usage_key: usageKey } = hit;
if (contextKey.startsWith('course-v1:')) {
if (newWindow) {
return `${getPath(getConfig().PUBLIC_PATH)}/course/${contextKey}?show=${encodeURIComponent(usageKey)}`;
}
return `/course/${contextKey}?show=${encodeURIComponent(usageKey)}`;
}
if (usageKey.includes('lb:')) {
return `${libraryAuthoringMfeUrl}library/${contextKey}`;
}
return '#';
};

/**
* A single search result (row), usually represents an XBlock/Component
* @type {React.FC<{ hit: CustomHit, closeSearch: () => void}>}
*/
const SearchResult = ({ hit, closeSearch }) => {
const navigate = useNavigate();
const { libraryAuthoringMfeUrl } = useSelector(getStudioHomeData);

/**
* Navigates to the context of the hit
* @param {React.MouseEvent} e
* @param {boolean} newWindow
* @returns {void}
* */
const navigateToContext = (e, newWindow) => {
e.stopPropagation();
const url = getContextUrl(hit, newWindow, libraryAuthoringMfeUrl);
if (newWindow) {
window.open(url, '_blank');
return;
}

if (url.startsWith('http')) {
window.location.href = url;
return;
}

navigate(url);
closeSearch();
};

return (
<Stack
className="border-bottom search-result p-2"
role="button"
direction="horizontal"
gap={2}
onClick={navigateToContext}
>
{console.log(hit)}

Check warning on line 131 in src/search-modal/SearchResult.jsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected console statement
<div className="align-top">
<Icon className="align-top" src={ItemIcon[hit.block_type] || Article} />
</div>
<Stack>
<div className="hit-name small">
<CustomHighlight attribute="display_name" hit={hit} />
</div>
<div className="hit-description x-small text-truncate">
<Snippet attribute="content.html_content" hit={hit} highlightedTagName="b" />
<Snippet attribute="content.capa_content" hit={hit} highlightedTagName="b" />
</div>
<div className="text-muted x-small">
<CustomHighlight attribute="breadcrumbsNames" separator=" / " hit={hit} />
</div>
</Stack>
<IconButton src={OpenInNew} iconAs={Icon} onClick={(e) => navigateToContext(e, true)} />
</Stack>
);
};

export default SearchResult;
25 changes: 22 additions & 3 deletions src/search-modal/SearchUI.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import FilterByTags from './FilterByTags';
import Stats from './Stats';
import messages from './messages';

/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string}>} */
/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string, closeSearch: () => void}>} */
const SearchUI = (props) => {
const { searchClient } = React.useMemo(
() => instantMeiliSearch(props.url, props.apiKey, { primaryKey: 'id' }),
Expand All @@ -41,7 +41,11 @@ const SearchUI = (props) => {
future={{ preserveSharedStateOnUnmount: true }}
>
{/* Add in a filter for the current course, if relevant */}
<Configure filters={searchThisCourse ? `context_key = "${props.courseId}"` : undefined} />
<Configure
filters={searchThisCourse ? `context_key = "${props.courseId}"` : undefined}
attributesToSnippet={['content.html_content:20', 'content.capa_content:20']}
/>

{/* We need to override z-index here or the <Dropdown.Menu> appears behind the <ModalDialog.Body>
* But it can't be more then 9 because the close button has z-index 10. */}
<ModalDialog.Header style={{ zIndex: 9 }} className="border-bottom">
Expand Down Expand Up @@ -77,7 +81,22 @@ const SearchUI = (props) => {
<ModalDialog.Body className="h-[calc(100vh-200px)]">
{/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */}
<EmptyStates>
<InfiniteHits hitComponent={SearchResult} />
<InfiniteHits
hitComponent={({ hit }) => <SearchResult hit={hit} closeSearch={props.closeSearch} />}

Check failure on line 85 in src/search-modal/SearchUI.jsx

View workflow job for this annotation

GitHub Actions / tests

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “SearchUI” and pass data as props. If you want to allow component creation in props, set allowAsProps option to true
classNames={{
list: 'list-unstyled',
}}
transformItems={(items) => items.map((item) => ({
...item,
breadcrumbsNames: item.breadcrumbs.map((bc) => bc.display_name),
_highlightResult: {
// eslint-disable-next-line no-underscore-dangle
...item._highlightResult,
// eslint-disable-next-line no-underscore-dangle
breadcrumbsNames: item._highlightResult.breadcrumbs.map((bc) => bc.display_name),
},
}))}
/>
</EmptyStates>
</ModalDialog.Body>
</InstantSearch>
Expand Down

0 comments on commit cca5d59

Please sign in to comment.