mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: add search history for search page (#749)
- save search history for search page - record number limit to 10 - add search history relate function - support both sql and lucene search - test https://github.com/user-attachments/assets/67bbc4ce-999d-494d-9fa4-1f085c2fbf8d ref: hdx-1565
This commit is contained in:
parent
b16c8e1429
commit
8c95b9e1ba
7 changed files with 250 additions and 12 deletions
5
.changeset/sweet-kiwis-cheer.md
Normal file
5
.changeset/sweet-kiwis-cheer.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
Add search history
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
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';
|
||||
|
||||
import InputLanguageSwitch from './components/InputLanguageSwitch';
|
||||
import { useDebounce } from './utils';
|
||||
|
|
@ -22,6 +24,7 @@ export default function AutocompleteInput({
|
|||
language,
|
||||
showHotkey,
|
||||
onSubmit,
|
||||
queryHistoryType,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
value: string;
|
||||
|
|
@ -38,21 +41,46 @@ export default function AutocompleteInput({
|
|||
onLanguageChange?: (language: 'sql' | 'lucene') => void;
|
||||
language?: 'sql' | 'lucene';
|
||||
showHotkey?: boolean;
|
||||
queryHistoryType?: string;
|
||||
}) {
|
||||
const suggestionsLimit = 10;
|
||||
|
||||
const [isSearchInputFocused, setIsSearchInputFocused] = useState(false);
|
||||
const [isInputDropdownOpen, setIsInputDropdownOpen] = useState(false);
|
||||
const [showSearchHistory, setShowSearchHistory] = useState(false);
|
||||
|
||||
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);
|
||||
}
|
||||
}, [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 ?? [], {
|
||||
|
|
@ -74,6 +102,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);
|
||||
|
||||
|
|
@ -154,6 +190,29 @@ export default function AutocompleteInput({
|
|||
{belowSuggestions}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{showSearchHistory && (
|
||||
<div className="border-top border-dark fs-8 py-2">
|
||||
<div className="text-muted fs-8 fw-bold me-1 px-3">
|
||||
Search History:
|
||||
</div>
|
||||
{queryHistoryList.map(({ value, label }, i) => {
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={`d-block w-100 text-start text-muted fw-normal px-3 py-2 fs-8 ${
|
||||
selectedQueryHistoryIndex === i ? 'bg-hdx-dark' : ''
|
||||
}`}
|
||||
key={value}
|
||||
onMouseOver={() => setSelectedQueryHistoryIndex(i)}
|
||||
onClick={() => onSelectSearchHistory(value)}
|
||||
>
|
||||
<span className="me-1 text-truncate">{label}</span>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
popperConfig={{
|
||||
|
|
@ -179,10 +238,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 +274,9 @@ export default function AutocompleteInput({
|
|||
suggestedProperties[selectedAutocompleteIndex].value,
|
||||
);
|
||||
} else {
|
||||
if (queryHistoryType) {
|
||||
setQueryHistory(value);
|
||||
}
|
||||
onSubmit?.();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -1146,6 +1146,7 @@ function DBSearchPage() {
|
|||
language="sql"
|
||||
onSubmit={onSubmit}
|
||||
label="WHERE"
|
||||
queryHistoryType={QUERY_LOCAL_STORAGE.SEARCH_SQL}
|
||||
enableHotkey
|
||||
/>
|
||||
}
|
||||
|
|
@ -1159,8 +1160,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
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<any>) {
|
||||
const {
|
||||
field: { onChange, value },
|
||||
|
|
@ -91,6 +93,7 @@ export default function SearchInputV2({
|
|||
showHotkey={enableHotkey}
|
||||
onLanguageChange={onLanguageChange}
|
||||
onSubmit={onSubmit}
|
||||
queryHistoryType={queryHistoryType}
|
||||
aboveSuggestions={
|
||||
<>
|
||||
<div className="text-muted fs-8 fw-bold me-1">Searching for:</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
formatDate,
|
||||
formatNumber,
|
||||
getMetricTableName,
|
||||
useQueryHistory,
|
||||
} from '../utils';
|
||||
|
||||
describe('utils', () => {
|
||||
|
|
@ -471,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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
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,
|
||||
CompletionSection,
|
||||
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 +21,7 @@ import CodeMirror, {
|
|||
} from '@uiw/react-codemirror';
|
||||
|
||||
import { useAllFields } from '@/hooks/useMetadata';
|
||||
import { useQueryHistory } from '@/utils';
|
||||
|
||||
import InputLanguageSwitch from './InputLanguageSwitch';
|
||||
|
||||
|
|
@ -103,6 +111,7 @@ type SQLInlineEditorProps = {
|
|||
disableKeywordAutocomplete?: boolean;
|
||||
enableHotkey?: boolean;
|
||||
additionalSuggestions?: string[];
|
||||
queryHistoryType?: string;
|
||||
};
|
||||
|
||||
const styleTheme = EditorView.baseTheme({
|
||||
|
|
@ -131,6 +140,7 @@ export default function SQLInlineEditor({
|
|||
disableKeywordAutocomplete,
|
||||
enableHotkey,
|
||||
additionalSuggestions = [],
|
||||
queryHistoryType,
|
||||
}: SQLInlineEditorProps) {
|
||||
const { data: fields } = useAllFields(tableConnections ?? [], {
|
||||
enabled:
|
||||
|
|
@ -141,6 +151,50 @@ 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,
|
||||
section: 'Search History',
|
||||
type: 'keyword',
|
||||
apply: (
|
||||
view: EditorView,
|
||||
_completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
onSelectSearchHistory(view, from, to, q);
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
}, [queryHistory]);
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const ref = useRef<ReactCodeMirrorRef>(null);
|
||||
|
|
@ -149,6 +203,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 +214,26 @@ export default function SQLInlineEditor({
|
|||
...additionalSuggestions,
|
||||
];
|
||||
|
||||
const auto = sql({
|
||||
dialect: SQLDialect.define({
|
||||
keywords:
|
||||
keywords.join(' ') +
|
||||
(disableKeywordAutocomplete ? '' : AUTOCOMPLETE_LIST_STRING),
|
||||
}),
|
||||
});
|
||||
const queryHistoryList = autocompletion({
|
||||
compareCompletions: (a: any, b: any) => {
|
||||
return 0;
|
||||
}, // don't sort the history search
|
||||
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 +306,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 +332,11 @@ export default function SQLInlineEditor({
|
|||
highlightActiveLineGutter: false,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onClick={() => {
|
||||
if (ref?.current?.view) {
|
||||
startCompletion(ref.current.view);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{onLanguageChange != null && language != null && (
|
||||
|
|
@ -285,6 +354,7 @@ export function SQLInlineEditorControlled({
|
|||
placeholder,
|
||||
filterField,
|
||||
additionalSuggestions,
|
||||
queryHistoryType,
|
||||
...props
|
||||
}: Omit<SQLInlineEditorProps, 'value' | 'onChange'> & UseControllerProps<any>) {
|
||||
const { field } = useController(props);
|
||||
|
|
@ -296,6 +366,7 @@ export function SQLInlineEditorControlled({
|
|||
placeholder={placeholder}
|
||||
value={field.value || props.defaultValue}
|
||||
additionalSuggestions={additionalSuggestions}
|
||||
queryHistoryType={queryHistoryType}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -175,6 +175,14 @@ export const useDebounce = <T>(
|
|||
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 getLocalStorageValue<T>(key: string): T | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
|
|
@ -286,6 +294,25 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|||
return [storedValue, setValue] as const;
|
||||
}
|
||||
|
||||
export function useQueryHistory<T>(type: string | undefined) {
|
||||
const key = `${QUERY_LOCAL_STORAGE.KEY}.${type}`;
|
||||
const [queryHistory, _setQueryHistory] = useLocalStorage<string[]>(key, []);
|
||||
const setQueryHistory = (query: string) => {
|
||||
// do not set up anything if there is no type or empty query
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
return [queryHistory, setQueryHistory] as const;
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(onIntersect: () => void) {
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
const observerRef = useCallback((node: Element | null) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue