From 0c1a00d26e636f9bbfb448949a7bdd7a197d7787 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:10:07 +0000 Subject: [PATCH 1/2] feat(search): editable filter pills Make the active filter pills under the search bar editable in place, reusing existing search primitives. Clicking an included or excluded pill opens a small action menu (the EventTag popover pattern) with: - Copy the value. - Flip the filter polarity (include vs exclude) in place via setFilterValue, with no remove and re-add. - Switch to a different value of the same field via a searchable value picker. Values come from the shared useGetKeyValues hook, fetched only while the menu is open and scoped to the field with where/filters cleared so the full value list shows. The swap goes through a new replaceFilterValue helper on useSearchPageFilterState so the query runs once and the polarity is preserved. The one-click remove (x) on each pill is unchanged. Range and not-applied pills keep their remove-only behavior. DBSearchPage passes the active source's filtersChartConfig to the pills for the value query. Implements HDX-4326. Co-Authored-By: Claude Opus 4.7 --- .changeset/editable-filter-pills.md | 7 + packages/app/src/DBSearchPage.tsx | 6 +- .../app/src/components/ActiveFilterPills.tsx | 223 ++++++++++++- .../__tests__/ActiveFilterPills.test.tsx | 309 +++++++++++++++++- packages/app/src/searchFilters.test.ts | 76 +++++ packages/app/src/searchFilters.tsx | 30 ++ 6 files changed, 627 insertions(+), 24 deletions(-) create mode 100644 .changeset/editable-filter-pills.md create mode 100644 packages/app/src/searchFilters.test.ts diff --git a/.changeset/editable-filter-pills.md b/.changeset/editable-filter-pills.md new file mode 100644 index 0000000000..f61e38c47b --- /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, flip the filter polarity (include vs exclude), or switch to a different value of the same field, without removing and re-adding the filter. The polarity is preserved when changing the value, and the one-click remove on each pill is unchanged. Range and not-applied pills keep their remove-only behavior. 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,32 @@ 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 +222,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 +239,82 @@ function FilterPill({ ); + + if (!isEditable) { + return pillWithTooltip; + } + + return ( + + {pillWithTooltip} + +