Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/sharp-rules-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

feat: Display static value filters
35 changes: 21 additions & 14 deletions packages/app/src/DashboardFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getPendingFilterValuesVariables,
isFilterVariableEnabled,
isQueryExpressionFilter,
isStaticListFilter,
} from '@hyperdx/common-utils/dist/filters';
import {
ChartVariable,
Expand All @@ -16,7 +17,7 @@ import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react';

import { FilterLinkToggle } from './components/FilterLinkToggle';
import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect';
import { useQueriedDashboardFilterValues } from './hooks/useQueriedDashboardFilterValues';
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';

interface DashboardFilterSelectProps {
filter: DashboardFilter;
Expand Down Expand Up @@ -161,6 +162,8 @@ const DashboardFilterSelect = ({
// so a completed/empty/failed query stays interactive and the user can
// still clear or adjust the selection.
loading={isLoading}
// A static list renders in the order its author wrote it.
sort={!isStaticListFilter(filter)}
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
onChange={onChange}
data-testid={`dashboard-filter-select-${filter.name}`}
/>
Expand Down Expand Up @@ -198,27 +201,29 @@ const DashboardFilters = ({
// on, all of a source's facets are computed in a single groupUniqArrayIf scan.
const [linked, setLinked] = useState(false);

const queriedFilters = useMemo(
() => filters.filter(f => isQueryExpressionFilter(f)),
[filters],
);

const {
data: filterValuesById,
erroredFilterIds,
filterErrorMessages,
isFetching,
} = useQueriedDashboardFilterValues({
filters: queriedFilters,
} = useDashboardFilterValues({
filters,
dateRange,
variables,
// Only narrow by sibling selections when linked.
selectionByFilterId: linked ? selectionByFilterId : EMPTY_SELECTIONS,
});

// Linking narrows queried dropdowns by sibling selections; a static
// list can neither constrain nor be constrained, so it doesn't count.
const linkableFiltersCount = useMemo(
() => filters.filter(isQueryExpressionFilter).length,
[filters],
);

return (
<Group align="start">
{queriedFilters.map(filter => {
{filters.map(filter => {
const queriedFilterValues = filterValuesById?.get(filter.id);
const included = selectionByFilterId.get(filter.id)?.included;
const selectedValues = included
Expand All @@ -230,24 +235,26 @@ const DashboardFilters = ({
const isLoadingValues = queriedFilterValues
? queriedFilterValues.isLoading
: isFetching;
// Only a queried dropdown has a values query that can await a
// variable's selection.
const pendingVariables = isQueryExpressionFilter(filter)
? getPendingFilterValuesVariables(filter, variables)
: undefined;
return (
<DashboardFilterSelect
key={filter.id}
filter={filter}
isLoading={isLoadingValues}
isError={erroredFilterIds?.has(filter.id) ?? false}
errorMessage={filterErrorMessages?.get(filter.id)}
pendingVariables={getPendingFilterValuesVariables(
filter,
variables,
)}
pendingVariables={pendingVariables}
onChange={values => onSetFilterValue(filter.id, values)}
values={queriedFilterValues?.values}
value={selectedValues}
/>
);
})}
{filters.length >= 2 && (
{linkableFiltersCount >= 2 && (
<Stack gap={2} justify="flex-end">
{/* Spacer to align the toggle with the inputs (filters have a label row above). */}
<Text size="xs" c="transparent" aria-hidden>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type VirtualMultiSelectProps = {
/** Show a "Loading…" empty state while values are being fetched. */
loading?: boolean;
placeholder?: string;
/** Whether to sort options before rendering. True by default */
sort?: boolean;
values: string[];
onChange: (values: string[]) => void;
'data-testid'?: string;
Expand All @@ -34,6 +36,7 @@ export function VirtualMultiSelect({
disabled,
loading,
placeholder,
sort = true,
values,
onChange,
'data-testid': dataTestId,
Expand All @@ -43,8 +46,8 @@ export function VirtualMultiSelect({
const [search, setSearch] = useState('');

const sorted = useMemo(() => {
return data.toSorted((a, b) => a.localeCompare(b));
}, [data]);
return sort ? data.toSorted((a, b) => a.localeCompare(b)) : data;
}, [data, sort]);

const options = useMemo(() => {
const searchLowerCase = search.trim().toLowerCase();
Expand Down
154 changes: 154 additions & 0 deletions packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React from 'react';
import {
DashboardFilter,
SourceKind,
StaticListDashboardFilter,
TSource,
} from '@hyperdx/common-utils/dist/types';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';

import { useDashboardFilterValues } from '@/hooks/useDashboardFilterValues';

const mockGetKeyValues = jest.fn();
let mockSourcesData: Partial<TSource>[];

// Untyped factories, so partial mocks need no unsafe type assertions.
jest.mock('@/source', () => ({
useSources: () => ({ data: mockSourcesData, isLoading: false }),
}));
jest.mock('../useMetadata', () => ({
useMetadataWithSettings: () => ({ getKeyValues: mockGetKeyValues }),
}));
jest.mock('@hyperdx/common-utils/dist/core/materializedViews', () => ({
optimizeGetKeyValuesCalls: jest
.fn()
.mockImplementation(async ({ keys, chartConfig }) => [
{ keys, chartConfig },
]),
optimizeFacetedKeyValuesConfig: jest
.fn()
.mockImplementation(async ({ chartConfig }) => chartConfig),
}));

describe('useDashboardFilterValues', () => {
let queryClient: QueryClient;
let wrapper: React.ComponentType<{ children: React.ReactNode }>;

// Deliberately not alphabetical: options must keep definition order.
const staticFilter: StaticListDashboardFilter = {
id: 'static1',
type: 'STATIC_LIST',
name: 'Tier',
options: ['low', 'medium', 'high'],
isBroadcastEnabled: false,
isVariableEnabled: true,
variableName: 'tier',
};

const queriedFilter: DashboardFilter = {
id: 'queried1',
type: 'QUERY_EXPRESSION',
name: 'Environment',
expression: 'environment',
source: 'logs-source',
};

const mockDateRange: [Date, Date] = [
new Date('2024-01-01'),
new Date('2024-01-02'),
];

beforeEach(() => {
mockSourcesData = [
{
id: 'logs-source',
kind: SourceKind.Log,
name: 'Logs',
timestampValueExpression: 'timestamp',
connection: 'clickhouse-conn',
from: {
databaseName: 'telemetry',
tableName: 'logs',
},
},
];
mockGetKeyValues.mockReset();
mockGetKeyValues.mockImplementation(({ keys }: { keys: string[] }) =>
Promise.resolve(
keys.map(key => ({ key, value: ['production', 'staging'] })),
),
);

queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
});

it('returns static options in definition order without loading or fetching', () => {
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
const { result } = renderHook(
() =>
useDashboardFilterValues({
filters: [staticFilter],
dateRange: mockDateRange,
}),
{ wrapper },
);

// Static values resolve synchronously, and the queried hook's perpetual
// "loading" for an empty filter list must not leak through.
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
expect(result.current.isError).toBe(false);
expect(result.current.data).toEqual(
new Map([
['static1', { values: ['low', 'medium', 'high'], isLoading: false }],
]),
);
expect(mockGetKeyValues).not.toHaveBeenCalled();
});

it('merges static and queried values keyed by filter id', async () => {
const { result } = renderHook(
() =>
useDashboardFilterValues({
filters: [staticFilter, queriedFilter],
dateRange: mockDateRange,
}),
{ wrapper },
);

await waitFor(() => expect(result.current.isFetching).toBe(false));

expect(result.current.data).toEqual(
new Map([
['queried1', { values: ['production', 'staging'], isLoading: false }],
['static1', { values: ['low', 'medium', 'high'], isLoading: false }],
]),
);
});

it('surfaces queried errors without implicating static filters', async () => {
mockGetKeyValues.mockRejectedValue(new Error('query failed'));

const { result } = renderHook(
() =>
useDashboardFilterValues({
filters: [staticFilter, queriedFilter],
dateRange: mockDateRange,
}),
{ wrapper },
);

await waitFor(() => expect(result.current.isError).toBe(true));

expect(result.current.erroredFilterIds).toEqual(new Set(['queried1']));
expect(result.current.data.get('static1')).toEqual({
values: ['low', 'medium', 'high'],
isLoading: false,
});
});
});
78 changes: 78 additions & 0 deletions packages/app/src/hooks/useDashboardFilterValues.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { FilterSelection } from '@hyperdx/common-utils/dist/dashboardFilterValues';
import {
isQueryExpressionFilter,
isStaticListFilter,
} from '@hyperdx/common-utils/dist/filters';
import {
ChartVariable,
DashboardFilter,
} from '@hyperdx/common-utils/dist/types';

import { useQueriedDashboardFilterValues } from './useQueriedDashboardFilterValues';
import { useStaticDashboardFilterValues } from './useStaticDashboardFilterValues';

export type DashboardFilterValuesResult = {
/** Dropdown values keyed by `filter.id` */
data: ReadonlyMap<string, { values: string[]; isLoading: boolean }>;
/** Filter IDs whose values lookup failed, or never resolved. */
erroredFilterIds: ReadonlySet<string>;
/** Error messages, when known, for filters in `erroredFilterIds`. */
filterErrorMessages: ReadonlyMap<string, string>;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
};

/** Dropdown values for a dashboard's filters. */
export function useDashboardFilterValues({

@pulpdrew pulpdrew Aug 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention is that this will be a "router" hook that partitions filters by type, delegates to type-specific hooks that fetch values for the type, and then merges the results.

This should make it more straightforward to add new filter types in the near future.

filters,
dateRange,
selectionByFilterId,
variables,
}: {
filters: DashboardFilter[];
dateRange: [Date, Date];
/** Each filter's current selection, keyed by `filter.id`. */
selectionByFilterId?: ReadonlyMap<string, FilterSelection>;
/** The dashboard's variables and their current selections, if any */
variables?: ChartVariable[];
}): DashboardFilterValuesResult {
const [queriedFilters, staticFilters] = useMemo(
() => [
filters.filter(isQueryExpressionFilter),
filters.filter(isStaticListFilter),
],
[filters],
);

const queriedValues = useQueriedDashboardFilterValues({
filters: queriedFilters,
dateRange,
selectionByFilterId,
variables,
});

const staticValues = useStaticDashboardFilterValues({
filters: staticFilters,
});

const data = useMemo(
() => new Map([...queriedValues.data, ...staticValues.data]),
[queriedValues.data, staticValues.data],
);

// The queried hook reports isLoading/isFetching true while sources load even
// when it was handed no filters at all; only let it speak for itself when it
// actually has filters to resolve.
const hasQueriedFilters = queriedFilters.length > 0;

return {
data,
erroredFilterIds: queriedValues.erroredFilterIds,
filterErrorMessages: queriedValues.filterErrorMessages,
isLoading: hasQueriedFilters && queriedValues.isLoading,
isFetching: hasQueriedFilters && queriedValues.isFetching,
isError: queriedValues.isError,
};
}
29 changes: 29 additions & 0 deletions packages/app/src/hooks/useStaticDashboardFilterValues.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { StaticListDashboardFilter } from '@hyperdx/common-utils/dist/types';

/** Stable identities: static filters never error, so these never change. */
const EMPTY_ERRORED_IDS: ReadonlySet<string> = new Set();
const EMPTY_ERROR_MESSAGES: ReadonlyMap<string, string> = new Map();

export function useStaticDashboardFilterValues({
filters,
}: {
filters: StaticListDashboardFilter[];
}) {
const data = useMemo(() => {
const byId = new Map<string, { values: string[]; isLoading: boolean }>();
for (const filter of filters) {
byId.set(filter.id, { values: filter.options, isLoading: false });
}
return byId;
}, [filters]);

return {
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
data,
erroredFilterIds: EMPTY_ERRORED_IDS,
filterErrorMessages: EMPTY_ERROR_MESSAGES,
isLoading: false,
isFetching: false,
isError: false,
};
}
Loading
Loading