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} + +