virtualize nested filter groups (#1979)

## Summary

Upon investigating filter performance, I found much of the latency was just in rendering the filters. 

I will follow this up with a team setting that determines how many filters will be fetched

### Screenshots or video

#### Before
https://github.com/user-attachments/assets/c0853cbb-1fd1-417e-96c8-8813c3158546 

#### After
https://github.com/user-attachments/assets/a07deac2-12e8-43aa-af76-57a800acc33a

### How to test locally or on Vercel

1. Go to demo preview
2. Load more filters
3. Scroll and explore

### References

Closes HDX-3806
This commit is contained in:
Aaron Knudtson 2026-03-31 12:20:37 -04:00 committed by GitHub
parent a15122b375
commit 9852e9b0b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 559 additions and 328 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
perf: Defer expensive hooks in collapsed filter groups and virtualize nested filter lists

View file

@ -360,74 +360,75 @@ export type FilterGroupProps = {
distributionKey?: string;
};
export const FilterGroup = ({
/**
* Inner body of a FilterGroup only mounted when expanded.
* All expensive hooks (useGetValuesDistribution, sorting memos, etc.)
* live here so collapsed groups pay near-zero cost.
*/
const FilterGroupBody = ({
name,
options,
optionsLoading,
selectedValues = { included: new Set(), excluded: new Set() },
selectedValues,
onChange,
onClearClick,
onOnlyClick,
onExcludeClick,
isPinned,
onPinClick,
onFieldPinClick,
isFieldPinned,
onColumnToggle,
isColumnDisplayed,
onLoadMore,
loadMoreLoading,
hasLoadedMore,
isDefaultExpanded,
'data-testid': dataTestId,
chartConfig,
isLive,
distributionKey,
onRangeChange,
}: FilterGroupProps) => {
showDistributions,
onDistributionError,
onFetchingDistributionChange,
}: {
name: string;
options: { value: string | boolean; label: string }[];
optionsLoading?: boolean;
selectedValues: {
included: Set<string | boolean>;
excluded: Set<string | boolean>;
range?: { min: number; max: number };
};
onChange: (value: string | boolean) => void;
onOnlyClick: (value: string | boolean) => void;
onExcludeClick: (value: string | boolean) => void;
isPinned: (value: string | boolean) => boolean;
onPinClick: (value: string | boolean) => void;
onLoadMore: (key: string) => void;
loadMoreLoading: boolean;
hasLoadedMore: boolean;
chartConfig: BuilderChartConfigWithDateRange;
isLive?: boolean;
distributionKey?: string;
showDistributions: boolean;
onDistributionError: () => void;
onFetchingDistributionChange: (isFetching: boolean) => void;
}) => {
const [search, setSearch] = useState('');
// "Show More" button when there's lots of options
const [shouldShowMore, setShowMore] = useState(false);
// Accordion expanded state
const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
// Track recently moved items for highlight animation
const [recentlyMoved, setRecentlyMoved] = useState<Set<string | boolean>>(
new Set(),
);
// Show what percentage of the data has each value
const [showDistributions, setShowDistributions] = useState(false);
// For live searches, don't refresh percentages when date range changes
const [dateRange, setDateRange] = useState<[Date, Date]>(
chartConfig.dateRange,
);
// If this filter has a range, display it differently
const hasRange = selectedValues.range != null;
const toggleShowDistributions = () => {
if (!showDistributions) {
setExpanded(true);
setDateRange(chartConfig.dateRange);
}
setShowDistributions(prev => !prev);
};
useEffect(() => {
if (!isLive) {
setDateRange(chartConfig.dateRange);
}
}, [chartConfig.dateRange, isLive]);
useEffect(() => {
if (isDefaultExpanded) {
setExpanded(true);
}
}, [isDefaultExpanded]);
const handleSetSearch = useCallback(
(value: string) => {
setSearch(value);
if (value && !hasLoadedMore) {
onLoadMore(name);
}
@ -450,6 +451,10 @@ export const FilterGroup = ({
},
);
useEffect(() => {
onFetchingDistributionChange(isFetchingDistribution);
}, [isFetchingDistribution, onFetchingDistributionChange]);
useEffect(() => {
if (distributionError) {
notifications.show({
@ -458,14 +463,14 @@ export const FilterGroup = ({
message: distributionError?.message,
autoClose: 5000,
});
setShowDistributions(false);
onDistributionError();
}
}, [distributionError]);
}, [distributionError, onDistributionError]);
const totalAppliedFiltersSize =
selectedValues.included.size +
selectedValues.excluded.size +
(hasRange ? 1 : 0);
(selectedValues.range != null ? 1 : 0);
// Loaded options + any selected options that aren't in the loaded list
const augmentedOptions = useMemo(() => {
@ -551,9 +556,8 @@ export const FilterGroup = ({
// Simple highlight animation when checkbox is checked
const handleChange = useCallback(
(value: string | boolean) => {
const wasIncluded = selectedValues.included.has(value);
// If checking (not unchecking), trigger highlight animation
const wasIncluded = selectedValues.included.has(value);
if (!wasIncluded) {
setRecentlyMoved(prev => new Set(prev).add(value));
setTimeout(() => {
@ -564,7 +568,6 @@ export const FilterGroup = ({
});
}, 600);
}
onChange(value);
},
[onChange, selectedValues],
@ -578,6 +581,313 @@ export const FilterGroup = ({
augmentedOptions.length > INITIAL_MAX_VALUES_DISPLAYED &&
totalAppliedFiltersSize < augmentedOptions.length;
return (
<Stack gap={0}>
{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>) =>
handleSetSearch(event.currentTarget.value)
}
rightSectionWidth={20}
rightSection={<IconSearch size={12} stroke={2} />}
classNames={{
input: 'ps-0.5',
}}
/>
</div>
)}
{displayedOptions.map(option => (
<FilterCheckbox
key={option.value.toString()}
columnName={name}
label={option.label}
pinned={isPinned(option.value)}
className={
recentlyMoved.has(option.value) ? classes.recentlyMoved : ''
}
value={
selectedValues.included.has(option.value)
? 'included'
: selectedValues.excluded.has(option.value)
? 'excluded'
: false
}
onChange={() => handleChange(option.value)}
onClickOnly={() => onOnlyClick(option.value)}
onClickExclude={() => onExcludeClick(option.value)}
onClickPin={() => onPinClick(option.value)}
isPercentageLoading={isFetchingDistribution}
percentage={
showDistributions && distributionData
? (distributionData.get(option.value.toString()) ?? 0)
: undefined
}
/>
))}
{optionsLoading ? (
<Group m={6} gap="xs">
<Loader size={12} color="gray" />
<Text c="dimmed" size="xs">
Loading...
</Text>
</Group>
) : displayedOptions.length === 0 ? (
<Group m={6} gap="xs">
<Text c="dimmed" size="xs">
No options found
</Text>
</Group>
) : null}
{isLimitingDisplayedItems && (shouldShowMore || search) && (
<Text size="xxs" ms={28} fs="italic">
Search to see more
</Text>
)}
{loadMoreLoading && (
<Group m={6} gap="xs">
<Loader size={12} color="gray" />
<Text c="dimmed" size="xs">
Loading more...
</Text>
</Group>
)}
{showShowMoreButton && (
<div className="d-flex m-1">
<TextButton
data-testid={`filter-show-more-${name}`}
label={
shouldShowMore ? (
<>
<IconChevronUp size={12} /> Less
</>
) : (
<>
<IconChevronRight size={12} /> Show more
</>
)
}
onClick={() => {
// When show more is clicked, immediately show all and also fetch more from server.
setShowMore(!shouldShowMore);
if (!shouldShowMore) {
onLoadMore?.(name);
}
}}
/>
</div>
)}
{onLoadMore &&
!showShowMoreButton &&
!shouldShowMore &&
!hasLoadedMore &&
!loadMoreLoading && (
<div className="d-flex m-1">
<TextButton
data-testid={`filter-load-more-${name}`}
display={hasLoadedMore ? 'none' : undefined}
label={
<>
<IconChevronRight size={12} /> Load more
</>
}
onClick={() => onLoadMore(name)}
/>
</div>
)}
</Stack>
);
};
type FilterGroupActionsProps = {
name: string;
hasRange: boolean;
showDistributions: boolean;
isFetchingDistribution: boolean;
isColumnDisplayed: boolean;
isFieldPinned: boolean;
totalAppliedFiltersSize: number;
toggleShowDistributions: VoidFunction;
onColumnToggle: VoidFunction;
onFieldPinClick: VoidFunction;
onClearClick: VoidFunction;
};
function FilterGroupActions({
name,
hasRange,
showDistributions,
isFetchingDistribution,
isColumnDisplayed,
isFieldPinned,
totalAppliedFiltersSize,
toggleShowDistributions,
onColumnToggle,
onFieldPinClick,
onClearClick,
}: FilterGroupActionsProps) {
return (
<Group gap={0} wrap="nowrap">
{!hasRange && (
<>
<Tooltip
label={
showDistributions ? 'Hide Distribution' : 'Show Distribution'
}
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={toggleShowDistributions}
data-testid={`toggle-distribution-button-${name}`}
aria-checked={showDistributions}
role="checkbox"
>
{isFetchingDistribution ? (
<Center>
<IconRefresh className="spin-animate" size={12} />
</Center>
) : showDistributions ? (
<IconChartBarOff size={14} />
) : (
<IconChartBar size={14} />
)}
</ActionIcon>
</Tooltip>
<Tooltip
label={isColumnDisplayed ? 'Remove Column' : 'Add Column'}
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={onColumnToggle}
data-testid={`toggle-column-button-${name}`}
>
{isColumnDisplayed ? (
<IconMinus size={14} />
) : (
<IconPlus size={14} />
)}
</ActionIcon>
</Tooltip>
{onFieldPinClick && (
<Tooltip
label={isFieldPinned ? 'Unpin Field' : 'Pin Field'}
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={onFieldPinClick}
>
{isFieldPinned ? (
<IconPinFilled size={14} />
) : (
<IconPin size={14} />
)}
</ActionIcon>
</Tooltip>
)}
</>
)}
{totalAppliedFiltersSize > 0 && (
<Tooltip
label="Clear Filters"
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={onClearClick}
>
<IconFilterOff size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}
const voidFunc = () => {};
export const FilterGroup = ({
name,
options,
optionsLoading,
selectedValues = { included: new Set(), excluded: new Set() },
onChange,
onClearClick,
onOnlyClick,
onExcludeClick,
isPinned,
onPinClick,
onFieldPinClick,
isFieldPinned,
onColumnToggle,
isColumnDisplayed,
onLoadMore,
loadMoreLoading,
hasLoadedMore,
isDefaultExpanded,
'data-testid': dataTestId,
chartConfig,
isLive,
distributionKey,
onRangeChange,
}: FilterGroupProps) => {
const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
const [showDistributions, setShowDistributions] = useState(false);
const [isFetchingDistribution, setIsFetchingDistribution] = useState(false);
const hasRange = selectedValues.range != null;
const toggleShowDistributions = useCallback(() => {
setShowDistributions(prev => {
if (!prev) {
setExpanded(true);
}
return !prev;
});
}, []);
const onDistributionError = useCallback(() => {
setShowDistributions(false);
}, []);
useEffect(() => {
if (isDefaultExpanded) {
setExpanded(true);
}
}, [isDefaultExpanded]);
const totalAppliedFiltersSize =
selectedValues.included.size +
selectedValues.excluded.size +
(hasRange ? 1 : 0);
const hasOptions = options.length > 0 || totalAppliedFiltersSize > 0;
return (
<Accordion
variant="unstyled"
@ -602,7 +912,7 @@ export const FilterGroup = ({
chevron: 'm-0',
label: 'p-0',
}}
className={displayedOptions.length ? '' : 'opacity-50'}
className={hasOptions ? '' : 'opacity-50'}
>
<Tooltip
openDelay={name.length > 26 ? 0 : 1500}
@ -617,109 +927,19 @@ export const FilterGroup = ({
</Text>
</Tooltip>
</Accordion.Control>
<Group gap={0} wrap="nowrap">
{!hasRange && (
<>
<Tooltip
label={
showDistributions
? 'Hide Distribution'
: 'Show Distribution'
}
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={toggleShowDistributions}
data-testid={`toggle-distribution-button-${name}`}
aria-checked={showDistributions}
role="checkbox"
>
{isFetchingDistribution ? (
<Center>
<IconRefresh className="spin-animate" size={12} />
</Center>
) : showDistributions ? (
<IconChartBarOff size={14} />
) : (
<IconChartBar size={14} />
)}
</ActionIcon>
</Tooltip>
{onColumnToggle && (
<Tooltip
label={isColumnDisplayed ? 'Remove Column' : 'Add Column'}
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={onColumnToggle}
data-testid={`toggle-column-button-${name}`}
>
{isColumnDisplayed ? (
<IconMinus size={14} />
) : (
<IconPlus size={14} />
)}
</ActionIcon>
</Tooltip>
)}
{onFieldPinClick && (
<Tooltip
label={isFieldPinned ? 'Unpin Field' : 'Pin Field'}
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={onFieldPinClick}
>
{isFieldPinned ? (
<IconPinFilled size={14} />
) : (
<IconPin size={14} />
)}
</ActionIcon>
</Tooltip>
)}
</>
)}
{totalAppliedFiltersSize > 0 && (
<Tooltip
label="Clear Filters"
position="top"
withArrow
fz="xxs"
color="gray"
>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => {
onClearClick();
setSearch('');
}}
>
<IconFilterOff size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
<FilterGroupActions
name={name}
hasRange={hasRange}
showDistributions={showDistributions}
isFetchingDistribution={isFetchingDistribution}
isColumnDisplayed={isColumnDisplayed ?? false}
isFieldPinned={isFieldPinned ?? false}
totalAppliedFiltersSize={totalAppliedFiltersSize}
toggleShowDistributions={toggleShowDistributions}
onColumnToggle={onColumnToggle ?? voidFunc}
onFieldPinClick={onFieldPinClick ?? voidFunc}
onClearClick={onClearClick}
/>
</Center>
<Accordion.Panel
data-testid="filter-group-panel"
@ -735,127 +955,28 @@ export const FilterGroup = ({
onRangeChange={onRangeChange}
/>
) : (
<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>) =>
handleSetSearch(event.currentTarget.value)
}
rightSectionWidth={20}
rightSection={<IconSearch size={12} stroke={2} />}
classNames={{
input: 'ps-0.5',
}}
/>
</div>
)}
{displayedOptions.map(option => (
<FilterCheckbox
key={option.value.toString()}
columnName={name}
label={option.label}
pinned={isPinned(option.value)}
className={
recentlyMoved.has(option.value)
? classes.recentlyMoved
: ''
}
value={
selectedValues.included.has(option.value)
? 'included'
: selectedValues.excluded.has(option.value)
? 'excluded'
: false
}
onChange={() => handleChange(option.value)}
onClickOnly={() => onOnlyClick(option.value)}
onClickExclude={() => onExcludeClick(option.value)}
onClickPin={() => onPinClick(option.value)}
isPercentageLoading={isFetchingDistribution}
percentage={
showDistributions && distributionData
? (distributionData.get(option.value.toString()) ?? 0)
: undefined
}
/>
))}
{optionsLoading ? (
<Group m={6} gap="xs">
<Loader size={12} color="gray" />
<Text c="dimmed" size="xs">
Loading...
</Text>
</Group>
) : displayedOptions.length === 0 ? (
<Group m={6} gap="xs">
<Text c="dimmed" size="xs">
No options found
</Text>
</Group>
) : null}
{isLimitingDisplayedItems && (shouldShowMore || search) && (
<Text size="xxs" ms={28} fs="italic">
Search to see more
</Text>
)}
{loadMoreLoading && (
<Group m={6} gap="xs">
<Loader size={12} color="gray" />
<Text c="dimmed" size="xs">
Loading more...
</Text>
</Group>
)}
{showShowMoreButton && (
<div className="d-flex m-1">
<TextButton
data-testid={`filter-show-more-${name}`}
label={
shouldShowMore ? (
<>
<IconChevronUp size={12} /> Less
</>
) : (
<>
<IconChevronRight size={12} /> Show more
</>
)
}
onClick={() => {
// When show more is clicked, immediately show all and also fetch more from server.
setShowMore(!shouldShowMore);
if (!shouldShowMore) {
onLoadMore?.(name);
}
}}
/>
</div>
)}
{onLoadMore &&
!showShowMoreButton &&
!shouldShowMore &&
!hasLoadedMore &&
!loadMoreLoading && (
<div className="d-flex m-1">
<TextButton
data-testid={`filter-load-more-${name}`}
display={hasLoadedMore ? 'none' : undefined}
label={
<>
<IconChevronRight size={12} /> Load more
</>
}
onClick={() => onLoadMore(name)}
/>
</div>
)}
</Stack>
isExpanded && (
<FilterGroupBody
name={name}
options={options}
optionsLoading={optionsLoading}
selectedValues={selectedValues}
onChange={onChange}
onOnlyClick={onOnlyClick}
onExcludeClick={onExcludeClick}
isPinned={isPinned}
onPinClick={onPinClick}
onLoadMore={onLoadMore}
loadMoreLoading={loadMoreLoading}
hasLoadedMore={hasLoadedMore}
chartConfig={chartConfig}
isLive={isLive}
distributionKey={distributionKey}
showDistributions={showDistributions}
onDistributionError={onDistributionError}
onFetchingDistributionChange={setIsFetchingDistribution}
/>
)
)}
</Accordion.Panel>
</Stack>

View file

@ -1,12 +1,6 @@
import { useMemo, useState } from 'react';
import {
Accordion,
Group,
Stack,
Text,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { useMemo, useRef, useState } from 'react';
import { Accordion, Group, Text, Tooltip, UnstyledButton } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { FilterState } from '@/searchFilters';
@ -41,6 +35,9 @@ type NestedFilterGroupProps = {
isLive?: boolean;
};
const VIRTUAL_ITEM_HEIGHT_ESTIMATE = 26;
const MAX_VIRTUAL_LIST_HEIGHT = 350;
export const NestedFilterGroup = ({
name,
childFilters,
@ -77,6 +74,16 @@ export const NestedFilterGroup = ({
isDefaultExpanded ?? hasSelections,
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: isExpanded ? childFilters.length : 0,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => VIRTUAL_ITEM_HEIGHT_ESTIMATE,
overscan: 10,
getItemKey: index => childFilters[index]?.key ?? index,
});
return (
<Accordion
variant="unstyled"
@ -125,62 +132,106 @@ export const NestedFilterGroup = ({
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;
{isExpanded && (
<div className={classes.filterGroupPanel}>
{childFilters.length === 0 ? (
<Group m={6} gap="xs">
<Text c="dimmed" size="xs">
No properties found
</Text>
</Group>
) : (
<div
ref={scrollContainerRef}
style={{
maxHeight: MAX_VIRTUAL_LIST_HEIGHT,
overflow: 'auto',
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const child = childFilters[virtualRow.index];
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: value,
label: value.toString(),
}))}
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)}
onColumnToggle={
onColumnToggle
? () => onColumnToggle(child.key)
: undefined
}
isColumnDisplayed={displayedColumns?.includes(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>
return (
<div
key={child.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
paddingTop: 4,
paddingBottom: 4,
}}
>
<FilterGroup
data-testid={`nested-filter-group-${child.key}`}
name={child.propertyPath}
distributionKey={child.key}
options={child.value.map(value => ({
value: value,
label: value.toString(),
}))}
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)}
onColumnToggle={
onColumnToggle
? () => onColumnToggle(child.key)
: undefined
}
isColumnDisplayed={displayedColumns?.includes(
child.key,
)}
onLoadMore={() => onLoadMore(child.key)}
loadMoreLoading={
loadMoreLoading[child.key] || false
}
hasLoadedMore={hasLoadedMore[child.key] || false}
isDefaultExpanded={childHasSelections}
chartConfig={chartConfig}
isLive={isLive}
/>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</Accordion.Panel>
</div>
</Accordion.Item>

View file

@ -9,6 +9,7 @@ import {
FilterGroup,
type FilterGroupProps,
} from '../DBSearchPageFilters';
import { NestedFilterGroup } from '../DBSearchPageFilters/NestedFilterGroup';
import {
cleanClickHouseExpression,
groupFacetsByBaseName,
@ -635,4 +636,57 @@ describe('FilterGroup', () => {
),
).toBe('false');
});
it('should not render children when collapsed', () => {
renderWithMantine(
<FilterGroup {...defaultProps} isDefaultExpanded={false} />,
);
// Checkboxes should not be in the DOM when collapsed
expect(screen.queryAllByTestId(/filter-checkbox-input/)).toHaveLength(0);
});
it('should render children when expanded', () => {
renderWithMantine(
<FilterGroup {...defaultProps} isDefaultExpanded={true} />,
);
expect(screen.getAllByTestId(/filter-checkbox-.+-input/)).toHaveLength(3);
});
});
describe('NestedFilterGroup', () => {
const defaultNestedProps = {
name: 'ResourceAttributes',
childFilters: [
{ key: 'attr1', value: ['val1', 'val2'], propertyPath: 'service.name' },
{ key: 'attr2', value: ['val3'], propertyPath: 'host.name' },
],
onChange: jest.fn(),
onClearClick: jest.fn(),
onOnlyClick: jest.fn(),
onExcludeClick: jest.fn(),
onPinClick: jest.fn(),
isPinned: jest.fn().mockReturnValue(false),
onLoadMore: jest.fn(),
loadMoreLoading: {} as Record<string, boolean>,
hasLoadedMore: {} as Record<string, boolean>,
chartConfig: {
from: { databaseName: 'test_db', tableName: 'test_table' },
select: '',
where: '',
whereLanguage: 'sql',
timestampValueExpression: '',
connection: 'test_connection',
dateRange: [new Date('2024-01-01'), new Date('2024-01-02')],
},
};
it('should not render child FilterGroups when collapsed', () => {
renderWithMantine(
<NestedFilterGroup {...defaultNestedProps} isDefaultExpanded={false} />,
);
expect(screen.queryAllByTestId(/nested-filter-group-attr/)).toHaveLength(0);
});
});