mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: pinned search filters (#746)
This commit is contained in:
parent
0d37943624
commit
08009ac3b3
7 changed files with 390 additions and 23 deletions
5
.changeset/mighty-crabs-fry.md
Normal file
5
.changeset/mighty-crabs-fry.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: add saved filters for searches
|
||||
|
|
@ -1249,6 +1249,7 @@ function DBSearchPage() {
|
|||
dateRange: searchedTimeRange,
|
||||
with: aliasWith,
|
||||
}}
|
||||
sourceId={inputSourceObj?.id}
|
||||
{...searchFilters}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue