diff --git a/src/search-modal/SearchEndpointLoader.jsx b/src/search-modal/SearchEndpointLoader.jsx index 664a3b5e03..3c5ab7f443 100644 --- a/src/search-modal/SearchEndpointLoader.jsx +++ b/src/search-modal/SearchEndpointLoader.jsx @@ -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 @@ -25,7 +25,7 @@ const SearchEndpointLoader = ({ courseId }) => { const title = intl.formatMessage(messages.title); if (searchEndpointData) { - return ; + return ; } return ( <> diff --git a/src/search-modal/SearchModal.jsx b/src/search-modal/SearchModal.jsx index 93fce12720..d7cd9ea91b 100644 --- a/src/search-modal/SearchModal.jsx +++ b/src/search-modal/SearchModal.jsx @@ -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'; @@ -24,7 +24,7 @@ const SearchModal = ({ courseId, ...props }) => { isFullscreenOnMobile className="courseware-search-modal" > - + ); }; diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss index c67c6ba2a9..cefd8f82df 100644 --- a/src/search-modal/SearchModal.scss +++ b/src/search-modal/SearchModal.scss @@ -68,4 +68,10 @@ .ais-InfiniteHits-loadMore--disabled { display: none; // temporary; remove this once we implement our own / component. } + + .search-result { + &:hover { + background-color: $gray-100 !important; + } + } } diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index cb28172eac..d04237e206 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -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, + getSavingStatuses, + 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 tag for highlighting + * @type {React.FC<{ + * attribute: keyof CustomHit | string[], + * hit: CustomHit, + * separator?: string, * }>} */ -const SearchResult = ({ hit }) => ( -
-
- {' '} - () -
-
- - -
-
- {hit.breadcrumbs.map((bc, i) => ( - // eslint-disable-next-line react/no-array-index-key - {bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '/' : ''} - ))} -
-
+const CustomHighlight = ({ attribute, hit, separator }) => ( + ); +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 ( + + {console.log(hit)} +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ navigateToContext(e, true)} /> +
+ ); +}; + export default SearchResult; diff --git a/src/search-modal/SearchUI.jsx b/src/search-modal/SearchUI.jsx index 6d008a3f96..cd1abb1f76 100644 --- a/src/search-modal/SearchUI.jsx +++ b/src/search-modal/SearchUI.jsx @@ -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' }), @@ -41,7 +41,11 @@ const SearchUI = (props) => { future={{ preserveSharedStateOnUnmount: true }} > {/* Add in a filter for the current course, if relevant */} - + + {/* We need to override z-index here or the appears behind the * But it can't be more then 9 because the close button has z-index 10. */} @@ -78,7 +82,22 @@ const SearchUI = (props) => { {/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */} - + } + 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), + }, + }))} + />