feat: Allow pinning a field in the filter panel (#998)

<img width="1080" height="776" alt="image" src="https://github.com/user-attachments/assets/fbc19329-432d-400d-8dcb-ea70da4c79ab" />

Resolves HDX-1866
This commit is contained in:
Mike Shi 2025-07-15 07:00:50 -07:00 committed by GitHub
parent b9ad3bdbd2
commit 40d0439ce8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 137 additions and 25 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Allow pinning a field in the filter panel

View file

@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Box,
Button,
Checkbox,
@ -142,6 +143,8 @@ export type FilterGroupProps = {
onExcludeClick: (value: string) => void;
onPinClick: (value: string) => void;
isPinned: (value: string) => boolean;
onFieldPinClick?: VoidFunction;
isFieldPinned?: boolean;
onLoadMore: (key: string) => void;
loadMoreLoading: boolean;
hasLoadedMore: boolean;
@ -160,6 +163,8 @@ export const FilterGroup = ({
onExcludeClick,
isPinned,
onPinClick,
onFieldPinClick,
isFieldPinned,
onLoadMore,
loadMoreLoading,
hasLoadedMore,
@ -260,16 +265,31 @@ export const FilterGroup = ({
leftSectionWidth={27}
leftSection={<IconSearch size={15} stroke={2} />}
rightSection={
selectedValues.included.size + selectedValues.excluded.size > 0 ? (
<TextButton
ms="xs"
label="Clear"
onClick={() => {
onClearClick();
setSearch('');
}}
/>
) : null
<Group gap="xs">
{onFieldPinClick && (
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={onFieldPinClick}
title={isFieldPinned ? 'Unpin field' : 'Pin field'}
>
<i
className={`bi bi-pin-angle${isFieldPinned ? '-fill' : ''}`}
/>
</ActionIcon>
)}
{selectedValues.included.size + selectedValues.excluded.size >
0 && (
<TextButton
label="Clear"
onClick={() => {
onClearClick();
setSearch('');
}}
/>
)}
</Group>
}
/>
</Tooltip>
@ -374,9 +394,13 @@ const DBSearchPageFiltersComponent = ({
denoiseResults: boolean;
setDenoiseResults: (denoiseResults: boolean) => void;
} & FilterStateHook) => {
const { toggleFilterPin, isFilterPinned } = usePinnedFilters(
sourceId ?? null,
);
const {
toggleFilterPin,
toggleFieldPin,
isFilterPinned,
isFieldPinned,
getPinnedFields,
} = usePinnedFilters(sourceId ?? null);
const { width, startResize } = useResizable(16, 'left');
const { data: countData } = useExplainQuery(chartConfig);
@ -390,7 +414,7 @@ const DBSearchPageFiltersComponent = ({
const [showMoreFields, setShowMoreFields] = useState(false);
const datum = useMemo(() => {
const keysToFetch = useMemo(() => {
if (!data) {
return [];
}
@ -413,7 +437,8 @@ const DBSearchPageFiltersComponent = ({
field =>
showMoreFields ||
field.type.includes('LowCardinality') || // query only low cardinality fields by default
Object.keys(filterState).includes(field.path), // keep selected fields
Object.keys(filterState).includes(field.path) || // keep selected fields
isFieldPinned(field.path), // keep pinned fields
)
.map(({ path }) => path)
.filter(
@ -446,7 +471,7 @@ const DBSearchPageFiltersComponent = ({
} = useGetKeyValues({
chartConfigs: { ...chartConfig, dateRange },
limit: keyLimit,
keys: datum,
keys: keysToFetch,
});
const [extraFacets, setExtraFacets] = useState<Record<string, string[]>>({});
@ -486,6 +511,7 @@ const DBSearchPageFiltersComponent = ({
},
[chartConfig, setExtraFacets, dateRange],
);
const shownFacets = useMemo(() => {
const _facets: { key: string; value: string[] }[] = [];
for (const facet of facets ?? []) {
@ -518,8 +544,23 @@ const DBSearchPageFiltersComponent = ({
for (const key of remainingFilterState) {
_facets.push({ key, value: Array.from(filterState[key].included) });
}
// Any other keys, let's add them in with empty values
for (const key of keysToFetch) {
if (!_facets.some(facet => facet.key === key)) {
_facets.push({ key, value: [] });
}
}
// reorder facets to put pinned fields first
_facets.sort((a, b) => {
const aPinned = isFieldPinned(a.key);
const bPinned = isFieldPinned(b.key);
return aPinned && !bPinned ? -1 : bPinned && !aPinned ? 1 : 0;
});
return _facets;
}, [facets, filterState, extraFacets]);
}, [facets, filterState, extraFacets, keysToFetch, isFieldPinned]);
const showClearAllButton = useMemo(
() =>
@ -655,6 +696,8 @@ const DBSearchPageFiltersComponent = ({
}}
onPinClick={value => toggleFilterPin(facet.key, value)}
isPinned={value => isFilterPinned(facet.key, value)}
onFieldPinClick={() => toggleFieldPin(facet.key)}
isFieldPinned={isFieldPinned(facet.key)}
onLoadMore={loadMoreFilterValuesForKey}
loadMoreLoading={loadMoreLoadingKeys.has(facet.key)}
hasLoadedMore={Boolean(extraFacets[facet.key])}

View file

@ -220,17 +220,28 @@ type PinnedFilters = {
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
// Keep the original structure for backwards compatibility
const [_pinnedFilters, _setPinnedFilters] = useLocalStorage<{
[sourceId: string]: PinnedFilters;
}>('hdx-pinned-search-filters', {});
// Separate storage for pinned fields
const [_pinnedFields, _setPinnedFields] = useLocalStorage<{
[sourceId: string]: string[];
}>('hdx-pinned-fields', {});
const pinnedFilters = React.useMemo<PinnedFilters>(
() =>
!sourceId || !_pinnedFilters[sourceId] ? {} : _pinnedFilters[sourceId],
[_pinnedFilters, sourceId],
);
const pinnedFields = React.useMemo<string[]>(
() =>
!sourceId || !_pinnedFields[sourceId] ? [] : _pinnedFields[sourceId],
[_pinnedFields, sourceId],
);
const setPinnedFilters = React.useCallback<
(val: PinnedFilters | ((pf: PinnedFilters) => PinnedFilters)) => void
>(
@ -245,30 +256,69 @@ function usePinnedFilterBySource(sourceId: string | null) {
},
[sourceId, _setPinnedFilters],
);
return { pinnedFilters, setPinnedFilters };
const setPinnedFields = React.useCallback<
(val: string[] | ((pf: string[]) => string[])) => void
>(
val => {
if (!sourceId) return;
_setPinnedFields(prev =>
produce(prev, draft => {
draft[sourceId] =
val instanceof Function ? val(draft[sourceId] ?? []) : val;
}),
);
},
[sourceId, _setPinnedFields],
);
return { pinnedFilters, setPinnedFilters, pinnedFields, setPinnedFields };
}
export function usePinnedFilters(sourceId: string | null) {
const { pinnedFilters, setPinnedFilters } = usePinnedFilterBySource(sourceId);
const { pinnedFilters, setPinnedFilters, pinnedFields, setPinnedFields } =
usePinnedFilterBySource(sourceId);
const toggleFilterPin = React.useCallback(
(property: string, value: string) => {
setPinnedFilters(prevPins =>
produce(prevPins, draft => {
setPinnedFilters(prevFilters =>
produce(prevFilters, draft => {
if (!draft[property]) {
draft[property] = [];
}
const idx = draft[property].findIndex(v => v === value);
if (idx >= 0) {
draft[property].splice(idx);
draft[property].splice(idx, 1);
} else {
draft[property].push(value);
}
return draft;
}),
);
// When pinning a value, also pin the field if not already pinned
setPinnedFields(prevFields => {
if (!prevFields.includes(property)) {
return [...prevFields, property];
}
return prevFields;
});
},
[setPinnedFilters],
[setPinnedFilters, setPinnedFields],
);
const toggleFieldPin = React.useCallback(
(field: string) => {
setPinnedFields(prevFields => {
const fieldIndex = prevFields.findIndex(f => f === field);
if (fieldIndex >= 0) {
return prevFields.filter((_, i) => i !== fieldIndex);
} else {
return [...prevFields, field];
}
});
},
[setPinnedFields],
);
const isFilterPinned = React.useCallback(
@ -281,8 +331,22 @@ export function usePinnedFilters(sourceId: string | null) {
[pinnedFilters],
);
const isFieldPinned = React.useCallback(
(field: string): boolean => {
return pinnedFields.includes(field);
},
[pinnedFields],
);
const getPinnedFields = React.useCallback((): string[] => {
return pinnedFields;
}, [pinnedFields]);
return {
toggleFilterPin,
toggleFieldPin,
isFilterPinned,
isFieldPinned,
getPinnedFields,
};
}