From ec3c0123e852ca47f0463dfd9641051287cf8701 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:37:27 +0000 Subject: [PATCH 1/6] feat: editable filter pills on the search page Make the active filter pills under the search bar editable in place. Clicking an included or excluded pill opens a small action menu to copy the value or flip the filter polarity (include vs exclude), reusing the EventTag popover pattern and setFilterValue's existing include/exclude actions. The one-click remove (x) on the pill is unchanged, and range and unapplied pills keep their remove-only behavior. Co-Authored-By: Claude Opus 4.7 --- .../app/src/components/ActiveFilterPills.tsx | 127 ++++++++++++++++- .../__tests__/ActiveFilterPills.test.tsx | 129 ++++++++++++++++++ 2 files changed, 252 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/ActiveFilterPills.tsx b/packages/app/src/components/ActiveFilterPills.tsx index 763cafbb19..c1d9ab20aa 100644 --- a/packages/app/src/components/ActiveFilterPills.tsx +++ b/packages/app/src/components/ActiveFilterPills.tsx @@ -1,8 +1,26 @@ 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 { + ActionIcon, + Flex, + FlexProps, + Popover, + Text, + Tooltip, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { + IconCheck, + IconCopy, + IconFilter, + IconFilterX, + IconX, +} from '@tabler/icons-react'; import type { FilterStateHook } from '@/searchFilters'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; const MAX_VISIBLE_PILLS = 8; @@ -62,15 +80,31 @@ function FilterPill({ isInvalid, invalidReason, onRemove, + onTogglePolarity, }: { pill: PillItem; isInvalid?: boolean; invalidReason?: string; onRemove: () => void; + onTogglePolarity: () => void; }) { const isExcluded = pill.type === 'excluded'; const operator = isExcluded ? ' != ' : pill.type === 'range' ? ': ' : ' = '; + // A range pill has no single value to copy or flip, and an unapplied filter + // (column missing on the active source) can only be removed. Both keep the + // plain remove-only pill; only included/excluded pills open the action menu. + const isEditable = pill.type !== 'range' && !isInvalid; + const polarityLabel = isExcluded ? 'Include' : 'Exclude'; + + const [opened, setOpened] = useState(false); + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef>(undefined); + + useEffect(() => { + return () => clearTimeout(copyTimerRef.current); + }, []); + const tooltipLabel = isInvalid ? (invalidReason ?? `Filter not applied: "${pill.field}" isn't a column on the current source. It will reapply if you switch back.`) @@ -78,13 +112,26 @@ function FilterPill({ const showDangerAccent = isExcluded && !isInvalid; - return ( + const handleCopy = async () => { + const ok = await copyTextToClipboard(pill.value); + if (!ok) { + notifications.show({ color: 'red', message: CLIPBOARD_ERROR_MESSAGE }); + return; + } + setCopied(true); + clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopied(false), 1500); + }; + + const pillWithTooltip = ( setOpened(o => !o) : undefined} style={{ ...pillStyle, + cursor: isEditable ? 'pointer' : 'default', backgroundColor: isInvalid ? 'transparent' : isExcluded @@ -134,7 +181,11 @@ function FilterPill({ size={14} variant="transparent" color="gray" - onClick={onRemove} + onClick={e => { + // Keep the one-click remove without also toggling the action menu. + e.stopPropagation(); + onRemove(); + }} style={{ flexShrink: 0, marginLeft: 2, @@ -147,6 +198,56 @@ function FilterPill({ ); + + if (!isEditable) { + return pillWithTooltip; + } + + return ( + + {pillWithTooltip} + + + + + {copied ? : } + + + + { + onTogglePolarity(); + setOpened(false); + }} + aria-label={polarityLabel} + > + {isExcluded ? ( + + ) : ( + + )} + + + + + + ); } export const ActiveFilterPills = memo(function ActiveFilterPills({ @@ -196,6 +297,23 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({ [setFilterValue, clearFilter], ); + // Flip a value between included and excluded in place. setFilterValue's + // 'include'/'exclude' actions already move the value across the two sets, so + // an excluded pill goes to included and vice versa without a remove + re-add. + const handleTogglePolarity = useCallback( + (pill: PillItem) => { + if (pill.rawValue == null) { + return; + } + setFilterValue( + pill.field, + pill.rawValue, + pill.type === 'excluded' ? 'include' : 'exclude', + ); + }, + [setFilterValue], + ); + const handleClearAll = useCallback(() => { if (!confirmClear) { setConfirmClear(true); @@ -228,6 +346,7 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({ isInvalid ? invalidFieldReason?.(pill.field) : undefined } onRemove={() => handleRemove(pill)} + onTogglePolarity={() => handleTogglePolarity(pill)} /> ); })} diff --git a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx index 99dac06164..7a66876166 100644 --- a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx +++ b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx @@ -1,9 +1,17 @@ import { act, fireEvent, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import type { FilterStateHook } from '@/searchFilters'; +import { copyTextToClipboard } from '@/utils/clipboard'; import { ActiveFilterPills } from '../ActiveFilterPills'; +jest.mock('@/utils/clipboard', () => ({ + __esModule: true, + CLIPBOARD_ERROR_MESSAGE: 'clipboard error', + copyTextToClipboard: jest.fn().mockResolvedValue(true), +})); + function makeSearchFilters( filters: FilterStateHook['filters'], ): FilterStateHook { @@ -275,4 +283,125 @@ describe('ActiveFilterPills', () => { expect(screen.getByText('500')).toBeInTheDocument(); expect(screen.getByText('10 – 200')).toBeInTheDocument(); }); + + // The popover dropdown mounts via floating-ui on a frame and Mantine guards + // the opening click through a real pointer sequence, so these tests run on + // real timers with userEvent, unlike the synchronous suite above. + it('opens the action menu when an editable pill is clicked', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200']), + excluded: new Set(), + }, + }); + renderWithMantine(); + + await user.click(screen.getByTestId('active-filter-pill-status')); + + const [copyButton, excludeButton] = await Promise.all([ + screen.findByRole('button', { name: 'Copy value' }), + screen.findByRole('button', { name: 'Exclude' }), + ]); + expect(copyButton).toBeInTheDocument(); + expect(excludeButton).toBeInTheDocument(); + }); + + it('excludes an included value from the menu', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200']), + excluded: new Set(), + }, + }); + renderWithMantine(); + + await user.click(screen.getByTestId('active-filter-pill-status')); + await user.click(await screen.findByRole('button', { name: 'Exclude' })); + + expect(searchFilters.setFilterValue).toHaveBeenCalledWith( + 'status', + '200', + 'exclude', + ); + }); + + it('includes an excluded value from the menu', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + const searchFilters = makeSearchFilters({ + status: { + included: new Set(), + excluded: new Set(['500']), + }, + }); + renderWithMantine(); + + await user.click(screen.getByTestId('active-filter-pill-status')); + await user.click(await screen.findByRole('button', { name: 'Include' })); + + expect(searchFilters.setFilterValue).toHaveBeenCalledWith( + 'status', + '500', + 'include', + ); + }); + + it('copies the value from the menu', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200']), + excluded: new Set(), + }, + }); + renderWithMantine(); + + await user.click(screen.getByTestId('active-filter-pill-status')); + await user.click(await screen.findByRole('button', { name: 'Copy value' })); + + expect(copyTextToClipboard).toHaveBeenCalledWith('200'); + }); + + it('removes via the x without opening the menu', () => { + const searchFilters = makeSearchFilters({ + status: { + included: new Set(['200']), + excluded: new Set(), + }, + }); + renderWithMantine(); + + fireEvent.click(screen.getByRole('button', { name: 'Remove filter' })); + + expect(searchFilters.setFilterValue).toHaveBeenCalledWith( + 'status', + '200', + undefined, + ); + expect( + screen.queryByRole('button', { name: 'Exclude' }), + ).not.toBeInTheDocument(); + }); + + it('does not open the menu for range pills', () => { + const searchFilters = makeSearchFilters({ + duration: { + included: new Set(), + excluded: new Set(), + range: { min: 100, max: 500 }, + }, + }); + renderWithMantine(); + + fireEvent.click(screen.getByTestId('active-filter-pill-duration')); + + expect( + screen.queryByRole('button', { name: 'Copy value' }), + ).not.toBeInTheDocument(); + }); }); From 6caa8484b8489120bce8a8ba3f450b66582f30dc Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:51:57 +0000 Subject: [PATCH 2/6] chore: changeset and hyphenate range pill separator Add the changeset for the editable filter pills change, and replace the en-dash in the range pill separator with a hyphen to satisfy the no-en-dash lint on the file. --- .changeset/editable-filter-pills.md | 7 +++++++ packages/app/src/components/ActiveFilterPills.tsx | 2 +- .../src/components/__tests__/ActiveFilterPills.test.tsx | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/editable-filter-pills.md diff --git a/.changeset/editable-filter-pills.md b/.changeset/editable-filter-pills.md new file mode 100644 index 0000000000..9a00fe1658 --- /dev/null +++ b/.changeset/editable-filter-pills.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/app": patch +--- + +feat(search): make active filter pills editable in place + +Clicking an active filter pill under the search bar now opens a small menu to copy the value or flip the filter polarity (include vs exclude), without removing and re-adding the filter. The one-click remove on each pill is unchanged. diff --git a/packages/app/src/components/ActiveFilterPills.tsx b/packages/app/src/components/ActiveFilterPills.tsx index c1d9ab20aa..4fe44bb33b 100644 --- a/packages/app/src/components/ActiveFilterPills.tsx +++ b/packages/app/src/components/ActiveFilterPills.tsx @@ -53,7 +53,7 @@ function flattenFilters(filters: FilterStateHook['filters']): PillItem[] { if (state.range != null) { pills.push({ field, - value: `${state.range.min} – ${state.range.max}`, + value: `${state.range.min} - ${state.range.max}`, type: 'range', }); } diff --git a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx index 7a66876166..11f06b4ca7 100644 --- a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx +++ b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx @@ -76,7 +76,7 @@ describe('ActiveFilterPills', () => { }); renderWithMantine(); expect(screen.getByText('duration')).toBeInTheDocument(); - expect(screen.getByText('100 – 500')).toBeInTheDocument(); + expect(screen.getByText('100 - 500')).toBeInTheDocument(); }); it('calls setFilterValue when removing an included pill', () => { @@ -281,7 +281,7 @@ describe('ActiveFilterPills', () => { expect(screen.getByText('200')).toBeInTheDocument(); expect(screen.getByText('500')).toBeInTheDocument(); - expect(screen.getByText('10 – 200')).toBeInTheDocument(); + expect(screen.getByText('10 - 200')).toBeInTheDocument(); }); // The popover dropdown mounts via floating-ui on a frame and Mantine guards From a9ae10234dc12c264cd827263dbffad324cdab4c Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:40:43 +0000 Subject: [PATCH 3/6] feat: edit a filter pill's value in place Add a value picker to the filter-pill action menu so a user can switch a filter to a different value of the same field without removing and re-adding it. The picker is a searchable Select fed by useGetKeyValues (fetched only while the menu is open), and a new replaceFilterValue helper on useSearchPageFilterState swaps the value in one state update so the query runs once. Polarity is preserved (an excluded pill stays excluded). DBSearchPage passes the active source's chart config to the pills for the value query. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/DBSearchPage.tsx | 6 +- .../app/src/components/ActiveFilterPills.tsx | 75 +++++++- .../__tests__/ActiveFilterPills.test.tsx | 164 +++++++++++++++--- packages/app/src/searchFilters.test.ts | 76 ++++++++ packages/app/src/searchFilters.tsx | 30 ++++ 5 files changed, 325 insertions(+), 26 deletions(-) create mode 100644 packages/app/src/searchFilters.test.ts diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 2862d3b857..62d3dd096d 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -2063,7 +2063,11 @@ export function DBSearchPage() { - + {searchedConfig != null && searchedSource != null && ( void; onTogglePolarity: () => void; + onReplaceValue: (value: string) => void; }) { const isExcluded = pill.type === 'excluded'; const operator = isExcluded ? ' != ' : pill.type === 'range' ? ': ' : ' = '; @@ -105,6 +114,17 @@ function FilterPill({ return () => clearTimeout(copyTimerRef.current); }, []); + // Fetch the field's values for the in-pill value picker, only while the + // menu is open (and never for range / not-applied pills). + const { data: keyValues, isFetching: isFetchingValues } = useGetKeyValues( + { chartConfig, keys: [pill.field], limit: VALUE_EDIT_LIMIT }, + { enabled: opened && isEditable }, + ); + const valueOptions = useMemo( + () => Array.from(new Set([pill.value, ...(keyValues?.[0]?.value ?? [])])), + [keyValues, pill.value], + ); + const tooltipLabel = isInvalid ? (invalidReason ?? `Filter not applied: "${pill.field}" isn't a column on the current source. It will reapply if you switch back.`) @@ -213,7 +233,26 @@ function FilterPill({ onChange={setOpened} > {pillWithTooltip} - + +