diff --git a/cypress/e2e/release-gate/search.cy.tsx b/cypress/e2e/release-gate/search.cy.tsx deleted file mode 100644 index b88347640..000000000 --- a/cypress/e2e/release-gate/search.cy.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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.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'); - }); -}); diff --git a/src/components/Search/SearchDescription.tsx b/src/components/Search/SearchDescription.tsx index d6d5a6e63..a530e30eb 100644 --- a/src/components/Search/SearchDescription.tsx +++ b/src/components/Search/SearchDescription.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Text, TextContent } from '@patternfly/react-core/dist/dynamic/components/Text'; -import parseHighlights from './parseHighlight'; import './SearchDescription.scss'; +import parseHighlights from './parseHighlight'; const SearchDescription = ({ description, highlight = [] }: { highlight?: string[]; description: string }) => { const parsedDescription = parseHighlights(description, highlight); diff --git a/src/components/Search/SearchGroup.tsx b/src/components/Search/SearchGroup.tsx deleted file mode 100644 index d6f646bc4..000000000 --- a/src/components/Search/SearchGroup.tsx +++ /dev/null @@ -1,25 +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 { HighlightingResponseType, SearchResultItem } from './SearchTypes'; - -const SearchGroup = ({ items, highlighting }: { items: SearchResultItem[]; highlighting: HighlightingResponseType }) => { - return items.length > 0 ? ( - - {items.map(({ id, allTitle, bundle_title, abstract, relative_uri }) => ( - } - description={} - key={id} - > - - - ))} - - ) : null; -}; - -export default SearchGroup; diff --git a/src/components/Search/SearchInput.scss b/src/components/Search/SearchInput.scss index 077ec00fe..c57481b62 100644 --- a/src/components/Search/SearchInput.scss +++ b/src/components/Search/SearchInput.scss @@ -37,6 +37,9 @@ display: none; } } + small { + display: inline-block; + } } &__empty-state { .pf-v5-c-empty-state__icon { diff --git a/src/components/Search/SearchInput.tsx b/src/components/Search/SearchInput.tsx index 7c0d3d982..5fd7dbfb9 100644 --- a/src/components/Search/SearchInput.tsx +++ b/src/components/Search/SearchInput.tsx @@ -1,19 +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 { HighlightingResponseType, SearchResponseType, SearchResultItem } 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; @@ -21,7 +25,7 @@ export type SearchInputprops = { const IS_PROD = isProd(); const REPLACE_TAG = 'REPLACE_TAG'; -const FUZZY_RANGE_TAG = 'FUZZY_RANGE_TAG'; +const REPLACE_COUNT_TAG = 'REPLACE_COUNT_TAG'; /** * The ?q is the search term. * ------ @@ -36,25 +40,23 @@ const FUZZY_RANGE_TAG = 'FUZZY_RANGE_TAG'; */ const BASE_SEARCH = new URLSearchParams(); -BASE_SEARCH.append( - 'q', - `${REPLACE_TAG} OR *${REPLACE_TAG}~${FUZZY_RANGE_TAG} OR ${REPLACE_TAG}*~${FUZZY_RANGE_TAG} OR ${REPLACE_TAG}~${FUZZY_RANGE_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 +SUGGEST_SEARCH.append('q', REPLACE_TAG); // add query replacement tag and enable fuzzy search with ~ and wildcards +SUGGEST_SEARCH.append('suggest.count', '10'); // request 10 results + +const SUGGEST_URL = new URL(`https://access.${IS_PROD ? '' : 'stage.'}redhat.com/hydra/proxy/gss-diag/rs/search/autosuggest`); +// search API stopped receiving encoded search string +SUGGEST_URL.search = decodeURIComponent(SUGGEST_SEARCH.toString()); +const SUGGEST_SEARCH_QUERY = SUGGEST_URL.toString(); const getMaxMenuHeight = (menuElement?: HTMLDivElement | null) => { if (!menuElement) { @@ -67,17 +69,11 @@ const getMaxMenuHeight = (menuElement?: HTMLDivElement | null) => { return bodyHeight - menuTopOffset - 4; }; -type SearchCategories = { - highLevel: SearchResultItem[]; - midLevel: SearchResultItem[]; - lowLevel: SearchResultItem[]; -}; - -const initialSearchState: SearchResponseType = { - docs: [], - maxScore: 0, - numFound: 0, - start: 0, +type SearchItem = { + title: string; + bundleTitle: string; + description: string; + pathname: string; }; type SearchInputListener = { @@ -88,8 +84,7 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { const [isOpen, setIsOpen] = useState(false); const [searchValue, setSearchValue] = useState(''); const [isFetching, setIsFetching] = useState(false); - const [searchResults, setSearchResults] = useState(initialSearchState); - const [highlighting, setHighlighting] = useState({}); + const [searchItems, setSearchItems] = useState([]); const { ready, analytics } = useSegment(); const blockCloseEvent = useRef(false); @@ -99,38 +94,7 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { const containerRef = useRef(null); const { md } = useWindowWidth(); - // sort result items based on matched field and its priority - const resultCategories = useMemo( - () => - searchResults.docs.reduce( - (acc, curr) => { - if (highlighting[curr.id]?.allTitle) { - return { - ...acc, - highLevel: [...acc.highLevel, curr], - }; - } - - if (highlighting[curr.id]?.abstract) { - return { - ...acc, - midLevel: [...acc.midLevel, curr], - }; - } - - return { - ...acc, - lowLevel: [...acc.lowLevel, curr], - }; - }, - { - highLevel: [], - midLevel: [], - lowLevel: [], - } - ), - [searchResults.docs, highlighting] - ); + const resultCount = searchItems.length; const handleMenuKeys = (event: KeyboardEvent) => { if (!isOpen) { @@ -155,7 +119,7 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { }; const onInputClick: SearchInputProps['onClick'] = () => { - if (!isOpen && searchResults.numFound > 0) { + if (!isOpen && resultCount > 0) { if (!md && isExpanded && searchValue !== '') { setIsOpen(true); onStateChange(true); @@ -213,28 +177,52 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { }; }, [isOpen, menuRef]); - const handleFetch = (value = '') => { - return fetch(SEARCH_QUERY.replaceAll(REPLACE_TAG, value).replaceAll(FUZZY_RANGE_TAG, value.length > 3 ? '2' : '1')) - .then((r) => r.json()) - .then(({ response, highlighting }: { highlighting: HighlightingResponseType; response: SearchResponseType }) => { - if (isMounted.current) { - setSearchResults(response); - 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); @@ -262,7 +250,7 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { onChange={handleChange} onClear={(ev) => { setSearchValue(''); - setSearchResults(initialSearchState); + setSearchItems([]); ev.stopPropagation(); setIsOpen(false); onStateChange(false); @@ -280,7 +268,6 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { className={isExpanded ? 'pf-u-flex-grow-1' : 'chr-c-search__collapsed'} /> ); - const menu = ( @@ -291,14 +278,17 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { ) : ( <> - 0 ? `Top ${searchResults.docs.length} results` : undefined}> - - - + 0 ? `Top ${searchItems.length} results` : undefined}> + {searchItems.map((item, index) => ( + }> + + + + ))} )} - {searchResults.numFound === 0 && !isFetching && } + {searchItems.length === 0 && !isFetching && } diff --git a/src/components/Search/SearchTitle.tsx b/src/components/Search/SearchTitle.tsx index 729ea9684..445e42f81 100644 --- a/src/components/Search/SearchTitle.tsx +++ b/src/components/Search/SearchTitle.tsx @@ -2,13 +2,16 @@ import React from 'react'; import { Text, TextContent } from '@patternfly/react-core/dist/dynamic/components/Text'; const SearchTitle = ({ title, bundleTitle }: { title: string; bundleTitle: string }) => { + const showBundleTitle = bundleTitle.replace(/\s/g, '').length > 0; return ( - - {title} - | - {bundleTitle} - + + {showBundleTitle && ( + + | + + )} + {showBundleTitle && } ); }; diff --git a/src/components/Search/SearchTypes.ts b/src/components/Search/SearchTypes.ts index 82f7738db..8eb286720 100644 --- a/src/components/Search/SearchTypes.ts +++ b/src/components/Search/SearchTypes.ts @@ -9,6 +9,12 @@ export type SearchResultItem = { view_uri: string; }; +export type SearchAutoSuggestionResultItem = { + term: string; + weight: string; + payload: string; +}; + export type SearchResponseType = { docs: SearchResultItem[]; start: number; @@ -16,8 +22,15 @@ export type SearchResponseType = { maxScore: number; }; -export type SearchHighlight = { allTitle?: string[]; abstract?: string[]; bundle_title?: string[]; bundle?: string[] }; - -export type HighlightingResponseType = { - [recordId: string]: SearchHighlight; +export type SearchAutoSuggestionResponseType = { + suggest: { + default: { + [recordId: string]: { + numFound: number; + suggestions: SearchAutoSuggestionResultItem[]; + }; + }; + }; }; + +export const AUTOSUGGEST_TERM_DELIMITER = '|';