mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Grouped filters for map/json types (#1385)
Fixes: HDX-2841 Also moves the filter search into the expand/collapse to avoid visual clutter (per recommendation from Elizabet). I've enhanced it to show if there are more than 5 items only. <img width="296" height="270" alt="image" src="https://github.com/user-attachments/assets/3348c508-b8a3-442f-b0b7-f5621d1fea26" /> Before: <img width="485" height="692" alt="image" src="https://github.com/user-attachments/assets/22dcfe50-9b5a-4236-bb4a-6e9f1ae92bea" /> After: <img width="281" height="451" alt="image" src="https://github.com/user-attachments/assets/a4d4b633-2944-4e14-b36f-9135e01e596d" />
This commit is contained in:
parent
3b2a8633dd
commit
087ff4008b
7 changed files with 665 additions and 108 deletions
5
.changeset/flat-ladybugs-dance.md
Normal file
5
.changeset/flat-ladybugs-dance.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Grouped filters for map/json types
|
||||
|
|
@ -1,16 +1,7 @@
|
|||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
TableMetadata,
|
||||
tcFromChartConfig,
|
||||
tcFromSource,
|
||||
} from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import {
|
||||
|
|
@ -39,7 +30,6 @@ import {
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
|
||||
import { useExplainQuery } from '@/hooks/useExplainQuery';
|
||||
import {
|
||||
useAllFields,
|
||||
useGetKeyValues,
|
||||
|
|
@ -53,6 +43,9 @@ import { FilterStateHook, usePinnedFilters } from '@/searchFilters';
|
|||
import { useSource } from '@/source';
|
||||
import { mergePath } from '@/utils';
|
||||
|
||||
import { NestedFilterGroup } from './DBSearchPageFilters/NestedFilterGroup';
|
||||
import { groupFacetsByBaseName } from './DBSearchPageFilters/utils';
|
||||
|
||||
import resizeStyles from '../../styles/ResizablePanel.module.scss';
|
||||
import classes from '../../styles/SearchPage.module.scss';
|
||||
|
||||
|
|
@ -131,7 +124,6 @@ const FilterPercentage = ({ percentage, isLoading }: FilterPercentageProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
const emptyFn = () => {};
|
||||
export const FilterCheckbox = ({
|
||||
value,
|
||||
label,
|
||||
|
|
@ -158,10 +150,9 @@ export const FilterCheckbox = ({
|
|||
<Checkbox
|
||||
checked={!!value}
|
||||
size={13 as any}
|
||||
onChange={
|
||||
// taken care by the onClick in the group, triggering here will double fire
|
||||
emptyFn
|
||||
}
|
||||
onChange={() => {
|
||||
// taken care by the onClick in the group
|
||||
}}
|
||||
indeterminate={value === 'excluded'}
|
||||
data-testid={`filter-checkbox-input-${label}`}
|
||||
/>
|
||||
|
|
@ -256,6 +247,7 @@ export type FilterGroupProps = {
|
|||
'data-testid'?: string;
|
||||
chartConfig: ChartConfigWithDateRange;
|
||||
isLive?: boolean;
|
||||
distributionKey?: string; // Optional key to use for distribution queries, defaults to name
|
||||
};
|
||||
|
||||
const MAX_FILTER_GROUP_ITEMS = 10;
|
||||
|
|
@ -280,6 +272,7 @@ export const FilterGroup = ({
|
|||
'data-testid': dataTestId,
|
||||
chartConfig,
|
||||
isLive,
|
||||
distributionKey,
|
||||
}: FilterGroupProps) => {
|
||||
const [search, setSearch] = useState('');
|
||||
// "Show More" button when there's lots of options
|
||||
|
|
@ -322,7 +315,7 @@ export const FilterGroup = ({
|
|||
} = useGetValuesDistribution(
|
||||
{
|
||||
chartConfig: { ...chartConfig, dateRange },
|
||||
key: name,
|
||||
key: distributionKey || name,
|
||||
limit: 100, // The 100 most common values are enough to find any values that are present in at least 1% of rows
|
||||
},
|
||||
{
|
||||
|
|
@ -476,28 +469,9 @@ export const FilterGroup = ({
|
|||
fz="xxs"
|
||||
color="gray"
|
||||
>
|
||||
<TextInput
|
||||
size="xs"
|
||||
flex="1"
|
||||
placeholder={name}
|
||||
value={search}
|
||||
data-testid={`filter-search-${name}`}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearch(event.currentTarget.value)
|
||||
}
|
||||
onClick={e => {
|
||||
// Prevent accordion from opening when clicking on the input, unless it's closed.
|
||||
if (isExpanded) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
styles={{ input: { transition: 'padding 0.2s' } }}
|
||||
rightSectionWidth={20}
|
||||
rightSection={<IconSearch size={12} stroke={2} />}
|
||||
classNames={{
|
||||
input: 'ps-0.5',
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw="500">
|
||||
{name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Accordion.Control>
|
||||
<Group gap="xxxs" wrap="nowrap">
|
||||
|
|
@ -549,6 +523,25 @@ export const FilterGroup = ({
|
|||
}}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{/* Show search bar if expanded and there are more than 5 values */}
|
||||
{isExpanded && augmentedOptions.length > 5 && (
|
||||
<div className="px-2 pb-2">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Search values..."
|
||||
value={search}
|
||||
data-testid={`filter-search-${name}`}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearch(event.currentTarget.value)
|
||||
}
|
||||
rightSectionWidth={20}
|
||||
rightSection={<IconSearch size={12} stroke={2} />}
|
||||
classNames={{
|
||||
input: 'ps-0.5',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{displayedOptions.map(option => (
|
||||
<FilterCheckbox
|
||||
key={option.value}
|
||||
|
|
@ -670,18 +663,22 @@ const DBSearchPageFiltersComponent = ({
|
|||
denoiseResults: boolean;
|
||||
setDenoiseResults: (denoiseResults: boolean) => void;
|
||||
} & FilterStateHook) => {
|
||||
const setFilterValue: typeof _setFilterValue = (
|
||||
property: string,
|
||||
value: string,
|
||||
action?: 'only' | 'exclude' | 'include' | undefined,
|
||||
) => {
|
||||
return _setFilterValue(property, value, action);
|
||||
};
|
||||
const setFilterValue = useCallback(
|
||||
(
|
||||
property: string,
|
||||
value: string,
|
||||
action?: 'only' | 'exclude' | 'include' | undefined,
|
||||
) => {
|
||||
return _setFilterValue(property, value, action);
|
||||
},
|
||||
[_setFilterValue],
|
||||
);
|
||||
const {
|
||||
toggleFilterPin,
|
||||
toggleFieldPin,
|
||||
isFilterPinned,
|
||||
isFieldPinned,
|
||||
getPinnedFields,
|
||||
pinnedFilters,
|
||||
} = usePinnedFilters(sourceId ?? null);
|
||||
const { size, startResize } = useResizable(16, 'left');
|
||||
|
|
@ -778,6 +775,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
const mergedKeys = new Set<string>([
|
||||
...facetsMap.keys(),
|
||||
...Object.keys(pinnedFilters),
|
||||
...getPinnedFields(),
|
||||
]);
|
||||
|
||||
return Array.from(mergedKeys).map(key => {
|
||||
|
|
@ -790,7 +788,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
|
||||
return { key, value: Array.from(mergedValues) };
|
||||
});
|
||||
}, [facets, pinnedFilters]);
|
||||
}, [facets, pinnedFilters, getPinnedFields]);
|
||||
|
||||
const metadata = useMetadataWithSettings();
|
||||
const [extraFacets, setExtraFacets] = useState<Record<string, string[]>>({});
|
||||
|
|
@ -827,7 +825,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
});
|
||||
}
|
||||
},
|
||||
[chartConfig, setExtraFacets, dateRange],
|
||||
[chartConfig, setExtraFacets, dateRange, metadata],
|
||||
);
|
||||
|
||||
const shownFacets = useMemo(() => {
|
||||
|
|
@ -838,11 +836,12 @@ const DBSearchPageFiltersComponent = ({
|
|||
facet.key = `toString(${facet.key})`;
|
||||
}
|
||||
|
||||
// don't include empty facets, unless they are already selected
|
||||
// don't include empty facets, unless they are already selected or pinned
|
||||
const filter = filterState[facet.key];
|
||||
const hasSelectedValues =
|
||||
filter && (filter.included.size > 0 || filter.excluded.size > 0);
|
||||
if (facet.value?.length > 0 || hasSelectedValues) {
|
||||
const isPinned = isFieldPinned(facet.key);
|
||||
if (facet.value?.length > 0 || hasSelectedValues || isPinned) {
|
||||
const extraValues = extraFacets[facet.key];
|
||||
if (extraValues && extraValues.length > 0) {
|
||||
const allValues = facet.value.slice();
|
||||
|
|
@ -1053,50 +1052,122 @@ const DBSearchPageFiltersComponent = ({
|
|||
)
|
||||
)}
|
||||
{/* Show facets even when loading to ensure pinned filters are visible while loading */}
|
||||
{shownFacets.map(facet => (
|
||||
<FilterGroup
|
||||
key={facet.key}
|
||||
data-testid={`filter-group-${facet.key}`}
|
||||
name={cleanedFacetName(facet.key)}
|
||||
options={facet.value.map(value => ({
|
||||
value,
|
||||
label: value,
|
||||
}))}
|
||||
optionsLoading={isFacetsLoading}
|
||||
selectedValues={
|
||||
filterState[facet.key]
|
||||
? filterState[facet.key]
|
||||
: { included: new Set(), excluded: new Set() }
|
||||
}
|
||||
onChange={value => {
|
||||
setFilterValue(facet.key, value);
|
||||
}}
|
||||
onClearClick={() => clearFilter(facet.key)}
|
||||
onOnlyClick={value => {
|
||||
setFilterValue(facet.key, value, 'only');
|
||||
}}
|
||||
onExcludeClick={value => {
|
||||
setFilterValue(facet.key, value, 'exclude');
|
||||
}}
|
||||
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])}
|
||||
isDefaultExpanded={
|
||||
// open by default if PK, or has selected values
|
||||
isFieldPrimary(tableMetadata, facet.key) ||
|
||||
isFieldPinned(facet.key) ||
|
||||
(filterState[facet.key] &&
|
||||
(filterState[facet.key].included.size > 0 ||
|
||||
filterState[facet.key].excluded.size > 0))
|
||||
}
|
||||
chartConfig={chartConfig}
|
||||
isLive={isLive}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const { grouped, nonGrouped } = groupFacetsByBaseName(shownFacets);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Render grouped facets as nested filter groups */}
|
||||
{grouped.map(group => (
|
||||
<NestedFilterGroup
|
||||
key={group.key}
|
||||
data-testid={`nested-filter-group-${group.key}`}
|
||||
name={group.key}
|
||||
childFilters={group.children}
|
||||
selectedValues={group.children.reduce(
|
||||
(acc, child) => {
|
||||
acc[child.key] = filterState[child.key]
|
||||
? filterState[child.key]
|
||||
: { included: new Set(), excluded: new Set() };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{ included: Set<string>; excluded: Set<string> }
|
||||
>,
|
||||
)}
|
||||
onChange={(key, value) => {
|
||||
setFilterValue(key, value);
|
||||
}}
|
||||
onClearClick={key => clearFilter(key)}
|
||||
onOnlyClick={(key, value) => {
|
||||
setFilterValue(key, value, 'only');
|
||||
}}
|
||||
onExcludeClick={(key, value) => {
|
||||
setFilterValue(key, value, 'exclude');
|
||||
}}
|
||||
onPinClick={(key, value) => toggleFilterPin(key, value)}
|
||||
isPinned={(key, value) => isFilterPinned(key, value)}
|
||||
onFieldPinClick={key => toggleFieldPin(key)}
|
||||
isFieldPinned={key => isFieldPinned(key)}
|
||||
onLoadMore={loadMoreFilterValuesForKey}
|
||||
loadMoreLoading={group.children.reduce(
|
||||
(acc, child) => {
|
||||
acc[child.key] = loadMoreLoadingKeys.has(child.key);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
)}
|
||||
hasLoadedMore={group.children.reduce(
|
||||
(acc, child) => {
|
||||
acc[child.key] = Boolean(extraFacets[child.key]);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
)}
|
||||
isDefaultExpanded={
|
||||
// open by default if has selected values or pinned children
|
||||
group.children.some(
|
||||
child =>
|
||||
(filterState[child.key] &&
|
||||
(filterState[child.key].included.size > 0 ||
|
||||
filterState[child.key].excluded.size > 0)) ||
|
||||
isFieldPinned(child.key),
|
||||
)
|
||||
}
|
||||
chartConfig={chartConfig}
|
||||
isLive={isLive}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render non-grouped facets as regular filter groups */}
|
||||
{nonGrouped.map(facet => (
|
||||
<FilterGroup
|
||||
key={facet.key}
|
||||
data-testid={`filter-group-${facet.key}`}
|
||||
name={cleanedFacetName(facet.key)}
|
||||
options={facet.value.map(value => ({
|
||||
value,
|
||||
label: value,
|
||||
}))}
|
||||
optionsLoading={isFacetsLoading}
|
||||
selectedValues={
|
||||
filterState[facet.key]
|
||||
? filterState[facet.key]
|
||||
: { included: new Set(), excluded: new Set() }
|
||||
}
|
||||
onChange={value => {
|
||||
setFilterValue(facet.key, value);
|
||||
}}
|
||||
onClearClick={() => clearFilter(facet.key)}
|
||||
onOnlyClick={value => {
|
||||
setFilterValue(facet.key, value, 'only');
|
||||
}}
|
||||
onExcludeClick={value => {
|
||||
setFilterValue(facet.key, value, 'exclude');
|
||||
}}
|
||||
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])}
|
||||
isDefaultExpanded={
|
||||
// open by default if PK, or has selected values
|
||||
isFieldPrimary(tableMetadata, facet.key) ||
|
||||
isFieldPinned(facet.key) ||
|
||||
(filterState[facet.key] &&
|
||||
(filterState[facet.key].included.size > 0 ||
|
||||
filterState[facet.key].excluded.size > 0))
|
||||
}
|
||||
chartConfig={chartConfig}
|
||||
isLive={isLive}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Button
|
||||
color="gray"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { FilterGroup } from '../DBSearchPageFilters';
|
||||
|
||||
import classes from '../../../styles/SearchPage.module.scss';
|
||||
|
||||
export type NestedFilterGroupProps = {
|
||||
name: string;
|
||||
childFilters: { key: string; value: string[]; propertyPath: string }[];
|
||||
selectedValues?: Record<
|
||||
string,
|
||||
{ included: Set<string>; excluded: Set<string> }
|
||||
>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onClearClick: (key: string) => void;
|
||||
onOnlyClick: (key: string, value: string) => void;
|
||||
onExcludeClick: (key: string, value: string) => void;
|
||||
onPinClick: (key: string, value: string) => void;
|
||||
isPinned: (key: string, value: string) => boolean;
|
||||
onFieldPinClick?: (key: string) => void;
|
||||
isFieldPinned?: (key: string) => boolean;
|
||||
onLoadMore: (key: string) => void;
|
||||
loadMoreLoading: Record<string, boolean>;
|
||||
hasLoadedMore: Record<string, boolean>;
|
||||
isDefaultExpanded?: boolean;
|
||||
'data-testid'?: string;
|
||||
chartConfig: any; // Using any to avoid importing ChartConfigWithDateRange
|
||||
isLive?: boolean;
|
||||
};
|
||||
|
||||
export const NestedFilterGroup = ({
|
||||
name,
|
||||
childFilters,
|
||||
selectedValues = {},
|
||||
onChange,
|
||||
onClearClick,
|
||||
onOnlyClick,
|
||||
onExcludeClick,
|
||||
onPinClick,
|
||||
isPinned,
|
||||
onFieldPinClick,
|
||||
isFieldPinned,
|
||||
onLoadMore,
|
||||
loadMoreLoading,
|
||||
hasLoadedMore,
|
||||
isDefaultExpanded,
|
||||
'data-testid': dataTestId,
|
||||
chartConfig,
|
||||
isLive,
|
||||
}: NestedFilterGroupProps) => {
|
||||
const totalFiltersSize = useMemo(
|
||||
() =>
|
||||
Object.values(selectedValues).reduce(
|
||||
(total, filter) => total + filter.included.size + filter.excluded.size,
|
||||
0,
|
||||
),
|
||||
[selectedValues],
|
||||
);
|
||||
|
||||
const hasSelections = totalFiltersSize > 0;
|
||||
const [isExpanded, setExpanded] = useState(
|
||||
isDefaultExpanded ?? hasSelections,
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
variant="unstyled"
|
||||
chevronPosition="left"
|
||||
classNames={{ chevron: classes.chevron }}
|
||||
value={isExpanded ? name : null}
|
||||
onChange={v => setExpanded(v === name)}
|
||||
>
|
||||
<Accordion.Item value={name} data-testid={dataTestId}>
|
||||
<div className={classes.filterGroup}>
|
||||
<div className={classes.filterGroupHeader}>
|
||||
<Accordion.Control
|
||||
component={UnstyledButton}
|
||||
flex="1"
|
||||
p="0"
|
||||
pr="xxxs"
|
||||
data-testid="nested-filter-group-control"
|
||||
classNames={{
|
||||
chevron: 'm-0',
|
||||
label: 'p-0',
|
||||
}}
|
||||
className={childFilters.length ? '' : 'opacity-50'}
|
||||
>
|
||||
<Tooltip
|
||||
openDelay={name.length > 26 ? 0 : 1500}
|
||||
label={name}
|
||||
position="top"
|
||||
withArrow
|
||||
fz="xxs"
|
||||
color="gray"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" flex="1">
|
||||
<Text size="xs" fw="500">
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{`{${childFilters.length}}`}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Accordion.Control>
|
||||
</div>
|
||||
<Accordion.Panel
|
||||
data-testid="nested-filter-group-panel"
|
||||
classNames={{
|
||||
content: 'pl-3 pt-1 pb-0',
|
||||
}}
|
||||
>
|
||||
<div className={classes.filterGroupPanel}>
|
||||
<Stack gap="xs">
|
||||
{childFilters.map(child => {
|
||||
const childSelectedValues = selectedValues[child.key] || {
|
||||
included: new Set(),
|
||||
excluded: new Set(),
|
||||
};
|
||||
const childHasSelections =
|
||||
childSelectedValues.included.size +
|
||||
childSelectedValues.excluded.size >
|
||||
0;
|
||||
|
||||
return (
|
||||
<FilterGroup
|
||||
key={child.key}
|
||||
data-testid={`nested-filter-group-${child.key}`}
|
||||
name={child.propertyPath}
|
||||
distributionKey={child.key}
|
||||
options={child.value.map(value => ({
|
||||
value,
|
||||
label: value,
|
||||
}))}
|
||||
optionsLoading={false}
|
||||
selectedValues={childSelectedValues}
|
||||
onChange={value => onChange(child.key, value)}
|
||||
onClearClick={() => onClearClick(child.key)}
|
||||
onOnlyClick={value => onOnlyClick(child.key, value)}
|
||||
onExcludeClick={value => onExcludeClick(child.key, value)}
|
||||
onPinClick={value => onPinClick(child.key, value)}
|
||||
isPinned={value => isPinned(child.key, value)}
|
||||
onFieldPinClick={() => onFieldPinClick?.(child.key)}
|
||||
isFieldPinned={isFieldPinned?.(child.key)}
|
||||
onLoadMore={() => onLoadMore(child.key)}
|
||||
loadMoreLoading={loadMoreLoading[child.key] || false}
|
||||
hasLoadedMore={hasLoadedMore[child.key] || false}
|
||||
isDefaultExpanded={childHasSelections}
|
||||
chartConfig={chartConfig}
|
||||
isLive={isLive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{childFilters.length === 0 && (
|
||||
<Group m={6} gap="xs">
|
||||
<Text c="dimmed" size="xs">
|
||||
No properties found
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</Accordion.Panel>
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
5
packages/app/src/components/DBSearchPageFilters/index.ts
Normal file
5
packages/app/src/components/DBSearchPageFilters/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
NestedFilterGroup,
|
||||
type NestedFilterGroupProps,
|
||||
} from './NestedFilterGroup';
|
||||
export { groupFacetsByBaseName, parseMapFieldName } from './utils';
|
||||
83
packages/app/src/components/DBSearchPageFilters/utils.ts
Normal file
83
packages/app/src/components/DBSearchPageFilters/utils.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Utility functions for parsing and grouping map-like field names
|
||||
|
||||
// Clean ClickHouse expressions to extract clean property paths
|
||||
export function cleanClickHouseExpression(key: string): string {
|
||||
// Remove toString() wrapper if present
|
||||
let cleanKey = key.replace(/^toString\((.+)\)$/, '$1');
|
||||
|
||||
// Convert backtick dot notation to clean dot notation
|
||||
// e.g., `host`.`arch` -> host.arch
|
||||
cleanKey = cleanKey.replace(/`([^`]+)`/g, '$1');
|
||||
|
||||
return cleanKey;
|
||||
}
|
||||
|
||||
// Parse map-like field names and extract the base name and property path
|
||||
export function parseMapFieldName(
|
||||
key: string,
|
||||
): { baseName: string; propertyPath: string } | null {
|
||||
// First clean the ClickHouse expression
|
||||
const cleanKey = cleanClickHouseExpression(key);
|
||||
|
||||
// Match patterns like: ResourceAttributes['some.property'], SpanAttributes['key'], or json_column.key
|
||||
const mapPattern = /^([^[]+)\[['"]([^'"]+)['"]\]$/;
|
||||
const match = cleanKey.match(mapPattern);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
baseName: match[1],
|
||||
propertyPath: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Match dot notation patterns like: json_column.key or json_column.key.subkey
|
||||
const dotPattern = /^([^.]+)\.(.+)$/;
|
||||
const dotMatch = cleanKey.match(dotPattern);
|
||||
|
||||
if (dotMatch) {
|
||||
return {
|
||||
baseName: dotMatch[1],
|
||||
propertyPath: dotMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Group facets by their base names for map-like fields
|
||||
export function groupFacetsByBaseName(
|
||||
facets: { key: string; value: string[] }[],
|
||||
) {
|
||||
const grouped: Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value: string[];
|
||||
children: { key: string; value: string[]; propertyPath: string }[];
|
||||
}
|
||||
> = new Map();
|
||||
const nonGrouped: { key: string; value: string[] }[] = [];
|
||||
|
||||
for (const facet of facets) {
|
||||
const parsed = parseMapFieldName(facet.key);
|
||||
if (parsed) {
|
||||
const { baseName, propertyPath } = parsed;
|
||||
if (!grouped.has(baseName)) {
|
||||
grouped.set(baseName, {
|
||||
key: baseName,
|
||||
value: [], // Base name doesn't have direct values
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
const group = grouped.get(baseName)!;
|
||||
group.children.push({
|
||||
...facet,
|
||||
propertyPath,
|
||||
});
|
||||
} else {
|
||||
nonGrouped.push(facet);
|
||||
}
|
||||
}
|
||||
|
||||
return { grouped: Array.from(grouped.values()), nonGrouped };
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { useGetValuesDistribution } from '@/hooks/useMetadata';
|
||||
|
|
@ -9,6 +9,45 @@ import {
|
|||
FilterGroup,
|
||||
type FilterGroupProps,
|
||||
} from '../DBSearchPageFilters';
|
||||
import {
|
||||
cleanClickHouseExpression,
|
||||
groupFacetsByBaseName,
|
||||
parseMapFieldName,
|
||||
} from '../DBSearchPageFilters/utils';
|
||||
|
||||
describe('cleanClickHouseExpression', () => {
|
||||
it('should remove toString wrapper', () => {
|
||||
expect(cleanClickHouseExpression('toString(ResourceAttributes)')).toBe(
|
||||
'ResourceAttributes',
|
||||
);
|
||||
expect(cleanClickHouseExpression('toString(user_data.name)')).toBe(
|
||||
'user_data.name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert backtick notation to clean dot notation', () => {
|
||||
expect(cleanClickHouseExpression('`host`.`arch`')).toBe('host.arch');
|
||||
expect(cleanClickHouseExpression('`host`.`os`.`type`')).toBe(
|
||||
'host.os.type',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed expressions', () => {
|
||||
expect(cleanClickHouseExpression('toString(`host`.`arch`)')).toBe(
|
||||
'host.arch',
|
||||
);
|
||||
expect(
|
||||
cleanClickHouseExpression('toString(`process`.`executable`.`name`)'),
|
||||
).toBe('process.executable.name');
|
||||
});
|
||||
|
||||
it('should return unchanged for already clean expressions', () => {
|
||||
expect(cleanClickHouseExpression("ResourceAttributes['host.arch']")).toBe(
|
||||
"ResourceAttributes['host.arch']",
|
||||
);
|
||||
expect(cleanClickHouseExpression('user_data.name')).toBe('user_data.name');
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock('@/hooks/useMetadata', () => ({
|
||||
useGetValuesDistribution: jest
|
||||
|
|
@ -192,6 +231,150 @@ describe('cleanedFacetName', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseMapFieldName', () => {
|
||||
it('should parse ResourceAttributes map access', () => {
|
||||
expect(parseMapFieldName("ResourceAttributes['service.name']")).toEqual({
|
||||
baseName: 'ResourceAttributes',
|
||||
propertyPath: 'service.name',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse SpanAttributes map access', () => {
|
||||
expect(parseMapFieldName("SpanAttributes['http.method']")).toEqual({
|
||||
baseName: 'SpanAttributes',
|
||||
propertyPath: 'http.method',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse single property access', () => {
|
||||
expect(parseMapFieldName("ResourceAttributes['service']")).toEqual({
|
||||
baseName: 'ResourceAttributes',
|
||||
propertyPath: 'service',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle double quotes', () => {
|
||||
expect(parseMapFieldName('ResourceAttributes["service.name"]')).toEqual({
|
||||
baseName: 'ResourceAttributes',
|
||||
propertyPath: 'service.name',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse dot notation JSON access', () => {
|
||||
expect(parseMapFieldName('user_data.name')).toEqual({
|
||||
baseName: 'user_data',
|
||||
propertyPath: 'name',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse nested dot notation JSON access', () => {
|
||||
expect(parseMapFieldName('user_data.address.city')).toEqual({
|
||||
baseName: 'user_data',
|
||||
propertyPath: 'address.city',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ClickHouse expressions with toString wrapper', () => {
|
||||
expect(parseMapFieldName('toString(`host`.`arch`)')).toEqual({
|
||||
baseName: 'host',
|
||||
propertyPath: 'arch',
|
||||
});
|
||||
expect(
|
||||
parseMapFieldName('toString(`process`.`executable`.`name`)'),
|
||||
).toEqual({
|
||||
baseName: 'process',
|
||||
propertyPath: 'executable.name',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ClickHouse backtick dot notation', () => {
|
||||
expect(parseMapFieldName('`host`.`arch`')).toEqual({
|
||||
baseName: 'host',
|
||||
propertyPath: 'arch',
|
||||
});
|
||||
expect(parseMapFieldName('`os`.`type`')).toEqual({
|
||||
baseName: 'os',
|
||||
propertyPath: 'type',
|
||||
});
|
||||
expect(parseMapFieldName('`process`.`executable`.`path`')).toEqual({
|
||||
baseName: 'process',
|
||||
propertyPath: 'executable.path',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for non-map fields', () => {
|
||||
expect(parseMapFieldName('service')).toBeNull();
|
||||
expect(parseMapFieldName('')).toBeNull();
|
||||
expect(parseMapFieldName('simple_field')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupFacetsByBaseName', () => {
|
||||
it('should group map-like facets by base name', () => {
|
||||
const facets = [
|
||||
{ key: "ResourceAttributes['service.name']", value: ['web', 'api'] },
|
||||
{ key: "ResourceAttributes['service.version']", value: ['1.0', '2.0'] },
|
||||
{ key: "SpanAttributes['http.method']", value: ['GET', 'POST'] },
|
||||
{ key: 'level', value: ['info', 'error'] },
|
||||
];
|
||||
|
||||
const result = groupFacetsByBaseName(facets);
|
||||
|
||||
expect(result.grouped).toHaveLength(2);
|
||||
expect(result.nonGrouped).toHaveLength(1);
|
||||
expect(result.nonGrouped[0]).toEqual({
|
||||
key: 'level',
|
||||
value: ['info', 'error'],
|
||||
});
|
||||
|
||||
// Check ResourceAttributes group
|
||||
const resourceAttrsGroup = result.grouped.find(
|
||||
g => g.key === 'ResourceAttributes',
|
||||
);
|
||||
expect(resourceAttrsGroup).toBeDefined();
|
||||
expect(resourceAttrsGroup!.children).toHaveLength(2);
|
||||
expect(resourceAttrsGroup!.children[0]).toEqual({
|
||||
key: "ResourceAttributes['service.name']",
|
||||
value: ['web', 'api'],
|
||||
propertyPath: 'service.name',
|
||||
});
|
||||
expect(resourceAttrsGroup!.children[1]).toEqual({
|
||||
key: "ResourceAttributes['service.version']",
|
||||
value: ['1.0', '2.0'],
|
||||
propertyPath: 'service.version',
|
||||
});
|
||||
|
||||
// Check SpanAttributes group
|
||||
const spanAttrsGroup = result.grouped.find(g => g.key === 'SpanAttributes');
|
||||
expect(spanAttrsGroup).toBeDefined();
|
||||
expect(spanAttrsGroup!.children).toHaveLength(1);
|
||||
expect(spanAttrsGroup!.children[0]).toEqual({
|
||||
key: "SpanAttributes['http.method']",
|
||||
value: ['GET', 'POST'],
|
||||
propertyPath: 'http.method',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty groups when no map-like facets exist', () => {
|
||||
const facets = [
|
||||
{ key: 'level', value: ['info', 'error'] },
|
||||
{ key: 'service', value: ['web', 'api'] },
|
||||
];
|
||||
|
||||
const result = groupFacetsByBaseName(facets);
|
||||
|
||||
expect(result.grouped).toHaveLength(0);
|
||||
expect(result.nonGrouped).toEqual(facets);
|
||||
});
|
||||
|
||||
it('should handle empty facet list', () => {
|
||||
const result = groupFacetsByBaseName([]);
|
||||
|
||||
expect(result.grouped).toHaveLength(0);
|
||||
expect(result.nonGrouped).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilterGroup', () => {
|
||||
const defaultProps: FilterGroupProps = {
|
||||
name: 'Test Filter',
|
||||
|
|
@ -404,16 +587,20 @@ describe('FilterGroup', () => {
|
|||
renderWithMantine(
|
||||
<FilterGroup
|
||||
{...defaultProps}
|
||||
isDefaultExpanded={true}
|
||||
options={[
|
||||
{ value: 'apple123', label: 'apple123' },
|
||||
{ value: 'apple456', label: 'apple456' },
|
||||
{ value: 'banana', label: 'banana' },
|
||||
{ value: 'cherry', label: 'cherry' },
|
||||
{ value: 'date', label: 'date' },
|
||||
{ value: 'elderberry', label: 'elderberry' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Type in search box
|
||||
const searchInput = screen.getByPlaceholderText('Test Filter');
|
||||
// Type in search box (shown when expanded and >5 options)
|
||||
const searchInput = screen.getByPlaceholderText('Search values...');
|
||||
await userEvent.type(searchInput, 'apple');
|
||||
|
||||
const labels = screen.getAllByText(/apple123|apple456/);
|
||||
|
|
|
|||
|
|
@ -50,20 +50,50 @@ test.describe('Search Filters', { tag: ['@search'] }, () => {
|
|||
});
|
||||
|
||||
await test.step('Test using search to find and apply the filter', async () => {
|
||||
// Search for "info" in the severity filter
|
||||
const searchInput = page.locator(
|
||||
'[data-testid="filter-search-SeverityText"]',
|
||||
// Find and expand a filter that shows a search input (has >5 values)
|
||||
const filterControls = page.locator(
|
||||
'[data-testid="filter-group-control"]',
|
||||
);
|
||||
await searchInput.fill('info');
|
||||
await page.waitForTimeout(500);
|
||||
const filterCount = await filterControls.count();
|
||||
|
||||
// Apply the Info filter from search results
|
||||
await page.locator('[data-testid="filter-checkbox-info"]').click();
|
||||
await page.waitForTimeout(500);
|
||||
// Try each filter until we find one with a search input
|
||||
for (let i = 0; i < Math.min(filterCount, 5); i++) {
|
||||
const filter = filterControls.nth(i);
|
||||
const filterText = await filter.textContent();
|
||||
const filterName =
|
||||
filterText?.trim().replace(/\s*\(\d+\)\s*$/, '') || `filter-${i}`;
|
||||
|
||||
// Clear the search
|
||||
await searchInput.clear();
|
||||
await page.waitForTimeout(500);
|
||||
// Skip severity-related filters as they likely have few values
|
||||
if (
|
||||
filterName.toLowerCase().includes('severity') ||
|
||||
filterName.toLowerCase().includes('level')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand the filter
|
||||
await filter.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check if search input appears
|
||||
const searchInput = page.locator(
|
||||
`[data-testid="filter-search-${filterName}"]`,
|
||||
);
|
||||
|
||||
try {
|
||||
await searchInput.waitFor({ state: 'visible', timeout: 1000 });
|
||||
// Search input is visible, test it
|
||||
await searchInput.fill('test');
|
||||
await page.waitForTimeout(500);
|
||||
await searchInput.clear();
|
||||
await page.waitForTimeout(500);
|
||||
break; // Found a working filter, stop testing
|
||||
} catch (e) {
|
||||
// Search input not visible, collapse and try next filter
|
||||
await filter.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Pin filter and verify it persists after reload', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue