From 90b48e71634ade5fbfe17d64bfd7e61d3cb05431 Mon Sep 17 00:00:00 2001 From: Mike Shi Date: Mon, 21 Sep 2026 00:22:19 -0700 Subject: [PATCH 1/8] feat: search filters by name in the search page sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar's filter list only requests low-cardinality columns and map sub-fields, so a team with a wide schema had no way to reach a filter that wasn't already on screen: the list is long, alphabetical, and truncated, with no way to jump to a column you know exists. Typing in the new search box filters what is already rendered instantly, and a second, debounced query fetches values for matching fields the browse list never asked for — so a high-cardinality column is reachable by name. The search runs as a separate query rather than widening the existing one. That keeps the browse list untouched while typing, makes clearing the search free, caches each term on its own, and gives search its own key budget instead of competing with browse for the 100-key cap. Also here, both needed to make the above usable: - NestedFilterGroup gains a transient `isForceExpanded` so a match inside a collapsed group is visible, and the group returns to the user's own state when the search is cleared. - Its virtualizer no longer caches a row height of 0 measured while the Filters section is collapsed, which previously left the group reopening empty. HDX-3495 --- .changeset/search-filters-by-name.md | 7 + .../src/components/DBSearchPageFilters.tsx | 95 +++++++++-- .../DBSearchPageFilters/FilterKeySearch.tsx | 49 ++++++ .../DBSearchPageFilters/NestedFilterGroup.tsx | 17 +- .../DBSearchPageFilters/hooks.test.tsx | 161 +++++++++++++++++- .../components/DBSearchPageFilters/hooks.ts | 148 +++++++++++++--- 6 files changed, 434 insertions(+), 43 deletions(-) create mode 100644 .changeset/search-filters-by-name.md create mode 100644 packages/app/src/components/DBSearchPageFilters/FilterKeySearch.tsx diff --git a/.changeset/search-filters-by-name.md b/.changeset/search-filters-by-name.md new file mode 100644 index 0000000000..751380076e --- /dev/null +++ b/.changeset/search-filters-by-name.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': patch +--- + +feat: search filters by name in the search page sidebar + +Teams with a wide schema had no way to reach a filter that wasn't in the sidebar's default list, which shows low-cardinality columns and map sub-fields only. The new search box filters what is already rendered as you type, and runs a second query for matching fields the browse list never loaded, so a high-cardinality column is reachable by name. diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 67a154aae7..121640768c 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -31,6 +31,7 @@ import { Tooltip, UnstyledButton, } from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { IconArrowBarToLeft, @@ -65,6 +66,7 @@ import { import { useSource } from '@/source'; import { useLocalStorage } from '@/utils'; +import { FilterKeySearch } from './DBSearchPageFilters/FilterKeySearch'; import { FilterSettingsPanel } from './DBSearchPageFilters/FilterSettingsPopover'; import { useFetchFacets } from './DBSearchPageFilters/hooks'; import { NestedFilterGroup } from './DBSearchPageFilters/NestedFilterGroup'; @@ -88,6 +90,9 @@ const INITIAL_MAX_VALUES_DISPLAYED = 10; /* The maximum number of values per filter to render at once after loading more */ const SHOW_MORE_MAX_VALUES_DISPLAYED = 50; +/* How long typing in the filter-name search settles before it queries */ +const SEARCH_DEBOUNCE_MS = 300; + // This function will clean json string attributes specifically. It will turn a string like // 'toString(ResourceAttributes.`hdx`.`sdk`.`version`)' into 'ResourceAttributes.hdx.sdk.version'. export function cleanedFacetName(key: string): string { @@ -400,6 +405,12 @@ export type FilterGroupProps = { loadMoreLoading: boolean; hasLoadedMore: boolean; isDefaultExpanded?: boolean; + /** + * Holds the group open for as long as it is true, without touching the + * user's own expand/collapse state — so a filter-name search can reveal + * matches and the group snaps back when the search is cleared. + */ + isForceExpanded?: boolean; showFilterCounts?: boolean; 'data-testid'?: string; chartConfig: BuilderChartConfigWithDateRange; @@ -898,6 +909,7 @@ export const FilterGroup = ({ loadMoreLoading, hasLoadedMore, isDefaultExpanded, + isForceExpanded, showFilterCounts, 'data-testid': dataTestId, chartConfig, @@ -906,6 +918,7 @@ export const FilterGroup = ({ onRangeChange, }: FilterGroupProps) => { const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false); + const expanded = isExpanded || !!isForceExpanded; const [showDistributions, setShowDistributions] = useState(false); const [isFetchingDistribution, setIsFetchingDistribution] = useState(false); @@ -951,7 +964,7 @@ export const FilterGroup = ({ variant="unstyled" chevronPosition="left" classNames={{ chevron: classes.chevron }} - value={isExpanded ? displayName : null} + value={expanded ? displayName : null} onChange={v => { setExpanded(v === displayName); }} @@ -1170,10 +1183,16 @@ const DBSearchPageFiltersComponent = ({ ); const [showMoreFields, setShowMoreFields] = useState(false); + const [filterSearch, setFilterSearch] = useState(''); + const [debouncedFilterSearch] = useDebouncedValue( + filterSearch.trim(), + SEARCH_DEBOUNCE_MS, + ); const { data: fetchFacetsData, isLoading: isFacetsLoading, isFetching: isFacetsFetching, + isSearchFetching, error, loadMoreFacetsForKey, loadMoreLoadingKeys, @@ -1185,6 +1204,7 @@ const DBSearchPageFiltersComponent = ({ mode: showAllValues ? 'all' : 'exact', filterState, showMoreFields, + searchQuery: debouncedFilterSearch, }); const facets = fetchFacetsData.keyValues; @@ -1342,6 +1362,34 @@ const DBSearchPageFiltersComponent = ({ sharedFilterKeys, ]); + // Applied against the raw input, not the debounced value: filtering what is + // already on screen costs nothing, so it should keep up with typing even + // while the query behind it is still settling. + const isSearching = filterSearch.trim().length > 0; + // The debounce has not fired yet, so no query is in flight for what is + // currently typed. Without this the sidebar answers "No matching filters" + // for 300ms before it has asked anything. + const isSearchPending = + isSearching && filterSearch.trim() !== debouncedFilterSearch; + const isSearchBusy = isSearchPending || isSearchFetching; + const matchesFilterSearch = useCallback( + (key: string) => { + const needle = filterSearch.trim().toLowerCase(); + if (!needle) return true; + return cleanedFacetName(key).toLowerCase().includes(needle); + }, + [filterSearch], + ); + + const visibleFacets = useMemo( + () => shownFacets.filter(facet => matchesFilterSearch(facet.key)), + [shownFacets, matchesFilterSearch], + ); + const visibleSharedFacets = useMemo( + () => sharedFacets.filter(facet => matchesFilterSearch(facet.key)), + [sharedFacets, matchesFilterSearch], + ); + // Check if shared facets have active selections const showSharedClearButton = useMemo( () => @@ -1421,10 +1469,17 @@ const DBSearchPageFiltersComponent = ({ const renderFacetList = useCallback( ( facets: { key: string; value: (string | boolean)[] }[], - options?: { keyPrefix?: string; isDefaultExpanded?: boolean }, + options?: { + keyPrefix?: string; + isDefaultExpanded?: boolean; + isForceExpanded?: boolean; + }, ) => { - const { keyPrefix = '', isDefaultExpanded: forceExpanded } = - options ?? {}; + const { + keyPrefix = '', + isDefaultExpanded: forceExpanded, + isForceExpanded, + } = options ?? {}; const { grouped, nonGrouped } = groupFacetsByBaseName(facets); const makeValuePins = (key: string): ValuePinHandlers => ({ @@ -1516,6 +1571,7 @@ const DBSearchPageFiltersComponent = ({ ); }) } + isForceExpanded={isForceExpanded} chartConfig={chartConfig} isLive={isLive} /> @@ -1571,6 +1627,7 @@ const DBSearchPageFiltersComponent = ({ entry.range != null))) ); })()} + isForceExpanded={isForceExpanded} chartConfig={chartConfig} isLive={isLive} onRangeChange={range => setFilterRange(facet.key, range)} @@ -1690,7 +1747,7 @@ const DBSearchPageFiltersComponent = ({ {isSharedFiltersVisible && ( 0} + hasSharedFacets={visibleSharedFacets.length > 0} opened={isSharedFiltersExpanded} onToggle={() => setSharedFiltersExpanded(!isSharedFiltersExpanded) @@ -1698,7 +1755,7 @@ const DBSearchPageFiltersComponent = ({ showClearButton={showSharedClearButton} onClearSelections={clearSharedSelections} > - {renderFacetList(sharedFacets, { + {renderFacetList(visibleSharedFacets, { keyPrefix: 'shared-', isDefaultExpanded: true, })} @@ -1706,7 +1763,7 @@ const DBSearchPageFiltersComponent = ({ )} {/* Divider between shared and regular filters */} - {isSharedFiltersVisible && sharedFacets.length > 0 && ( + {isSharedFiltersVisible && visibleSharedFacets.length > 0 && ( )} @@ -1764,6 +1821,12 @@ const DBSearchPageFiltersComponent = ({ + + {analysisMode === 'results' && ( ) : ( - shownFacets.length === 0 && ( + visibleFacets.length === 0 && + (isSearching ? ( + + {isSearchBusy + ? 'Searching filters…' + : 'No matching filters'} + + ) : ( No filters available - ) + )) )} {/* Show facets even when loading to ensure pinned filters are visible while loading */} - {renderFacetList(shownFacets)} + {renderFacetList(visibleFacets, { + // A match buried in a collapsed group is a match the user + // cannot see, which defeats searching for it. Transient, so + // clearing the search restores the browse view as it was. + isForceExpanded: isSearching, + })}