diff --git a/.changeset/editable-filter-pills-value.md b/.changeset/editable-filter-pills-value.md
new file mode 100644
index 0000000000..2f9b683fb1
--- /dev/null
+++ b/.changeset/editable-filter-pills-value.md
@@ -0,0 +1,7 @@
+---
+"@hyperdx/app": patch
+---
+
+feat(search): edit a filter pill's value in place
+
+The filter-pill action menu now includes a searchable value picker, so you can switch a filter to a different value of the same field without removing and re-adding it. The filter's polarity (include or exclude) is preserved.
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/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' ? ': ' : ' = ';
+ // 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);
+ }, []);
+
+ // The picker lists values to switch this pill to, so it must not be scoped
+ // by the active query or by the pill's own filter. Reusing chartConfig
+ // verbatim only ever returns values already matching the current filters, so
+ // an included pill would list just its own value. Clear where + filters to
+ // list all of the field's values in range, like the sidebar facet list's
+ // default "Show All Values" behavior.
+ const valueChartConfig = useMemo(
+ () => ({ ...chartConfig, where: '', filters: [] }),
+ [chartConfig],
+ );
+
+ // 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: valueChartConfig,
+ 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.`)
@@ -78,13 +147,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 +216,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,12 +233,82 @@ function FilterPill({
);
+
+ if (!isEditable) {
+ return pillWithTooltip;
+ }
+
+ return (
+
+ {pillWithTooltip}
+
+
+
+ );
}
export const ActiveFilterPills = memo(function ActiveFilterPills({
searchFilters,
invalidFields,
invalidFieldReason,
+ chartConfig,
...flexProps
}: {
searchFilters: FilterStateHook;
@@ -168,9 +324,19 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({
* returns the tooltip text.
*/
invalidFieldReason?: (field: string) => string;
+ /**
+ * Chart config for the active source. Passed to useGetKeyValues so the
+ * in-pill value picker can list the field's values.
+ */
+ chartConfig: BuilderChartConfigWithDateRange;
} & FlexProps) {
- const { filters, setFilterValue, clearFilter, clearAllFilters } =
- searchFilters;
+ const {
+ filters,
+ setFilterValue,
+ replaceFilterValue,
+ clearFilter,
+ clearAllFilters,
+ } = searchFilters;
const pills = useMemo(() => flattenFilters(filters), [filters]);
const [expanded, setExpanded] = useState(false);
@@ -196,6 +362,40 @@ 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],
+ );
+
+ // Swap a pill's value for another value of the same field, preserving the
+ // pill's polarity. One atomic update (no remove + re-add double query run).
+ const handleReplaceValue = useCallback(
+ (pill: PillItem, newValue: string) => {
+ if (pill.rawValue == null) {
+ return;
+ }
+ replaceFilterValue(
+ pill.field,
+ pill.rawValue,
+ newValue,
+ pill.type === 'excluded' ? 'exclude' : 'include',
+ );
+ },
+ [replaceFilterValue],
+ );
+
const handleClearAll = useCallback(() => {
if (!confirmClear) {
setConfirmClear(true);
@@ -227,7 +427,10 @@ export const ActiveFilterPills = memo(function ActiveFilterPills({
invalidReason={
isInvalid ? invalidFieldReason?.(pill.field) : undefined
}
+ chartConfig={chartConfig}
onRemove={() => handleRemove(pill)}
+ onTogglePolarity={() => handleTogglePolarity(pill)}
+ onReplaceValue={value => handleReplaceValue(pill, value)}
/>
);
})}
diff --git a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
index 99dac06164..1ec8a7315b 100644
--- a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
+++ b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
@@ -1,9 +1,42 @@
+import type { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { act, fireEvent, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useGetKeyValues } from '@/hooks/useMetadata';
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),
+}));
+
+jest.mock('@/hooks/useMetadata', () => ({
+ __esModule: true,
+ useGetKeyValues: jest.fn(() => ({ data: [], isFetching: false })),
+}));
+
+const mockedUseGetKeyValues = useGetKeyValues as jest.Mock;
+
+// Mantine's Combobox calls scrollIntoView when its dropdown opens; jsdom lacks
+// it. jsdom also has no layout, so portaled options compute as "hidden" and
+// must be queried with { hidden: true }.
+window.HTMLElement.prototype.scrollIntoView = jest.fn();
+
+// useGetKeyValues is mocked, so this only needs to satisfy the prop type.
+const CHART_CONFIG = {
+ from: { databaseName: 'db', tableName: 'logs' },
+ connection: 'conn',
+ select: '',
+ where: '',
+ whereLanguage: 'lucene',
+ timestampValueExpression: 'Timestamp',
+ dateRange: [new Date(0), new Date()],
+} as BuilderChartConfigWithDateRange;
+
function makeSearchFilters(
filters: FilterStateHook['filters'],
): FilterStateHook {
@@ -11,6 +44,7 @@ function makeSearchFilters(
filters,
setFilters: jest.fn(),
setFilterValue: jest.fn(),
+ replaceFilterValue: jest.fn(),
setFilterRange: jest.fn(),
clearFilter: jest.fn(),
clearAllFilters: jest.fn(),
@@ -18,9 +52,19 @@ function makeSearchFilters(
};
}
+function renderPills(searchFilters: FilterStateHook) {
+ return renderWithMantine(
+ ,
+ );
+}
+
describe('ActiveFilterPills', () => {
beforeEach(() => {
jest.useFakeTimers();
+ mockedUseGetKeyValues.mockReturnValue({ data: [], isFetching: false });
});
afterEach(() => {
jest.useRealTimers();
@@ -28,7 +72,7 @@ describe('ActiveFilterPills', () => {
it('renders nothing when there are no filters', () => {
const searchFilters = makeSearchFilters({});
- renderWithMantine();
+ renderPills(searchFilters);
expect(screen.queryByText('Clear all')).not.toBeInTheDocument();
expect(screen.queryByText(' = ')).not.toBeInTheDocument();
});
@@ -40,7 +84,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
expect(screen.getByText('200')).toBeInTheDocument();
expect(screen.getByText('404')).toBeInTheDocument();
expect(screen.getAllByText('status')).toHaveLength(2);
@@ -53,7 +97,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(['500']),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
expect(screen.getByText('500')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
});
@@ -66,9 +110,9 @@ describe('ActiveFilterPills', () => {
range: { min: 100, max: 500 },
},
});
- renderWithMantine();
+ renderPills(searchFilters);
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', () => {
@@ -78,7 +122,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
// Click the x button (the svg icon's parent ActionIcon)
const removeButtons = screen.getAllByRole('button');
fireEvent.click(removeButtons[0]);
@@ -96,7 +140,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(['500']),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
const removeButtons = screen.getAllByRole('button');
fireEvent.click(removeButtons[0]);
expect(searchFilters.setFilterValue).toHaveBeenCalledWith(
@@ -114,7 +158,7 @@ describe('ActiveFilterPills', () => {
range: { min: 0, max: 100 },
},
});
- renderWithMantine();
+ renderPills(searchFilters);
const removeButtons = screen.getAllByRole('button');
fireEvent.click(removeButtons[0]);
expect(searchFilters.clearFilter).toHaveBeenCalledWith('duration');
@@ -127,7 +171,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
expect(screen.queryByText('Clear all')).not.toBeInTheDocument();
});
@@ -138,7 +182,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
expect(screen.getByText('Clear all')).toBeInTheDocument();
});
@@ -149,7 +193,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
// First click shows confirmation
fireEvent.click(screen.getByText('Clear all'));
@@ -168,7 +212,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
fireEvent.click(screen.getByText('Clear all'));
expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
@@ -188,7 +232,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
fireEvent.click(screen.getByText('Clear all'));
expect(screen.getByText('Confirm clear all?')).toBeInTheDocument();
@@ -208,7 +252,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
expect(screen.getByText('+2 more')).toBeInTheDocument();
// Only 8 value pills should be visible
@@ -226,7 +270,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
fireEvent.click(screen.getByText('+2 more'));
@@ -248,7 +292,7 @@ describe('ActiveFilterPills', () => {
excluded: new Set(),
},
});
- renderWithMantine();
+ renderPills(searchFilters);
fireEvent.click(screen.getByText('+2 more'));
fireEvent.click(screen.getByText('Show less'));
@@ -269,10 +313,243 @@ describe('ActiveFilterPills', () => {
range: { min: 10, max: 200 },
},
});
- renderWithMantine();
+ renderPills(searchFilters);
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
+ // 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(),
+ },
+ });
+ renderPills(searchFilters);
+
+ 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(),
+ },
+ });
+ renderPills(searchFilters);
+
+ 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']),
+ },
+ });
+ renderPills(searchFilters);
+
+ 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(),
+ },
+ });
+ renderPills(searchFilters);
+
+ 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(),
+ },
+ });
+ renderPills(searchFilters);
+
+ 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 },
+ },
+ });
+ renderPills(searchFilters);
+
+ fireEvent.click(screen.getByTestId('active-filter-pill-duration'));
+
+ expect(
+ screen.queryByRole('button', { name: 'Copy value' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('fetches picker values with the active query and filters stripped', () => {
+ // A picker scoped to the current query would only list values already
+ // matching it, so an included pill could never switch to another value.
+ const scopedConfig = {
+ ...CHART_CONFIG,
+ where: "status = '200'",
+ filters: [{ type: 'sql', condition: "status = '200'" }],
+ } as BuilderChartConfigWithDateRange;
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200']),
+ excluded: new Set(),
+ },
+ });
+ renderWithMantine(
+ ,
+ );
+
+ expect(mockedUseGetKeyValues).toHaveBeenCalled();
+ const { chartConfig: passedConfig } =
+ mockedUseGetKeyValues.mock.calls[0][0];
+ expect(passedConfig.where).toBe('');
+ expect(passedConfig.filters).toEqual([]);
+ });
+
+ it('shows a value picker populated from useGetKeyValues', async () => {
+ jest.useRealTimers();
+ mockedUseGetKeyValues.mockReturnValue({
+ data: [{ key: 'status', value: ['200', '404', '500'] }],
+ isFetching: false,
+ });
+ const user = userEvent.setup();
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200']),
+ excluded: new Set(),
+ },
+ });
+ renderPills(searchFilters);
+
+ await user.click(screen.getByTestId('active-filter-pill-status'));
+ await user.click(await screen.findByLabelText('Change filter value'));
+
+ expect(
+ await screen.findByRole('option', { name: '404', hidden: true }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('option', { name: '500', hidden: true }),
+ ).toBeInTheDocument();
+ });
+
+ it('replaces the value from the menu, preserving include polarity', async () => {
+ jest.useRealTimers();
+ mockedUseGetKeyValues.mockReturnValue({
+ data: [{ key: 'status', value: ['200', '404', '500'] }],
+ isFetching: false,
+ });
+ const user = userEvent.setup();
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(['200']),
+ excluded: new Set(),
+ },
+ });
+ renderPills(searchFilters);
+
+ await user.click(screen.getByTestId('active-filter-pill-status'));
+ await user.click(await screen.findByLabelText('Change filter value'));
+ fireEvent.click(
+ await screen.findByRole('option', { name: '404', hidden: true }),
+ );
+
+ expect(searchFilters.replaceFilterValue).toHaveBeenCalledWith(
+ 'status',
+ '200',
+ '404',
+ 'include',
+ );
+ });
+
+ it('replaces the value from the menu, preserving exclude polarity', async () => {
+ jest.useRealTimers();
+ mockedUseGetKeyValues.mockReturnValue({
+ data: [{ key: 'status', value: ['500', '502', '503'] }],
+ isFetching: false,
+ });
+ const user = userEvent.setup();
+ const searchFilters = makeSearchFilters({
+ status: {
+ included: new Set(),
+ excluded: new Set(['500']),
+ },
+ });
+ renderPills(searchFilters);
+
+ await user.click(screen.getByTestId('active-filter-pill-status'));
+ await user.click(await screen.findByLabelText('Change filter value'));
+ fireEvent.click(
+ await screen.findByRole('option', { name: '502', hidden: true }),
+ );
+
+ expect(searchFilters.replaceFilterValue).toHaveBeenCalledWith(
+ 'status',
+ '500',
+ '502',
+ 'exclude',
+ );
});
});
diff --git a/packages/app/src/searchFilters.test.ts b/packages/app/src/searchFilters.test.ts
new file mode 100644
index 0000000000..a4919d4749
--- /dev/null
+++ b/packages/app/src/searchFilters.test.ts
@@ -0,0 +1,76 @@
+import { enableMapSet } from 'immer';
+import { Filter } from '@hyperdx/common-utils/dist/types';
+import { act, renderHook } from '@testing-library/react';
+
+import { useSearchPageFilterState } from '@/searchFilters';
+
+// Filter state stores values in Sets; the app enables immer's MapSet plugin at
+// startup, but this isolated hook test must enable it explicitly.
+enableMapSet();
+
+// Stable reference so the hook's parsed-query effect does not re-run and reset
+// local filter state between renders (the empty default is a new array each
+// render).
+const EMPTY_SEARCH_QUERY: Filter[] = [];
+
+describe('useSearchPageFilterState replaceFilterValue', () => {
+ it('swaps an included value for a new one, preserving included polarity', () => {
+ const onFilterChange = jest.fn();
+ const { result } = renderHook(() =>
+ useSearchPageFilterState({
+ searchQuery: EMPTY_SEARCH_QUERY,
+ onFilterChange,
+ }),
+ );
+
+ act(() => {
+ result.current.setFilterValue('status', '200');
+ });
+ act(() => {
+ result.current.replaceFilterValue('status', '200', '404', 'include');
+ });
+
+ expect([...result.current.filters.status.included]).toEqual(['404']);
+ expect([...result.current.filters.status.excluded]).toEqual([]);
+ });
+
+ it('swaps an excluded value for a new one, preserving excluded polarity', () => {
+ const onFilterChange = jest.fn();
+ const { result } = renderHook(() =>
+ useSearchPageFilterState({
+ searchQuery: EMPTY_SEARCH_QUERY,
+ onFilterChange,
+ }),
+ );
+
+ act(() => {
+ result.current.setFilterValue('status', '500', 'exclude');
+ });
+ act(() => {
+ result.current.replaceFilterValue('status', '500', '502', 'exclude');
+ });
+
+ expect([...result.current.filters.status.excluded]).toEqual(['502']);
+ expect([...result.current.filters.status.included]).toEqual([]);
+ });
+
+ it('emits onFilterChange exactly once per replace', () => {
+ const onFilterChange = jest.fn();
+ const { result } = renderHook(() =>
+ useSearchPageFilterState({
+ searchQuery: EMPTY_SEARCH_QUERY,
+ onFilterChange,
+ }),
+ );
+
+ act(() => {
+ result.current.setFilterValue('status', '200');
+ });
+ onFilterChange.mockClear();
+ act(() => {
+ result.current.replaceFilterValue('status', '200', '404', 'include');
+ });
+
+ expect(onFilterChange).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/app/src/searchFilters.tsx b/packages/app/src/searchFilters.tsx
index 28d8fa50ae..9d7597927d 100644
--- a/packages/app/src/searchFilters.tsx
+++ b/packages/app/src/searchFilters.tsx
@@ -464,6 +464,35 @@ export const useSearchPageFilterState = ({
[updateFilterQuery],
);
+ // Swap one value for another within the same set (included or excluded),
+ // preserving polarity, in a single state update. Two setFilterValue calls
+ // would emit onFilterChange twice (one query run each); this emits once.
+ const replaceFilterValue = useCallback(
+ (
+ property: string,
+ oldValue: string | boolean,
+ newValue: string | boolean,
+ action: 'include' | 'exclude',
+ ) => {
+ setFilters(prevFilters => {
+ const newFilters = produce(prevFilters, draft => {
+ if (!draft[property]) {
+ draft[property] = { included: new Set(), excluded: new Set() };
+ }
+ const set =
+ action === 'exclude'
+ ? draft[property].excluded
+ : draft[property].included;
+ set.delete(oldValue);
+ set.add(newValue);
+ });
+ updateFilterQuery(newFilters);
+ return newFilters;
+ });
+ },
+ [updateFilterQuery],
+ );
+
const clearAllFilters = useCallback(() => {
setFilters(() => ({}));
updateFilterQuery({});
@@ -477,7 +506,7 @@ export const useSearchPageFilterState = ({
const dropped: string[] = [];
const kept: FilterState = {};
for (const [key, value] of Object.entries(filters)) {
- // Filter keys are dot-normalized — top-level columns are stored as-is,
+ // Filter keys are dot-normalized, top-level columns are stored as-is,
// nested JSON/Map keys as `Root.nested.path`. An exact match handles
// the rare case of a column with dots in its name.
const dotIdx = key.indexOf('.');
@@ -501,6 +530,7 @@ export const useSearchPageFilterState = ({
filters,
setFilters,
setFilterValue,
+ replaceFilterValue,
setFilterRange,
clearFilter,
clearAllFilters,
@@ -578,7 +608,7 @@ function toggleValueInFilters(
/**
* Hook for personal pinned filters stored in localStorage.
- * This is the original storage mechanism — per-user, per-browser.
+ * This is the original storage mechanism, per-user, per-browser.
*/
function usePersonalPinnedFilters(sourceId: string | null) {
const [_pinnedFilters, _setPinnedFilters] = useLocalStorage<{
@@ -639,7 +669,7 @@ export function usePinnedFilters(sourceId: string | null) {
const updateTeamMutation = useUpdatePinnedFilters();
// Optimistic state keyed by sourceId so it is automatically ignored when
- // the source changes — no useEffect needed to clear stale state.
+ // the source changes, no useEffect needed to clear stale state.
const [optimisticTeam, setOptimisticTeam] = useState<{
sourceId: string;
fields: string[];
@@ -667,7 +697,7 @@ export function usePinnedFilters(sourceId: string | null) {
[effectiveTeam, personal.fields, personal.filters],
);
- // Debounce for team API writes — cancelled on unmount to prevent stale writes.
+ // Debounce for team API writes, cancelled on unmount to prevent stale writes.
const pendingTeamUpdateRef = useRef | null>(
null,
);
@@ -730,7 +760,7 @@ export function usePinnedFilters(sourceId: string | null) {
[personal],
);
- // Personal-only checks (not merged) — so team pins don't show as personal
+ // Personal-only checks (not merged), so team pins don't show as personal
const isFilterPinned = useCallback(
(property: string, value: string | boolean): boolean => {
return (
@@ -761,7 +791,7 @@ export function usePinnedFilters(sourceId: string | null) {
const fieldIndex = currentFields.indexOf(field);
if (fieldIndex >= 0) {
- // Removing field from shared — also clean up its filter values
+ // Removing field from shared, also clean up its filter values
const newFields = currentFields.filter((_, i) => i !== fieldIndex);
delete currentFilters[field];
flushTeamUpdate(newFields, currentFilters);