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
15 changes: 15 additions & 0 deletions .changeset/linked-filters-persist-and-group.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@hyperdx/app': patch
---

feat(dashboards): persist the filter link toggle and clarify within-source linking

The "link filters" toggle now remembers its state in browser storage — the
dashboard filter bar (shared across all dashboards and the Services page) and
the Kubernetes filter bar each keep their own preference — so it no longer has
to be re-enabled on every page load. Dashboard filters are now always displayed
grouped by source (preserving the defined order within each group), and while
link mode is on, small chain icons connect the filters that actually narrow
each other. Tooltips now spell out that only filters from the same source link
to each other — a selection can't narrow a dropdown whose values come from a
different source.
142 changes: 113 additions & 29 deletions packages/app/src/DashboardFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { useMemo, useState } from 'react';
import { Fragment, useMemo } from 'react';
import { FilterState } from '@hyperdx/common-utils/dist/filters';
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, Stack, Text, Tooltip } from '@mantine/core';
import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react';
import { Center, Group, Stack, Text, Tooltip } from '@mantine/core';
import {
IconAlertTriangle,
IconHelp,
IconLink,
IconRefresh,
} from '@tabler/icons-react';

import { FilterLinkToggle } from './components/FilterLinkToggle';
import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect';
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
import {
filtersLink,
useDashboardFilterValues,
} from './hooks/useDashboardFilterValues';
import { useLocalStorage } from './utils';

interface DashboardFilterSelectProps {
filter: DashboardFilter;
Expand Down Expand Up @@ -77,6 +86,56 @@ const DashboardFilterSelect = ({
);
};

/**
* Groups filters by source (and metric type) for display, so filters that can
* link to each other sit adjacent in the bar. Coarser than `filtersLink`: two
* filters sharing an expression are grouped side by side even though they don't
* narrow each other, since they still read from the same source. Whether a
* chain is actually drawn between neighbors is decided by `filtersLink`.
*
* Stable: within-group order preserves the user-defined filter order, and
* groups are ordered by first appearance. Exported for tests.
*/
export function groupFiltersForDisplay(
filters: DashboardFilter[],
): DashboardFilter[][] {
const groups = new Map<string, DashboardFilter[]>();
for (const filter of filters) {
const key = JSON.stringify([
filter.source,
filter.sourceMetricType ?? null,
]);
const group = groups.get(key);
if (group) {
group.push(filter);
} else {
groups.set(key, [filter]);
}
}
return [...groups.values()];
}

/**
* Small chain icon rendered between adjacent same-source filters while link
* mode is on, to show which filters narrow each other.
*/
const FilterChainIcon = () => (
<Stack gap={2} data-testid="dashboard-filter-chain-icon">
{/* Spacer to align the icon with the inputs (filters have a label row above). */}
<Text size="xs" c="transparent" aria-hidden>
&nbsp;
</Text>
<Center h={30}>
<Tooltip
label="Linked: these filters narrow each other's values (same source)"
withinPortal
>
<IconLink size={14} color="var(--color-text-muted)" />
</Tooltip>
</Center>
</Stack>
);

interface DashboardFilterProps {
filters: DashboardFilter[];
filterValues: FilterState;
Expand All @@ -94,7 +153,12 @@ const DashboardFilters = ({
// the others' selections. Off by default because contingent value lookups
// can't use the cheap per-key rollups and are more expensive at scale. When
// on, all of a source's facets are computed in a single groupUniqArrayIf scan.
const [linked, setLinked] = useState(false);
// Persisted globally (all dashboards share it) so the preference survives
// page loads; the Kubernetes filter bar keeps its own key.
const [linked, setLinked] = useLocalStorage<boolean>(
'hdx-dashboard-filters-linked',
false,
Comment thread
teeohhem marked this conversation as resolved.
);

const {
data: filterValuesById,
Expand All @@ -107,32 +171,52 @@ const DashboardFilters = ({
filterValues: linked ? filterValues : {},
});

// Always display linked filters adjacent (grouped by source), whether or not
// link mode is on, so toggling it never reorders the bar.
const filterGroups = useMemo(
() => groupFiltersForDisplay(filters),
[filters],
);

return (
<Group align="start">
{Object.values(filters).map(filter => {
const queriedFilterValues = filterValuesById?.get(filter.id);
const included = filterValues[filter.expression]?.included;
const selectedValues = included
? Array.from(included).map(v => v.toString())
: [];
// Fall back to the hook-level fetching state only until this filter's
// query has produced an entry; once it has (even with empty values),
// honor its own loading flag.
const isLoadingValues = queriedFilterValues
? queriedFilterValues.isLoading
: isFetching;
return (
<DashboardFilterSelect
key={filter.id}
filter={filter}
isLoading={isLoadingValues}
isError={erroredFilterIds?.has(filter.id) ?? false}
onChange={values => onSetFilterValue(filter.expression, values)}
values={queriedFilterValues?.values}
value={selectedValues}
/>
);
})}
{/* flatMap, not nested map: an array-of-arrays child list makes each
filter's reconciliation key group-index-relative, so removing or
reordering a group would remount the surviving filters (losing
dropdown/search state). One flat, id-keyed list avoids that. */}
{filterGroups.flatMap(group =>
group.map((filter, indexInGroup) => {
const queriedFilterValues = filterValuesById?.get(filter.id);
const included = filterValues[filter.expression]?.included;
const selectedValues = included
? Array.from(included).map(v => v.toString())
: [];
// Fall back to the hook-level fetching state only until this filter's
// query has produced an entry; once it has (even with empty values),
// honor its own loading flag.
const isLoadingValues = queriedFilterValues
? queriedFilterValues.isLoading
: isFetching;
// Only chain neighbors that genuinely narrow each other, so the icon
// never claims a link the query layer doesn't make.
const previous = group[indexInGroup - 1];
return (
<Fragment key={filter.id}>
{linked && previous != null && filtersLink(previous, filter) && (
<FilterChainIcon />
)}
<DashboardFilterSelect
filter={filter}
isLoading={isLoadingValues}
isError={erroredFilterIds?.has(filter.id) ?? false}
onChange={values => onSetFilterValue(filter.expression, values)}
values={queriedFilterValues?.values}
value={selectedValues}
/>
</Fragment>
);
}),
)}
{filters.length >= 2 && (
<Stack gap={2} justify="flex-end">
{/* Spacer to align the toggle with the inputs (filters have a label row above). */}
Expand Down
Loading
Loading