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 }) => (
-
- ))}
-
- ) : 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 = (
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 = '|';