mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
676e4f4bc9
commit
20e4720761
4 changed files with 491 additions and 0 deletions
5
.changeset/small-mangos-bake.md
Normal file
5
.changeset/small-mangos-bake.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
207
packages/app/src/components/ActiveFilterPills.tsx
Normal file
207
packages/app/src/components/ActiveFilterPills.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
277
packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
Normal file
277
packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue