diff --git a/.changeset/small-mangos-bake.md b/.changeset/small-mangos-bake.md new file mode 100644 index 00000000..5013a536 --- /dev/null +++ b/.changeset/small-mangos-bake.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": minor +--- + +feat: Add input filter pills below search input to make filters usage more clear on seach page. diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index b7617cae..514bbbf6 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -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() { + {searchedConfig != null && searchedSource != null && ( void; +}) { + const isExcluded = pill.type === 'excluded'; + const operator = isExcluded ? ' != ' : pill.type === 'range' ? ': ' : ' = '; + + return ( + + + + {pill.field} + + + {operator} + + + {pill.value} + + + + + + + ); +} + +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>(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 ( + + {visiblePills.map((pill, i) => ( + handleRemove(pill)} + /> + ))} + {!expanded && hiddenCount > 0 && ( + setExpanded(true)} + > + +{hiddenCount} more + + )} + {expanded && hiddenCount > 0 && ( + setExpanded(false)} + > + Show less + + )} + {pills.length >= 2 && ( + setConfirmClear(false)} + > + {confirmClear ? 'Confirm clear all?' : 'Clear all'} + + )} + + ); +}); diff --git a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx new file mode 100644 index 00000000..cbe4efd4 --- /dev/null +++ b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx @@ -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(); + expect(screen.queryByText('Clear all')).not.toBeInTheDocument(); + expect(screen.queryByText(' = ')).not.toBeInTheDocument(); + }); + + it('renders included filter pills', () => { + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200', '404']), + excluded: new Set(), + }, + }); + renderWithMantine(); + 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(), + excluded: new Set(['500']), + }, + }); + renderWithMantine(); + expect(screen.getByText('500')).toBeInTheDocument(); + expect(screen.getByText('status')).toBeInTheDocument(); + }); + + it('renders range filter pills', () => { + const searchFilters = makeSearchFilters({ + duration: { + included: new Set(), + excluded: new Set(), + range: { min: 100, max: 500 }, + }, + }); + renderWithMantine(); + 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(['200']), + excluded: new Set(), + }, + }); + renderWithMantine(); + // 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(), + excluded: new Set(['500']), + }, + }); + renderWithMantine(); + 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(), + excluded: new Set(), + range: { min: 0, max: 100 }, + }, + }); + renderWithMantine(); + 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(['200']), + excluded: new Set(), + }, + }); + renderWithMantine(); + expect(screen.queryByText('Clear all')).not.toBeInTheDocument(); + }); + + it('shows "Clear all" when there are 2+ pills', () => { + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200', '404']), + excluded: new Set(), + }, + }); + renderWithMantine(); + expect(screen.getByText('Clear all')).toBeInTheDocument(); + }); + + it('requires double click to clear all filters', () => { + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200', '404']), + excluded: new Set(), + }, + }); + renderWithMantine(); + + // 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(['200', '404']), + excluded: new Set(), + }, + }); + renderWithMantine(); + + 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(['200', '404']), + excluded: new Set(), + }, + }); + renderWithMantine(); + + 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( + Array.from({ length: 10 }, (_, i) => `val${i}`), + ); + const searchFilters = makeSearchFilters({ + field: { + included: values, + excluded: new Set(), + }, + }); + renderWithMantine(); + + 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( + Array.from({ length: 10 }, (_, i) => `val${i}`), + ); + const searchFilters = makeSearchFilters({ + field: { + included: values, + excluded: new Set(), + }, + }); + renderWithMantine(); + + 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( + Array.from({ length: 10 }, (_, i) => `val${i}`), + ); + const searchFilters = makeSearchFilters({ + field: { + included: values, + excluded: new Set(), + }, + }); + renderWithMantine(); + + 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(['200']), + excluded: new Set(['500']), + }, + duration: { + included: new Set(), + excluded: new Set(), + range: { min: 10, max: 200 }, + }, + }); + renderWithMantine(); + + expect(screen.getByText('200')).toBeInTheDocument(); + expect(screen.getByText('500')).toBeInTheDocument(); + expect(screen.getByText('10 – 200')).toBeInTheDocument(); + }); +});