mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
a15122b375
commit
9852e9b0b7
4 changed files with 559 additions and 328 deletions
5
.changeset/filter-panel-perf.md
Normal file
5
.changeset/filter-panel-perf.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
perf: Defer expensive hooks in collapsed filter groups and virtualize nested filter lists
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue