From 766a619adfa0527df80465405498bdee945198ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 11 Apr 2024 14:34:37 -0300 Subject: [PATCH] feat: improve search modal results --- src/course-outline/CourseOutline.scss | 18 ++ .../section-card/SectionCard.jsx | 11 +- .../subsection-card/SubsectionCard.jsx | 8 +- src/course-outline/unit-card/UnitCard.jsx | 10 +- src/search-modal/BlockTypeLabel.jsx | 2 +- src/search-modal/EmptyStates.jsx | 28 ++- src/search-modal/SearchEndpointLoader.jsx | 6 +- src/search-modal/SearchModal.jsx | 4 +- src/search-modal/SearchModal.scss | 10 ++ src/search-modal/SearchResult.jsx | 168 +++++++++++++++--- src/search-modal/SearchUI.jsx | 30 +++- src/search-modal/images/empty-search.svg | 51 ++++++ src/search-modal/images/no-results.svg | 43 +++++ src/search-modal/messages.js | 30 ++-- 14 files changed, 358 insertions(+), 61 deletions(-) create mode 100644 src/search-modal/images/empty-search.svg create mode 100644 src/search-modal/images/no-results.svg diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 1dad6b4a51..e84b40860f 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -11,3 +11,21 @@ @import "./drag-helper/SortableItem"; @import "./xblock-status/XBlockStatus"; @import "./paste-button/PasteButton"; + +div.row:has(> div > div.highlight) { + animation: 5s glow; +} + +@keyframes glow { + 0% { + box-shadow: 0 0 5px 5px $primary-500; + } + + 90% { + box-shadow: 0 0 5px 5px $primary-500; + } + + 100% { + box-shadow: unset; + } +} diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 6d3f3f490b..914d201ab8 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -7,6 +7,7 @@ import { useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Bubble, Button, useToggle } from '@openedx/paragon'; import { Add as IconAdd } from '@openedx/paragon/icons'; +import { useSearchParams } from 'react-router-dom'; import classNames from 'classnames'; import { setCurrentItem, setCurrentSection } from '../data/slice'; @@ -42,6 +43,9 @@ const SectionCard = ({ const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); const [isExpanded, setIsExpanded] = useState(isSectionsExpanded); + const [searchParams] = useSearchParams(); + const locatorId = searchParams.get('show'); + const isScrolledToElement = locatorId === section.id; const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'section'; @@ -70,11 +74,10 @@ const SectionCard = ({ }, [activeId, overId]); useEffect(() => { - // if this items has been newly added, scroll to it. - if (currentRef.current && section.shouldScroll) { + if (currentRef.current && (section.shouldScroll || isScrolledToElement)) { scrollToElement(currentRef.current); } - }, []); + }, [isScrolledToElement]); // re-create actions object for customizations const actions = { ...sectionActions }; @@ -155,7 +158,7 @@ const SectionCard = ({ }} >
diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 441a4e34f3..7814eb99a1 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -134,7 +134,7 @@ const SubsectionCard = ({ if (currentRef.current && (section.shouldScroll || subsection.shouldScroll || isScrolledToElement)) { scrollToElement(currentRef.current); } - }, []); + }, [isScrolledToElement]); useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { @@ -160,7 +160,11 @@ const SubsectionCard = ({ ...borderStyle, }} > -
+
{isHeaderVisible && ( <> { const currentRef = useRef(null); const dispatch = useDispatch(); + const [searchParams] = useSearchParams(); + const locatorId = searchParams.get('show'); + const isScrolledToElement = locatorId === unit.id; const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'unit'; @@ -109,10 +113,10 @@ const UnitCard = ({ // if this items has been newly added, scroll to it. // we need to check section.shouldScroll as whole section is fetched when a // unit is duplicated under it. - if (currentRef.current && (section.shouldScroll || unit.shouldScroll)) { + if (currentRef.current && (section.shouldScroll || unit.shouldScroll || isScrolledToElement)) { scrollToElement(currentRef.current); } - }, []); + }, [isScrolledToElement]); useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { @@ -139,7 +143,7 @@ const UnitCard = ({ }} >
diff --git a/src/search-modal/BlockTypeLabel.jsx b/src/search-modal/BlockTypeLabel.jsx index 8a6048df47..8eb41f613b 100644 --- a/src/search-modal/BlockTypeLabel.jsx +++ b/src/search-modal/BlockTypeLabel.jsx @@ -19,7 +19,7 @@ const BlockTypeLabel = ({ type }) => { // Replace underscores and hypens with spaces, then let the browser capitalize this // in a locale-aware way to get a reasonable display value. // e.g. 'drag-and-drop-v2' -> "Drag And Drop V2" - return {type.replace(/[_-]/g, ' ')}; + return XXX {type.replace(/[_-]/g, ' ')}; }; export default BlockTypeLabel; diff --git a/src/search-modal/EmptyStates.jsx b/src/search-modal/EmptyStates.jsx index a90a294df2..a1714b1da5 100644 --- a/src/search-modal/EmptyStates.jsx +++ b/src/search-modal/EmptyStates.jsx @@ -1,8 +1,30 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Stack } from '@openedx/paragon'; import { useStats, useClearRefinements } from 'react-instantsearch'; +import EmptySearchImage from './images/empty-search.svg'; +import NoResultImage from './images/no-results.svg'; +import messages from './messages'; + +const EmptySearch = () => ( + +

+

+ +
+); + +const NoResults = () => ( + +

+

+ +
+); + /** * If the user hasn't put any keywords/filters yet, display an "empty state". * Likewise, if the results are empty (0 results), display a special message. @@ -16,12 +38,10 @@ const EmptyStates = ({ children }) => { if (!hasQuery && !hasFiltersApplied) { // We haven't started the search yet. Display the "start your search" empty state - // Note this isn't localized because it's going to be replaced in a fast-follow PR. - return

Enter a keyword or select a filter to begin searching.

; + return ; } if (nbHits === 0) { - // Note this isn't localized because it's going to be replaced in a fast-follow PR. - return

No results found. Change your search and try again.

; + return ; } return children; 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..3054909dab 100644 --- a/src/search-modal/SearchModal.scss +++ b/src/search-modal/SearchModal.scss @@ -68,4 +68,14 @@ .ais-InfiniteHits-loadMore--disabled { display: none; // temporary; remove this once we implement our own / component. } + + .search-result { + &:hover { + background-color: $gray-100 !important; + } + } + + .fs-large { + font-size: $font-size-base * 1.25; + } } diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index cb28172eac..db6f39b386 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -1,37 +1,151 @@ /* 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 ( + +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ navigateToContext(e, true)} /> +
+ ); +}; + export default SearchResult; diff --git a/src/search-modal/SearchUI.jsx b/src/search-modal/SearchUI.jsx index 8becfbe64d..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,13 +41,18 @@ 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. */}
+ {/* Give this toggle button a fixed width so it doesn't change size when the selected option changes */} { iconAfter={searchThisCourse ? Check : undefined} disabled={!props.courseId} > - + This course - + All courses
@@ -77,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), + }, + }))} + /> diff --git a/src/search-modal/images/empty-search.svg b/src/search-modal/images/empty-search.svg new file mode 100644 index 0000000000..09d41ba50b --- /dev/null +++ b/src/search-modal/images/empty-search.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/search-modal/images/no-results.svg b/src/search-modal/images/no-results.svg new file mode 100644 index 0000000000..e6d72bed5b --- /dev/null +++ b/src/search-modal/images/no-results.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/search-modal/messages.js b/src/search-modal/messages.js index 4f85c472e7..5382501c89 100644 --- a/src/search-modal/messages.js +++ b/src/search-modal/messages.js @@ -90,16 +90,6 @@ const messages = defineMessages({ defaultMessage: '{numResults, plural, one {# result} other {# results}} found', description: 'This count displays how many matching results were found from the user\'s search', }, - searchAllCourses: { - id: 'course-authoring.course-search.searchAllCourses', - defaultMessage: 'All courses', - description: 'Option to get search results from all courses.', - }, - searchThisCourse: { - id: 'course-authoring.course-search.searchThisCourse', - defaultMessage: 'This course', - description: 'Option to limit search results to the current course only.', - }, title: { id: 'course-authoring.course-search.title', defaultMessage: 'Search', @@ -115,6 +105,26 @@ const messages = defineMessages({ defaultMessage: 'Show more', description: 'Show more tags / filter options', }, + emptySearchTitle: { + id: 'course-authoring.course-search.emptySearchTitle', + defaultMessage: 'Start searching to find content', + description: 'Title shown when the user has not yet entered a keyword', + }, + emptySearchSubtitle: { + id: 'course-authoring.course-search.emptySearchSubtitle', + defaultMessage: 'Find sections, subsections, units and components', + description: 'Subtitle shown when the user has not yet entered a keyword', + }, + noResultsTitle: { + id: 'course-authoring.course-search.noResultsTitle', + defaultMessage: 'We didn\'t find anything matching your search', + description: 'Title shown when the search returned no results', + }, + noResultsSubtitle: { + id: 'course-authoring.course-search.noResultsSubtitle', + defaultMessage: 'Please try a different search term or filter', + description: 'Subtitle shown when the search returned no results', + }, }); export default messages;