Repository navigation
feat: search filters by name in the search page sidebar #3167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
90b48e7
f37947c
68e1b80
b0c6229
1879a84
cd255d0
d130083
9f67027
55ded36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -73,6 +75,7 @@ import { | |
| PinShareMenu, | ||
| } from './DBSearchPageFilters/PinShareMenu'; | ||
| import { SharedFiltersSection } from './DBSearchPageFilters/SharedFilters'; | ||
| import { useGroupExpansion } from './DBSearchPageFilters/useGroupExpansion'; | ||
| import { | ||
| getFilterStateEntry, | ||
| groupFacetsByBaseName, | ||
|
|
@@ -88,6 +91,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 +406,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,14 +910,18 @@ export const FilterGroup = ({ | |
| loadMoreLoading, | ||
| hasLoadedMore, | ||
| isDefaultExpanded, | ||
| isForceExpanded, | ||
| showFilterCounts, | ||
| 'data-testid': dataTestId, | ||
| chartConfig, | ||
| isLive, | ||
| distributionKey, | ||
| onRangeChange, | ||
| }: FilterGroupProps) => { | ||
| const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false); | ||
| const [expanded, setExpanded, expandBrowse] = useGroupExpansion( | ||
| isDefaultExpanded ?? false, | ||
| isForceExpanded, | ||
| ); | ||
| const [showDistributions, setShowDistributions] = useState(false); | ||
| const [isFetchingDistribution, setIsFetchingDistribution] = useState(false); | ||
|
|
||
|
|
@@ -923,17 +939,17 @@ export const FilterGroup = ({ | |
| } | ||
| return !prev; | ||
| }); | ||
| }, []); | ||
| }, [setExpanded]); | ||
|
|
||
| const onDistributionError = useCallback(() => { | ||
| setShowDistributions(false); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| if (isDefaultExpanded) { | ||
| setExpanded(true); | ||
| expandBrowse(); | ||
| } | ||
| }, [isDefaultExpanded]); | ||
| }, [isDefaultExpanded, expandBrowse]); | ||
|
|
||
| const totalAppliedFiltersSize = | ||
| selectedValues.included.size + | ||
|
|
@@ -951,7 +967,7 @@ export const FilterGroup = ({ | |
| variant="unstyled" | ||
| chevronPosition="left" | ||
| classNames={{ chevron: classes.chevron }} | ||
| value={isExpanded ? displayName : null} | ||
| value={expanded ? displayName : null} | ||
| onChange={v => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major — Force-expand makes every group's collapse control a no-op during a search, and silently flips the stored state
Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond |
||
| setExpanded(v === displayName); | ||
| }} | ||
|
|
@@ -1024,7 +1040,7 @@ export const FilterGroup = ({ | |
| onRangeChange={onRangeChange} | ||
| /> | ||
| ) : ( | ||
| isExpanded && ( | ||
| expanded && ( | ||
| <FilterGroupBody | ||
| name={name} | ||
| options={options} | ||
|
|
@@ -1170,10 +1186,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 +1207,7 @@ const DBSearchPageFiltersComponent = ({ | |
| mode: showAllValues ? 'all' : 'exact', | ||
| filterState, | ||
| showMoreFields, | ||
| searchQuery: debouncedFilterSearch, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Clearing the search box leaves search-only facets in the browse list for 300 ms
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| }); | ||
| const facets = fetchFacetsData.keyValues; | ||
|
|
||
|
|
@@ -1342,6 +1365,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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — A one-character search force-expands nearly every filter group for no benefit
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| // 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Spinner and "Searching filters…" shown for inputs that will never be queried
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| const matchesFilterSearch = useCallback( | ||
| (key: string) => { | ||
| const needle = filterSearch.trim().toLowerCase(); | ||
| if (!needle) return true; | ||
| return cleanedFacetName(key).toLowerCase().includes(needle); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Search matches the raw bracket key, not the Map sub-fields render as a nested group ( Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The instant filter and the search query match different strings for JSON sub-fields The hook strips backticks before matching, but IMO one helper in export const filterSearchHaystack = (key: string) =>
cleanedFacetName(key).toLowerCase().replaceAll('`', ''); |
||
| }, | ||
| [filterSearch], | ||
| ); | ||
|
|
||
| const visibleFacets = useMemo( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — No test covers the client-side name filter or the search empty and loading states
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| () => shownFacets.filter(facet => matchesFilterSearch(facet.key)), | ||
| [shownFacets, matchesFilterSearch], | ||
|
greptile-apps[bot] marked this conversation as resolved.
Resolved
|
||
| ); | ||
| const visibleSharedFacets = useMemo( | ||
| () => sharedFacets.filter(facet => matchesFilterSearch(facet.key)), | ||
| [sharedFacets, matchesFilterSearch], | ||
| ); | ||
|
|
||
| // Check if shared facets have active selections | ||
| const showSharedClearButton = useMemo( | ||
| () => | ||
|
|
@@ -1421,10 +1472,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 +1574,7 @@ const DBSearchPageFiltersComponent = ({ | |
| ); | ||
| }) | ||
| } | ||
| isForceExpanded={isForceExpanded} | ||
| chartConfig={chartConfig} | ||
| isLive={isLive} | ||
| /> | ||
|
|
@@ -1571,6 +1630,7 @@ const DBSearchPageFiltersComponent = ({ | |
| entry.range != null))) | ||
| ); | ||
| })()} | ||
| isForceExpanded={isForceExpanded} | ||
| chartConfig={chartConfig} | ||
| isLive={isLive} | ||
| onRangeChange={range => setFilterRange(facet.key, range)} | ||
|
|
@@ -1690,23 +1750,23 @@ const DBSearchPageFiltersComponent = ({ | |
|
|
||
| {isSharedFiltersVisible && ( | ||
| <SharedFiltersSection | ||
| hasSharedFacets={sharedFacets.length > 0} | ||
| hasSharedFacets={visibleSharedFacets.length > 0} | ||
| opened={isSharedFiltersExpanded} | ||
| onToggle={() => | ||
| setSharedFiltersExpanded(!isSharedFiltersExpanded) | ||
| } | ||
| showClearButton={showSharedClearButton} | ||
| onClearSelections={clearSharedSelections} | ||
| > | ||
| {renderFacetList(sharedFacets, { | ||
| {renderFacetList(visibleSharedFacets, { | ||
| keyPrefix: 'shared-', | ||
| isDefaultExpanded: true, | ||
| })} | ||
| </SharedFiltersSection> | ||
| )} | ||
|
|
||
| {/* Divider between shared and regular filters */} | ||
| {isSharedFiltersVisible && sharedFacets.length > 0 && ( | ||
| {isSharedFiltersVisible && visibleSharedFacets.length > 0 && ( | ||
| <Divider color="dark.4" /> | ||
| )} | ||
|
|
||
|
|
@@ -1827,17 +1887,35 @@ const DBSearchPageFiltersComponent = ({ | |
| /> | ||
| )} | ||
|
|
||
| <FilterKeySearch | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Search input is nested inside the collapsible "Filters" section but also filters Shared Filters above it
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Search box is inside the Filters collapse but also filters the Shared Filters section outside it
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| value={filterSearch} | ||
| onChange={setFilterSearch} | ||
| isFetching={isSearchBusy} | ||
| /> | ||
|
|
||
| {isFacetsLoading ? ( | ||
| <Flex align="center" justify="center"> | ||
| <Loader size="xs" color="gray" /> | ||
| </Flex> | ||
| ) : ( | ||
| shownFacets.length === 0 && ( | ||
| visibleFacets.length === 0 && | ||
| (isSearching ? ( | ||
| <Text size="xxs"> | ||
| {isSearchBusy | ||
| ? 'Searching filters…' | ||
| : 'No matching filters'} | ||
| </Text> | ||
|
greptile-apps[bot] marked this conversation as resolved.
Resolved
|
||
| ) : ( | ||
| <Text size="xxs">No filters available</Text> | ||
| ) | ||
| )) | ||
| )} | ||
| {/* Show facets even when loading to ensure pinned filters are visible while loading */} | ||
| {renderFacetList(shownFacets)} | ||
| {renderFacetList(visibleFacets, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — No test covers any of the new UI behaviour Seven tests cover the hook, but Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| // 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, | ||
| })} | ||
|
|
||
| <Button | ||
| variant="secondary" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { CloseButton, Loader, TextInput } from '@mantine/core'; | ||
| import { IconSearch } from '@tabler/icons-react'; | ||
|
|
||
| /** | ||
| * Search box for the filter sidebar, matching on filter (column) names rather | ||
| * than their values. Matches already on screen filter instantly; `isFetching` | ||
| * covers the query that goes looking for fields the browse list never loaded. | ||
| */ | ||
| export function FilterKeySearch({ | ||
| value, | ||
| onChange, | ||
| isFetching, | ||
| }: { | ||
| value: string; | ||
| onChange: (value: string) => void; | ||
| isFetching: boolean; | ||
| }) { | ||
| return ( | ||
| <TextInput | ||
| size="xs" | ||
| placeholder="Search filter column/properties" | ||
| aria-label="Search filter column/properties" | ||
| data-testid="filter-key-search" | ||
| value={value} | ||
| onChange={event => onChange(event.currentTarget.value)} | ||
| onKeyDown={event => { | ||
| if (event.key === 'Escape' && value) { | ||
| // Stop it bubbling to whatever else listens for Escape on the page; | ||
| // clearing the search is the more specific intent here. | ||
| event.stopPropagation(); | ||
| onChange(''); | ||
| } | ||
| }} | ||
| leftSection={<IconSearch size={12} stroke={2} />} | ||
| leftSectionWidth={24} | ||
| rightSection={ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The clear button disappears while the search query is in flight
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Clear button is swapped out for the loader on every keystroke, so the search can't be cleared while busy
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Clear button is swapped out for the loader on every keystroke, so it is never clickable while typing
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| isFetching ? ( | ||
| <Loader size={12} color="gray" /> | ||
| ) : value ? ( | ||
| <CloseButton | ||
| size="xs" | ||
| aria-label="Clear filter search" | ||
| onClick={() => onChange('')} | ||
| /> | ||
| ) : null | ||
| } | ||
| /> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,12 @@ | ||
| import { useMemo, useRef, useState } from 'react'; | ||
| import { useMemo, useRef } from 'react'; | ||
| import { FilterState } from '@hyperdx/common-utils/dist/filters'; | ||
| import { Accordion, Group, Text, Tooltip, UnstyledButton } from '@mantine/core'; | ||
| import { useVirtualizer } from '@tanstack/react-virtual'; | ||
|
|
||
| import { FilterGroup } from '@/components/DBSearchPageFilters'; | ||
|
|
||
| import { useGroupExpansion } from './useGroupExpansion'; | ||
|
|
||
| import classes from '@styles/SearchPage.module.scss'; | ||
|
|
||
| type NestedFilterGroupProps = { | ||
|
|
@@ -38,6 +40,12 @@ type NestedFilterGroupProps = { | |
| loadMoreLoading: Record<string, boolean>; | ||
| hasLoadedMore: Record<string, 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 | ||
| * matching children and the group snaps back when the search is cleared. | ||
| */ | ||
| isForceExpanded?: boolean; | ||
| 'data-testid'?: string; | ||
| chartConfig: any; // Using any to avoid importing ChartConfigWithDateRange | ||
| isLive?: boolean; | ||
|
|
@@ -69,6 +77,7 @@ export const NestedFilterGroup = ({ | |
| loadMoreLoading, | ||
| hasLoadedMore, | ||
| isDefaultExpanded, | ||
| isForceExpanded, | ||
| 'data-testid': dataTestId, | ||
| chartConfig, | ||
| isLive, | ||
|
|
@@ -88,26 +97,32 @@ export const NestedFilterGroup = ({ | |
| ); | ||
|
|
||
| const hasSelections = totalFiltersSize > 0; | ||
| const [isExpanded, setExpanded] = useState( | ||
| const [expanded, setExpanded] = useGroupExpansion( | ||
| isDefaultExpanded ?? hasSelections, | ||
| isForceExpanded, | ||
| ); | ||
|
|
||
| const scrollContainerRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| const rowVirtualizer = useVirtualizer({ | ||
| count: isExpanded ? childFilters.length : 0, | ||
| count: expanded ? childFilters.length : 0, | ||
| getScrollElement: () => scrollContainerRef.current, | ||
| estimateSize: () => VIRTUAL_ITEM_HEIGHT_ESTIMATE, | ||
| overscan: 10, | ||
| getItemKey: index => childFilters[index]?.key ?? index, | ||
| // Collapsing the whole Filters section measures every row at 0 while it is | ||
| // hidden; that zero is cached, so the total size stays 0 and the group | ||
| // reopens empty. Fall back to the estimate rather than cache a zero. | ||
| measureElement: el => | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The virtualizer zero-height fix and the whole search UI have no component-level test
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| el.getBoundingClientRect().height || VIRTUAL_ITEM_HEIGHT_ESTIMATE, | ||
| }); | ||
|
|
||
| return ( | ||
| <Accordion | ||
| variant="unstyled" | ||
| chevronPosition="left" | ||
| classNames={{ chevron: classes.chevron }} | ||
| value={isExpanded ? name : null} | ||
| value={expanded ? name : null} | ||
| onChange={v => setExpanded(v === name)} | ||
| > | ||
| <Accordion.Item value={name} data-testid={dataTestId}> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: This feels more like a minor than a patch