feat: Add conditions to Dashboard filters; Support filter multi-select (#1969)

## Summary

This PR improves dashboard filters

1. Dashboard filters can now have an associated WHERE condition which filters the rows from which filter values will be queried.
2. Multiple values can now be selected for a single dashboard filter

### Screenshots or video

Multiple values can now be selected for a single filters:

<img width="544" height="77" alt="Screenshot 2026-03-23 at 12 31 02 PM" src="https://github.com/user-attachments/assets/2390a2d7-8514-4eb8-ac3c-db102a5df99b" />

Filters now have an optional condition, which filters the values which show up in the dropdown:

<img width="451" height="476" alt="Screenshot 2026-03-23 at 12 30 44 PM" src="https://github.com/user-attachments/assets/eed7f69e-466e-42fd-93f1-c27bfbc06204" />

<img width="265" height="94" alt="Screenshot 2026-03-23 at 12 30 54 PM" src="https://github.com/user-attachments/assets/2ba46e33-a44a-45ea-a6bf-fb71f5373e46" />

This also applies to Preset Dashboard Filters

<img width="726" height="908" alt="Screenshot 2026-03-23 at 12 33 34 PM" src="https://github.com/user-attachments/assets/df648feb-32e2-4f5e-80e5-409e0443b38e" />

### How to test locally or on Vercel

This can be partially tested in the preview environment, but testing the following requires running locally

1. Preset dashboard filters
2. External API support

### References



- Linear Issue: Closes HDX-3631 Closes HDX-2987
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-24 14:05:41 -04:00 committed by GitHub
parent dd313f7754
commit 275dc94161
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 445 additions and 232 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Add conditions to Dashboard filters; Support filter multi-select

View file

@ -1838,6 +1838,21 @@
],
"description": "Metric type when source is metrics",
"example": "gauge"
},
"where": {
"type": "string",
"description": "Optional WHERE condition to scope which rows this filter key reads values from",
"example": "ServiceName:api"
},
"whereLanguage": {
"type": "string",
"enum": [
"sql",
"lucene"
],
"description": "Language of the where condition",
"default": "sql",
"example": "lucene"
}
}
},

View file

@ -42,6 +42,12 @@ const PresetDashboardFilterSchema = new Schema<IPresetDashboardFilter>(
},
type: { type: String, required: true },
expression: { type: String, required: true },
where: { type: String, required: false },
whereLanguage: {
type: String,
required: false,
enum: ['sql', 'lucene'],
},
},
{
timestamps: true,

View file

@ -848,6 +848,14 @@ describe('External API v2 Dashboards - old format', () => {
sourceId: traceSource._id.toString(),
sourceMetricType: undefined,
},
{
type: 'QUERY_EXPRESSION' as const,
name: 'Region (Filtered)',
expression: 'region',
sourceId: traceSource._id.toString(),
where: "environment = 'production'",
whereLanguage: 'sql' as const,
},
],
};
@ -855,7 +863,7 @@ describe('External API v2 Dashboards - old format', () => {
.send(dashboardPayload)
.expect(200);
expect(response.body.data.filters).toHaveLength(2);
expect(response.body.data.filters).toHaveLength(3);
response.body.data.filters.forEach(
(f: {
id: string;
@ -879,12 +887,18 @@ describe('External API v2 Dashboards - old format', () => {
expect(response.body.data.filters[0].expression).toBe('environment');
expect(response.body.data.filters[1].name).toBe('Service Filter');
expect(response.body.data.filters[1].expression).toBe('service_name');
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
expect(response.body.data.filters[2].expression).toBe('region');
expect(response.body.data.filters[2].where).toBe(
"environment = 'production'",
);
expect(response.body.data.filters[2].whereLanguage).toBe('sql');
const getResponse = await authRequest(
'get',
`${BASE_URL}/${response.body.data.id}`,
).expect(200);
expect(getResponse.body.data.filters).toHaveLength(2);
expect(getResponse.body.data.filters).toHaveLength(3);
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
});
@ -2519,6 +2533,14 @@ describe('External API v2 Dashboards - new format', () => {
sourceId: traceSource._id.toString(),
sourceMetricType: undefined,
},
{
type: 'QUERY_EXPRESSION' as const,
name: 'Region (Filtered)',
expression: 'region',
sourceId: traceSource._id.toString(),
where: "environment = 'production'",
whereLanguage: 'sql' as const,
},
],
};
@ -2526,7 +2548,7 @@ describe('External API v2 Dashboards - new format', () => {
.send(dashboardPayload)
.expect(200);
expect(response.body.data.filters).toHaveLength(2);
expect(response.body.data.filters).toHaveLength(3);
response.body.data.filters.forEach(
(f: {
id: string;
@ -2550,12 +2572,18 @@ describe('External API v2 Dashboards - new format', () => {
expect(response.body.data.filters[0].expression).toBe('environment');
expect(response.body.data.filters[1].name).toBe('Service Filter');
expect(response.body.data.filters[1].expression).toBe('service_name');
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
expect(response.body.data.filters[2].expression).toBe('region');
expect(response.body.data.filters[2].where).toBe(
"environment = 'production'",
);
expect(response.body.data.filters[2].whereLanguage).toBe('sql');
const getResponse = await authRequest(
'get',
`${BASE_URL}/${response.body.data.id}`,
).expect(200);
expect(getResponse.body.data.filters).toHaveLength(2);
expect(getResponse.body.data.filters).toHaveLength(3);
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
});

View file

@ -1156,6 +1156,16 @@ const updateDashboardBodySchema = buildDashboardBodySchema(
* enum: [sum, gauge, histogram, summary, exponential histogram]
* description: Metric type when source is metrics
* example: "gauge"
* where:
* type: string
* description: Optional WHERE condition to scope which rows this filter key reads values from
* example: "ServiceName:api"
* whereLanguage:
* type: string
* enum: [sql, lucene]
* description: Language of the where condition
* default: "sql"
* example: "lucene"
*
* Filter:
* allOf:

View file

@ -1,5 +1,5 @@
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, Select } from '@mantine/core';
import { Group, MultiSelect } from '@mantine/core';
import { IconRefresh } from '@tabler/icons-react';
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
@ -7,8 +7,8 @@ import { FilterState } from './searchFilters';
interface DashboardFilterSelectProps {
filter: DashboardFilter;
onChange: (value: string | null) => void;
value?: string | null;
onChange: (values: string[]) => void;
value: string[];
values?: string[];
isLoading?: boolean;
}
@ -26,18 +26,17 @@ const DashboardFilterSelect = ({
}));
return (
<Select
placeholder={filter.name}
value={value ?? null} // null clears the select, undefined makes the select uncontrolled
<MultiSelect
placeholder={value.length === 0 ? filter.name : undefined}
value={value}
data={selectValues || []}
searchable
clearable
allowDeselect
size="xs"
maxDropdownHeight={280}
disabled={isLoading}
variant="filled"
w={200}
w={250}
limit={20}
onChange={onChange}
data-testid={`dashboard-filter-select-${filter.name}`}
@ -48,7 +47,7 @@ const DashboardFilterSelect = ({
interface DashboardFilterProps {
filters: DashboardFilter[];
filterValues: FilterState;
onSetFilterValue: (expression: string, value: string | null) => void;
onSetFilterValue: (expression: string, values: string[]) => void;
dateRange: [Date, Date];
}
@ -58,28 +57,27 @@ const DashboardFilters = ({
filterValues,
onSetFilterValue,
}: DashboardFilterProps) => {
const { data: filterValuesBySource, isFetching } = useDashboardFilterValues({
const { data: filterValuesById, isFetching } = useDashboardFilterValues({
filters,
dateRange,
});
return (
<Group mt="sm">
<Group mt="sm" align="start">
{Object.values(filters).map(filter => {
const queriedFilterValues = filterValuesBySource?.get(
filter.expression,
);
const queriedFilterValues = filterValuesById?.get(filter.id);
const included = filterValues[filter.expression]?.included;
const selectedValues = included
? Array.from(included).map(v => v.toString())
: [];
return (
<DashboardFilterSelect
key={filter.id}
filter={filter}
isLoading={!queriedFilterValues}
onChange={value => onSetFilterValue(filter.expression, value)}
onChange={values => onSetFilterValue(filter.expression, values)}
values={queriedFilterValues?.values}
value={filterValues[filter.expression]?.included
.values()
.next()
.value?.toString()}
value={selectedValues}
/>
);
})}

View file

@ -31,6 +31,9 @@ import {
IconTrash,
} from '@tabler/icons-react';
import SearchWhereInput, {
getStoredLanguage,
} from '@/components/SearchInput/SearchWhereInput';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import SourceSchemaPreview from './components/SourceSchemaPreview';
@ -40,7 +43,7 @@ import { getMetricTableName } from './utils';
import styles from '../styles/DashboardFiltersModal.module.scss';
const MODAL_SIZE = 'sm';
const MODAL_SIZE = 'md';
interface CustomInputWrapperProps {
children: React.ReactNode;
@ -97,11 +100,19 @@ const DashboardFilterEditForm = ({
}: DashboardFilterEditFormProps) => {
const { handleSubmit, register, formState, control, reset } =
useForm<DashboardFilter>({
defaultValues: filter,
defaultValues: {
...filter,
where: filter.where ?? '',
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
},
});
useEffect(() => {
reset(filter);
reset({
...filter,
where: filter.where ?? '',
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
});
}, [filter, reset]);
const sourceId = useWatch({ control, name: 'source' });
@ -134,7 +145,18 @@ const DashboardFilterEditForm = ({
size={MODAL_SIZE}
>
<div ref={setModalContentRef}>
<form onSubmit={handleSubmit(onSave)}>
<form
onSubmit={handleSubmit(values => {
const trimmedWhere = values.where?.trim() ?? '';
onSave({
...values,
where: trimmedWhere || undefined,
whereLanguage: trimmedWhere
? (values.whereLanguage ?? 'sql')
: undefined,
});
})}
>
<Stack>
<CustomInputWrapper label="Name" error={formState.errors.name}>
<TextInput
@ -204,6 +226,22 @@ const DashboardFilterEditForm = ({
/>
</CustomInputWrapper>
<CustomInputWrapper
label="Dropdown values filter"
tooltipText="Optional condition used to filter the rows from which available filter values are queried"
>
<SearchWhereInput
tableConnection={tableConnection}
control={control}
name="where"
languageName="whereLanguage"
showLabel={false}
allowMultiline={true}
sqlPlaceholder="Filter for dropdown values"
lucenePlaceholder="Filter for dropdown values"
/>
</CustomInputWrapper>
<Group justify="space-between" my="xs">
<Button variant="secondary" onClick={onCancel}>
Cancel
@ -394,6 +432,8 @@ const DashboardFiltersModal = ({
name: '',
expression: '',
source: source?.id ?? '',
where: '',
whereLanguage: getStoredLanguage() ?? 'sql',
});
};

View file

@ -186,7 +186,7 @@ describe('useDashboardFilterValues', () => {
expect(result.current.data).toEqual(
new Map([
[
'SeverityNumber',
'filterSevNumber',
{
values: ['1', '2'],
isLoading: false,
@ -219,21 +219,21 @@ describe('useDashboardFilterValues', () => {
expect(result.current.data).toEqual(
new Map([
[
'environment',
'filter1',
{
values: ['production', 'staging', 'development'],
isLoading: false,
},
],
[
'service.name',
'filter2',
{
values: ['frontend', 'backend', 'database'],
isLoading: false,
},
],
[
'MetricName',
'filter3',
{ values: ['CPU_Usage', 'Memory_Usage'], isLoading: false },
],
]),
@ -350,6 +350,56 @@ describe('useDashboardFilterValues', () => {
);
});
it('should not group filters with different where clauses', async () => {
// Arrange
const sameSourceFiltersDifferentWhere: DashboardFilter[] = [
{
id: 'filter1',
type: 'QUERY_EXPRESSION',
name: 'Environment',
expression: 'environment',
source: 'logs-source',
where: "service_name = 'api'",
whereLanguage: 'sql',
},
{
id: 'filter2',
type: 'QUERY_EXPRESSION',
name: 'Status',
expression: 'status',
source: 'logs-source',
where: "service_name = 'worker'",
whereLanguage: 'sql',
},
];
jest.spyOn(sourceModule, 'useSources').mockReturnValue({
data: mockSources,
isLoading: false,
} as any);
// Act
const { result } = renderHook(
() =>
useDashboardFilterValues({
filters: sameSourceFiltersDifferentWhere,
dateRange: mockDateRange,
}),
{ wrapper },
);
// Assert
await waitFor(() => expect(result.current.isFetching).toBe(false));
// Filters with different WHERE clauses are separate queries
expect(optimizeGetKeyValuesCalls).toHaveBeenCalledTimes(2);
expect(mockMetadata.getKeyValues).toHaveBeenCalledTimes(2);
// Both filters should have their own values keyed by filter ID
expect(result.current.data?.has('filter1')).toBe(true);
expect(result.current.data?.has('filter2')).toBe(true);
});
it('should not fetch when filters array is empty', () => {
// Arrange
jest.spyOn(sourceModule, 'useSources').mockReturnValue({
@ -596,15 +646,15 @@ describe('useDashboardFilterValues', () => {
expect(result.current.data).toEqual(
new Map([
[
'environment',
'filter1',
{
values: ['production', 'staging', 'development'],
isLoading: false,
},
],
['log_level', { values: ['info', 'error'], isLoading: false }],
['filter2', { values: ['info', 'error'], isLoading: false }],
[
'service.name',
'filter3',
{ values: ['frontend', 'backend', 'database'], isLoading: false },
],
]),
@ -700,18 +750,18 @@ describe('useDashboardFilterValues', () => {
}),
);
// Should return combined results
// Should return combined results keyed by filter ID
expect(result.current.data).toEqual(
new Map([
[
'environment',
'filter1',
{
values: ['production', 'staging', 'development'],
isLoading: false,
},
],
[
'service.name',
'filter2',
{ values: ['frontend', 'backend', 'database'], isLoading: false },
],
]),
@ -763,8 +813,8 @@ describe('useDashboardFilterValues', () => {
// Assert - Wait for first query to complete
await waitFor(() => expect(result.current.isLoading).toBe(false));
// At this point, environment should be loaded but service.name should still be loading
expect(result.current.data?.get('environment')).toEqual({
// At this point, filter1 (environment) should be loaded but filter2 (service.name) should still be loading
expect(result.current.data?.get('filter1')).toEqual({
values: ['production'],
isLoading: false,
});
@ -781,8 +831,8 @@ describe('useDashboardFilterValues', () => {
// Now both should be loaded
expect(result.current.data).toEqual(
new Map([
['environment', { values: ['production'], isLoading: false }],
['service.name', { values: ['backend'], isLoading: false }],
['filter1', { values: ['production'], isLoading: false }],
['filter2', { values: ['backend'], isLoading: false }],
]),
);
});
@ -825,14 +875,14 @@ describe('useDashboardFilterValues', () => {
await waitFor(() => expect(result.current.isLoading).toBe(false));
await waitFor(() => expect(result.current.isFetching).toBe(false));
// Should have partial results - environment loaded successfully
expect(result.current.data?.get('environment')).toEqual({
// Should have partial results - filter1 (environment) loaded successfully
expect(result.current.data?.get('filter1')).toEqual({
values: ['production', 'staging'],
isLoading: false,
});
// service.name should not be in the map because the query failed
expect(result.current.data?.has('service.name')).toBe(false);
// filter2 (service.name) should not be in the map because the query failed
expect(result.current.data?.has('filter2')).toBe(false);
// Overall error state should be true
expect(result.current.isError).toBe(true);
@ -895,7 +945,7 @@ describe('useDashboardFilterValues', () => {
await waitFor(() => expect(result.current.isFetching).toBe(false));
const initialData = result.current.data;
expect(initialData?.get('environment')).toEqual({
expect(initialData?.get('filter1')).toEqual({
values: ['production', 'staging'],
isLoading: false,
});
@ -922,7 +972,7 @@ describe('useDashboardFilterValues', () => {
await waitFor(() => expect(result.current.isFetching).toBe(true));
// Verify that previous data is still available during fetch (placeholderData behavior)
expect(result.current.data?.get('environment')).toEqual({
expect(result.current.data?.get('filter1')).toEqual({
values: ['production', 'staging'],
isLoading: false,
});
@ -940,7 +990,7 @@ describe('useDashboardFilterValues', () => {
await waitFor(() => expect(result.current.isFetching).toBe(false));
// Verify that new data has replaced the old data
expect(result.current.data?.get('environment')).toEqual({
expect(result.current.data?.get('filter1')).toEqual({
values: ['development', 'testing'],
isLoading: false,
});

View file

@ -1,11 +1,26 @@
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { DashboardFilter, Filter } from '@hyperdx/common-utils/dist/types';
import { act, renderHook } from '@testing-library/react';
import useDashboardFilters from '../useDashboardFilters';
// TODO: Re-enable tests after nuqs is upgraded to support unit testing
// https://github.com/47ng/nuqs/issues/259
describe.skip('useDashboardFilters', () => {
// Mock nuqs useQueryState with a simple useState-like implementation
let mockState: Filter[] | null = null;
const mockSetState = jest.fn(
(updater: Filter[] | null | ((prev: Filter[] | null) => Filter[] | null)) => {
if (typeof updater === 'function') {
mockState = updater(mockState);
} else {
mockState = updater;
}
},
);
jest.mock('nuqs', () => ({
useQueryState: () => [mockState, mockSetState],
createParser: (opts: { parse: Function; serialize: Function }) => opts,
}));
describe('useDashboardFilters', () => {
const mockFilters: DashboardFilter[] = [
{
id: 'filter1',
@ -30,6 +45,11 @@ describe.skip('useDashboardFilters', () => {
},
];
beforeEach(() => {
mockState = null;
mockSetState.mockClear();
});
it('should initialize with empty filter values', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
@ -37,157 +57,155 @@ describe.skip('useDashboardFilters', () => {
expect(result.current.filterQueries).toEqual([]);
});
it('should set filter values correctly', () => {
it('should set a single filter value', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter1', 'production');
result.current.setFilterValue('environment', ['production']);
});
expect(result.current.filterValues).toEqual({
filter1: 'production',
});
});
it('should set multiple filter values', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter1', 'production');
result.current.setFilterValue('filter2', 'api-service');
});
expect(result.current.filterValues).toEqual({
filter1: 'production',
filter2: 'api-service',
});
});
it('should remove filter value when set to null', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter1', 'production');
result.current.setFilterValue('filter2', 'api-service');
});
act(() => {
result.current.setFilterValue('filter1', null);
});
expect(result.current.filterValues).toEqual({
filter2: 'api-service',
});
});
it('should convert filter values to SQL filters', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter1', 'production');
result.current.setFilterValue('filter2', 'api-service');
});
expect(result.current.filterQueries).toEqual([
{
type: 'sql',
condition: "environment = 'production'",
},
{
type: 'sql',
condition: "service.name = 'api-service'",
},
]);
});
it('should handle numeric filter values', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter3', '200');
});
expect(result.current.filterQueries).toEqual([
{
type: 'sql',
condition: "status_code = '200'",
},
]);
});
it('should ignore filter values for non-existent filters', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter1', 'production');
result.current.setFilterValue('nonexistent', 'value');
});
expect(result.current.filterQueries).toEqual([
{
type: 'sql',
condition: "environment = 'production'",
},
]);
});
it('should update SQL filters when filter values change', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter1', 'staging');
});
expect(result.current.filterQueries).toEqual([
{
type: 'sql',
condition: "environment = 'staging'",
},
]);
act(() => {
result.current.setFilterValue('filter1', 'production');
});
expect(result.current.filterQueries).toEqual([
{
type: 'sql',
condition: "environment = 'production'",
},
]);
});
it('should maintain filter values when filters array changes', () => {
const newFilters: DashboardFilter[] = [
...mockFilters,
{
id: 'filter4',
type: 'QUERY_EXPRESSION',
name: 'Region',
expression: 'region',
source: 'logs',
},
];
const { result, rerender } = renderHook(
({ filters }) => useDashboardFilters(filters),
{
initialProps: { filters: mockFilters },
},
// Re-render to pick up the new mockState
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(result2.current.filterValues.environment.included).toEqual(
new Set(['production']),
);
});
it('should set multiple values for a single filter (multi-select)', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('filter1', 'production');
result.current.setFilterValue('environment', ['production', 'staging']);
});
expect(result.current.filterValues).toEqual({
filter1: 'production',
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(result2.current.filterValues.environment.included).toEqual(
new Set(['production', 'staging']),
);
});
it('should generate IN clause for multi-select values', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('environment', ['production', 'staging']);
});
rerender({ filters: newFilters });
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(result.current.filterValues).toEqual({
filter1: 'production',
expect(result2.current.filterQueries).toHaveLength(1);
const query = result2.current.filterQueries[0];
const condition = 'condition' in query ? query.condition : '';
expect(condition).toEqual(
"toString(environment) IN ('production', 'staging')",
);
});
it('should clear filter when set to empty array', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('environment', ['production']);
});
act(() => {
result.current.setFilterValue('environment', []);
});
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(result2.current.filterValues.environment).toBeUndefined();
expect(result2.current.filterQueries).toEqual([]);
});
it('should support multi-select on multiple expressions simultaneously', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('environment', ['production', 'staging']);
});
act(() => {
result.current.setFilterValue('service.name', ['api', 'web']);
});
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(result2.current.filterValues.environment.included).toEqual(
new Set(['production', 'staging']),
);
expect(result2.current.filterValues['service.name'].included).toEqual(
new Set(['api', 'web']),
);
expect(result2.current.filterQueries).toHaveLength(2);
});
it('should replace previous multi-select values when updated', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('environment', ['production', 'staging']);
});
act(() => {
result.current.setFilterValue('environment', ['development']);
});
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(result2.current.filterValues.environment.included).toEqual(
new Set(['development']),
);
});
it('should ignore filter values for non-existent filter expressions', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('environment', ['production']);
});
act(() => {
result.current.setFilterValue('nonexistent', ['value']);
});
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(Object.keys(result2.current.filterValues)).toEqual(['environment']);
});
it('should clear one filter without affecting others', () => {
const { result } = renderHook(() => useDashboardFilters(mockFilters));
act(() => {
result.current.setFilterValue('environment', ['production', 'staging']);
});
act(() => {
result.current.setFilterValue('service.name', ['api']);
});
act(() => {
result.current.setFilterValue('environment', []);
});
const { result: result2 } = renderHook(() =>
useDashboardFilters(mockFilters),
);
expect(result2.current.filterValues.environment).toBeUndefined();
expect(result2.current.filterValues['service.name'].included).toEqual(
new Set(['api']),
);
});
});

View file

@ -7,6 +7,8 @@ import {
import {
BuilderChartConfigWithDateRange,
DashboardFilter,
isLogSource,
isTraceSource,
} from '@hyperdx/common-utils/dist/types';
import {
useQueries,
@ -20,17 +22,27 @@ import { getMetricTableName, mapKeyBy } from '@/utils';
import { useMetadataWithSettings } from './useMetadata';
const filterToKey = (filter: DashboardFilter) =>
filter.sourceMetricType
? `${filter.source}~${filter.sourceMetricType}`
: `${filter.source}`;
type FilterSourceKey = {
sourceId: string;
metricType?: string;
where: string;
whereLanguage: 'sql' | 'lucene';
};
const filterFromKey = (key: string) => {
const [sourceId, metricType] = key.split('~');
return {
sourceId,
metricType,
};
const filterToKey = (filter: DashboardFilter): string =>
JSON.stringify({
sourceId: filter.source,
metricType: filter.sourceMetricType,
where: filter.where ?? '',
whereLanguage: filter.whereLanguage ?? 'sql',
} satisfies FilterSourceKey);
const filterFromKey = (key: string): FilterSourceKey =>
JSON.parse(key) as FilterSourceKey;
type EnrichedCall = GetKeyValueCall<BuilderChartConfigWithDateRange> & {
/** filterIds[i] = array of filter IDs whose values come from keys[i] */
filterIds: string[][];
};
function useOptimizedKeyValuesCalls({
@ -44,30 +56,30 @@ function useOptimizedKeyValuesCalls({
const metadata = useMetadataWithSettings();
const { data: sources, isLoading: isLoadingSources } = useSources();
const filtersBySourceIdAndMetric = useMemo(() => {
const filtersBySourceIdAndMetric = new Map<string, DashboardFilter[]>();
// Group filters by (source, metricType, where, whereLanguage) so that we can test each group for MV compatibility separately.
const filtersByGroupKey = useMemo(() => {
const filtersByGroupKey = new Map<string, DashboardFilter[]>();
for (const filter of filters) {
const key = filterToKey(filter);
if (!filtersBySourceIdAndMetric.has(key)) {
filtersBySourceIdAndMetric.set(key, [filter]);
if (!filtersByGroupKey.has(key)) {
filtersByGroupKey.set(key, [filter]);
} else {
filtersBySourceIdAndMetric.get(key)!.push(filter);
filtersByGroupKey.get(key)!.push(filter);
}
}
return filtersBySourceIdAndMetric;
return filtersByGroupKey;
}, [filters]);
const results: UseQueryResult<
GetKeyValueCall<BuilderChartConfigWithDateRange>[]
>[] = useQueries({
queries: Array.from(filtersBySourceIdAndMetric.entries())
const results: UseQueryResult<EnrichedCall[]>[] = useQueries({
queries: Array.from(filtersByGroupKey.entries())
.filter(([key]) =>
sources?.some(s => s.id === filterFromKey(key).sourceId),
)
.map(([key, filters]) => {
const { sourceId, metricType } = filterFromKey(key);
.map(([key, filtersInGroup]) => {
const { sourceId, metricType, where, whereLanguage } =
filterFromKey(key);
const source = sources!.find(s => s.id === sourceId)!;
const keys = filters.map(f => f.expression);
const keyExpressions = filtersInGroup.map(f => f.expression);
const tableName = getMetricTableName(source, metricType) ?? '';
const chartConfig: BuilderChartConfigWithDateRange = {
@ -76,10 +88,14 @@ function useOptimizedKeyValuesCalls({
databaseName: source.from.databaseName,
tableName,
},
implicitColumnExpression:
isTraceSource(source) || isLogSource(source)
? source.implicitColumnExpression
: undefined,
dateRange,
source: source.id,
where: '',
whereLanguage: 'sql',
where,
whereLanguage,
select: '',
};
@ -89,19 +105,31 @@ function useOptimizedKeyValuesCalls({
sourceId,
metricType,
dateRange,
keys,
keyExpressions,
where,
whereLanguage,
],
enabled: !isLoadingSources,
staleTime: 1000 * 60 * 5, // Cache every 5 min
queryFn: async ({ signal }) =>
await optimizeGetKeyValuesCalls({
queryFn: async ({ signal }) => {
const calls = await optimizeGetKeyValuesCalls({
chartConfig,
source,
clickhouseClient,
metadata,
keys,
keys: keyExpressions,
signal,
}),
});
// Enrich each call with the filter IDs that correspond to each key expression
return calls.map(call => ({
...call,
filterIds: call.keys.map(expression =>
filtersInGroup
.filter(f => f.expression === expression)
.map(f => f.id),
),
}));
},
};
}),
});
@ -179,20 +207,27 @@ export function useDashboardFilterValues({
}),
});
// Map results by filter ID instead of expression so that two filters with
// the same expression but different sources/WHERE clauses get distinct values.
const flattenedData = useMemo(
() =>
new Map(
results.flatMap(({ isLoading, data = [] }) => {
return data.map(({ key, value }) => [
key,
{
results.flatMap(({ isLoading, data = [] }, resultIndex) => {
const call = calls[resultIndex];
return data.flatMap(({ key: expression, value }) => {
const keyIndex = call.keys.indexOf(expression);
const filterIds = call.filterIds?.[keyIndex] ?? [];
const entry = {
values: value.map(v => v.toString()),
isLoading,
},
]);
};
return filterIds.map(
filterId => [filterId, entry] as [string, typeof entry],
);
});
}),
),
[results],
[results, calls],
);
return {

View file

@ -14,14 +14,14 @@ const useDashboardFilters = (filters: DashboardFilter[]) => {
);
const setFilterValue = useCallback(
(expression: string, value: string | null) => {
(expression: string, values: string[]) => {
setFilterQueries(prev => {
const { filters: filterValues } = parseQuery(prev ?? []);
if (value === undefined || value === null) {
if (values.length === 0) {
delete filterValues[expression];
} else {
filterValues[expression] = {
included: new Set([value]),
included: new Set(values),
excluded: new Set(),
};
}

View file

@ -535,7 +535,9 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
// Verify the filter is applied
const filterSelect = dashboardPage.getFilterSelectByName('Service');
await expect(filterSelect).toHaveValue('accounting');
await expect(
filterSelect.locator('..').getByText('accounting'),
).toBeVisible();
});
await test.step('Enter query in search bar', async () => {
@ -584,7 +586,9 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
// Verify the saved filter value is populated
const filterSelect = dashboardPage.getFilterSelectByName('Service');
await expect(filterSelect).toHaveValue('accounting');
await expect(
filterSelect.locator('..').getByText('accounting'),
).toBeVisible();
});
});

View file

@ -717,6 +717,8 @@ export const DashboardFilterSchema = z.object({
expression: z.string().min(1),
source: z.string().min(1),
sourceMetricType: z.nativeEnum(MetricsDataType).optional(),
where: z.string().optional(),
whereLanguage: SearchConditionLanguageSchema,
});
export type DashboardFilter = z.infer<typeof DashboardFilterSchema>;