From 1e6ba3be402723d34771e4dc60ac8ed6b2c01bb6 Mon Sep 17 00:00:00 2001 From: Liang yung huang Date: Fri, 11 Apr 2025 03:50:53 -0700 Subject: [PATCH 1/6] feat: add search history for search page - save search history for search page - record number limit to 10 - add search history relate function - support both sql and lucene search - test ref: hdx-1565 --- .changeset/sweet-kiwis-cheer.md | 5 ++ packages/app/src/AutocompleteInput.tsx | 62 ++++++++++++++ packages/app/src/DBSearchPage.tsx | 5 +- packages/app/src/SearchInputV2.tsx | 3 + packages/app/src/__tests__/utils.test.ts | 61 +++++++++++++ .../app/src/components/SQLInlineEditor.tsx | 85 ++++++++++++++++--- packages/app/src/utils.ts | 34 ++++++++ 7 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 .changeset/sweet-kiwis-cheer.md diff --git a/.changeset/sweet-kiwis-cheer.md b/.changeset/sweet-kiwis-cheer.md new file mode 100644 index 000000000..8c0ed1714 --- /dev/null +++ b/.changeset/sweet-kiwis-cheer.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Add search history diff --git a/packages/app/src/AutocompleteInput.tsx b/packages/app/src/AutocompleteInput.tsx index 7ba6407f9..4c2cd1de7 100644 --- a/packages/app/src/AutocompleteInput.tsx +++ b/packages/app/src/AutocompleteInput.tsx @@ -3,6 +3,8 @@ import Fuse from 'fuse.js'; import { OverlayTrigger } from 'react-bootstrap'; import { TextInput } from '@mantine/core'; +import { useQueryHistory } from '@/utils'; + import InputLanguageSwitch from './components/InputLanguageSwitch'; import { useDebounce } from './utils'; @@ -22,6 +24,7 @@ export default function AutocompleteInput({ language, showHotkey, onSubmit, + queryHistoryType, }: { inputRef: React.RefObject; value: string; @@ -38,6 +41,7 @@ export default function AutocompleteInput({ onLanguageChange?: (language: 'sql' | 'lucene') => void; language?: 'sql' | 'lucene'; showHotkey?: boolean; + queryHistoryType?: string; }) { const suggestionsLimit = 10; @@ -47,6 +51,20 @@ export default function AutocompleteInput({ const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] = useState(-1); + const [selectedQueryHistoryIndex, setSelectedQueryHistoryIndex] = + useState(-1); + // query search history + const [queryHistory, setQueryHistory] = useQueryHistory(queryHistoryType); + const queryHistoryList = useMemo(() => { + if (!queryHistoryType || !queryHistory) return []; + return queryHistory.map(q => { + return { + value: q, + label: q, + }; + }); + }, [queryHistory]); + useEffect(() => { if (isSearchInputFocused) { setIsInputDropdownOpen(true); @@ -74,6 +92,14 @@ export default function AutocompleteInput({ return fuse.search(lastToken).map(result => result.item); }, [debouncedValue, fuse, autocompleteOptions, showSuggestionsOnEmpty]); + const onSelectSearchHistory = (query: string) => { + setSelectedQueryHistoryIndex(-1); + onChange(query); // update inputText bar + setQueryHistory(query); // update history order + setIsInputDropdownOpen(false); // close dropdown since we execute search + onSubmit?.(); // search + }; + const onAcceptSuggestion = (suggestion: string) => { setSelectedAutocompleteIndex(-1); @@ -117,6 +143,37 @@ export default function AutocompleteInput({
{aboveSuggestions}
)}
+ { + // only show search history when: 1. on input, 2. has search type, 3. has history list + value.length === 0 && + queryHistoryType && + queryHistoryList.length > 0 && ( +
+
+ Search History: +
+ {queryHistoryList.map(({ value, label }, i) => { + return ( +
{ + setSelectedQueryHistoryIndex(i); + }} + onClick={() => { + onSelectSearchHistory(value); + }} + > + {label} +
+ ); + })} +
+ ) + } {suggestedProperties.length > 0 && (
@@ -179,10 +236,12 @@ export default function AutocompleteInput({ onChange={e => onChange(e.target.value)} onFocus={() => { setSelectedAutocompleteIndex(-1); + setSelectedQueryHistoryIndex(-1); setIsSearchInputFocused(true); }} onBlur={() => { setSelectedAutocompleteIndex(-1); + setSelectedQueryHistoryIndex(-1); setIsSearchInputFocused(false); }} onKeyDown={e => { @@ -213,6 +272,9 @@ export default function AutocompleteInput({ suggestedProperties[selectedAutocompleteIndex].value, ); } else { + if (queryHistoryType) { + setQueryHistory(value); + } onSubmit?.(); } } diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 6ce38e239..63154e185 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -87,7 +87,7 @@ import { useSources, } from '@/source'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; -import { usePrevious } from '@/utils'; +import { QUERY_LOCAL_STORAGE, usePrevious } from '@/utils'; import { SQLPreview } from './components/ChartSQLPreview'; import { useSqlSuggestions } from './hooks/useSqlSuggestions'; @@ -1143,6 +1143,7 @@ function DBSearchPage() { language="sql" onSubmit={onSubmit} label="WHERE" + queryHistoryType={QUERY_LOCAL_STORAGE.SEARCH_SQL} enableHotkey /> } @@ -1156,8 +1157,10 @@ function DBSearchPage() { shouldDirty: true, }) } + onSubmit={onSubmit} language="lucene" placeholder="Search your events w/ Lucene ex. column:foo" + queryHistoryType={QUERY_LOCAL_STORAGE.SEARCH_LUCENE} enableHotkey /> } diff --git a/packages/app/src/SearchInputV2.tsx b/packages/app/src/SearchInputV2.tsx index 5f7e2b318..a39033427 100644 --- a/packages/app/src/SearchInputV2.tsx +++ b/packages/app/src/SearchInputV2.tsx @@ -33,6 +33,7 @@ export default function SearchInputV2({ enableHotkey, onSubmit, additionalSuggestions, + queryHistoryType, ...props }: { tableConnections?: TableConnection | TableConnection[]; @@ -44,6 +45,7 @@ export default function SearchInputV2({ enableHotkey?: boolean; onSubmit?: () => void; additionalSuggestions?: string[]; + queryHistoryType?: string; } & UseControllerProps) { const { field: { onChange, value }, @@ -91,6 +93,7 @@ export default function SearchInputV2({ showHotkey={enableHotkey} onLanguageChange={onLanguageChange} onSubmit={onSubmit} + queryHistoryType={queryHistoryType} aboveSuggestions={ <>
Searching for:
diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index a1a9fb1da..c10946d21 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1,4 +1,5 @@ import { TSource } from '@hyperdx/common-utils/dist/types'; +import { act, renderHook } from '@testing-library/react'; import { MetricsDataType, NumberFormat } from '../types'; import { @@ -6,6 +7,7 @@ import { formatDate, formatNumber, getMetricTableName, + useQueryHistory, } from '../utils'; describe('utils', () => { @@ -268,3 +270,62 @@ describe('formatNumber', () => { }); }); }); + +describe('useQueryHistory', () => { + const mockGetItem = jest.fn(); + const mockSetItem = jest.fn(); + const mockRemoveItem = jest.fn(); + + beforeEach(() => { + mockGetItem.mockClear(); + mockSetItem.mockClear(); + mockRemoveItem.mockClear(); + mockGetItem.mockReturnValue('["service = test3","service = test1"]'); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (...args: string[]) => mockGetItem(...args), + setItem: (...args: string[]) => mockSetItem(...args), + removeItem: (...args: string[]) => mockRemoveItem(...args), + }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('adds new query', () => { + const { result } = renderHook(() => useQueryHistory('searchSQL')); + const setQueryHistory = result.current[1]; + act(() => { + setQueryHistory('service = test2'); + }); + + expect(mockSetItem).toHaveBeenCalledWith( + 'QuerySearchHistory.searchSQL', + '["service = test2","service = test3","service = test1"]', + ); + }); + + it('does not add duplicate query, but change the order to front', () => { + const { result } = renderHook(() => useQueryHistory('searchSQL')); + const setQueryHistory = result.current[1]; + act(() => { + setQueryHistory('service = test1'); + }); + + expect(mockSetItem).toHaveBeenCalledWith( + 'QuerySearchHistory.searchSQL', + '["service = test1","service = test3"]', + ); + }); + + it('does not add empty query', () => { + const { result } = renderHook(() => useQueryHistory('searchSQL')); + const setQueryHistory = result.current[1]; + act(() => { + setQueryHistory(' '); // empty after trim + }); + expect(mockSetItem).not.toBeCalled(); + }); +}); diff --git a/packages/app/src/components/SQLInlineEditor.tsx b/packages/app/src/components/SQLInlineEditor.tsx index c28ae8b46..ba74043c0 100644 --- a/packages/app/src/components/SQLInlineEditor.tsx +++ b/packages/app/src/components/SQLInlineEditor.tsx @@ -1,7 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useController, UseControllerProps } from 'react-hook-form'; import { useHotkeys } from 'react-hotkeys-hook'; -import { acceptCompletion, startCompletion } from '@codemirror/autocomplete'; +import { + acceptCompletion, + autocompletion, + closeCompletion, + Completion, + startCompletion, +} from '@codemirror/autocomplete'; import { sql, SQLDialect } from '@codemirror/lang-sql'; import { Field, TableConnection } from '@hyperdx/common-utils/dist/metadata'; import { Paper, Text } from '@mantine/core'; @@ -14,6 +20,7 @@ import CodeMirror, { } from '@uiw/react-codemirror'; import { useAllFields } from '@/hooks/useMetadata'; +import { useQueryHistory } from '@/utils'; import InputLanguageSwitch from './InputLanguageSwitch'; @@ -103,6 +110,7 @@ type SQLInlineEditorProps = { disableKeywordAutocomplete?: boolean; enableHotkey?: boolean; additionalSuggestions?: string[]; + queryHistoryType?: string; }; const styleTheme = EditorView.baseTheme({ @@ -131,6 +139,7 @@ export default function SQLInlineEditor({ disableKeywordAutocomplete, enableHotkey, additionalSuggestions = [], + queryHistoryType, }: SQLInlineEditorProps) { const { data: fields } = useAllFields(tableConnections ?? [], { enabled: @@ -141,6 +150,48 @@ export default function SQLInlineEditor({ return filterField ? fields?.filter(filterField) : fields; }, [fields, filterField]); + // query search history + const [queryHistory, setQueryHistory] = useQueryHistory(queryHistoryType); + + const onSelectSearchHistory = ( + view: EditorView, + from: number, + to: number, + q: string, + ) => { + // update history into search bar + view.dispatch({ + changes: { from, to, insert: q }, + }); + // close history bar; + closeCompletion(view); + // update history order + setQueryHistory(q); + // execute search + if (onSubmit) onSubmit(); + }; + + const createHistoryList = useMemo(() => { + return () => { + return { + from: 0, + options: queryHistory.map(q => { + return { + label: q, + apply: ( + view: EditorView, + _completion: Completion, + from: number, + to: number, + ) => { + onSelectSearchHistory(view, from, to, q); + }, + }; + }), + }; + }; + }, [queryHistory]); + const [isFocused, setIsFocused] = useState(false); const ref = useRef(null); @@ -149,6 +200,7 @@ export default function SQLInlineEditor({ const updateAutocompleteColumns = useCallback( (viewRef: EditorView) => { + const currentText = viewRef.state.doc.toString(); const keywords = [ ...(filteredFields?.map(column => { if (column.path.length > 1) { @@ -159,19 +211,23 @@ export default function SQLInlineEditor({ ...additionalSuggestions, ]; + const auto = sql({ + dialect: SQLDialect.define({ + keywords: + keywords.join(' ') + + (disableKeywordAutocomplete ? '' : AUTOCOMPLETE_LIST_STRING), + }), + }); + const queryHistoryList = autocompletion({ + override: [createHistoryList], + }); viewRef.dispatch({ effects: compartmentRef.current.reconfigure( - sql({ - dialect: SQLDialect.define({ - keywords: - keywords.join(' ') + - (disableKeywordAutocomplete ? '' : AUTOCOMPLETE_LIST_STRING), - }), - }), + currentText.length > 0 ? auto : queryHistoryList, ), }); }, - [filteredFields, additionalSuggestions], + [filteredFields, additionalSuggestions, queryHistory], ); useEffect(() => { @@ -244,7 +300,9 @@ export default function SQLInlineEditor({ if (onSubmit == null) { return false; } - + if (queryHistoryType && ref?.current?.view) { + setQueryHistory(ref?.current?.view.state.doc.toString()); + } onSubmit(); return true; }, @@ -268,6 +326,11 @@ export default function SQLInlineEditor({ highlightActiveLineGutter: false, }} placeholder={placeholder} + onClick={() => { + if (ref?.current?.view) { + startCompletion(ref.current.view); + } + }} />
{onLanguageChange != null && language != null && ( @@ -285,6 +348,7 @@ export function SQLInlineEditorControlled({ placeholder, filterField, additionalSuggestions, + queryHistoryType, ...props }: Omit & UseControllerProps) { const { field } = useController(props); @@ -296,6 +360,7 @@ export function SQLInlineEditorControlled({ placeholder={placeholder} value={field.value || props.defaultValue} additionalSuggestions={additionalSuggestions} + queryHistoryType={queryHistoryType} {...props} /> ); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 5b6c7cc8e..8aed65ea7 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -175,6 +175,14 @@ export const useDebounce = ( return debouncedValue; }; +// localStorage key for query +export const QUERY_LOCAL_STORAGE = { + KEY: 'QuerySearchHistory', + SEARCH_SQL: 'searchSQL', + SEARCH_LUCENE: 'searchLucene', + LIMIT: 10, // cache up to 10 +}; + export function useLocalStorage(key: string, initialValue: T) { // State to store our value // Pass initial state function to useState so logic is only executed once @@ -222,6 +230,32 @@ export function useLocalStorage(key: string, initialValue: T) { return [storedValue, setValue] as const; } +export function useQueryHistory(type: string | undefined) { + const key = `${QUERY_LOCAL_STORAGE.KEY}.${type}`; + const [queryHistory, _setQueryHistory] = useLocalStorage(key, []); + const setQueryHistory = (query: string) => { + // do not set up anything if there is no type or empty query + try { + const trimQuery = query.trim(); + if (!type || !trimQuery) return null; + const newHistory = [trimQuery]; + + const dedupe = new Set(); + dedupe.add(trimQuery); + for (const q of queryHistory) { + if (dedupe.has(q)) continue; + dedupe.add(q); + newHistory.push(q); + } + _setQueryHistory(newHistory.slice(0, QUERY_LOCAL_STORAGE.LIMIT)); + } catch (e) { + // eslint-disable-next-line no-console + console.log(`Failed to cache query history, error ${e.message}`); + } + }; + return [queryHistory, setQueryHistory] as const; +} + export function useIntersectionObserver(onIntersect: () => void) { const observer = useRef(null); const observerRef = useCallback((node: Element | null) => { From 323c13e05ba538298930365878dcef9592f2b435 Mon Sep 17 00:00:00 2001 From: Liang yung huang Date: Mon, 14 Apr 2025 05:00:45 -0700 Subject: [PATCH 2/6] disable sort for search history --- packages/app/src/AutocompleteInput.tsx | 2 +- packages/app/src/components/SQLInlineEditor.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/AutocompleteInput.tsx b/packages/app/src/AutocompleteInput.tsx index 4c2cd1de7..8b9093d02 100644 --- a/packages/app/src/AutocompleteInput.tsx +++ b/packages/app/src/AutocompleteInput.tsx @@ -144,7 +144,7 @@ export default function AutocompleteInput({ )}
{ - // only show search history when: 1. on input, 2. has search type, 3. has history list + // only show search history when: 1.no input, 2.has search type, 3.has history list value.length === 0 && queryHistoryType && queryHistoryList.length > 0 && ( diff --git a/packages/app/src/components/SQLInlineEditor.tsx b/packages/app/src/components/SQLInlineEditor.tsx index ba74043c0..29160672a 100644 --- a/packages/app/src/components/SQLInlineEditor.tsx +++ b/packages/app/src/components/SQLInlineEditor.tsx @@ -219,6 +219,9 @@ export default function SQLInlineEditor({ }), }); const queryHistoryList = autocompletion({ + compareCompletions: (a: any, b: any) => { + return 0; + }, // don't sort the history search override: [createHistoryList], }); viewRef.dispatch({ From 81f7cbf786f58ff640c9ed25116a7b05a54fc4a2 Mon Sep 17 00:00:00 2001 From: Liang yung huang Date: Tue, 15 Apr 2025 02:17:53 -0700 Subject: [PATCH 3/6] add section for sql history, change history order for lucene --- packages/app/src/AutocompleteInput.tsx | 64 ++++++++++--------- .../app/src/components/SQLInlineEditor.tsx | 3 + 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/packages/app/src/AutocompleteInput.tsx b/packages/app/src/AutocompleteInput.tsx index 8b9093d02..dd7a5d1be 100644 --- a/packages/app/src/AutocompleteInput.tsx +++ b/packages/app/src/AutocompleteInput.tsx @@ -143,37 +143,6 @@ export default function AutocompleteInput({
{aboveSuggestions}
)}
- { - // only show search history when: 1.no input, 2.has search type, 3.has history list - value.length === 0 && - queryHistoryType && - queryHistoryList.length > 0 && ( -
-
- Search History: -
- {queryHistoryList.map(({ value, label }, i) => { - return ( -
{ - setSelectedQueryHistoryIndex(i); - }} - onClick={() => { - onSelectSearchHistory(value); - }} - > - {label} -
- ); - })} -
- ) - } {suggestedProperties.length > 0 && (
@@ -211,6 +180,39 @@ export default function AutocompleteInput({ {belowSuggestions}
)} +
+ { + // only show search history when: 1.no input, 2.has search type, 3.has history list + value.length === 0 && + queryHistoryType && + queryHistoryList.length > 0 && ( +
+
+ Search History: +
+ {queryHistoryList.map(({ value, label }, i) => { + return ( +
{ + setSelectedQueryHistoryIndex(i); + }} + onClick={() => { + onSelectSearchHistory(value); + }} + > + {label} +
+ ); + })} +
+ ) + } +
)} popperConfig={{ diff --git a/packages/app/src/components/SQLInlineEditor.tsx b/packages/app/src/components/SQLInlineEditor.tsx index 29160672a..b001c46f7 100644 --- a/packages/app/src/components/SQLInlineEditor.tsx +++ b/packages/app/src/components/SQLInlineEditor.tsx @@ -6,6 +6,7 @@ import { autocompletion, closeCompletion, Completion, + CompletionSection, startCompletion, } from '@codemirror/autocomplete'; import { sql, SQLDialect } from '@codemirror/lang-sql'; @@ -178,6 +179,8 @@ export default function SQLInlineEditor({ options: queryHistory.map(q => { return { label: q, + section: 'Search History', + type: 'keyword', apply: ( view: EditorView, _completion: Completion, From b96e6571c487f7066ebfa8b7ebbba23b065f3b94 Mon Sep 17 00:00:00 2001 From: Liang yung huang Date: Tue, 15 Apr 2025 02:35:26 -0700 Subject: [PATCH 4/6] update test --- packages/app/src/__tests__/utils.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 216237cb4..773ab29f4 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -276,6 +276,7 @@ describe('useQueryHistory', () => { const mockGetItem = jest.fn(); const mockSetItem = jest.fn(); const mockRemoveItem = jest.fn(); + const originalLocalStorage = window.localStorage; beforeEach(() => { mockGetItem.mockClear(); @@ -293,6 +294,10 @@ describe('useQueryHistory', () => { afterEach(() => { jest.restoreAllMocks(); + Object.defineProperty(window, 'localStorage', { + value: originalLocalStorage, + configurable: true, + }); }); it('adds new query', () => { From d500f8a2e0c84ffbd6de9528ce49d7947431f780 Mon Sep 17 00:00:00 2001 From: Liang yung huang Date: Tue, 15 Apr 2025 02:54:01 -0700 Subject: [PATCH 5/6] update test --- packages/app/src/__tests__/utils.test.ts | 128 +++++++++++------------ 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 773ab29f4..a0284cfdb 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -272,70 +272,6 @@ describe('formatNumber', () => { }); }); -describe('useQueryHistory', () => { - const mockGetItem = jest.fn(); - const mockSetItem = jest.fn(); - const mockRemoveItem = jest.fn(); - const originalLocalStorage = window.localStorage; - - beforeEach(() => { - mockGetItem.mockClear(); - mockSetItem.mockClear(); - mockRemoveItem.mockClear(); - mockGetItem.mockReturnValue('["service = test3","service = test1"]'); - Object.defineProperty(window, 'localStorage', { - value: { - getItem: (...args: string[]) => mockGetItem(...args), - setItem: (...args: string[]) => mockSetItem(...args), - removeItem: (...args: string[]) => mockRemoveItem(...args), - }, - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - Object.defineProperty(window, 'localStorage', { - value: originalLocalStorage, - configurable: true, - }); - }); - - it('adds new query', () => { - const { result } = renderHook(() => useQueryHistory('searchSQL')); - const setQueryHistory = result.current[1]; - act(() => { - setQueryHistory('service = test2'); - }); - - expect(mockSetItem).toHaveBeenCalledWith( - 'QuerySearchHistory.searchSQL', - '["service = test2","service = test3","service = test1"]', - ); - }); - - it('does not add duplicate query, but change the order to front', () => { - const { result } = renderHook(() => useQueryHistory('searchSQL')); - const setQueryHistory = result.current[1]; - act(() => { - setQueryHistory('service = test1'); - }); - - expect(mockSetItem).toHaveBeenCalledWith( - 'QuerySearchHistory.searchSQL', - '["service = test1","service = test3"]', - ); - }); - - it('does not add empty query', () => { - const { result } = renderHook(() => useQueryHistory('searchSQL')); - const setQueryHistory = result.current[1]; - act(() => { - setQueryHistory(' '); // empty after trim - }); - expect(mockSetItem).not.toBeCalled(); - }); -}); - describe('useLocalStorage', () => { // Create a mock for localStorage let localStorageMock: jest.Mocked; @@ -536,3 +472,67 @@ describe('useLocalStorage', () => { expect(localStorageMock.getItem).not.toHaveBeenCalled(); }); }); + +describe('useQueryHistory', () => { + const mockGetItem = jest.fn(); + const mockSetItem = jest.fn(); + const mockRemoveItem = jest.fn(); + const originalLocalStorage = window.localStorage; + + beforeEach(() => { + mockGetItem.mockClear(); + mockSetItem.mockClear(); + mockRemoveItem.mockClear(); + mockGetItem.mockReturnValue('["service = test3","service = test1"]'); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (...args: string[]) => mockGetItem(...args), + setItem: (...args: string[]) => mockSetItem(...args), + removeItem: (...args: string[]) => mockRemoveItem(...args), + }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(window, 'localStorage', { + value: originalLocalStorage, + configurable: true, + }); + }); + + it('adds new query', () => { + const { result } = renderHook(() => useQueryHistory('searchSQL')); + const setQueryHistory = result.current[1]; + act(() => { + setQueryHistory('service = test2'); + }); + + expect(mockSetItem).toHaveBeenCalledWith( + 'QuerySearchHistory.searchSQL', + '["service = test2","service = test3","service = test1"]', + ); + }); + + it('does not add duplicate query, but change the order to front', () => { + const { result } = renderHook(() => useQueryHistory('searchSQL')); + const setQueryHistory = result.current[1]; + act(() => { + setQueryHistory('service = test1'); + }); + + expect(mockSetItem).toHaveBeenCalledWith( + 'QuerySearchHistory.searchSQL', + '["service = test1","service = test3"]', + ); + }); + + it('does not add empty query', () => { + const { result } = renderHook(() => useQueryHistory('searchSQL')); + const setQueryHistory = result.current[1]; + act(() => { + setQueryHistory(' '); // empty after trim + }); + expect(mockSetItem).not.toBeCalled(); + }); +}); From 1dfd761bc92092fd90ade7e4de1ac919fec1d0cd Mon Sep 17 00:00:00 2001 From: Liang yung huang Date: Thu, 17 Apr 2025 00:50:24 -0700 Subject: [PATCH 6/6] using button instead of div, improve dedupe logic --- packages/app/src/AutocompleteInput.tsx | 64 +++++++++++++------------- packages/app/src/utils.ts | 17 ++----- 2 files changed, 37 insertions(+), 44 deletions(-) diff --git a/packages/app/src/AutocompleteInput.tsx b/packages/app/src/AutocompleteInput.tsx index dd7a5d1be..f5bec7e7b 100644 --- a/packages/app/src/AutocompleteInput.tsx +++ b/packages/app/src/AutocompleteInput.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Fuse from 'fuse.js'; import { OverlayTrigger } from 'react-bootstrap'; -import { TextInput } from '@mantine/core'; +import { TextInput, UnstyledButton } from '@mantine/core'; import { useQueryHistory } from '@/utils'; @@ -47,6 +47,7 @@ export default function AutocompleteInput({ const [isSearchInputFocused, setIsSearchInputFocused] = useState(false); const [isInputDropdownOpen, setIsInputDropdownOpen] = useState(false); + const [showSearchHistory, setShowSearchHistory] = useState(false); const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] = useState(-1); @@ -71,6 +72,15 @@ export default function AutocompleteInput({ } }, [isSearchInputFocused]); + useEffect(() => { + // only show search history when: 1.no input, 2.has search type, 3.has history list + if (value.length === 0 && queryHistoryList.length > 0 && queryHistoryType) { + setShowSearchHistory(true); + } else { + setShowSearchHistory(false); + } + }, [value, queryHistoryType, queryHistoryList]); + const fuse = useMemo( () => new Fuse(autocompleteOptions ?? [], { @@ -181,37 +191,27 @@ export default function AutocompleteInput({
)}
- { - // only show search history when: 1.no input, 2.has search type, 3.has history list - value.length === 0 && - queryHistoryType && - queryHistoryList.length > 0 && ( -
-
- Search History: -
- {queryHistoryList.map(({ value, label }, i) => { - return ( -
{ - setSelectedQueryHistoryIndex(i); - }} - onClick={() => { - onSelectSearchHistory(value); - }} - > - {label} -
- ); - })} -
- ) - } + {showSearchHistory && ( +
+
+ Search History: +
+ {queryHistoryList.map(({ value, label }, i) => { + return ( + setSelectedQueryHistoryIndex(i)} + onClick={() => onSelectSearchHistory(value)} + > + {label} + + ); + })} +
+ )}
)} diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 602be55f6..b31586c3d 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -300,18 +300,11 @@ export function useQueryHistory(type: string | undefined) { const setQueryHistory = (query: string) => { // do not set up anything if there is no type or empty query try { - const trimQuery = query.trim(); - if (!type || !trimQuery) return null; - const newHistory = [trimQuery]; - - const dedupe = new Set(); - dedupe.add(trimQuery); - for (const q of queryHistory) { - if (dedupe.has(q)) continue; - dedupe.add(q); - newHistory.push(q); - } - _setQueryHistory(newHistory.slice(0, QUERY_LOCAL_STORAGE.LIMIT)); + const trimmed = query.trim(); + if (!type || !trimmed) return null; + const deduped = [trimmed, ...queryHistory.filter(q => q !== trimmed)]; + const limited = deduped.slice(0, QUERY_LOCAL_STORAGE.LIMIT); + _setQueryHistory(limited); } catch (e) { // eslint-disable-next-line no-console console.log(`Failed to cache query history, error ${e.message}`);