Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .changeset/editable-filter-pills-value.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/editable-filter-pills.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2063,7 +2063,11 @@ export function DBSearchPage() {
<SearchSubmitButton isFormStateDirty={formState.isDirty} />
</Flex>
</Flex>
<ActiveFilterPills searchFilters={searchFilters} mt={6} />
<ActiveFilterPills
searchFilters={searchFilters}
chartConfig={filtersChartConfig}
mt={6}
/>
</form>
{searchedConfig != null && searchedSource != null && (
<SaveSearchModal
Expand Down
217 changes: 210 additions & 7 deletions packages/app/src/components/ActiveFilterPills.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
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 type { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Flex,
FlexProps,
Popover,
Select,
Text,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconCheck,
IconCopy,
IconFilter,
IconFilterX,
IconX,
} from '@tabler/icons-react';

import { useGetKeyValues } from '@/hooks/useMetadata';
import type { FilterStateHook } from '@/searchFilters';
import {
CLIPBOARD_ERROR_MESSAGE,
copyTextToClipboard,
} from '@/utils/clipboard';

const MAX_VISIBLE_PILLS = 8;
// Cap the value list fetched for the in-pill value picker.
const VALUE_EDIT_LIMIT = 50;

type PillItem = {
field: string;
Expand Down Expand Up @@ -35,7 +58,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',
});
}
Expand All @@ -61,30 +84,89 @@ function FilterPill({
pill,
isInvalid,
invalidReason,
chartConfig,
onRemove,
onTogglePolarity,
onReplaceValue,
}: {
pill: PillItem;
isInvalid?: boolean;
invalidReason?: string;
chartConfig: BuilderChartConfigWithDateRange;
onRemove: () => 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<ReturnType<typeof setTimeout>>(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.`)
: `${pill.field}${operator}${pill.value}`;

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 = (
<Tooltip label={tooltipLabel} openDelay={300} multiline maw={280}>
<span
data-testid={`active-filter-pill-${pill.field}`}
data-invalid={isInvalid ? 'true' : undefined}
onClick={isEditable ? () => setOpened(o => !o) : undefined}
style={{
...pillStyle,
cursor: isEditable ? 'pointer' : 'default',
backgroundColor: isInvalid
? 'transparent'
: isExcluded
Expand Down Expand Up @@ -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,
Expand All @@ -147,12 +233,82 @@ function FilterPill({
</span>
</Tooltip>
);

if (!isEditable) {
return pillWithTooltip;
}

return (
<Popover
position="bottom-start"
withArrow
shadow="md"
radius="sm"
opened={opened}
onChange={setOpened}
>
<Popover.Target>{pillWithTooltip}</Popover.Target>
<Popover.Dropdown p={6}>
<Select
size="xs"
w={220}
searchable
mb={6}
data={valueOptions}
value={pill.value}
onChange={value => {
if (value && value !== pill.value) {
onReplaceValue(value);
setOpened(false);
}
}}
comboboxProps={{ withinPortal: false }}
nothingFoundMessage={
isFetchingValues ? 'Loading values...' : 'No values'
}
aria-label="Change filter value"
/>
<Flex gap={4} align="center">
<Tooltip label={copied ? 'Copied' : 'Copy value'}>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={handleCopy}
aria-label="Copy value"
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
<Tooltip label={polarityLabel}>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={() => {
onTogglePolarity();
setOpened(false);
}}
aria-label={polarityLabel}
>
{isExcluded ? (
<IconFilter size={14} />
) : (
<IconFilterX size={14} />
)}
</ActionIcon>
</Tooltip>
</Flex>
</Popover.Dropdown>
</Popover>
);
}

export const ActiveFilterPills = memo(function ActiveFilterPills({
searchFilters,
invalidFields,
invalidFieldReason,
chartConfig,
...flexProps
}: {
searchFilters: FilterStateHook;
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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)}
/>
);
})}
Expand Down
Loading