From 3651ee0f0e77e461956175d98aaa4addb38a1762 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Thu, 1 Feb 2024 10:22:27 -0800 Subject: [PATCH] feat: add prequery search suggestions (#371) * feat: add prequery search suggestions --- packages/catalog-search/src/SearchBox.jsx | 35 ++++++--- packages/catalog-search/src/SearchHeader.jsx | 11 ++- .../src/SearchSuggestionItem.jsx | 72 ++++++++++++----- .../catalog-search/src/SearchSuggestions.jsx | 50 +++++++++++- packages/catalog-search/src/data/constants.js | 1 + .../src/styles/_SearchSuggestions.scss | 77 +++++++++++++------ .../src/tests/SearchHeader.test.jsx | 7 +- .../src/tests/SearchSuggestions.test.jsx | 19 ++++- 8 files changed, 211 insertions(+), 61 deletions(-) diff --git a/packages/catalog-search/src/SearchBox.jsx b/packages/catalog-search/src/SearchBox.jsx index 5d76226c..22007aac 100644 --- a/packages/catalog-search/src/SearchBox.jsx +++ b/packages/catalog-search/src/SearchBox.jsx @@ -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 @@ -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) => { @@ -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 @@ -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]); /** @@ -143,10 +152,10 @@ export const SearchBoxBase = ({ return (
{!hideTitle && ( - /* eslint-disable-next-line jsx-a11y/label-has-associated-control */ - + /* eslint-disable-next-line jsx-a11y/label-has-associated-control */ + )} { + setSearchQuery(query); + }} onChange={(query) => { setSearchQuery(query); }} @@ -169,9 +181,10 @@ export const SearchBoxBase = ({ - { showSuggestions && ( + {showSuggestions && ( handleSubmit(searchQuery)} handleSuggestionClickSubmit={hit => handleSuggestionSubmit(hit)} @@ -193,6 +206,7 @@ SearchBoxBase.propTypes = { enterpriseSlug: PropTypes.string, suggestionSubmitOverride: PropTypes.func, disableSuggestionRedirect: PropTypes.bool, + isPreQueryEnabled: PropTypes.bool, }; SearchBoxBase.defaultProps = { @@ -206,6 +220,7 @@ SearchBoxBase.defaultProps = { index: undefined, suggestionSubmitOverride: undefined, disableSuggestionRedirect: false, + isPreQueryEnabled: false, }; export default connectSearchBox(SearchBoxBase); diff --git a/packages/catalog-search/src/SearchHeader.jsx b/packages/catalog-search/src/SearchHeader.jsx index ebd8bc0b..7b20f5ab 100644 --- a/packages/catalog-search/src/SearchHeader.jsx +++ b/packages/catalog-search/src/SearchHeader.jsx @@ -21,7 +21,7 @@ const SearchHeader = ({ index, filters, suggestionSubmitOverride, - enterpriseConfig: { slug, enablePathways }, + enterpriseConfig: { slug, enablePathways, enterpriseFeatures }, disableSuggestionRedirect, }) => { const { refinements } = useContext(SearchContext); @@ -57,6 +57,7 @@ const SearchHeader = ({ enterpriseSlug={slug} suggestionSubmitOverride={suggestionSubmitOverride} disableSuggestionRedirect={disableSuggestionRedirect} + isPreQueryEnabled={enterpriseFeatures.featurePrequerySearchSuggestions} /> { 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 @@ -14,28 +15,48 @@ const SearchSuggestionItem = ({ suggestionItemHandler(hit); } }; - return ( - -
- { /* eslint-disable-next-line react/no-danger, no-underscore-dangle */ } -
- { - authoringOrganization && ( -
- {authoringOrganization} +
+ {isPreQuery ? ( + +
+ +
+ {/* eslint-disable-next-line react/no-danger, no-underscore-dangle */} +
+
+ + {hit.partners[0]?.name} + + | + {hit.learning_type} +
- ) - } -
- { - hit.program_type && ( -

- {hit.program_type} -

- ) - } - +
+ + ) : ( + +
+ { /* eslint-disable-next-line react/no-danger, no-underscore-dangle */} +
+ { + authoringOrganization && ( +
+ {authoringOrganization} +
+ ) + } +
+ { + hit.program_type && ( +

+ {hit.program_type} +

+ ) + } + + )} +
); }; @@ -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; diff --git a/packages/catalog-search/src/SearchSuggestions.jsx b/packages/catalog-search/src/SearchSuggestions.jsx index af8f743d..6f126ca2 100644 --- a/packages/catalog-search/src/SearchSuggestions.jsx +++ b/packages/catalog-search/src/SearchSuggestions.jsx @@ -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, @@ -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); } @@ -35,6 +44,36 @@ const SearchSuggestions = ({ }); return (
+ {preQuerySuggestions.length > 0 && ( +
+
+ Top-rated courses +
+ { + 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 ( + 0} + disableSuggestionRedirect={disableSuggestionRedirect} + suggestionItemHandler={handleSuggestionClickSubmit} + /> + ); + }) + } +
+ )} {courses.length > 0 && (
@@ -47,6 +86,7 @@ const SearchSuggestions = ({ key={hit.title} url={getLinkToCourse(hit)} hit={hit} + isPreQuery={preQuerySuggestions.length > 0} disableSuggestionRedirect={disableSuggestionRedirect} suggestionItemHandler={handleSuggestionClickSubmit} /> @@ -92,9 +132,11 @@ const SearchSuggestions = ({ }
)} - + {!preQuerySuggestions.length && ( + + )}
); }; @@ -107,6 +149,7 @@ SearchSuggestions.propTypes = { handleSubmit: PropTypes.func, handleSuggestionClickSubmit: PropTypes.func, disableSuggestionRedirect: PropTypes.bool, + preQueryHits: PropTypes.arrayOf(PropTypes.shape()), }; SearchSuggestions.defaultProps = { @@ -114,6 +157,7 @@ SearchSuggestions.defaultProps = { enterpriseSlug: '', handleSuggestionClickSubmit: undefined, disableSuggestionRedirect: false, + preQueryHits: undefined, }; export default SearchSuggestions; diff --git a/packages/catalog-search/src/data/constants.js b/packages/catalog-search/src/data/constants.js index e000b0fc..bf11b32b 100644 --- a/packages/catalog-search/src/data/constants.js +++ b/packages/catalog-search/src/data/constants.js @@ -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.'; diff --git a/packages/catalog-search/src/styles/_SearchSuggestions.scss b/packages/catalog-search/src/styles/_SearchSuggestions.scss index 7d589775..b75aadfc 100644 --- a/packages/catalog-search/src/styles/_SearchSuggestions.scss +++ b/packages/catalog-search/src/styles/_SearchSuggestions.scss @@ -1,4 +1,4 @@ -.suggestions{ +.suggestions { position: absolute; background: white; margin-top: auto; @@ -13,53 +13,84 @@ margin: 5px 10px 5px 10px; padding: 8px; color: #000; + font-size: 75%; white-space: pre-wrap; - &:hover{ - color:white; - background-color:#000000 ; - text-decoration: none; - } - &:hover span { - color:white; + &:hover { + color: white; + background-color: #000000; + text-decoration: none; } - em { - font-weight: bold; - font-style: normal; - &:hover{ + + &:hover span { color: white; } - &:not(:first-child) { - margin-left: 4px; // add space between _highlightResult.title.value hits + + em { + font-weight: bold; + font-style: normal; + + &:hover { + color: white; + } + + &:not(:first-child) { + margin-left: 4px; // add space between _highlightResult.title.value hits + } } -} - div{ + + div { display: flex; } - .authoring-org-badge{ + + .authoring-org-badge { line-height: 1.5; } - .program-type{ + .program-type { font-size: .9rem; margin-bottom: 0px; } } +.prequery-item { + margin: 5px 10px 5px 10px; + color: #000; + white-space: pre-wrap; + + &:hover { + color: white; + background-color: #000000; + text-decoration: none; + } + + &:hover span { + color: white; + } +} + +.prequery-image { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 0 !important; +} + .suggestion-heading { - color: #707070!important + color: #707070 !important } .view-all-btn { - color:#000; + color: #000; border-radius: 0px; - &:hover{ - color:#000; + + &:hover { + color: #000; } } .suggestions-section { color: #707070; font-size: .9rem; -} +} \ No newline at end of file diff --git a/packages/catalog-search/src/tests/SearchHeader.test.jsx b/packages/catalog-search/src/tests/SearchHeader.test.jsx index 41c6e5c9..2c84132f 100644 --- a/packages/catalog-search/src/tests/SearchHeader.test.jsx +++ b/packages/catalog-search/src/tests/SearchHeader.test.jsx @@ -19,7 +19,12 @@ jest.mock('../SearchFilters', () => ({ __esModule: true, default: () =>
Filter
, })); -const enterpriseConfig = { slug: 'test-enterprise' }; +const enterpriseConfig = { + slug: 'test-enterprise', + enterpriseFeatures: { + featurePrequerySearchSuggestions: true, + }, +}; describe('SearchHeader', () => { test('displays a SearchBox', () => { renderWithSearchContext(); diff --git a/packages/catalog-search/src/tests/SearchSuggestions.test.jsx b/packages/catalog-search/src/tests/SearchSuggestions.test.jsx index 1d322c1c..2c94bc48 100644 --- a/packages/catalog-search/src/tests/SearchSuggestions.test.jsx +++ b/packages/catalog-search/src/tests/SearchSuggestions.test.jsx @@ -13,6 +13,9 @@ const fakeSuggestionsData = { key: 'edX+courseX', title: 'test-course', _highlightResult: { title: { value: 'test-course' } }, + partners: [{ + name: 'edx-partner', + }], }, { learning_type: 'program', @@ -22,6 +25,9 @@ const fakeSuggestionsData = { aggregation_key: '123:456', authoring_organizations: [{ key: 'harvard' }], program_type: 'xSeries', + partners: [{ + name: 'harvard-partner', + }], }, ], }; @@ -74,7 +80,18 @@ describe('', () => { expect(screen.getByText('xSeries')).not.toBeNull(); expect(screen.getByText('View all results')).not.toBeNull(); }); - + test('renders only prequery suggestions if isPreQueryEnabled is true', () => { + renderWithRouter(); + expect(screen.getByText('Top-rated courses')).not.toBeNull(); + expect(screen.queryByText('Courses')).toBeNull(); + expect(screen.queryByText('Programs')).toBeNull(); + }); test('renders no errors when no authoring orgs found for programs data', () => { renderWithRouter(