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:
Tom Alexander 2025-11-26 14:04:33 -05:00 committed by GitHub
parent 3b2a8633dd
commit 087ff4008b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 665 additions and 108 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Grouped filters for map/json types

View file

@ -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"

View file

@ -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>
);
};

View file

@ -0,0 +1,5 @@
export {
NestedFilterGroup,
type NestedFilterGroupProps,
} from './NestedFilterGroup';
export { groupFacetsByBaseName, parseMapFieldName } from './utils';

View 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 };
}

View file

@ -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/);

View file

@ -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 () => {