Skip to content

Commit

Permalink
Use local search DB in search input.
Browse files Browse the repository at this point in the history
  • Loading branch information
Hyperkid123 committed May 22, 2024
1 parent a87aee9 commit 2a9fa98
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 146 deletions.
6 changes: 4 additions & 2 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Fragment, useContext, useState } from 'react';
import React, { Fragment, Suspense, useContext, useState } from 'react';
import ReactDOM from 'react-dom';
import Tools from './Tools';
import UnAuthtedHeader from './UnAuthtedHeader';
Expand Down Expand Up @@ -82,7 +82,9 @@ export const Header = ({ breadcrumbsProps }: { breadcrumbsProps?: Breadcrumbspro
)}
</ToolbarGroup>
<ToolbarGroup className="pf-v5-u-flex-grow-1 pf-v5-u-mr-0 pf-v5-u-mr-md-on-2xl" variant="filter-group">
<SearchInput onStateChange={hideAllServices} />
<Suspense fallback={null}>
<SearchInput onStateChange={hideAllServices} />
</Suspense>
</ToolbarGroup>
<ToolbarGroup
className="pf-v5-m-icon-button-group pf-v5-u-ml-auto"
Expand Down
9 changes: 9 additions & 0 deletions src/components/Search/SearchInput.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
@import "~@redhat-cloud-services/frontend-components-utilities/styles/_all";
@import '~@patternfly/patternfly/patternfly-addons.scss';


.chr-c-search-title {
mark {
font-weight: var(--pf-v5-global--FontWeight--bold);
background-color: transparent;
}
}

.chr-c-search {
&__collapsed {
justify-content: flex-end;
Expand Down
116 changes: 15 additions & 101 deletions src/components/Search/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,27 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import debounce from 'lodash/debounce';
import { Bullseye } from '@patternfly/react-core/dist/dynamic/layouts/Bullseye';
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 { AUTOSUGGEST_TERM_DELIMITER, SearchAutoSuggestionResponseType, SearchAutoSuggestionResultItem, 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';
import { useAtomValue } from 'jotai';
import { asyncLocalOrama } from '../../state/atoms/localSearchAtom';
import { localQuery } from '../../utils/localSearch';

export type SearchInputprops = {
isExpanded?: boolean;
};

const IS_PROD = isProd();
const REPLACE_TAG = 'REPLACE_TAG';
const REPLACE_COUNT_TAG = 'REPLACE_COUNT_TAG';
/**
* The ?q is the search term.
* ------
* The "~" after the search term enables fuzzy search (case sensitivity, similar results for typos).
* For example "inventry" query yields results with Inventory string within it.
* We can use distance ~(0-2) for example: "~2" to narrow restrict/expand the fuzzy search range
*
* Query parsin docs: https://solr.apache.org/guide/7_7/the-standard-query-parser.html#the-standard-query-parser
*
* hl=true enables string "highlight"
* hl.fl=field_name specifies field to be highlighted
*/

const BASE_SEARCH = new URLSearchParams();
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', `${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 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
SUGGEST_SEARCH.append('suggest.dictionary', 'improvedInfixSuggester'); // console new suggest dictionary
SUGGEST_SEARCH.append('suggest.dictionary', 'default');

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) {
return 0;
Expand All @@ -82,29 +44,16 @@ type SearchInputListener = {
onStateChange: (isOpen: boolean) => void;
};

function parseSuggestions(suggestions: SearchAutoSuggestionResultItem[] = []) {
return suggestions.map((suggestion) => {
const [allTitle, bundleTitle, abstract] = suggestion.term.split(AUTOSUGGEST_TERM_DELIMITER);
const url = new URL(suggestion.payload);
return {
item: {
title: allTitle,
bundleTitle,
description: abstract,
pathname: url.pathname,
},
allTitle,
};
});
}

const SearchInput = ({ onStateChange }: SearchInputListener) => {
const [isOpen, setIsOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [isFetching, setIsFetching] = useState(false);
const [searchItems, setSearchItems] = useState<SearchItem[]>([]);
const { ready, analytics } = useSegment();
const blockCloseEvent = useRef(false);
const asyncLocalOramaData = useAtomValue(asyncLocalOrama);

const debouncedTrack = useCallback(analytics ? debounce(analytics.track, 1000) : () => null, [analytics]);

const isMounted = useRef(false);
const toggleRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -183,10 +132,9 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => {
window.removeEventListener('resize', handleWindowResize);
isMounted.current = false;
};
}, []);
}, [isOpen, menuRef, resultCount]);

useEffect(() => {
handleWindowResize();
window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);
return () => {
Expand All @@ -195,51 +143,17 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => {
};
}, [isOpen, menuRef]);

const handleFetch = async (value = '') => {
const response = (await fetch(SUGGEST_SEARCH_QUERY.replaceAll(REPLACE_TAG, value)).then((r) => r.json())) as SearchAutoSuggestionResponseType;

// parse default suggester
// parse improved suggester
let items: { item: SearchItem; allTitle: string }[] = [];
items = items
.concat(
parseSuggestions(response?.suggest?.default[value]?.suggestions),
parseSuggestions(response?.suggest?.improvedInfixSuggester[value]?.suggestions)
)
.slice(0, 10);
const suggests = uniq(items.map(({ allTitle }) => allTitle.replace(/(<b>|<\/b>)/gm, '').trim()));
let searchItems = items.map(({ item }) => item);
if (items.length < 10) {
const altTitleResults = (await fetch(
BASE_URL.toString()
.replaceAll(REPLACE_TAG, `(${suggests.length > 0 ? 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>|<\/b>)/gm, '').trim() === b.title.replace(/(<b>|<\/b>)/gm, '').trim());
setSearchItems(searchItems.slice(0, 10));
const handleChange: SearchInputProps['onChange'] = async (_e, value) => {
setSearchValue(value);
setIsFetching(true);
const results = await localQuery(asyncLocalOramaData, value);
setSearchItems(results ?? []);
isMounted.current && setIsFetching(false);
if (ready && analytics) {
analytics.track('chrome.search-query', { query: value });
debouncedTrack('chrome.search-query', { query: value });
}
};

const debouncedFetch = useCallback(debounce(handleFetch, 500), []);

const handleChange: SearchInputProps['onChange'] = (_e, value) => {
setSearchValue(value);
setIsFetching(true);
debouncedFetch(value);
};

const [isExpanded, setIsExpanded] = React.useState(false);

const onToggleExpand = (_event: React.SyntheticEvent<HTMLButtonElement>, isExpanded: boolean) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Search/SearchTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const SearchTitle = ({ title, bundleTitle }: { title: string; bundleTitle: strin
const showBundleTitle = bundleTitle.replace(/\s/g, '').length > 0;
return (
<TextContent>
<Text component="small" className="pf-v5-u-link-color" dangerouslySetInnerHTML={{ __html: title }}></Text>
<Text component="small" className="pf-v5-u-link-color chr-c-search-title" dangerouslySetInnerHTML={{ __html: title }}></Text>
{showBundleTitle && (
<Text component="small" className="pf-v5-u-link-color">
<span className="pf-v5-u-px-sm">|</span>
Expand Down
42 changes: 0 additions & 42 deletions src/components/Search/SearchTypes.ts

This file was deleted.

109 changes: 109 additions & 0 deletions src/utils/localSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { search } from '@orama/orama';
import { fuzzySearch } from './levenshtein-search';

type HighlightCategories = 'title' | 'description';

const matchCache: {
[key in HighlightCategories]: {
[termKey: string]: string;
};
} = {
title: {},
description: {},
};

type ResultItem = {
title: string;
description: string;
bundleTitle: string;
pathname: string;
};

const resultCache: {
[term: string]: ResultItem[];
} = {};

const START_MARK_LENGTH = 6;
const END_MARK_LENGTH = START_MARK_LENGTH + 1;
const OFFSET_BASE = START_MARK_LENGTH + END_MARK_LENGTH;

function markText(text: string, start: number, end: number, offset: number) {
const markStart = OFFSET_BASE * offset + start + offset * 2 - 1;
const markEnd = OFFSET_BASE * offset + end + offset * 2;
return `${text.substring(0, markStart)}<mark>${text.substring(markStart, markEnd)}</mark>${text.substring(markEnd)}`;
}

function highlightText(term: string, text: string, category: HighlightCategories) {
const key = `${term}-${text}`;
// check cache
if (matchCache[category]?.[key]) {
return matchCache[category][key];
}
let internalText = text;
// generate fuzzy matches
const res = fuzzySearch(term, internalText, 2);
const marks = [...res].sort((a, b) => a.start! - b.start!);
// merge overlapping marks into smaller sets
// example: start: 1, end: 5, start: 3, end: 7 => start: 1, end: 7
const merged = marks.reduce<{ start: number; end: number }[]>((acc, { start, end }) => {
if (!start || !end) return acc;
const bounded = acc.findIndex((o) => {
return (o.start >= start && o.start <= end) || (start >= o.start && start <= o.end);
});
if (bounded >= 0) {
acc[bounded] = { start: Math.min(start, acc[bounded].start), end: Math.max(end, acc[bounded].end) };
} else {
acc.push({ start, end });
}
return acc;
}, []);
// mark text from reduced match set
merged.forEach(({ start, end }, index) => {
internalText = markText(internalText, start!, end, index);
});

// cache result
matchCache[category][key] = internalText;
return internalText;
}

export const localQuery = async (db: any, term: string) => {
try {
let results: ResultItem[] | undefined = resultCache[term];
if (results) {
return results;
}

const r = await search(db, {
term,
threshold: 0.5,
tolerance: 1.5,
properties: ['title', 'description', 'altTitle'],
limit: 10,
boost: {
title: 10,
altTitle: 5,
description: 3,
},
});

results = r.hits.map(({ document: { title, description, bundleTitle, pathname } }) => {
let matchedTitle = title;
let matchedDescription = description;
matchedTitle = highlightText(term, matchedTitle, 'title');
matchedDescription = highlightText(term, matchedDescription, 'description');

return {
title: matchedTitle,
description: matchedDescription,
bundleTitle,
pathname,
};
});
resultCache[term] = results;
return results;
} catch (error) {
console.log(error);
return [];
}
};

0 comments on commit 2a9fa98

Please sign in to comment.