From afafff9fbe0b04d15d52744a869d58eced3f742e Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Wed, 1 Nov 2023 15:31:06 +0100 Subject: [PATCH] Use autosuggest search API. --- cypress/e2e/release-gate/search.cy.tsx | 64 +------- src/components/Search/SearchGroup.tsx | 48 ------ src/components/Search/SearchInput.tsx | 208 ++++++++----------------- src/components/Search/SearchTypes.ts | 20 --- 4 files changed, 71 insertions(+), 269 deletions(-) delete mode 100644 src/components/Search/SearchGroup.tsx diff --git a/cypress/e2e/release-gate/search.cy.tsx b/cypress/e2e/release-gate/search.cy.tsx index b88347640b..10b543d312 100644 --- a/cypress/e2e/release-gate/search.cy.tsx +++ b/cypress/e2e/release-gate/search.cy.tsx @@ -1,69 +1,11 @@ -const searchResponse = { - response: { - numFound: 2, - start: 0, - maxScore: 10.0, - docs: [ - { - id: 'hcc-module-/openshift/create-OPENSHIFT.cluster.create.azure', - view_uri: 'https://console.redhat.com/openshift/create', - documentKind: 'ModuleDefinition', - allTitle: 'Azure Red Hat OpenShift', - bundle: ['openshift'], - bundle_title: ['OpenShift'], - relative_uri: '/openshift/create', - alt_titles: ['ARO', 'Azure', 'OpenShift on Azure'], - abstract: 'https://console.redhat.com/openshift/create', - timestamp: '2023-08-22T17:01:31.717Z', - _version_: 1774949404248113152, - }, - { - id: 'hcc-module-/openshift/releases-openshift.releases', - view_uri: 'https://console.redhat.com/openshift/releases', - documentKind: 'ModuleDefinition', - allTitle: 'Releases', - bundle: ['openshift'], - bundle_title: ['OpenShift'], - relative_uri: '/openshift/releases', - icons: 'InfrastructureIcon', - abstract: 'View general information on the most recent OpenShift Container Platform release versions that you can install.', - timestamp: '2023-08-15T10:55:46.769Z', - _version_: 1774949404248113152, - }, - ], - }, - highlighting: { - 'hcc-module-/openshift/create-OPENSHIFT.cluster.create.azure': { - abstract: ['https://console.redhat.com/openshift/create'], - allTitle: ['Azure Red Hat OpenShift'], - bundle: ['openshift'], - }, - 'hcc-module-/openshift/releases-openshift.releases': { - abstract: ['View general information on the most recent OpenShift Container Platform release versions that you can install.'], - allTitle: ['Releases'], - bundle: ['openshift'], - }, - }, -}; - describe('Search', () => { it('search for openshift services', () => { cy.login(); cy.visit('/'); - cy.intercept( - { - method: 'GET', - url: '**/hydra/rest/search/**', - }, - searchResponse - ).as('search'); + cy.wait(4000); cy.get('.chr-c-search__input').click().type('openshift'); - cy.wait('@search').its('response.statusCode').should('equal', 200); - cy.get('@search.all').should('have.length', 1); cy.screenshot(); - cy.get('.chr-c-search__input').should('contain', 'Top 2 results'); - cy.get('.chr-c-search__input li').first().should('contain', 'Azure'); - cy.get('.chr-c-search__input li').last().should('contain', 'Releases').click(); - cy.url().should('contain', '/openshift/releases'); + cy.get('.chr-c-search__input').should('contain', 'Top 10 results'); + cy.get('.chr-c-search__input li').first().should('contain', 'Red Hat OpenShift'); }); }); diff --git a/src/components/Search/SearchGroup.tsx b/src/components/Search/SearchGroup.tsx deleted file mode 100644 index 44bbdce93e..0000000000 --- a/src/components/Search/SearchGroup.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { MenuGroup, MenuItem } from '@patternfly/react-core/dist/dynamic/components/Menu'; -import React from 'react'; -import ChromeLink from '../ChromeLink'; -import SearchDescription from './SearchDescription'; -import SearchTitle from './SearchTitle'; -import { - AUTOSUGGEST_TERM_DELIMITER, - HighlightingResponseType, - SearchAutoSuggestionResultItem, - SearchResultItem, - SearchResultItemAggregate, -} from './SearchTypes'; - -const SearchGroup = ({ items, highlighting }: { items: SearchResultItemAggregate[]; highlighting: HighlightingResponseType }) => { - return items.length > 0 ? ( - - {items.map((item) => { - if (item.term) { - const { term, payload } = item as SearchAutoSuggestionResultItem; - const [allTitle, bundle_title, abstract] = term.split(AUTOSUGGEST_TERM_DELIMITER); - return ( - } - description={} - key={crypto.randomUUID()} - > - - - ); - } - const { id, allTitle, bundle_title, abstract, relative_uri } = item as SearchResultItem; - return ( - } - description={} - key={id} - > - - - ); - })} - - ) : null; -}; - -export default SearchGroup; diff --git a/src/components/Search/SearchInput.tsx b/src/components/Search/SearchInput.tsx index 7c99a73684..5fd7dbfb9e 100644 --- a/src/components/Search/SearchInput.tsx +++ b/src/components/Search/SearchInput.tsx @@ -1,31 +1,23 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Bullseye } from '@patternfly/react-core/dist/dynamic/layouts/Bullseye'; -import { Menu, MenuContent, MenuGroup, MenuList } from '@patternfly/react-core/dist/dynamic/components/Menu'; +import { Menu, MenuContent, MenuGroup, MenuItem, MenuList } from '@patternfly/react-core/dist/dynamic/components/Menu'; import { SearchInput as PFSearchInput, SearchInputProps } from '@patternfly/react-core/dist/dynamic/components/SearchInput'; import { Spinner } from '@patternfly/react-core/dist/dynamic/components/Spinner'; import { Popper } from '@patternfly/react-core/dist/dynamic/helpers/Popper/Popper'; import debounce from 'lodash/debounce'; import uniq from 'lodash/uniq'; +import uniqWith from 'lodash/uniqWith'; import './SearchInput.scss'; -import SearchGroup from './SearchGroup'; -import { - AUTOSUGGEST_BUNDLE_CLOSE_TAG, - AUTOSUGGEST_BUNDLE_OPEN_TAG, - AUTOSUGGEST_HIGHLIGHT_CLOSE_TAG, - AUTOSUGGEST_HIGHLIGHT_OPEN_TAG, - AUTOSUGGEST_TERM_DELIMITER, - HighlightingResponseType, - SearchAutoSuggestionResponseType, - SearchResponseAggregate, - SearchResponseType, - SearchResultItemAggregate, -} from './SearchTypes'; +import { AUTOSUGGEST_TERM_DELIMITER, SearchAutoSuggestionResponseType, SearchResponseType } from './SearchTypes'; import EmptySearchState from './EmptySearchState'; import { isProd } from '../../utils/common'; import { useSegment } from '../../analytics/useSegment'; import useWindowWidth from '../../hooks/useWindowWidth'; +import ChromeLink from '../ChromeLink'; +import SearchTitle from './SearchTitle'; +import SearchDescription from './SearchDescription'; export type SearchInputprops = { isExpanded?: boolean; @@ -33,6 +25,7 @@ export type SearchInputprops = { const IS_PROD = isProd(); const REPLACE_TAG = 'REPLACE_TAG'; +const REPLACE_COUNT_TAG = 'REPLACE_COUNT_TAG'; /** * The ?q is the search term. * ------ @@ -47,22 +40,13 @@ const REPLACE_TAG = 'REPLACE_TAG'; */ const BASE_SEARCH = new URLSearchParams(); -BASE_SEARCH.append('q', `${REPLACE_TAG}`); // add query replacement tag and enable fuzzy search with ~ and wildcards +BASE_SEARCH.append('q', `alt_titles:${REPLACE_TAG}`); // add query replacement tag and enable fuzzy search with ~ and wildcards BASE_SEARCH.append('fq', 'documentKind:ModuleDefinition'); // search for ModuleDefinition documents -BASE_SEARCH.append('rows', '10'); // request 10 results -BASE_SEARCH.append('hl', 'true'); // enable highlight -BASE_SEARCH.append('hl.method', 'original'); // choose highlight method -BASE_SEARCH.append('hl.fl', 'abstract'); // highlight description -BASE_SEARCH.append('hl.fl', 'allTitle'); // highlight title -BASE_SEARCH.append('hl.fl', 'bundle_title'); // highlight bundle title -BASE_SEARCH.append('hl.fl', 'bundle'); // highlight bundle id -BASE_SEARCH.append('hl.snippets', '3'); // enable up to 3 highlights in a single string -BASE_SEARCH.append('hl.mergeContiguous', 'true'); // Use only one highlight attribute to simply tag replacement. +BASE_SEARCH.append('rows', `${REPLACE_COUNT_TAG}`); // request 10 results const BASE_URL = new URL(`https://access.${IS_PROD ? '' : 'stage.'}redhat.com/hydra/rest/search/platform/console/`); // search API stopped receiving encoded search string BASE_URL.search = decodeURIComponent(BASE_SEARCH.toString()); -const SEARCH_QUERY = BASE_URL.toString(); const SUGGEST_SEARCH = new URLSearchParams(); SUGGEST_SEARCH.append('redhat_client', 'console'); // required client id @@ -85,20 +69,11 @@ const getMaxMenuHeight = (menuElement?: HTMLDivElement | null) => { return bodyHeight - menuTopOffset - 4; }; -type SearchCategories = { - highLevel: SearchResultItemAggregate[]; - midLevel: SearchResultItemAggregate[]; - lowLevel: SearchResultItemAggregate[]; -}; - -const initialSearchState: SearchResponseAggregate = { - docs: [], - maxScore: 0, - numFound: 0, - start: 0, - suggest: { - default: {}, - }, +type SearchItem = { + title: string; + bundleTitle: string; + description: string; + pathname: string; }; type SearchInputListener = { @@ -109,8 +84,7 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { const [isOpen, setIsOpen] = useState(false); const [searchValue, setSearchValue] = useState(''); const [isFetching, setIsFetching] = useState(false); - const [highlighting, setHighlighting] = useState({}); - const [searchResults, setSearchResults] = useState(initialSearchState); + const [searchItems, setSearchItems] = useState([]); const { ready, analytics } = useSegment(); const blockCloseEvent = useRef(false); @@ -120,51 +94,7 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { const containerRef = useRef(null); const { md } = useWindowWidth(); - const resultCount = searchResults?.suggest?.default[searchValue]?.numFound || 0; - - // sort result items based on matched field and its priority - const resultCategories = useMemo(() => { - const categories = (searchResults?.suggest?.default[searchValue]?.suggestions || []).reduce( - (acc, curr) => { - const [allTitle, , abstract] = curr.term.split(AUTOSUGGEST_TERM_DELIMITER); - if (allTitle.includes(AUTOSUGGEST_HIGHLIGHT_OPEN_TAG)) { - return { - ...acc, - highLevel: [...acc.highLevel, curr], - }; - } - - if (abstract.includes(AUTOSUGGEST_HIGHLIGHT_OPEN_TAG)) { - return { - ...acc, - midLevel: [...acc.midLevel, curr], - }; - } - - return { - ...acc, - lowLevel: [...acc.lowLevel, curr], - }; - }, - { - highLevel: [], - midLevel: [], - lowLevel: [], - } - ); - searchResults.docs.forEach((doc) => { - if (highlighting[doc.id]?.allTitle) { - categories.highLevel.push(doc); - } - - if (highlighting[doc.id]?.abstract) { - categories.midLevel.push(doc); - } - - categories.lowLevel.push(doc); - }); - return categories; - }, [searchResults, searchValue]); + const resultCount = searchItems.length; const handleMenuKeys = (event: KeyboardEvent) => { if (!isOpen) { @@ -247,57 +177,52 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { }; }, [isOpen, menuRef]); - const handleFetch = (value = '') => { - return fetch(SUGGEST_SEARCH_QUERY.replaceAll(REPLACE_TAG, value)) - .then((r) => r.json()) - .then((response: SearchAutoSuggestionResponseType) => { - let valueToSearch = value; - const autoSuggestions = (response?.suggest?.default[value]?.suggestions || []).map((suggestion) => { - const [allTitle, bundle_title, abstract] = suggestion.term.split(AUTOSUGGEST_TERM_DELIMITER); - // TODO it is not safe to assume only one field will have a highlight. Need to revisit this and above logic - let matched; - if (allTitle.includes(AUTOSUGGEST_HIGHLIGHT_OPEN_TAG)) { - matched = allTitle; - } else if (bundle_title.includes(AUTOSUGGEST_HIGHLIGHT_OPEN_TAG)) { - matched = bundle_title; - } else { - matched = abstract; - } - const result = matched - .replaceAll(AUTOSUGGEST_HIGHLIGHT_OPEN_TAG, '') - .replaceAll(AUTOSUGGEST_HIGHLIGHT_CLOSE_TAG, '') - .replaceAll(AUTOSUGGEST_BUNDLE_OPEN_TAG, '') - .replaceAll(AUTOSUGGEST_BUNDLE_CLOSE_TAG, '') - .trim(); - // wrap multiple terms in quotes - otherwise search treats each as an individual term to search - return `"${result}"`; - }); - if (autoSuggestions.length > 0) { - valueToSearch = uniq(autoSuggestions).join('+OR+'); - } - return fetch(SEARCH_QUERY.replaceAll(REPLACE_TAG, valueToSearch)); - }) - .then((r) => r.json()) - .then(({ response, highlighting }: { highlighting: HighlightingResponseType; response: SearchResponseType }) => { - const results: SearchResponseAggregate = { ...initialSearchState, ...response }; - if (isMounted.current) { - setSearchResults(results); - setHighlighting(highlighting); - // make sure to calculate resize when switching from loading to sucess state - handleWindowResize(); - } - if (ready && analytics) { - analytics.track('chrome.search-query', { query: value }); - } - }) - .finally(() => { - isMounted.current && setIsFetching(false); - }); + const handleFetch = async (value = '') => { + const response = (await fetch(SUGGEST_SEARCH_QUERY.replaceAll(REPLACE_TAG, value)).then((r) => r.json())) as SearchAutoSuggestionResponseType; + + const items = (response?.suggest?.default[value]?.suggestions || []).map((suggestion) => { + const [allTitle, bundleTitle, abstract] = suggestion.term.split(AUTOSUGGEST_TERM_DELIMITER); + const url = new URL(suggestion.payload); + const pathname = url.pathname; + const item = { + title: allTitle, + bundleTitle, + description: abstract, + pathname, + }; + // wrap multiple terms in quotes - otherwise search treats each as an individual term to search + return { item, allTitle }; + }); + const suggests = uniq(items.map(({ allTitle }) => allTitle.replace(/(|<\/b>)/gm, '').trim())); + let searchItems = items.map(({ item }) => item); + console.log(suggests); + if (items.length < 10) { + console.log({ value }); + const altTitleResults = (await fetch( + BASE_URL.toString() + .replaceAll(REPLACE_TAG, `(${suggests.join(' OR ')} OR ${value})`) + .replaceAll(REPLACE_COUNT_TAG, '10') + ).then((r) => r.json())) as { response: SearchResponseType }; + searchItems = searchItems.concat( + altTitleResults.response.docs.map((doc) => ({ + pathname: doc.relative_uri, + bundleTitle: doc.bundle_title[0], + title: doc.allTitle, + description: doc.abstract, + })) + ); + } + searchItems = uniqWith(searchItems, (a, b) => a.title.replace(/(|<\/b>)/gm, '').trim() === b.title.replace(/(|<\/b>)/gm, '').trim()); + setSearchItems(searchItems.slice(0, 10)); + isMounted.current && setIsFetching(false); + if (ready && analytics) { + analytics.track('chrome.search-query', { query: value }); + } }; const debouncedFetch = useCallback(debounce(handleFetch, 500), []); - const handleChange = (_e: any, value: string) => { + const handleChange: SearchInputProps['onChange'] = (_e, value) => { setSearchValue(value); setIsFetching(true); debouncedFetch(value); @@ -325,7 +250,7 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { onChange={handleChange} onClear={(ev) => { setSearchValue(''); - setSearchResults(initialSearchState); + setSearchItems([]); ev.stopPropagation(); setIsOpen(false); onStateChange(false); @@ -353,14 +278,17 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { ) : ( <> - 0 ? `Top ${resultCount} results` : undefined}> - - - + 0 ? `Top ${searchItems.length} results` : undefined}> + {searchItems.map((item, index) => ( + }> + + + + ))} )} - {resultCount === 0 && !isFetching && } + {searchItems.length === 0 && !isFetching && } diff --git a/src/components/Search/SearchTypes.ts b/src/components/Search/SearchTypes.ts index c2684daa48..8eb2867209 100644 --- a/src/components/Search/SearchTypes.ts +++ b/src/components/Search/SearchTypes.ts @@ -1,5 +1,3 @@ -import { EitherNotBoth } from '@openshift/dynamic-plugin-sdk'; - export type SearchResultItem = { abstract: string; allTitle: string; @@ -17,8 +15,6 @@ export type SearchAutoSuggestionResultItem = { payload: string; }; -export type SearchResultItemAggregate = EitherNotBoth; - export type SearchResponseType = { docs: SearchResultItem[]; start: number; @@ -37,20 +33,4 @@ export type SearchAutoSuggestionResponseType = { }; }; -export type SearchResponseAggregate = SearchResponseType & SearchAutoSuggestionResponseType; - -export type SearchHighlight = { allTitle?: string[]; abstract?: string[]; bundle_title?: string[]; bundle?: string[] }; - -export type HighlightingResponseType = { - [recordId: string]: SearchHighlight; -}; - -export const AUTOSUGGEST_HIGHLIGHT_OPEN_TAG = ''; - -export const AUTOSUGGEST_HIGHLIGHT_CLOSE_TAG = ''; - -export const AUTOSUGGEST_BUNDLE_OPEN_TAG = '['; - -export const AUTOSUGGEST_BUNDLE_CLOSE_TAG = ']'; - export const AUTOSUGGEST_TERM_DELIMITER = '|';