From e6bafdf755d0606c11948627a98b04b6b6a49708 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 30 Jul 2026 15:34:33 -0400 Subject: [PATCH] feat(dashboards): persist filter link toggle and clarify within-source linking Follow-ups to #2423: - The link-filters toggle now persists in localStorage. The dashboard filter bar (hdx-dashboard-filters-linked, shared with the Services page) and the Kubernetes bar (hdx-k8s-filters-linked) each keep their own preference, since their faceted-lookup cost profiles differ. - Dashboard filters are always displayed grouped by source (defined order within a group, groups by first appearance), so toggling link mode never reorders the bar. While linked, chain icons connect adjacent filters that genuinely narrow each other. - The linking rule is now a single exported predicate, filtersLink, consumed by both constraintByFilterId and the chain icons, so what the UI claims and what the query layer does cannot drift. Filters sharing an expression do not link (FilterState is keyed by expression), so they are not chained. - Toggle tooltips spell out that linking only happens within-source. --- .../linked-filters-persist-and-group.md | 15 + packages/app/src/DashboardFilters.tsx | 142 +++++-- .../src/__tests__/DashboardFilters.test.tsx | 367 ++++++++++++++++++ .../app/src/components/FilterLinkToggle.tsx | 10 +- .../app/src/components/KubernetesFilters.tsx | 9 +- .../KubernetesFiltersLinkToggle.test.tsx | 92 +++++ .../src/hooks/useDashboardFilterValues.tsx | 35 +- 7 files changed, 623 insertions(+), 47 deletions(-) create mode 100644 .changeset/linked-filters-persist-and-group.md create mode 100644 packages/app/src/__tests__/DashboardFilters.test.tsx create mode 100644 packages/app/src/components/__tests__/KubernetesFiltersLinkToggle.test.tsx 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];