Skip to content

Commit

Permalink
feat: add prequery search suggestions (#371)
Browse files Browse the repository at this point in the history
* feat: add prequery search suggestions
  • Loading branch information
katrinan029 authored Feb 1, 2024
1 parent 1541b86 commit 3651ee0
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 61 deletions.
35 changes: 25 additions & 10 deletions packages/catalog-search/src/SearchBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ export const SearchBoxBase = ({
enterpriseSlug,
suggestionSubmitOverride,
disableSuggestionRedirect,
isPreQueryEnabled,
}) => {
const { dispatch, trackingName } = useContext(SearchContext);

const [autocompleteHits, setAutocompleteHits] = useState([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [preQueryHits, setPreQueryHits] = useState([]);

/**
* Handles when a search is submitted by adding the user's search
Expand Down Expand Up @@ -91,7 +93,6 @@ export const SearchBoxBase = ({

// Track the focused element
const focusedElement = useActiveElement();

// Function to be called when the user stops typing, will fetch algolia hits for query after `DEBOUNCE_TIME_MS` has
// elapsed
const debounceFunc = async (query) => {
Expand All @@ -103,15 +104,23 @@ export const SearchBoxBase = ({
attributesToRetrieve: ALGOLIA_ATTRIBUTES_TO_RETRIEVE,
});
if (nbHits > 0) {
setPreQueryHits([]);
setAutocompleteHits(hits);
setShowSuggestions(true);
} else {
// If there are no results of the suggested search, hide the empty suggestion component
setShowSuggestions(false);
}
// Hide the results as soon as the user removes the entire query string, instead of waiting a second
} else {
setShowSuggestions(false);
// If isPreQueryEnabled is true display the prequery results when user clicks on search box but has not began typing
} else if (query === '' && isPreQueryEnabled) {
const { hits } = await index.search(query, {
filters,
attributesToHighlight: ['title'],
attributesToRetrieve: ALGOLIA_ATTRIBUTES_TO_RETRIEVE,
});
setAutocompleteHits([]);
setPreQueryHits(hits);
setShowSuggestions(true);
}
};
// Since the debounced method is called in a useEffect hook, use `useCallback` to account for repeated invoking of the
Expand All @@ -124,7 +133,7 @@ export const SearchBoxBase = ({
if (index !== undefined && focusedElement.classList.contains(SEARCH_BOX_CLASS_NAME)) {
debounceHandler(searchQuery);
}
// Retry this method if the focused element or the search query changes
// Retry this method if the focused element or the search query changes
}, [searchQuery, focusedElement]);

/**
Expand All @@ -143,10 +152,10 @@ export const SearchBoxBase = ({
return (
<div className={className}>
{!hideTitle && (
/* eslint-disable-next-line jsx-a11y/label-has-associated-control */
<label id="search-input-box" className="fe__searchfield-input-box text-brand-primary">
{ headerTitle || searchText }
</label>
/* eslint-disable-next-line jsx-a11y/label-has-associated-control */
<label id="search-input-box" className="fe__searchfield-input-box text-brand-primary">
{headerTitle || searchText}
</label>
)}
<SearchField.Advanced
className={classNames('fe__searchfield', {
Expand All @@ -155,6 +164,9 @@ export const SearchBoxBase = ({
value={defaultRefinement}
onSubmit={handleSubmit}
onClear={handleClear}
onFocus={(query) => {
setSearchQuery(query);
}}
onChange={(query) => {
setSearchQuery(query);
}}
Expand All @@ -169,9 +181,10 @@ export const SearchBoxBase = ({
<SearchField.ClearButton data-nr-synth-id="catalog-search-clear-button" />
<SearchField.SubmitButton data-nr-synth-id="catalog-search-submit-button" />
</SearchField.Advanced>
{ showSuggestions && (
{showSuggestions && (
<SearchSuggestions
enterpriseSlug={enterpriseSlug}
preQueryHits={preQueryHits}
autoCompleteHits={autocompleteHits}
handleSubmit={() => handleSubmit(searchQuery)}
handleSuggestionClickSubmit={hit => handleSuggestionSubmit(hit)}
Expand All @@ -193,6 +206,7 @@ SearchBoxBase.propTypes = {
enterpriseSlug: PropTypes.string,
suggestionSubmitOverride: PropTypes.func,
disableSuggestionRedirect: PropTypes.bool,
isPreQueryEnabled: PropTypes.bool,
};

SearchBoxBase.defaultProps = {
Expand All @@ -206,6 +220,7 @@ SearchBoxBase.defaultProps = {
index: undefined,
suggestionSubmitOverride: undefined,
disableSuggestionRedirect: false,
isPreQueryEnabled: false,
};

export default connectSearchBox(SearchBoxBase);
11 changes: 9 additions & 2 deletions packages/catalog-search/src/SearchHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const SearchHeader = ({
index,
filters,
suggestionSubmitOverride,
enterpriseConfig: { slug, enablePathways },
enterpriseConfig: { slug, enablePathways, enterpriseFeatures },
disableSuggestionRedirect,
}) => {
const { refinements } = useContext(SearchContext);
Expand Down Expand Up @@ -57,6 +57,7 @@ const SearchHeader = ({
enterpriseSlug={slug}
suggestionSubmitOverride={suggestionSubmitOverride}
disableSuggestionRedirect={disableSuggestionRedirect}
isPreQueryEnabled={enterpriseFeatures.featurePrequerySearchSuggestions}
/>
</Col>
<Col
Expand Down Expand Up @@ -92,7 +93,13 @@ SearchHeader.propTypes = {
index: PropTypes.shape({ search: PropTypes.func.isRequired }),
filters: PropTypes.string,
enterpriseConfig: PropTypes.shape(
{ slug: PropTypes.string, enablePathways: PropTypes.bool },
{
slug: PropTypes.string,
enablePathways: PropTypes.bool,
enterpriseFeatures: PropTypes.shape({
featurePrequerySearchSuggestions: PropTypes.bool,
}),
},
),
suggestionSubmitOverride: PropTypes.func,
disableSuggestionRedirect: PropTypes.bool,
Expand Down
72 changes: 51 additions & 21 deletions packages/catalog-search/src/SearchSuggestionItem.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react';
import { Image } from '@edx/paragon';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';

const SearchSuggestionItem = ({
url, suggestionItemHandler, hit, disableSuggestionRedirect,
url, suggestionItemHandler, hit, disableSuggestionRedirect, isPreQuery,
}) => {
const authoringOrganization = hit.key && hit.key.split('+')[0];
// If the disable redirect bool is provided, prevent the redirect from happening and instead call the provided submit
Expand All @@ -14,28 +15,48 @@ const SearchSuggestionItem = ({
suggestionItemHandler(hit);
}
};

return (
<Link to={url} key={hit.title} className="suggestion-item" onClick={handleLinkDisable}>
<div>
{ /* eslint-disable-next-line react/no-danger, no-underscore-dangle */ }
<div dangerouslySetInnerHTML={{ __html: hit._highlightResult.title.value }} />
{
authoringOrganization && (
<div className="badge badge-light ml-3 font-weight-light authoring-org-badge">
{authoringOrganization}
<div>
{isPreQuery ? (
<Link to={url} key={hit.title} className="prequery-item pr-4 d-flex flex-column" onClick={handleLinkDisable}>
<div className="d-flex align-items-center justify-content-start">
<Image className="prequery-image mr-2" src={hit.card_image_url} />
<div className="d-flex flex-column">
{/* eslint-disable-next-line react/no-danger, no-underscore-dangle */}
<div dangerouslySetInnerHTML={{ __html: hit._highlightResult.title.value }} />
<div className="x-small d-flex">
<span>
{hit.partners[0]?.name}
</span>
<span> | </span>
<span className="text-capitalize">{hit.learning_type}</span>
</div>
</div>
)
}
</div>
{
hit.program_type && (
<p className="font-weight-light text-gray-400 program-type">
{hit.program_type}
</p>
)
}
</Link>
</div>
</Link>
) : (
<Link to={url} key={hit.title} className="suggestion-item" onClick={handleLinkDisable}>
<div>
{ /* eslint-disable-next-line react/no-danger, no-underscore-dangle */}
<div dangerouslySetInnerHTML={{ __html: hit._highlightResult.title.value }} />
{
authoringOrganization && (
<div className="badge badge-light ml-3 font-weight-light authoring-org-badge">
{authoringOrganization}
</div>
)
}
</div>
{
hit.program_type && (
<p className="font-weight-light text-gray-400 program-type">
{hit.program_type}
</p>
)
}
</Link>
)}
</div>
);
};

Expand All @@ -47,12 +68,21 @@ SearchSuggestionItem.propTypes = {
title: PropTypes.string,
program_type: PropTypes.string,
_highlightResult: PropTypes.shape({ title: PropTypes.shape({ value: PropTypes.string }) }),
card_image_url: PropTypes.string,
partners: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
}),
),
learning_type: PropTypes.string,
}).isRequired,
disableSuggestionRedirect: PropTypes.bool.isRequired,
isPreQuery: PropTypes.bool,
};

SearchSuggestionItem.defaultProps = {
suggestionItemHandler: undefined,
isPreQuery: false,
};

export default SearchSuggestionItem;
50 changes: 47 additions & 3 deletions packages/catalog-search/src/SearchSuggestions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import PropTypes from 'prop-types';
import {
MAX_NUM_SUGGESTIONS, LEARNING_TYPE_COURSE, LEARNING_TYPE_PROGRAM,
LEARNING_TYPE_EXECUTIVE_EDUCATION, COURSE_TYPE_EXECUTIVE_EDUCATION,
MAX_NUM_PRE_QUERY_SUGGESTIONS,
} from './data/constants';
import SearchSuggestionItem from './SearchSuggestionItem';

const SearchSuggestions = ({
preQueryHits,
autoCompleteHits,
enterpriseSlug,
handleSubmit,
Expand All @@ -24,9 +26,16 @@ const SearchSuggestions = ({
};
const getLinkToProgram = (program) => `/${enterpriseSlug}/program/${program.aggregation_key.split(':').pop()}`;

const preQuerySuggestions = [];
const courses = [];
const programs = [];
const execEdCourses = [];

if (preQueryHits) {
preQueryHits.forEach((hit) => {
preQuerySuggestions.push(hit);
});
}
autoCompleteHits.forEach((hit) => {
const { learning_type: learningType } = hit;
if (learningType === LEARNING_TYPE_COURSE) { courses.push(hit); }
Expand All @@ -35,6 +44,36 @@ const SearchSuggestions = ({
});
return (
<div className="suggestions" data-testid="suggestions">
{preQuerySuggestions.length > 0 && (
<div>
<div className="mb-2 ml-2 mt-1 font-weight-bold suggestions-section">
Top-rated courses
</div>
{
preQuerySuggestions.slice(0, MAX_NUM_PRE_QUERY_SUGGESTIONS)
.map((hit) => {
const getUrl = (course) => {
const { learning_type: learningType } = course;
if (learningType === LEARNING_TYPE_COURSE || learningType === LEARNING_TYPE_EXECUTIVE_EDUCATION) {
return getLinkToCourse(course);
}
return getLinkToProgram(course);
};

return (
<SearchSuggestionItem
key={hit.title}
url={getUrl(hit)}
hit={hit}
isPreQuery={preQuerySuggestions.length > 0}
disableSuggestionRedirect={disableSuggestionRedirect}
suggestionItemHandler={handleSuggestionClickSubmit}
/>
);
})
}
</div>
)}
{courses.length > 0 && (
<div>
<div className="mb-2 ml-2 mt-1 font-weight-bold suggestions-section">
Expand All @@ -47,6 +86,7 @@ const SearchSuggestions = ({
key={hit.title}
url={getLinkToCourse(hit)}
hit={hit}
isPreQuery={preQuerySuggestions.length > 0}
disableSuggestionRedirect={disableSuggestionRedirect}
suggestionItemHandler={handleSuggestionClickSubmit}
/>
Expand Down Expand Up @@ -92,9 +132,11 @@ const SearchSuggestions = ({
}
</div>
)}
<button type="button" className="btn btn-light w-100 view-all-btn" onClick={handleSubmit}>
View all results
</button>
{!preQuerySuggestions.length && (
<button type="button" className="btn btn-light w-100 view-all-btn" onClick={handleSubmit}>
View all results
</button>
)}
</div>
);
};
Expand All @@ -107,13 +149,15 @@ SearchSuggestions.propTypes = {
handleSubmit: PropTypes.func,
handleSuggestionClickSubmit: PropTypes.func,
disableSuggestionRedirect: PropTypes.bool,
preQueryHits: PropTypes.arrayOf(PropTypes.shape()),
};

SearchSuggestions.defaultProps = {
handleSubmit: undefined,
enterpriseSlug: '',
handleSuggestionClickSubmit: undefined,
disableSuggestionRedirect: false,
preQueryHits: undefined,
};

export default SearchSuggestions;
1 change: 1 addition & 0 deletions packages/catalog-search/src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const NUM_CURRENT_REFINEMENTS_TO_DISPLAY = 3;
export const NUM_RESULTS_PER_PAGE = 24;

export const MAX_NUM_SUGGESTIONS = 3;
export const MAX_NUM_PRE_QUERY_SUGGESTIONS = 5;

export const NO_OPTIONS_FOUND = 'No options found.';

Expand Down
Loading

0 comments on commit 3651ee0

Please sign in to comment.