mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
b9ad3bdbd2
commit
40d0439ce8
3 changed files with 137 additions and 25 deletions
5
.changeset/thin-walls-destroy.md
Normal file
5
.changeset/thin-walls-destroy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Allow pinning a field in the filter panel
|
||||
|
|
@ -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])}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue