Skip to content

Commit 275dc94

Browse files
authored
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:
1 parent dd313f7 commit 275dc94

File tree

13 files changed

+401
-188
lines changed

13 files changed

+401
-188
lines changed

.changeset/lemon-gifts-suffer.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/api": patch
4+
"@hyperdx/app": patch
5+
---
6+
7+
feat: Add conditions to Dashboard filters; Support filter multi-select

packages/api/openapi.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,6 +1838,21 @@
18381838
],
18391839
"description": "Metric type when source is metrics",
18401840
"example": "gauge"
1841+
},
1842+
"where": {
1843+
"type": "string",
1844+
"description": "Optional WHERE condition to scope which rows this filter key reads values from",
1845+
"example": "ServiceName:api"
1846+
},
1847+
"whereLanguage": {
1848+
"type": "string",
1849+
"enum": [
1850+
"sql",
1851+
"lucene"
1852+
],
1853+
"description": "Language of the where condition",
1854+
"default": "sql",
1855+
"example": "lucene"
18411856
}
18421857
}
18431858
},

packages/api/src/models/presetDashboardFilter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ const PresetDashboardFilterSchema = new Schema<IPresetDashboardFilter>(
4242
},
4343
type: { type: String, required: true },
4444
expression: { type: String, required: true },
45+
where: { type: String, required: false },
46+
whereLanguage: {
47+
type: String,
48+
required: false,
49+
enum: ['sql', 'lucene'],
50+
},
4551
},
4652
{
4753
timestamps: true,

packages/api/src/routers/external-api/__tests__/dashboards.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -848,14 +848,22 @@ describe('External API v2 Dashboards - old format', () => {
848848
sourceId: traceSource._id.toString(),
849849
sourceMetricType: undefined,
850850
},
851+
{
852+
type: 'QUERY_EXPRESSION' as const,
853+
name: 'Region (Filtered)',
854+
expression: 'region',
855+
sourceId: traceSource._id.toString(),
856+
where: "environment = 'production'",
857+
whereLanguage: 'sql' as const,
858+
},
851859
],
852860
};
853861

854862
const response = await authRequest('post', BASE_URL)
855863
.send(dashboardPayload)
856864
.expect(200);
857865

858-
expect(response.body.data.filters).toHaveLength(2);
866+
expect(response.body.data.filters).toHaveLength(3);
859867
response.body.data.filters.forEach(
860868
(f: {
861869
id: string;
@@ -879,12 +887,18 @@ describe('External API v2 Dashboards - old format', () => {
879887
expect(response.body.data.filters[0].expression).toBe('environment');
880888
expect(response.body.data.filters[1].name).toBe('Service Filter');
881889
expect(response.body.data.filters[1].expression).toBe('service_name');
890+
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
891+
expect(response.body.data.filters[2].expression).toBe('region');
892+
expect(response.body.data.filters[2].where).toBe(
893+
"environment = 'production'",
894+
);
895+
expect(response.body.data.filters[2].whereLanguage).toBe('sql');
882896

883897
const getResponse = await authRequest(
884898
'get',
885899
`${BASE_URL}/${response.body.data.id}`,
886900
).expect(200);
887-
expect(getResponse.body.data.filters).toHaveLength(2);
901+
expect(getResponse.body.data.filters).toHaveLength(3);
888902
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
889903
});
890904

@@ -2519,14 +2533,22 @@ describe('External API v2 Dashboards - new format', () => {
25192533
sourceId: traceSource._id.toString(),
25202534
sourceMetricType: undefined,
25212535
},
2536+
{
2537+
type: 'QUERY_EXPRESSION' as const,
2538+
name: 'Region (Filtered)',
2539+
expression: 'region',
2540+
sourceId: traceSource._id.toString(),
2541+
where: "environment = 'production'",
2542+
whereLanguage: 'sql' as const,
2543+
},
25222544
],
25232545
};
25242546

25252547
const response = await authRequest('post', BASE_URL)
25262548
.send(dashboardPayload)
25272549
.expect(200);
25282550

2529-
expect(response.body.data.filters).toHaveLength(2);
2551+
expect(response.body.data.filters).toHaveLength(3);
25302552
response.body.data.filters.forEach(
25312553
(f: {
25322554
id: string;
@@ -2550,12 +2572,18 @@ describe('External API v2 Dashboards - new format', () => {
25502572
expect(response.body.data.filters[0].expression).toBe('environment');
25512573
expect(response.body.data.filters[1].name).toBe('Service Filter');
25522574
expect(response.body.data.filters[1].expression).toBe('service_name');
2575+
expect(response.body.data.filters[2].name).toBe('Region (Filtered)');
2576+
expect(response.body.data.filters[2].expression).toBe('region');
2577+
expect(response.body.data.filters[2].where).toBe(
2578+
"environment = 'production'",
2579+
);
2580+
expect(response.body.data.filters[2].whereLanguage).toBe('sql');
25532581

25542582
const getResponse = await authRequest(
25552583
'get',
25562584
`${BASE_URL}/${response.body.data.id}`,
25572585
).expect(200);
2558-
expect(getResponse.body.data.filters).toHaveLength(2);
2586+
expect(getResponse.body.data.filters).toHaveLength(3);
25592587
expect(getResponse.body.data.filters).toEqual(response.body.data.filters);
25602588
});
25612589

packages/api/src/routers/external-api/v2/dashboards.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,16 @@ const updateDashboardBodySchema = buildDashboardBodySchema(
11561156
* enum: [sum, gauge, histogram, summary, exponential histogram]
11571157
* description: Metric type when source is metrics
11581158
* example: "gauge"
1159+
* where:
1160+
* type: string
1161+
* description: Optional WHERE condition to scope which rows this filter key reads values from
1162+
* example: "ServiceName:api"
1163+
* whereLanguage:
1164+
* type: string
1165+
* enum: [sql, lucene]
1166+
* description: Language of the where condition
1167+
* default: "sql"
1168+
* example: "lucene"
11591169
*
11601170
* Filter:
11611171
* allOf:

packages/app/src/DashboardFilters.tsx

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
2-
import { Group, Select } from '@mantine/core';
2+
import { Group, MultiSelect } from '@mantine/core';
33
import { IconRefresh } from '@tabler/icons-react';
44

55
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
66
import { FilterState } from './searchFilters';
77

88
interface DashboardFilterSelectProps {
99
filter: DashboardFilter;
10-
onChange: (value: string | null) => void;
11-
value?: string | null;
10+
onChange: (values: string[]) => void;
11+
value: string[];
1212
values?: string[];
1313
isLoading?: boolean;
1414
}
@@ -26,18 +26,17 @@ const DashboardFilterSelect = ({
2626
}));
2727

2828
return (
29-
<Select
30-
placeholder={filter.name}
31-
value={value ?? null} // null clears the select, undefined makes the select uncontrolled
29+
<MultiSelect
30+
placeholder={value.length === 0 ? filter.name : undefined}
31+
value={value}
3232
data={selectValues || []}
3333
searchable
3434
clearable
35-
allowDeselect
3635
size="xs"
3736
maxDropdownHeight={280}
3837
disabled={isLoading}
3938
variant="filled"
40-
w={200}
39+
w={250}
4140
limit={20}
4241
onChange={onChange}
4342
data-testid={`dashboard-filter-select-${filter.name}`}
@@ -48,7 +47,7 @@ const DashboardFilterSelect = ({
4847
interface DashboardFilterProps {
4948
filters: DashboardFilter[];
5049
filterValues: FilterState;
51-
onSetFilterValue: (expression: string, value: string | null) => void;
50+
onSetFilterValue: (expression: string, values: string[]) => void;
5251
dateRange: [Date, Date];
5352
}
5453

@@ -58,28 +57,27 @@ const DashboardFilters = ({
5857
filterValues,
5958
onSetFilterValue,
6059
}: DashboardFilterProps) => {
61-
const { data: filterValuesBySource, isFetching } = useDashboardFilterValues({
60+
const { data: filterValuesById, isFetching } = useDashboardFilterValues({
6261
filters,
6362
dateRange,
6463
});
6564

6665
return (
67-
<Group mt="sm">
66+
<Group mt="sm" align="start">
6867
{Object.values(filters).map(filter => {
69-
const queriedFilterValues = filterValuesBySource?.get(
70-
filter.expression,
71-
);
68+
const queriedFilterValues = filterValuesById?.get(filter.id);
69+
const included = filterValues[filter.expression]?.included;
70+
const selectedValues = included
71+
? Array.from(included).map(v => v.toString())
72+
: [];
7273
return (
7374
<DashboardFilterSelect
7475
key={filter.id}
7576
filter={filter}
7677
isLoading={!queriedFilterValues}
77-
onChange={value => onSetFilterValue(filter.expression, value)}
78+
onChange={values => onSetFilterValue(filter.expression, values)}
7879
values={queriedFilterValues?.values}
79-
value={filterValues[filter.expression]?.included
80-
.values()
81-
.next()
82-
.value?.toString()}
80+
value={selectedValues}
8381
/>
8482
);
8583
})}

packages/app/src/DashboardFiltersModal.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import {
3131
IconTrash,
3232
} from '@tabler/icons-react';
3333

34+
import SearchWhereInput, {
35+
getStoredLanguage,
36+
} from '@/components/SearchInput/SearchWhereInput';
3437
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
3538

3639
import SourceSchemaPreview from './components/SourceSchemaPreview';
@@ -40,7 +43,7 @@ import { getMetricTableName } from './utils';
4043

4144
import styles from '../styles/DashboardFiltersModal.module.scss';
4245

43-
const MODAL_SIZE = 'sm';
46+
const MODAL_SIZE = 'md';
4447

4548
interface CustomInputWrapperProps {
4649
children: React.ReactNode;
@@ -97,11 +100,19 @@ const DashboardFilterEditForm = ({
97100
}: DashboardFilterEditFormProps) => {
98101
const { handleSubmit, register, formState, control, reset } =
99102
useForm<DashboardFilter>({
100-
defaultValues: filter,
103+
defaultValues: {
104+
...filter,
105+
where: filter.where ?? '',
106+
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
107+
},
101108
});
102109

103110
useEffect(() => {
104-
reset(filter);
111+
reset({
112+
...filter,
113+
where: filter.where ?? '',
114+
whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
115+
});
105116
}, [filter, reset]);
106117

107118
const sourceId = useWatch({ control, name: 'source' });
@@ -134,7 +145,18 @@ const DashboardFilterEditForm = ({
134145
size={MODAL_SIZE}
135146
>
136147
<div ref={setModalContentRef}>
137-
<form onSubmit={handleSubmit(onSave)}>
148+
<form
149+
onSubmit={handleSubmit(values => {
150+
const trimmedWhere = values.where?.trim() ?? '';
151+
onSave({
152+
...values,
153+
where: trimmedWhere || undefined,
154+
whereLanguage: trimmedWhere
155+
? (values.whereLanguage ?? 'sql')
156+
: undefined,
157+
});
158+
})}
159+
>
138160
<Stack>
139161
<CustomInputWrapper label="Name" error={formState.errors.name}>
140162
<TextInput
@@ -204,6 +226,22 @@ const DashboardFilterEditForm = ({
204226
/>
205227
</CustomInputWrapper>
206228

229+
<CustomInputWrapper
230+
label="Dropdown values filter"
231+
tooltipText="Optional condition used to filter the rows from which available filter values are queried"
232+
>
233+
<SearchWhereInput
234+
tableConnection={tableConnection}
235+
control={control}
236+
name="where"
237+
languageName="whereLanguage"
238+
showLabel={false}
239+
allowMultiline={true}
240+
sqlPlaceholder="Filter for dropdown values"
241+
lucenePlaceholder="Filter for dropdown values"
242+
/>
243+
</CustomInputWrapper>
244+
207245
<Group justify="space-between" my="xs">
208246
<Button variant="secondary" onClick={onCancel}>
209247
Cancel
@@ -394,6 +432,8 @@ const DashboardFiltersModal = ({
394432
name: '',
395433
expression: '',
396434
source: source?.id ?? '',
435+
where: '',
436+
whereLanguage: getStoredLanguage() ?? 'sql',
397437
});
398438
};
399439

0 commit comments

Comments
 (0)