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 11, 2024
1 parent fc3e38f commit 21e7cab
Show file tree
Hide file tree
Showing 13 changed files with 356 additions and 49 deletions.
18 changes: 18 additions & 0 deletions src/course-outline/CourseOutline.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
11 changes: 7 additions & 4 deletions src/course-outline/section-card/SectionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -155,7 +158,7 @@ const SectionCard = ({
}}
>
<div
className="section-card"
className={`section-card ${isScrolledToElement ? 'highlight' : ''}`}
data-testid="section-card"
ref={currentRef}
>
Expand Down
8 changes: 6 additions & 2 deletions src/course-outline/subsection-card/SubsectionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ const SubsectionCard = ({
if (currentRef.current && (section.shouldScroll || subsection.shouldScroll || isScrolledToElement)) {
scrollToElement(currentRef.current);
}
}, []);
}, [isScrolledToElement]);

useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
Expand All @@ -160,7 +160,11 @@ const SubsectionCard = ({
...borderStyle,
}}
>
<div className="subsection-card" data-testid="subsection-card" ref={currentRef}>
<div
className={`subsection-card ${isScrolledToElement ? 'highlight' : ''}`}
data-testid="subsection-card"
ref={currentRef}
>
{isHeaderVisible && (
<>
<CardHeader
Expand Down
10 changes: 7 additions & 3 deletions src/course-outline/unit-card/UnitCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useToggle } from '@openedx/paragon';
import { isEmpty } from 'lodash';
import { useSearchParams } from 'react-router-dom';

import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
import { RequestStatus } from '../../data/constants';
Expand Down Expand Up @@ -34,6 +35,9 @@ const UnitCard = ({
}) => {
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';

Expand Down Expand Up @@ -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) {
Expand All @@ -139,7 +143,7 @@ const UnitCard = ({
}}
>
<div
className="unit-card"
className={`unit-card ${isScrolledToElement ? 'highlight' : ''}`}
data-testid="unit-card"
ref={currentRef}
>
Expand Down
28 changes: 24 additions & 4 deletions src/search-modal/EmptyStates.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Stack className="d-flex mt-6 align-items-center">
<p className="fs-large"> <FormattedMessage {...messages.emptySearchTitle} /> </p>
<p className="small text-muted"> <FormattedMessage {...messages.emptySearchSubtitle} /> </p>
<img src={EmptySearchImage} alt="" />
</Stack>
);

const NoResults = () => (
<Stack className="d-flex mt-6 align-items-center">
<p className="fs-large"> <FormattedMessage {...messages.noResultsTitle} /> </p>
<p className="small text-muted"> <FormattedMessage {...messages.noResultsSubtitle} /> </p>
<img src={NoResultImage} alt="" />
</Stack>
);

/**
* 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.
Expand All @@ -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 <p className="text-muted text-center mt-6">Enter a keyword or select a filter to begin searching.</p>;
return <EmptySearch />;
}
if (nbHits === 0) {
// Note this isn't localized because it's going to be replaced in a fast-follow PR.
return <p className="text-muted text-center mt-6">No results found. Change your search and try again.</p>;
return <NoResults />;
}

return children;
Expand Down
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} />;
}
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
10 changes: 10 additions & 0 deletions src/search-modal/SearchModal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,14 @@
.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;
}
}

.fs-large {
font-size: $font-size-base * 1.25;
}
}
164 changes: 137 additions & 27 deletions src/search-modal/SearchResult.jsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,147 @@
/* 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 { 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}
>
<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;
Loading

0 comments on commit 21e7cab

Please sign in to comment.