feat: pinned search filters (#746)

This commit is contained in:
Aaron Knudtson 2025-04-14 12:58:19 -04:00 committed by GitHub
parent 0d37943624
commit 08009ac3b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 390 additions and 23 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: add saved filters for searches

View file

@ -1249,6 +1249,7 @@ function DBSearchPage() {
dateRange: searchedTimeRange,
with: aliasWith,
}}
sourceId={inputSourceObj?.id}
{...searchFilters}
/>
</ErrorBoundary>

View file

@ -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<Storage>;
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<number>('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<utils.CustomStorageChangeDetail>(
'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();
});
});

View file

@ -20,7 +20,7 @@ import { IconSearch } from '@tabler/icons-react';
import { useAllFields, useGetKeyValues } from '@/hooks/useMetadata';
import useResizable from '@/hooks/useResizable';
import { useSearchPageFilterState } from '@/searchFilters';
import { FilterStateHook, usePinnedFilters } from '@/searchFilters';
import { mergePath } from '@/utils';
import resizeStyles from '../../styles/ResizablePanel.module.scss';
@ -29,9 +29,11 @@ import classes from '../../styles/SearchPage.module.scss';
type FilterCheckboxProps = {
label: string;
value?: 'included' | 'excluded' | false;
pinned: boolean;
onChange?: (checked: boolean) => void;
onClickOnly?: VoidFunction;
onClickExclude?: VoidFunction;
onClickPin: VoidFunction;
};
export const TextButton = ({
@ -56,9 +58,11 @@ const emptyFn = () => {};
export const FilterCheckbox = ({
value,
label,
pinned,
onChange,
onClickOnly,
onClickExclude,
onClickPin,
}: FilterCheckboxProps) => {
return (
<div className={classes.filterCheckbox}>
@ -102,7 +106,16 @@ export const FilterCheckbox = ({
{onClickExclude && (
<TextButton onClick={onClickExclude} label="Exclude" />
)}
<TextButton
onClick={onClickPin}
label={<i className={`bi bi-pin-angle${pinned ? '-fill' : ''}`}></i>}
/>
</div>
{pinned && (
<Text size="xxs" c="gray.6">
<i className="bi bi-pin-angle-fill"></i>
</Text>
)}
</div>
);
};
@ -119,6 +132,8 @@ export type FilterGroupProps = {
onClearClick: VoidFunction;
onOnlyClick: (value: string) => void;
onExcludeClick: (value: string) => void;
onPinClick: (value: string) => void;
isPinned: (value: string) => boolean;
};
const MAX_FILTER_GROUP_ITEMS = 10;
@ -132,6 +147,8 @@ export const FilterGroup = ({
onClearClick,
onOnlyClick,
onExcludeClick,
isPinned,
onPinClick,
}: FilterGroupProps) => {
const [search, setSearch] = useState('');
const [isExpanded, setExpanded] = useState(false);
@ -163,12 +180,18 @@ export const FilterGroup = ({
a: (typeof augmentedOptions)[0],
b: (typeof augmentedOptions)[0],
) => {
const aPinned = isPinned(a.value);
const aIncluded = selectedValues.included.has(a.value);
const aExcluded = selectedValues.excluded.has(a.value);
const bPinned = isPinned(b.value);
const bIncluded = selectedValues.included.has(b.value);
const bExcluded = selectedValues.excluded.has(b.value);
// First sort by included status
// First sort by pinned status
if (aPinned && !bPinned) return -1;
if (!aPinned && bPinned) return 1;
// Then sort by included status
if (aIncluded && !bIncluded) return -1;
if (!aIncluded && bIncluded) return 1;
@ -186,22 +209,8 @@ export const FilterGroup = ({
}
// Do not rearrange items if all selected values are visible without expanding
const shouldSortBySelected =
isExpanded ||
augmentedOptions.some(
(option, index) =>
(selectedValues.included.has(option.value) ||
selectedValues.excluded.has(option.value)) &&
index >= MAX_FILTER_GROUP_ITEMS,
);
return augmentedOptions
.slice()
.sort((a, b) =>
shouldSortBySelected
? sortBySelectionAndAlpha(a, b)
: a.value.localeCompare(b.value),
)
.sort((a, b) => sortBySelectionAndAlpha(a, b))
.slice(
0,
Math.max(
@ -255,6 +264,7 @@ export const FilterGroup = ({
<FilterCheckbox
key={option.value}
label={option.label}
pinned={isPinned(option.value)}
value={
selectedValues.included.has(option.value)
? 'included'
@ -265,6 +275,7 @@ export const FilterGroup = ({
onChange={() => onChange(option.value)}
onClickOnly={() => onOnlyClick(option.value)}
onClickExclude={() => onExcludeClick(option.value)}
onClickPin={() => onPinClick(option.value)}
/>
))}
{optionsLoading ? (
@ -304,8 +315,6 @@ export const FilterGroup = ({
);
};
type FilterStateHook = ReturnType<typeof useSearchPageFilterState>;
export const DBSearchPageFilters = ({
filters: filterState,
clearAllFilters,
@ -315,12 +324,17 @@ export const DBSearchPageFilters = ({
chartConfig,
analysisMode,
setAnalysisMode,
sourceId,
}: {
analysisMode: 'results' | 'delta' | 'pattern';
setAnalysisMode: (mode: 'results' | 'delta' | 'pattern') => void;
isLive: boolean;
chartConfig: ChartConfigWithDateRange;
sourceId?: string;
} & FilterStateHook) => {
const { toggleFilterPin, isFilterPinned } = usePinnedFilters(
sourceId ?? null,
);
const { width, startResize } = useResizable(16, 'left');
const { data, isLoading } = useAllFields({
@ -504,6 +518,8 @@ export const DBSearchPageFilters = ({
onExcludeClick={value => {
setFilterValue(facet.key, value, 'exclude');
}}
onPinClick={value => toggleFilterPin(facet.key, value)}
isPinned={value => isFilterPinned(facet.key, value)}
/>
))}

View file

@ -16,6 +16,8 @@ describe('FilterGroup', () => {
onClearClick: jest.fn(),
onOnlyClick: jest.fn(),
onExcludeClick: jest.fn(),
onPinClick: jest.fn(),
isPinned: jest.fn(),
};
it('should sort options alphabetically by default', () => {

View file

@ -2,6 +2,8 @@ import React from 'react';
import produce from 'immer';
import type { Filter } from '@hyperdx/common-utils/dist/types';
import { useLocalStorage } from './utils';
export type FilterState = {
[key: string]: {
included: Set<string>;
@ -198,15 +200,89 @@ export const useSearchPageFilterState = ({
);
const clearAllFilters = React.useCallback(() => {
setFilters({});
setFilters(() => ({}));
updateFilterQuery({});
}, [updateFilterQuery]);
return {
filters,
clearFilter,
setFilters,
setFilterValue,
clearFilter,
clearAllFilters,
};
};
type PinnedFilters = {
[key: string]: string[];
};
export type FilterStateHook = ReturnType<typeof useSearchPageFilterState>;
function usePinnedFilterBySource(sourceId: string | null) {
// Eventually replace pinnedFilters with a GET from api/mongo
// Eventually replace setPinnedFilters with a POST to api/mongo
const [_pinnedFilters, _setPinnedFilters] = useLocalStorage<{
[sourceId: string]: PinnedFilters;
}>('hdx-pinned-search-filters', {});
const pinnedFilters = React.useMemo<PinnedFilters>(
() =>
!sourceId || !_pinnedFilters[sourceId] ? {} : _pinnedFilters[sourceId],
[_pinnedFilters, sourceId],
);
const setPinnedFilters = React.useCallback<
(val: PinnedFilters | ((pf: PinnedFilters) => PinnedFilters)) => void
>(
val => {
if (!sourceId) return;
_setPinnedFilters(prev =>
produce(prev, draft => {
draft[sourceId] =
val instanceof Function ? val(draft[sourceId] ?? {}) : val;
}),
);
},
[sourceId, _setPinnedFilters],
);
return { pinnedFilters, setPinnedFilters };
}
export function usePinnedFilters(sourceId: string | null) {
const { pinnedFilters, setPinnedFilters } = usePinnedFilterBySource(sourceId);
const toggleFilterPin = React.useCallback(
(property: string, value: string) => {
setPinnedFilters(prevPins =>
produce(prevPins, draft => {
if (!draft[property]) {
draft[property] = [];
}
const idx = draft[property].findIndex(v => v === value);
if (idx >= 0) {
draft[property].splice(idx);
} else {
draft[property].push(value);
}
return draft;
}),
);
},
[setPinnedFilters],
);
const isFilterPinned = React.useCallback(
(property: string, value: string): boolean => {
return (
pinnedFilters[property] &&
pinnedFilters[property].some(v => v === value)
);
},
[pinnedFilters],
);
return {
toggleFilterPin,
isFilterPinned,
};
}

View file

@ -175,10 +175,62 @@ export const useDebounce = <T>(
return debouncedValue;
};
export function getLocalStorageValue<T>(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<T>(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<T>(initialValue);
const [storedValue, setStoredValue] = useState<T>(
getLocalStorageValue<T>(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<CustomStorageChangeDetail> &&
event.detail.key === key &&
event.detail.instanceId !== instanceId
) {
setStoredValue(getLocalStorageValue<T>(key)!);
}
};
const handleStorageChange = (event: Event) => {
if (event instanceof StorageEvent && event.key === key) {
setStoredValue(getLocalStorageValue<T>(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<T>(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<T>(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<CustomStorageChangeDetail>(
'customStorage',
{
detail: {
key,
instanceId,
},
},
);
window.dispatchEvent(event);
} catch (error) {
// A more advanced implementation would handle the error case
// eslint-disable-next-line no-console