diff --git a/.changeset/linked-filters-persist-and-group.md b/.changeset/linked-filters-persist-and-group.md new file mode 100644 index 0000000000..b79913f624 --- /dev/null +++ b/.changeset/linked-filters-persist-and-group.md @@ -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. diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 4713dc49e4..52bf218468 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -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; @@ -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(); + 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 = () => ( + + {/* Spacer to align the icon with the inputs (filters have a label row above). */} + +   + +
+ + + +
+
+); + interface DashboardFilterProps { filters: DashboardFilter[]; filterValues: FilterState; @@ -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( + 'hdx-dashboard-filters-linked', + false, + ); const { data: filterValuesById, @@ -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 ( - {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 ( - 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 ( + + {linked && previous != null && filtersLink(previous, filter) && ( + + )} + onSetFilterValue(filter.expression, values)} + values={queriedFilterValues?.values} + value={selectedValues} + /> + + ); + }), + )} {filters.length >= 2 && ( {/* Spacer to align the toggle with the inputs (filters have a label row above). */} diff --git a/packages/app/src/__tests__/DashboardFilters.test.tsx b/packages/app/src/__tests__/DashboardFilters.test.tsx new file mode 100644 index 0000000000..e15215dd4f --- /dev/null +++ b/packages/app/src/__tests__/DashboardFilters.test.tsx @@ -0,0 +1,367 @@ +import type { FilterState } from '@hyperdx/common-utils/dist/filters'; +import { + type DashboardFilter, + MetricsDataType, +} from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { Notifications } from '@mantine/notifications'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DashboardFilters, { groupFiltersForDisplay } from '@/DashboardFilters'; +import { useDashboardFilterValues } from '@/hooks/useDashboardFilterValues'; + +// Mock only the hook — `filtersLink` stays real, since the chain icons are +// supposed to agree with the actual linking rule. +jest.mock('@/hooks/useDashboardFilterValues', () => ({ + ...jest.requireActual('@/hooks/useDashboardFilterValues'), + __esModule: true, + useDashboardFilterValues: jest.fn(), +})); + +const mockedUseDashboardFilterValues = jest.mocked(useDashboardFilterValues); + +const LINKED_STORAGE_KEY = 'hdx-dashboard-filters-linked'; + +const makeFilter = ( + overrides: Pick & + Partial, +): DashboardFilter => ({ + type: 'QUERY_EXPRESSION', + ...overrides, +}); + +const DATE_RANGE: [Date, Date] = [ + new Date('2026-01-01T00:00:00Z'), + new Date('2026-01-02T00:00:00Z'), +]; + +// VirtualMultiSelect virtualizes its options, and jsdom reports zero-height +// scroll containers, so no option ever renders. Stub it down to the surface +// these tests care about: the testid, and a way to fire onChange. +jest.mock('@/components/VirtualMultiSelect/VirtualMultiSelect', () => ({ + __esModule: true, + VirtualMultiSelect: ({ + data, + onChange, + 'data-testid': dataTestId, + }: { + data: string[]; + onChange: (values: string[]) => void; + 'data-testid'?: string; + }) => ( +
+ {data.map(value => ( + + ))} +
+ ), +})); + +function renderFilters({ + filters, + filterValues = {}, +}: { + filters: DashboardFilter[]; + filterValues?: FilterState; +}) { + const onSetFilterValue = jest.fn(); + const element = (nextFilters: DashboardFilter[]) => ( + + ); + const utils = renderWithMantine(element(filters)); + // RTL's rerender drops the render wrapper, so re-supply it. The tree must + // match renderWithMantine's exactly, or React remounts everything and the + // node-identity assertions below would pass/fail for the wrong reason. + const rerenderFilters = (nextFilters: DashboardFilter[]) => + utils.rerender( + + + {element(nextFilters)} + , + ); + return { ...utils, onSetFilterValue, rerenderFilters }; +} + +describe('groupFiltersForDisplay', () => { + const a1 = makeFilter({ + id: 'a1', + name: 'A1', + expression: 'env', + source: 'src-a', + }); + const a2 = makeFilter({ + id: 'a2', + name: 'A2', + expression: 'service', + source: 'src-a', + }); + const b1 = makeFilter({ + id: 'b1', + name: 'B1', + expression: 'pod', + source: 'src-b', + }); + const b2 = makeFilter({ + id: 'b2', + name: 'B2', + expression: 'node', + source: 'src-b', + }); + + it('returns an empty array for no filters', () => { + expect(groupFiltersForDisplay([])).toEqual([]); + }); + + it('returns a single group for a single filter', () => { + expect(groupFiltersForDisplay([a1])).toEqual([[a1]]); + }); + + it('groups interleaved sources stably: within-group order preserved, groups by first appearance', () => { + expect(groupFiltersForDisplay([a1, b1, a2, b2])).toEqual([ + [a1, a2], + [b1, b2], + ]); + }); + + it('splits same-source filters with different metric types', () => { + const gauge = makeFilter({ + id: 'g', + name: 'Gauge', + expression: 'cpu', + source: 'metrics', + sourceMetricType: MetricsDataType.Gauge, + }); + const sum = makeFilter({ + id: 's', + name: 'Sum', + expression: 'requests', + source: 'metrics', + sourceMetricType: MetricsDataType.Sum, + }); + expect(groupFiltersForDisplay([gauge, sum])).toEqual([[gauge], [sum]]); + }); + + it('groups filters with undefined metric types together', () => { + expect(groupFiltersForDisplay([a1, a2])).toEqual([[a1, a2]]); + }); + + it('keeps same-source filters with different where clauses in one group (linking ignores where)', () => { + // The linking rule (constraintByFilterId in useDashboardFilterValues) only + // keys on source + sourceMetricType; `where` only affects fetch batching. + const withWhere = makeFilter({ + id: 'w1', + name: 'W1', + expression: 'env', + source: 'src-a', + where: "service = 'api'", + whereLanguage: 'sql', + }); + const withOtherWhere = makeFilter({ + id: 'w2', + name: 'W2', + expression: 'status', + source: 'src-a', + where: "service = 'worker'", + whereLanguage: 'sql', + }); + expect(groupFiltersForDisplay([withWhere, withOtherWhere])).toEqual([ + [withWhere, withOtherWhere], + ]); + }); +}); + +describe('DashboardFilters', () => { + const filterA1 = makeFilter({ + id: 'a1', + name: 'Env', + expression: 'env', + source: 'src-a', + }); + const filterA2 = makeFilter({ + id: 'a2', + name: 'Service', + expression: 'service', + source: 'src-a', + }); + const filterB1 = makeFilter({ + id: 'b1', + name: 'Pod', + expression: 'pod', + source: 'src-b', + }); + + beforeEach(() => { + window.localStorage.clear(); + mockedUseDashboardFilterValues.mockClear(); + mockedUseDashboardFilterValues.mockReturnValue({ + data: new Map(), + erroredFilterIds: new Set(), + isLoading: false, + isFetching: false, + isError: false, + }); + }); + + it('reads a persisted linked preference and narrows values by sibling selections', () => { + window.localStorage.setItem(LINKED_STORAGE_KEY, JSON.stringify(true)); + const filterValues: FilterState = { + env: { included: new Set(['prod']), excluded: new Set() }, + }; + + renderFilters({ filters: [filterA1, filterA2], filterValues }); + + expect(screen.getByTestId('dashboard-filters-link-toggle')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + // Assert the FIRST call: the point of persisting is that a returning user's + // very first query is already the faceted one, with no extra unconstrained + // fetch beforehand. + expect(mockedUseDashboardFilterValues.mock.calls[0][0]).toMatchObject({ + filterValues, + }); + }); + + it('does not narrow values by sibling selections when unlinked', () => { + const filterValues: FilterState = { + env: { included: new Set(['prod']), excluded: new Set() }, + }; + + renderFilters({ filters: [filterA1, filterA2], filterValues }); + + expect(screen.getByTestId('dashboard-filters-link-toggle')).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(mockedUseDashboardFilterValues).toHaveBeenCalledWith( + expect.objectContaining({ filterValues: {} }), + ); + }); + + it('persists toggle changes to localStorage', async () => { + renderFilters({ filters: [filterA1, filterA2] }); + + await userEvent.click(screen.getByTestId('dashboard-filters-link-toggle')); + expect(window.localStorage.getItem(LINKED_STORAGE_KEY)).toBe('true'); + + await userEvent.click(screen.getByTestId('dashboard-filters-link-toggle')); + expect(window.localStorage.getItem(LINKED_STORAGE_KEY)).toBe('false'); + }); + + it('shows chain icons between same-source filters only while linked', async () => { + renderFilters({ filters: [filterA1, filterB1, filterA2] }); + + expect(screen.queryAllByTestId('dashboard-filter-chain-icon')).toHaveLength( + 0, + ); + + await userEvent.click(screen.getByTestId('dashboard-filters-link-toggle')); + + // One chain between the two src-a filters; none across the source boundary. + expect(screen.queryAllByTestId('dashboard-filter-chain-icon')).toHaveLength( + 1, + ); + }); + + it('does not chain same-source filters that share an expression', async () => { + // Filters sharing an expression don't narrow each other (FilterState is + // keyed by expression), so the chain icon must not claim they do. + const prodService = makeFilter({ + id: 'p', + name: 'Prod Service', + expression: 'ServiceName', + source: 'src-a', + where: "env = 'prod'", + whereLanguage: 'sql', + }); + const stagingService = makeFilter({ + id: 's', + name: 'Staging Service', + expression: 'ServiceName', + source: 'src-a', + where: "env = 'staging'", + whereLanguage: 'sql', + }); + + renderFilters({ filters: [prodService, stagingService] }); + await userEvent.click(screen.getByTestId('dashboard-filters-link-toggle')); + + expect(screen.queryAllByTestId('dashboard-filter-chain-icon')).toHaveLength( + 0, + ); + }); + + it('keeps surviving filters mounted when another filter is removed', () => { + // Grouping must not make a filter's React key group-index-relative, or + // removing one filter would remount the others and drop their dropdown and + // search state. + const { rerenderFilters } = renderFilters({ + filters: [filterA1, filterB1, filterA2], + }); + const before = screen.getByTestId('dashboard-filter-select-Pod'); + + rerenderFilters([filterB1, filterA2]); + + expect(screen.getByTestId('dashboard-filter-select-Pod')).toBe(before); + }); + + it('dispatches the correct expression for a regrouped filter', async () => { + // Grouping moved this filter's position, so verify its onChange still + // carries its own expression rather than a neighbor's. + mockedUseDashboardFilterValues.mockReturnValue({ + data: new Map([['b1', { values: ['pod-1'], isLoading: false }]]), + erroredFilterIds: new Set(), + isLoading: false, + isFetching: false, + isError: false, + }); + const { onSetFilterValue } = renderFilters({ + filters: [filterA1, filterB1, filterA2], + }); + + await userEvent.click(await screen.findByRole('button', { name: 'pod-1' })); + + expect(onSetFilterValue).toHaveBeenCalledWith('pod', ['pod-1']); + }); + + it('orders filters grouped by source, identically whether linked or not', async () => { + renderFilters({ filters: [filterA1, filterB1, filterA2] }); + + const getSelectOrder = () => + screen + .getAllByTestId(/^dashboard-filter-select-/) + .map(el => el.getAttribute('data-testid')); + + const groupedOrder = [ + 'dashboard-filter-select-Env', + 'dashboard-filter-select-Service', + 'dashboard-filter-select-Pod', + ]; + expect(getSelectOrder()).toEqual(groupedOrder); + + await userEvent.click(screen.getByTestId('dashboard-filters-link-toggle')); + expect(getSelectOrder()).toEqual(groupedOrder); + }); + + it('renders a single filter with a stored linked preference without the toggle or chains', () => { + window.localStorage.setItem(LINKED_STORAGE_KEY, JSON.stringify(true)); + + renderFilters({ filters: [filterA1] }); + + expect( + screen.queryByTestId('dashboard-filters-link-toggle'), + ).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('dashboard-filter-chain-icon')).toHaveLength( + 0, + ); + expect(screen.getByTestId('dashboard-filter-select-Env')).toBeVisible(); + }); +}); diff --git a/packages/app/src/components/FilterLinkToggle.tsx b/packages/app/src/components/FilterLinkToggle.tsx index 7c53464f6e..3ef164fefa 100644 --- a/packages/app/src/components/FilterLinkToggle.tsx +++ b/packages/app/src/components/FilterLinkToggle.tsx @@ -11,8 +11,10 @@ type FilterLinkToggleProps = { /** * Opt-in toggle that "links" a set of filter dropdowns so each one's selectable * values are narrowed by the others' current selections (faceted / filter-aware - * values). Off by default because contingent value lookups can't be served from - * the cheap per-key rollups and are far more expensive at scale. + * values). Only filters from the same source link to each other — a selection + * can't narrow a dropdown whose values come from a different table. Off by + * default because contingent value lookups can't be served from the cheap + * per-key rollups and are far more expensive at scale. */ export function FilterLinkToggle({ linked, @@ -26,8 +28,8 @@ export function FilterLinkToggle({ w={250} label={ linked - ? 'Filters are linked: each dropdown only shows values that match the other selections. Click to unlink.' - : 'Link filters: narrow each dropdown to values that match the other selections (filter-aware). May be slower on large datasets.' + ? 'Filters are linked: each dropdown only shows values that match the other selections from the same source. Click to unlink.' + : 'Link filters: narrow each dropdown to values that match the other selections from the same source (filter-aware). May be slower on large datasets.' } > = ({ // "Link" mode (opt-in, off by default): narrow each dropdown's values by the // other selections + the free-text search. Off by default because contingent // value lookups can't use the cheap per-key rollups and cost far more at scale. - const [linked, setLinked] = useState(false); + // Persisted separately from the dashboard filter bar's key: the K8s facets + // are cheap (a few keys on one metrics table) while dashboard facets can be + // arbitrary expressions over large tables, so the preferences may differ. + const [linked, setLinked] = useLocalStorage( + 'hdx-k8s-filters-linked', + false, + ); const resourceAttr = metricSource.resourceAttributesExpression; const valueByField: Record = { diff --git a/packages/app/src/components/__tests__/KubernetesFiltersLinkToggle.test.tsx b/packages/app/src/components/__tests__/KubernetesFiltersLinkToggle.test.tsx new file mode 100644 index 0000000000..6baf7758c9 --- /dev/null +++ b/packages/app/src/components/__tests__/KubernetesFiltersLinkToggle.test.tsx @@ -0,0 +1,92 @@ +import type { TMetricSource } from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { KubernetesFilters } from '@/components/KubernetesFilters'; +import { useGetKeyValues } from '@/hooks/useMetadata'; + +jest.mock('@/hooks/useMetadata', () => ({ + __esModule: true, + useGetKeyValues: jest.fn(), +})); + +// Pulls in networked editors; the toggle behavior doesn't need it. +jest.mock('@/components/SearchInput/SearchInputV2', () => ({ + __esModule: true, + default: () => null, +})); + +const mockedUseGetKeyValues = jest.mocked(useGetKeyValues); + +const K8S_STORAGE_KEY = 'hdx-k8s-filters-linked'; +const DASHBOARD_STORAGE_KEY = 'hdx-dashboard-filters-linked'; + +// useGetKeyValues is mocked, so this only needs to satisfy the prop type. +const METRIC_SOURCE = { + id: 'metrics', + kind: 'metric', + name: 'Metrics', + connection: 'conn', + from: { databaseName: 'default', tableName: '' }, + timestampValueExpression: 'TimeUnix', + resourceAttributesExpression: 'ResourceAttributes', + metricTables: { gauge: 'otel_metrics_gauge' }, +} as unknown as TMetricSource; + +function renderK8sFilters() { + return renderWithMantine( + , + ); +} + +describe('KubernetesFilters link toggle persistence', () => { + beforeEach(() => { + window.localStorage.clear(); + mockedUseGetKeyValues.mockClear(); + mockedUseGetKeyValues.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType); + }); + + it('defaults to unlinked and requests values without per-key conditions', () => { + renderK8sFilters(); + + expect(screen.getByTestId('k8s-filters-link-toggle')).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(mockedUseGetKeyValues.mock.calls[0][0]).toMatchObject({ + keyConditions: undefined, + }); + }); + + it('reads a persisted preference and facets the first values request', () => { + window.localStorage.setItem(K8S_STORAGE_KEY, JSON.stringify(true)); + + renderK8sFilters(); + + expect(screen.getByTestId('k8s-filters-link-toggle')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(mockedUseGetKeyValues.mock.calls[0][0].keyConditions).toBeDefined(); + }); + + it('persists toggle changes under its own key, leaving the dashboard preference alone', async () => { + renderK8sFilters(); + + await userEvent.click(screen.getByTestId('k8s-filters-link-toggle')); + + expect(window.localStorage.getItem(K8S_STORAGE_KEY)).toBe('true'); + expect(window.localStorage.getItem(DASHBOARD_STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/packages/app/src/hooks/useDashboardFilterValues.tsx b/packages/app/src/hooks/useDashboardFilterValues.tsx index 1b25ff518c..04e57c00b2 100644 --- a/packages/app/src/hooks/useDashboardFilterValues.tsx +++ b/packages/app/src/hooks/useDashboardFilterValues.tsx @@ -42,6 +42,24 @@ const filterToKey = (filter: DashboardFilter): string => whereLanguage: filter.whereLanguage ?? 'sql', } satisfies FilterSourceKey); +/** + * Whether `b`'s selections narrow `a`'s selectable values in link mode. The + * single source of truth for the linking rule: both the faceted constraint + * below and the chain icons in DashboardFilters.tsx consume this, so the query + * behavior and what the UI claims about it can't drift apart. + * + * Requires the same source + metric type (so the constrained column exists in + * the queried table). `where`/`whereLanguage` are deliberately NOT part of it — + * those only affect query batching (`filterToKey`), not narrowing. Filters + * sharing an expression don't link: FilterState is keyed by expression, so such + * a sibling carries this filter's own selection and would collapse a + * multi-select to what's already picked. + */ +export const filtersLink = (a: DashboardFilter, b: DashboardFilter): boolean => + a.source === b.source && + a.sourceMetricType === b.sourceMetricType && + a.expression !== b.expression; + type EnrichedCall = GetKeyValueCall & { /** filterIds[i] = array of filter IDs whose values come from keys[i] */ filterIds: string[][]; @@ -64,12 +82,9 @@ function useOptimizedKeyValuesCalls({ // Faceted filtering: each filter's selectable values are narrowed by the // CURRENT selections of its sibling filters. For every filter, collect the - // selections of the OTHER filters that target the same source + metric type - // (so the constrained columns exist in the queried table), EXCLUDING the - // filter's own expression (otherwise a multi-select would collapse to only - // its already-selected values). Passing this down as a per-key constraint - // lets all of a source's filters resolve in one `groupUniqArrayIf` scan - // instead of one query per filter. + // selections of the siblings that link to it (see `filtersLink`). Passing + // this down as a per-key constraint lets all of a source's filters resolve in + // one `groupUniqArrayIf` scan instead of one query per filter. // // The constraint stays a FilterState rather than SQL: getKeyValues renders // it against the same key expressions it puts in the SELECT, which raw SQL @@ -80,13 +95,7 @@ function useOptimizedKeyValuesCalls({ const prunedState: FilterState = {}; let hasSelection = false; for (const sibling of filters) { - if ( - sibling.source !== filter.source || - sibling.sourceMetricType !== filter.sourceMetricType || - // Exclude-self: FilterState is keyed by expression, so a sibling that - // shares this filter's expression carries this filter's own selection. - sibling.expression === filter.expression - ) { + if (!filtersLink(filter, sibling)) { continue; } const selection = filterValues[sibling.expression];