feat: Add input filter pills below search input to make filters usage more clear on seach page (#2039)

<img width="1470" height="754" alt="image" src="https://github.com/user-attachments/assets/fd6281c5-ded2-48d4-9fcd-01e5d0fb9c8e" />

Fixes HDX-2405
This commit is contained in:
Mike Shi 2026-04-02 15:18:58 -07:00 committed by GitHub
parent 676e4f4bc9
commit 20e4720761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 491 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": minor
---
feat: Add input filter pills below search input to make filters usage more clear on seach page.

View file

@ -78,6 +78,7 @@ import { useIsFetching } from '@tanstack/react-query';
import { SortingState } from '@tanstack/react-table';
import CodeMirror from '@uiw/react-codemirror';
import { ActiveFilterPills } from '@/components/ActiveFilterPills';
import { ContactSupportText } from '@/components/ContactSupportText';
import { DBSearchPageFilters } from '@/components/DBSearchPageFilters';
import { DBTimeChart } from '@/components/DBTimeChart';
@ -1779,6 +1780,7 @@ function DBSearchPage() {
<SearchSubmitButton isFormStateDirty={formState.isDirty} />
</Flex>
</Flex>
<ActiveFilterPills searchFilters={searchFilters} mt={6} />
</form>
{searchedConfig != null && searchedSource != null && (
<SaveSearchModal

View file

@ -0,0 +1,207 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActionIcon, Flex, FlexProps, Text, Tooltip } from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import type { FilterStateHook } from '@/searchFilters';
const MAX_VISIBLE_PILLS = 8;
type PillItem = {
field: string;
value: string;
type: 'included' | 'excluded' | 'range';
rawValue?: string | boolean;
};
function flattenFilters(filters: FilterStateHook['filters']): PillItem[] {
const pills: PillItem[] = [];
for (const [field, state] of Object.entries(filters)) {
for (const val of state.included) {
pills.push({
field,
value: String(val),
type: 'included',
rawValue: val,
});
}
for (const val of state.excluded) {
pills.push({
field,
value: String(val),
type: 'excluded',
rawValue: val,
});
}
if (state.range != null) {
pills.push({
field,
value: `${state.range.min} ${state.range.max}`,
type: 'range',
});
}
}
return pills;
}
const pillStyle = {
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '1px 6px',
borderRadius: 3,
fontSize: 11,
lineHeight: '18px',
cursor: 'default',
whiteSpace: 'nowrap' as const,
maxWidth: 260,
overflow: 'hidden',
};
function FilterPill({
pill,
onRemove,
}: {
pill: PillItem;
onRemove: () => void;
}) {
const isExcluded = pill.type === 'excluded';
const operator = isExcluded ? ' != ' : pill.type === 'range' ? ': ' : ' = ';
return (
<Tooltip label={`${pill.field}${operator}${pill.value}`} openDelay={300}>
<span
style={{
...pillStyle,
backgroundColor: isExcluded
? 'var(--mantine-color-red-light)'
: 'var(--color-bg-hover)',
}}
>
<Text
span
size="xxs"
c="dimmed"
fw={500}
style={{ flexShrink: 0, maxWidth: 100 }}
truncate="start"
>
{pill.field}
</Text>
<Text span size="xxs" c={isExcluded ? 'red.4' : 'dimmed'}>
{operator}
</Text>
<Text span size="xxs" fw={500} truncate>
{pill.value}
</Text>
<ActionIcon
size={14}
variant="transparent"
color={isExcluded ? 'red.4' : 'gray'}
onClick={onRemove}
style={{ flexShrink: 0, marginLeft: 2 }}
>
<IconX size={9} />
</ActionIcon>
</span>
</Tooltip>
);
}
export const ActiveFilterPills = memo(function ActiveFilterPills({
searchFilters,
...flexProps
}: {
searchFilters: FilterStateHook;
} & FlexProps) {
const { filters, setFilterValue, clearFilter, clearAllFilters } =
searchFilters;
const pills = useMemo(() => flattenFilters(filters), [filters]);
const [expanded, setExpanded] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const confirmTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => clearTimeout(confirmTimerRef.current);
}, []);
const handleRemove = useCallback(
(pill: PillItem) => {
if (pill.type === 'range') {
clearFilter(pill.field);
} else {
setFilterValue(
pill.field,
pill.rawValue!,
pill.type === 'excluded' ? 'exclude' : undefined,
);
}
},
[setFilterValue, clearFilter],
);
const handleClearAll = useCallback(() => {
if (!confirmClear) {
setConfirmClear(true);
clearTimeout(confirmTimerRef.current);
confirmTimerRef.current = setTimeout(() => setConfirmClear(false), 2000);
return;
}
clearAllFilters();
setConfirmClear(false);
clearTimeout(confirmTimerRef.current);
}, [confirmClear, clearAllFilters]);
if (pills.length === 0) {
return null;
}
const visiblePills = expanded ? pills : pills.slice(0, MAX_VISIBLE_PILLS);
const hiddenCount = pills.length - MAX_VISIBLE_PILLS;
return (
<Flex gap={4} px="sm" wrap="wrap" align="center" {...flexProps}>
{visiblePills.map((pill, i) => (
<FilterPill
key={`${pill.field}-${pill.type}-${pill.value}-${i}`}
pill={pill}
onRemove={() => handleRemove(pill)}
/>
))}
{!expanded && hiddenCount > 0 && (
<Text
size="xxs"
c="dimmed"
style={{ cursor: 'pointer' }}
td="underline"
onClick={() => setExpanded(true)}
>
+{hiddenCount} more
</Text>
)}
{expanded && hiddenCount > 0 && (
<Text
size="xxs"
c="dimmed"
style={{ cursor: 'pointer' }}
td="underline"
onClick={() => setExpanded(false)}
>
Show less
</Text>
)}
{pills.length >= 2 && (
<Text
size="xxs"
c={confirmClear ? 'red.4' : 'dimmed'}
style={{ cursor: 'pointer' }}
td="underline"
onClick={handleClearAll}
onMouseLeave={() => setConfirmClear(false)}
>
{confirmClear ? 'Confirm clear all?' : 'Clear all'}
</Text>
)}
</Flex>
);
});

View file

@ -0,0 +1,277 @@
import { act, fireEvent, screen } from '@testing-library/react';
import type { FilterStateHook } from '@/searchFilters';
import { ActiveFilterPills } from '../ActiveFilterPills';
function makeSearchFilters(
filters: FilterStateHook['filters'],
): FilterStateHook {
return {
filters,
setFilters: jest.fn(),
setFilterValue: jest.fn(),
setFilterRange: jest.fn(),
clearFilter: jest.fn(),
clearAllFilters: jest.fn(),
};
}
describe('ActiveFilterPills', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders nothing when there are no filters', () => {
const searchFilters = makeSearchFilters({});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.queryByText('Clear all')).not.toBeInTheDocument();
expect(screen.queryByText(' = ')).not.toBeInTheDocument();
});
it('renders included filter pills', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200', '404']),
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.getByText('200')).toBeInTheDocument();
expect(screen.getByText('404')).toBeInTheDocument();
expect(screen.getAllByText('status')).toHaveLength(2);
});
it('renders excluded filter pills', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>(['500']),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.getByText('500')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
});
it('renders range filter pills', () => {
const searchFilters = makeSearchFilters({
duration: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>(),
range: { min: 100, max: 500 },
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.getByText('duration')).toBeInTheDocument();
expect(screen.getByText('100 500')).toBeInTheDocument();
});
it('calls setFilterValue when removing an included pill', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200']),
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
// Click the x button (the svg icon's parent ActionIcon)
const removeButtons = screen.getAllByRole('button');
fireEvent.click(removeButtons[0]);
expect(searchFilters.setFilterValue).toHaveBeenCalledWith(
'status',
'200',
undefined,
);
});
it('calls setFilterValue with exclude action when removing an excluded pill', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>(['500']),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
const removeButtons = screen.getAllByRole('button');
fireEvent.click(removeButtons[0]);
expect(searchFilters.setFilterValue).toHaveBeenCalledWith(
'status',
'500',
'exclude',
);
});
it('calls clearFilter when removing a range pill', () => {
const searchFilters = makeSearchFilters({
duration: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>(),
range: { min: 0, max: 100 },
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
const removeButtons = screen.getAllByRole('button');
fireEvent.click(removeButtons[0]);
expect(searchFilters.clearFilter).toHaveBeenCalledWith('duration');
});
it('does not show "Clear all" when there is only one pill', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200']),
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.queryByText('Clear all')).not.toBeInTheDocument();
});
it('shows "Clear all" when there are 2+ pills', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200', '404']),
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.getByText('Clear all')).toBeInTheDocument();
});
it('requires double click to clear all filters', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200', '404']),
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
// First click shows confirmation
fireEvent.click(screen.getByText('Clear all'));
expect(searchFilters.clearAllFilters).not.toHaveBeenCalled();
expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
// Second click actually clears
fireEvent.click(screen.getByText('Confirm clear all?'));
expect(searchFilters.clearAllFilters).toHaveBeenCalledTimes(1);
});
it('resets confirm state after 2s timeout', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200', '404']),
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
fireEvent.click(screen.getByText('Clear all'));
expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Clear all')).toBeInTheDocument();
expect(screen.queryByText('Confirm clear all?')).not.toBeInTheDocument();
});
it('resets confirm state on mouse leave', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200', '404']),
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
fireEvent.click(screen.getByText('Clear all'));
expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
fireEvent.mouseLeave(screen.getByText('Confirm clear all?'));
expect(screen.getByText('Clear all')).toBeInTheDocument();
});
it('collapses pills beyond MAX_VISIBLE_PILLS and shows "+N more"', () => {
// Create 10 included values to exceed the limit of 8
const values = new Set<string | boolean>(
Array.from({ length: 10 }, (_, i) => `val${i}`),
);
const searchFilters = makeSearchFilters({
field: {
included: values,
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.getByText('+2 more')).toBeInTheDocument();
// Only 8 value pills should be visible
expect(screen.queryByText('val8')).not.toBeInTheDocument();
expect(screen.queryByText('val9')).not.toBeInTheDocument();
});
it('expands to show all pills when clicking "+N more"', () => {
const values = new Set<string | boolean>(
Array.from({ length: 10 }, (_, i) => `val${i}`),
);
const searchFilters = makeSearchFilters({
field: {
included: values,
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
fireEvent.click(screen.getByText('+2 more'));
// All values should now be visible
for (let i = 0; i < 10; i++) {
expect(screen.getByText(`val${i}`)).toBeInTheDocument();
}
expect(screen.getByText('Show less')).toBeInTheDocument();
expect(screen.queryByText('+2 more')).not.toBeInTheDocument();
});
it('collapses back when clicking "Show less"', () => {
const values = new Set<string | boolean>(
Array.from({ length: 10 }, (_, i) => `val${i}`),
);
const searchFilters = makeSearchFilters({
field: {
included: values,
excluded: new Set<string | boolean>(),
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
fireEvent.click(screen.getByText('+2 more'));
fireEvent.click(screen.getByText('Show less'));
expect(screen.getByText('+2 more')).toBeInTheDocument();
expect(screen.queryByText('val8')).not.toBeInTheDocument();
});
it('renders mixed included, excluded, and range filters together', () => {
const searchFilters = makeSearchFilters({
status: {
included: new Set<string | boolean>(['200']),
excluded: new Set<string | boolean>(['500']),
},
duration: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>(),
range: { min: 10, max: 200 },
},
});
renderWithMantine(<ActiveFilterPills searchFilters={searchFilters} />);
expect(screen.getByText('200')).toBeInTheDocument();
expect(screen.getByText('500')).toBeInTheDocument();
expect(screen.getByText('10 200')).toBeInTheDocument();
});
});