diff --git a/.changeset/mighty-crabs-fry.md b/.changeset/mighty-crabs-fry.md new file mode 100644 index 000000000..884472f2b --- /dev/null +++ b/.changeset/mighty-crabs-fry.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: add saved filters for searches diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index a1a9fb1da..b70983461 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1,6 +1,8 @@ import { TSource } from '@hyperdx/common-utils/dist/types'; +import { act, renderHook } from '@testing-library/react'; import { MetricsDataType, NumberFormat } from '../types'; +import * as utils from '../utils'; import { formatAttributeClause, formatDate, @@ -268,3 +270,204 @@ describe('formatNumber', () => { }); }); }); + +describe('useLocalStorage', () => { + // Create a mock for localStorage + let localStorageMock: jest.Mocked; + + beforeEach(() => { + // Clear all mocks between tests + jest.clearAllMocks(); + + // Create localStorage mock + localStorageMock = { + getItem: jest.fn().mockImplementation((_: string) => null), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 0, + }; + + // Replace window.localStorage with our mock + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); + }); + + afterAll(() => { + // Restore original implementations + jest.restoreAllMocks(); + }); + + test('should initialize with initial value when localStorage is empty', () => { + // Mock localStorage.getItem to return null (empty) + localStorageMock.getItem.mockReturnValueOnce(null); + + const initialValue = { test: 'value' }; + const { result } = renderHook(() => + utils.useLocalStorage('testKey', initialValue), + ); + + // Check if initialized with initial value + expect(result.current[0]).toEqual(initialValue); + + // Verify localStorage was checked + expect(localStorageMock.getItem).toHaveBeenCalledWith('testKey'); + }); + + test('should retrieve existing value from localStorage', () => { + // Mock localStorage to return existing value + const existingValue = { test: 'existing' }; + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(existingValue)); + + const { result } = renderHook(() => + utils.useLocalStorage('testKey', { test: 'default' }), + ); + + // Should use the value from localStorage, not the initial value + expect(result.current[0]).toEqual(existingValue); + expect(localStorageMock.getItem).toHaveBeenCalledWith('testKey'); + }); + + test('should update localStorage when setValue is called', () => { + localStorageMock.getItem.mockReturnValueOnce(null); + + const { result } = renderHook(() => + utils.useLocalStorage('testKey', 'initial'), + ); + + // Update value + const newValue = 'updated'; + act(() => { + result.current[1](newValue); + }); + + // Check if state updated + expect(result.current[0]).toBe(newValue); + + // Check if localStorage was updated + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'testKey', + JSON.stringify(newValue), + ); + }); + + test('should handle functional updates', () => { + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(0)); + + const { result } = renderHook(() => + utils.useLocalStorage('testKey', 0), + ); + + // Update using function + act(() => { + result.current[1](prev => prev + 1); + }); + + // Check if state updated correctly + expect(result.current[0]).toBe(1); + + // Check if localStorage was updated + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'testKey', + JSON.stringify(1), + ); + }); + + test('should handle storage event from another window', () => { + // Initial setup + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify('initial')); + + const { result } = renderHook(() => + utils.useLocalStorage('testKey', 'initial'), + ); + + // Update mock to return new value when checked after event + localStorageMock.getItem.mockReturnValue(JSON.stringify('external update')); + + // Dispatch storage event + act(() => { + window.dispatchEvent( + new StorageEvent('storage', { + key: 'testKey', + newValue: JSON.stringify('external update'), + }), + ); + }); + + // State should be updated + expect(result.current[0]).toBe('external update'); + }); + + test('should handle customStorage event from same window but different hook instance', () => { + // First hook instance + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify('initial1')); + const { result: result1 } = renderHook(() => + utils.useLocalStorage('sharedKey', 'initial1'), + ); + + // Second hook instance + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify('initial1')); + const { result: result2 } = renderHook(() => + utils.useLocalStorage('sharedKey', 'initial2'), + ); + + // Clear mock calls count + localStorageMock.getItem.mockClear(); + + // When the second hook checks localStorage after custom event + localStorageMock.getItem.mockReturnValue( + JSON.stringify('updated by hook 1'), + ); + + // Update value in the first instance + act(() => { + result1.current[1]('updated by hook 1'); + }); + + // Manually trigger custom event (since it's happening within the same test) + act(() => { + const event = new CustomEvent( + 'customStorage', + { + detail: { + key: 'sharedKey', + instanceId: 'some-id', // Different from the instance updating + }, + }, + ); + window.dispatchEvent(event); + }); + + // The second instance should have updated values + expect(result2.current[0]).toBe('updated by hook 1'); + }); + + test('should not update if storage event is for a different key', () => { + // Initial setup + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify('initial')); + const { result } = renderHook(() => + utils.useLocalStorage('testKey', 'initial'), + ); + + // Clear the mock calls counter + localStorageMock.getItem.mockClear(); + + // Simulate storage event for a different key + act(() => { + window.dispatchEvent( + new StorageEvent('storage', { + key: 'differentKey', + newValue: JSON.stringify('different value'), + }), + ); + }); + + // State should remain unchanged + expect(result.current[0]).toBe('initial'); + // localStorage should not be accessed since key doesn't match + expect(localStorageMock.getItem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 612a2014d..2855af03a 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -1,5 +1,10 @@ -import { useEffect, useMemo, useState } from 'react'; -import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; +import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { parseAsJson, useQueryState } from 'nuqs'; +import objectHash from 'object-hash'; +import { + ChartConfigWithDateRange, + Filter, +} from '@hyperdx/common-utils/dist/types'; import { Box, Button, @@ -21,7 +26,7 @@ import { IconSearch } from '@tabler/icons-react'; import { useAllFields, useGetKeyValues } from '@/hooks/useMetadata'; import useResizable from '@/hooks/useResizable'; import { useSearchPageFilterState } from '@/searchFilters'; -import { mergePath } from '@/utils'; +import { mergePath, useLocalStorage } from '@/utils'; import resizeStyles from '../../styles/ResizablePanel.module.scss'; import classes from '../../styles/SearchPage.module.scss'; @@ -304,6 +309,149 @@ export const FilterGroup = ({ ); }; +type SavedFilters = { + [key: string]: Filter[]; +}; + +function SaveFilterInput() { + const [savedFilters, setSavedFilters] = useLocalStorage( + 'hdx-saved-search-filters', + {}, + ); + const [queryFilters] = useQueryState( + 'filters', + parseAsJson(), + ); + const [newFilterName, setNewFilterName] = useState(''); + const [showButton, setShowButton] = useState(true); + const handleChange = (e: ChangeEvent) => { + e.preventDefault(); + setNewFilterName(e.target.value); + }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!queryFilters) return; + const tmp = savedFilters; + tmp[newFilterName] = queryFilters; + setSavedFilters(tmp); + }; + + return ( + + {showButton ? ( + setShowButton(false)} + className={classes.textButton} + style={{ width: '100%' }} + > + + + Save Filter + + + ) : ( +
+ setShowButton(true)} + placeholder="New Filter" + onChange={handleChange} + name="newFilterName" + /> + + )} +
+ ); +} + +export function SavedFilters() { + const [queryFilters, setQueryFilters] = useQueryState( + 'filters', + parseAsJson(), + ); + const [savedFilters, setSavedFilters] = useLocalStorage( + 'hdx-saved-search-filters', + {}, + ); + const showSaveButton = useMemo( + // true if no saved filter matches the current filters + () => + queryFilters && + queryFilters.length > 0 && + !Object.entries(savedFilters).some( + ([_, filter]) => + objectHash.sha1(filter) === objectHash.sha1(queryFilters), + ), + [queryFilters, savedFilters], + ); + const removeFilter = useCallback( + (label: string) => { + const newFilters = structuredClone(savedFilters); + delete newFilters[label]; + setSavedFilters(newFilters); + }, + [savedFilters, setSavedFilters], + ); + + const SavedFilterOption = ({ + label, + filters, + }: { + label: string; + filters: Filter[]; + }) => { + const [isHovered, setIsHovered] = useState(false); + const active = objectHash.sha1(filters) === objectHash.sha1(queryFilters); + return ( + setIsHovered(true)} + onMouseOut={() => setIsHovered(false)} + className={classes.highlightRow} + > + setQueryFilters(filters)} + style={{ cursor: 'pointer', opacity: 0.8 }} + > + {label} + + {/* ONLY SHOW X IF HOVERING OVER THIS COMPONENT */} + removeFilter(label)} + > + + + + ); + }; + + return ( + + {(Object.keys(savedFilters).length > 0 || showSaveButton) && ( + + Saved Filters + + )} + {Object.keys(savedFilters).length > 0 && ( + + {Object.entries(savedFilters).map(([label, filters]) => ( + + ))} + + )} + {showSaveButton && } + + ); +} + type FilterStateHook = ReturnType; export const DBSearchPageFilters = ({ @@ -443,6 +591,8 @@ export const DBSearchPageFilters = ({ + + diff --git a/packages/app/src/components/__tests__/DBSearchPageFilters.tsx b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx similarity index 100% rename from packages/app/src/components/__tests__/DBSearchPageFilters.tsx rename to packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index eb48be176..ffd946b4a 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -175,10 +175,62 @@ export const useDebounce = ( return debouncedValue; }; +export function getLocalStorageValue(key: string): T | null { + if (typeof window === 'undefined') { + return null; + } + try { + const item = window.localStorage.getItem(key); + return item != null ? JSON.parse(item) : null; + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + return null; + } +} + +export interface CustomStorageChangeDetail { + key: string; + instanceId: string; +} + export function useLocalStorage(key: string, initialValue: T) { // State to store our value // Pass initial state function to useState so logic is only executed once - const [storedValue, setStoredValue] = useState(initialValue); + const [storedValue, setStoredValue] = useState( + getLocalStorageValue(key) ?? initialValue, + ); + + // Create a unique ID for this hook instance + const [instanceId] = useState(() => + Math.random().toString(36).substring(2, 9), + ); + + useEffect(() => { + const handleCustomStorageChange = (event: Event) => { + if ( + event instanceof CustomEvent && + event.detail.key === key && + event.detail.instanceId !== instanceId + ) { + setStoredValue(getLocalStorageValue(key)!); + } + }; + const handleStorageChange = (event: Event) => { + if (event instanceof StorageEvent && event.key === key) { + setStoredValue(getLocalStorageValue(key)!); + } + }; + // check if local storage changed from current window + window.addEventListener('customStorage', handleCustomStorageChange); + // check if local storage changed from another window + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('customStorage', handleCustomStorageChange); + window.removeEventListener('storage', handleStorageChange); + }; + }, []); // Fetch the value on client-side to avoid SSR issues useEffect(() => { @@ -201,7 +253,7 @@ export function useLocalStorage(key: string, initialValue: T) { // Return a wrapped version of useState's setter function that ... // ... persists the new value to localStorage. - const setValue = (value: T | Function) => { + const setValue = (value: T | ((prevState: T) => T)) => { if (typeof window === 'undefined') { return; } @@ -213,6 +265,18 @@ export function useLocalStorage(key: string, initialValue: T) { setStoredValue(valueToStore); // Save to local storage window.localStorage.setItem(key, JSON.stringify(valueToStore)); + // Fire off event so other localStorage hooks listening with the same key + // will update + const event = new CustomEvent( + 'customStorage', + { + detail: { + key, + instanceId, + }, + }, + ); + window.dispatchEvent(event); } catch (error) { // A more advanced implementation would handle the error case // eslint-disable-next-line no-console diff --git a/packages/app/styles/SearchPage.module.scss b/packages/app/styles/SearchPage.module.scss index 1b696813f..cce5a6340 100644 --- a/packages/app/styles/SearchPage.module.scss +++ b/packages/app/styles/SearchPage.module.scss @@ -56,7 +56,37 @@ } } +.highlightRow { + &:hover { + input { + opacity: 1; + } + + background-color: $slate-950; + + .filterActions { + display: flex; + } + } +} + +.highlightButton { + border-radius: 3px; + + &:hover { + background-color: $slate-800; + color: $slate-200; + } + + &:active { + background-color: $slate-700; + color: $slate-100; + } +} + .filterCheckbox { + @extend .highlightRow; + width: 100%; display: grid; grid-template-columns: 1fr 20px; @@ -87,29 +117,8 @@ .textButton { padding: 2px 6px; - border-radius: 3px; - - &:hover { - background-color: $slate-800; - color: $slate-200; - } - - &:active { - background-color: $slate-700; - color: $slate-100; - } - } - } - &:hover { - input { - opacity: 1; - } - - background-color: $slate-950; - - .filterActions { - display: flex; + @extend .highlightButton; } } }