Skip to content

Commit 0d2425d

Browse files
authored
feat: Support single-select dashboard filters (#3295)
## Summary Support configuring dashboard variables / filters as single-select, so that no more than one value can be selected. If the URL contains multiple values for the filter, then the first is used. ### Screenshots or video <img width="653" height="146" alt="Screenshot 2026-10-05 at 11 59 45 AM" src="https://github.com/user-attachments/assets/e0c214bb-b40e-47ef-8047-cdf7d70b4122" /> <img width="282" height="189" alt="Screenshot 2026-10-05 at 11 59 49 AM" src="https://github.com/user-attachments/assets/78b08baf-5602-4c03-b485-d294fe4dee36" /> ### How to test on Vercel preview - Create a dashboard - Add some single select variables - Observe the selection behavior ### References - Linear Issue: Related to HDX-5563 - Related PRs: #3297
1 parent 6ea5f52 commit 0d2425d

27 files changed

Lines changed: 578 additions & 38 deletions
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: Allow configuring dashboard filters as single-select

‎packages/api/openapi.json‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3504,6 +3504,14 @@
35043504
"default": false,
35053505
"description": "Widens a required filter's block to every tile on the dashboard. False\n(the default) blocks only the tiles that read the filter: those\nreferencing its variableName, and those its broadcast applies to.\nIgnored unless minSelections is 1.\n",
35063506
"example": true
3507+
},
3508+
"maxSelections": {
3509+
"type": "integer",
3510+
"enum": [
3511+
1
3512+
],
3513+
"description": "Maximum number of values that can be selected at once. Set to 1 to make\nthe filter single-select. Omit the field for the default multi-select\nbehavior.\n",
3514+
"example": 1
35073515
}
35083516
}
35093517
},
@@ -3586,6 +3594,14 @@
35863594
"default": false,
35873595
"description": "Widens a required filter's block to every tile on the dashboard. False\n(the default) blocks only the tiles that read the filter: those\nreferencing its variableName, and those its broadcast applies to.\nIgnored unless minSelections is 1.\n",
35883596
"example": true
3597+
},
3598+
"maxSelections": {
3599+
"type": "integer",
3600+
"enum": [
3601+
1
3602+
],
3603+
"description": "Maximum number of values that can be selected at once. Set to 1 to make\nthe filter single-select. Omit the field for the default multi-select\nbehavior.\n",
3604+
"example": 1
35893605
}
35903606
}
35913607
},
@@ -3671,6 +3687,14 @@
36713687
"default": false,
36723688
"description": "Widens a required filter's block to every tile on the dashboard. False\n(the default) blocks only the tiles that read the filter: those\nreferencing its variableName, and those its broadcast applies to.\nIgnored unless minSelections is 1.\n",
36733689
"example": true
3690+
},
3691+
"maxSelections": {
3692+
"type": "integer",
3693+
"enum": [
3694+
1
3695+
],
3696+
"description": "Maximum number of values that can be selected at once. Set to 1 to make\nthe filter single-select. Omit the field for the default multi-select\nbehavior.\n",
3697+
"example": 1
36743698
}
36753699
}
36763700
},

‎packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2709,6 +2709,47 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
27092709
}
27102710
});
27112711

2712+
it('round-trips a single-select filter through create and get', async () => {
2713+
const sourceId = ctx.traceSource._id.toString();
2714+
const createResult = await callTool(
2715+
ctx.client!,
2716+
'clickstack_save_dashboard',
2717+
{
2718+
name: 'Single-select filter',
2719+
tiles: [traceTile(sourceId)],
2720+
filters: [queryFilter({ maxSelections: 1 })],
2721+
},
2722+
);
2723+
expect(createResult.isError).toBeFalsy();
2724+
const created = JSON.parse(getFirstText(createResult));
2725+
expect(created.filters[0].maxSelections).toBe(1);
2726+
2727+
const getResult = await callTool(
2728+
ctx.client!,
2729+
'clickstack_get_dashboard',
2730+
{ id: created.id },
2731+
);
2732+
expect(
2733+
JSON.parse(getFirstText(getResult)).filters[0].maxSelections,
2734+
).toBe(1);
2735+
});
2736+
2737+
it.each([0, 2])('rejects maxSelections %s', async value => {
2738+
const sourceId = ctx.traceSource._id.toString();
2739+
const result = await callTool(
2740+
ctx.client!,
2741+
'clickstack_save_dashboard',
2742+
{
2743+
name: 'Bad maxSelections',
2744+
tiles: [traceTile(sourceId)],
2745+
filters: [queryFilter({ maxSelections: value })],
2746+
},
2747+
);
2748+
2749+
expect(result.isError).toBe(true);
2750+
expect(getFirstText(result)).toContain('maxSelections');
2751+
});
2752+
27122753
it.each([2, -1, 1.5])('rejects minSelections %s', async value => {
27132754
const sourceId = ctx.traceSource._id.toString();
27142755
const result = await callTool(

‎packages/api/src/mcp/__tests__/dashboards/variables.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,35 @@ describe('resolveDashboardVariables', () => {
128128
expect(resolved.error).toContain('Available variables: (none)');
129129
});
130130

131+
it('rejects several values for a single-select variable', () => {
132+
const resolved = resolveDashboardVariables(
133+
[staticFilter({ maxSelections: 1 })],
134+
[{ name: 'env', values: ['prod', 'staging'] }],
135+
);
136+
137+
if (!('error' in resolved)) throw new Error('expected an error');
138+
expect(resolved.error).toContain('"env"');
139+
expect(resolved.error).toContain('single-select');
140+
});
141+
142+
it('applies one value to a single-select variable', () => {
143+
expect(
144+
resolveDashboardVariables(
145+
[staticFilter({ maxSelections: 1 })],
146+
[{ name: 'env', values: ['prod'] }],
147+
),
148+
).toEqual({
149+
variables: [
150+
{
151+
name: 'env',
152+
expression: undefined,
153+
values: ['prod'],
154+
maxSelections: 1,
155+
},
156+
],
157+
});
158+
});
159+
131160
it('declares a variable for a static filter', () => {
132161
expect(resolveDashboardVariables([staticFilter()], undefined)).toEqual({
133162
variables: [{ name: 'env', expression: undefined, values: [] }],

‎packages/api/src/mcp/prompts/dashboards/content.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ Apply these before calling clickstack_save_dashboard. Each rule is enforced by t
118118
119119
9e. MAKE A FILTER REQUIRED ONLY WHEN AN UNSCOPED VIEW IS MEANINGLESS. minSelections: 1 blocks the tiles that read the filter - the ones referencing its $variableName, and the ones its broadcast applies to - until the user picks a value. isGlobalRequirement: true widens that to every tile on the dashboard. Consider pairing either form with savedFilterValues so the dashboard opens on a sensible default rather than blocked.
120120
121+
9f. MAKE A FILTER SINGLE-SELECT WHEN A TILE ONLY MAKES SENSE FOR ONE VALUE. maxSelections: 1 limits the dropdown to one value at a time, for example a host picker feeding a per-host detail view.
122+
121123
10. UPDATE IS REPLACE, NOT MERGE. clickstack_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call clickstack_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted.
122124
123125
11. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not.

‎packages/api/src/mcp/tools/dashboards/schemas.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,16 @@ const mcpDashboardFilterBaseShape = {
13191319
'Widens a REQUIRED filter (minSelections: 1) to block EVERY tile on the dashboard, ' +
13201320
'not just the ones that read it. Ignored unless minSelections is 1.',
13211321
),
1322+
maxSelections: z
1323+
.number()
1324+
.int()
1325+
.min(1)
1326+
.max(1)
1327+
.optional()
1328+
.describe(
1329+
'Set to 1 to make this filter SINGLE-SELECT: the user can pick at most one ' +
1330+
'value. Omit for the normal multi-select behavior.',
1331+
),
13221332
};
13231333

13241334
const mcpQueryExpressionFilterSchema = z

‎packages/api/src/mcp/tools/dashboards/variables.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
getDashboardVariableDeclarations,
33
getDashboardVariableFilters,
44
getFilterVariableName,
5+
isFilterSingleSelect,
56
isFilterVariableEnabled,
67
isStaticListFilter,
78
} from '@hyperdx/common-utils/dist/filters';
@@ -101,6 +102,23 @@ export function resolveDashboardVariables(
101102
return { error: optionErrors.join('\n') };
102103
}
103104

105+
// The dashboard can never hold several values for a single-select variable.
106+
const singleSelectNames = new Set(
107+
variables.filter(isFilterSingleSelect).map(variable => variable.name),
108+
);
109+
const singleSelectErrors = variableValues
110+
.filter(
111+
({ name, values }) => values.length > 1 && singleSelectNames.has(name),
112+
)
113+
.map(
114+
({ name }) =>
115+
`Variable "${name}" belongs to a single-select filter (maxSelections: 1) ` +
116+
'and accepts at most one value.',
117+
);
118+
if (singleSelectErrors.length > 0) {
119+
return { error: singleSelectErrors.join('\n') };
120+
}
121+
104122
// Override the default empty selection with the given variableValues
105123
for (const { name, values } of variableValues) {
106124
byName.get(name)!.values = values;

‎packages/api/src/routers/api/__tests__/dashboard.int.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,27 @@ describe('dashboard router', () => {
12101210
.expect(400);
12111211
});
12121212

1213+
it('persists maxSelections on create', async () => {
1214+
const filter = makeFilter({ maxSelections: 1 });
1215+
1216+
const created = await agent
1217+
.post('/dashboards')
1218+
.send({ ...MOCK_DASHBOARD, filters: [filter] })
1219+
.expect(200);
1220+
1221+
expect(created.body.filters).toEqual([filter]);
1222+
});
1223+
1224+
it.each([0, 2, 1.5, '1'])('rejects maxSelections %s', async value => {
1225+
await agent
1226+
.post('/dashboards')
1227+
.send({
1228+
...MOCK_DASHBOARD,
1229+
filters: [makeFilter({ maxSelections: value })],
1230+
})
1231+
.expect(400);
1232+
});
1233+
12131234
it('persists a dashboard-wide requirement', async () => {
12141235
const filter = makeFilter({
12151236
minSelections: 1,

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6117,6 +6117,37 @@ describe('External API v2 Dashboards - new format', () => {
61176117
});
61186118
});
61196119

6120+
describe('single-select filters', () => {
6121+
const variants = [
6122+
['QUERY_EXPRESSION', filterInput],
6123+
['STATIC_LIST', staticFilterInput],
6124+
['PROMETHEUS_LABEL', promqlFilterInput],
6125+
] as const;
6126+
6127+
it.each(variants)(
6128+
'accepts a single-select %s filter',
6129+
async (_type, makeFilter) => {
6130+
const response = await sendFilters([
6131+
makeFilter({ maxSelections: 1 }),
6132+
]);
6133+
expect(response.status).toBe(200);
6134+
expect(response.body.data.filters[0].maxSelections).toBe(1);
6135+
},
6136+
);
6137+
6138+
it('omits the key from responses for a multi-select filter', async () => {
6139+
const response = await sendFilters([filterInput()]);
6140+
expect(response.status).toBe(200);
6141+
expect(response.body.data.filters[0]).not.toHaveProperty(
6142+
'maxSelections',
6143+
);
6144+
});
6145+
6146+
it.each([0, 2, 1.5, '1'])('rejects maxSelections %s', async value => {
6147+
await expectFilters([filterInput({ maxSelections: value })], 400);
6148+
});
6149+
});
6150+
61206151
describe('static-list filters', () => {
61216152
it('accepts a static-list filter and round-trips it through GET', async () => {
61226153
const response = await sendFilters([staticFilterInput()]);

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,6 +2055,14 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
20552055
* referencing its variableName, and those its broadcast applies to.
20562056
* Ignored unless minSelections is 1.
20572057
* example: true
2058+
* maxSelections:
2059+
* type: integer
2060+
* enum: [1]
2061+
* description: |
2062+
* Maximum number of values that can be selected at once. Set to 1 to make
2063+
* the filter single-select. Omit the field for the default multi-select
2064+
* behavior.
2065+
* example: 1
20582066
*
20592067
* StaticListFilterInput:
20602068
* type: object
@@ -2141,6 +2149,14 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
21412149
* referencing its variableName, and those its broadcast applies to.
21422150
* Ignored unless minSelections is 1.
21432151
* example: true
2152+
* maxSelections:
2153+
* type: integer
2154+
* enum: [1]
2155+
* description: |
2156+
* Maximum number of values that can be selected at once. Set to 1 to make
2157+
* the filter single-select. Omit the field for the default multi-select
2158+
* behavior.
2159+
* example: 1
21442160
*
21452161
* PrometheusLabelFilterInput:
21462162
* type: object
@@ -2228,6 +2244,14 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
22282244
* referencing its variableName, and those its broadcast applies to.
22292245
* Ignored unless minSelections is 1.
22302246
* example: true
2247+
* maxSelections:
2248+
* type: integer
2249+
* enum: [1]
2250+
* description: |
2251+
* Maximum number of values that can be selected at once. Set to 1 to make
2252+
* the filter single-select. Omit the field for the default multi-select
2253+
* behavior.
2254+
* example: 1
22312255
*
22322256
* Filter:
22332257
* allOf:

0 commit comments

Comments
 (0)