Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/filter-pill-free-text-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

Allow typing an arbitrary value when editing an active filter pill. The pill's value picker now accepts free text (committed on Enter or blur) in addition to selecting from the suggested values, so you can filter on values that aren't present in the sampled data.
68 changes: 57 additions & 11 deletions packages/app/src/components/ActiveFilterPills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Autocomplete,
Flex,
FlexProps,
Popover,
Select,
Text,
Tooltip,
} from '@mantine/core';
Expand Down Expand Up @@ -133,12 +133,29 @@ function FilterPill({

const [opened, setOpened] = useState(false);
const [copied, setCopied] = useState(false);
// Draft of the value being typed in the picker. Free text is allowed (the
// field may not have this value in the sampled data yet), so we track what
// the user types and only commit it on submit/blur. It starts empty (the
// current value shows as a placeholder) so the full suggestion list is
// visible on open instead of being filtered down to just the current value.
const [draftValue, setDraftValue] = useState('');
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Guards against committing the same value twice when picking an option
// (onOptionSubmit fires, then onBlur as the menu closes). Reset on open.
const committedRef = useRef(false);

useEffect(() => {
return () => clearTimeout(copyTimerRef.current);
}, []);

// Reset the draft (and commit guard) each time the menu opens so reopening
// on a different pill (or after a replace) starts from a clean, unfiltered
// list.
useEffect(() => {
setDraftValue('');
committedRef.current = false;
}, [pill.value, opened]);

// 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
Expand Down Expand Up @@ -173,6 +190,18 @@ function FilterPill({

const showDangerAccent = isExcluded && !isInvalid;

// Commit the typed/picked value, but only when it actually differs from the
// current one (avoids a redundant query on blur with no change). The
// committedRef guard prevents a double commit when picking an option.
const commitValue = (value: string) => {
const trimmed = value.trim();
if (!committedRef.current && trimmed && trimmed !== pill.value) {
committedRef.current = true;
onReplaceValue(trimmed);
}
setOpened(false);
};

const handleCopy = async () => {
const ok = await copyTextToClipboard(pill.value);
if (!ok) {
Expand Down Expand Up @@ -285,23 +314,40 @@ function FilterPill({
>
<Popover.Target>{pillWithTooltip}</Popover.Target>
<Popover.Dropdown p={6}>
<Select
<Autocomplete
size="xs"
w={220}
searchable
mb={6}
data={valueOptions}
value={pill.value}
onChange={value => {
if (value && value !== pill.value) {
onReplaceValue(value);
setOpened(false);
value={draftValue}
onChange={setDraftValue}
// Picking a suggestion commits immediately.
onOptionSubmit={commitValue}
onKeyDown={e => {
if (e.key !== 'Enter') {
return;
}
// If the user is keyboard-navigating the dropdown, an option is
// highlighted (Mantine marks it with [data-combobox-selected]).
// Let the combobox handle Enter natively so it submits that option
// via onOptionSubmit. Only commit free text when no option is
// highlighted, so a typed value not in the list still applies.
// Scope the lookup to this input's own listbox (via aria-controls)
// rather than the whole document, so another open combobox on the
// page can't make us swallow Enter here.
const listId = e.currentTarget.getAttribute('aria-controls');
const list = listId ? document.getElementById(listId) : null;
const hasHighlightedOption = !!list?.querySelector(
'[data-combobox-selected]',
);
if (!hasHighlightedOption) {
e.preventDefault();
commitValue(draftValue);
}
}}
onBlur={() => commitValue(draftValue)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this prop necessary here? I would generally not expect what I type to submit whenever clicking out of the popover, and it seems (while testing) like in that case commitValue is being called with an empty draftValue anyway.

And if we got rid of this, could we also get rid of the commitRef?

Please let me know if there is a reason this is needed that I'm overlooking!

comboboxProps={{ withinPortal: false }}
nothingFoundMessage={
isFetchingValues ? 'Loading values...' : 'No values'
}
placeholder={isFetchingValues ? 'Loading values...' : pill.value}
aria-label="Change filter value"
Comment thread
zoov-xavier marked this conversation as resolved.
/>
<Flex gap={4} align="center">
Expand Down
59 changes: 59 additions & 0 deletions packages/app/src/components/__tests__/ActiveFilterPills.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,65 @@ describe('ActiveFilterPills', () => {
);
});

it('submits the highlighted option when navigating with the keyboard and pressing Enter', 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<string | boolean>(['200']),
excluded: new Set<string | boolean>(),
},
});
renderPills(searchFilters);

await user.click(screen.getByTestId('active-filter-pill-status'));
const input = await screen.findByLabelText('Change filter value');
input.focus();
// The picker lists ['200', '404', '500']. First ArrowDown highlights the
// first option ('200'), the second moves to '404'; Enter then submits the
// highlighted '404' — not the empty typed draft.
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');

expect(searchFilters.replaceFilterValue).toHaveBeenCalledWith(
'status',
'200',
'404',
'include',
);
});

it('replaces with a free-typed value not in the suggestion list', 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<string | boolean>(['200']),
excluded: new Set<string | boolean>(),
},
});
renderPills(searchFilters);

await user.click(screen.getByTestId('active-filter-pill-status'));
const input = await screen.findByLabelText('Change filter value');
await user.clear(input);
await user.type(input, '418{Enter}');

expect(searchFilters.replaceFilterValue).toHaveBeenCalledWith(
'status',
'200',
'418',
'include',
);
});

it('replaces the value from the menu, preserving exclude polarity', async () => {
jest.useRealTimers();
mockedUseGetKeyValues.mockReturnValue({
Expand Down
Loading