fix: Fix dashboard error when using filter on non-String column (#1650)

Closes HDX-3266

# Summary

This PR fixes an error in dashboards that occurred when using a Dashboard filter on a non-String column.

## Demo

Such filters work now:

<img width="1074" height="523" alt="Screenshot 2026-01-23 at 3 44 19 PM" src="https://github.com/user-attachments/assets/2e026590-f4cb-44a2-8677-37a80b65886b" />


Co-authored-by: Karl Power <85935352+karl-power@users.noreply.github.com>
This commit is contained in:
Drew Davis 2026-01-26 08:53:52 -05:00 committed by GitHub
parent 866de771b4
commit 0dd585437d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 87 additions and 33 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: Fix dashboard error when using filter on non-String column

View file

@ -2,7 +2,7 @@ import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, Select } from '@mantine/core';
import { IconRefresh } from '@tabler/icons-react';
import { useDashboardFilterKeyValues } from './hooks/useDashboardFilterValues';
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
import { FilterState } from './searchFilters';
interface DashboardFilterSelectProps {
@ -58,8 +58,10 @@ const DashboardFilters = ({
filterValues,
onSetFilterValue,
}: DashboardFilterProps) => {
const { data: filterValuesBySource, isFetching } =
useDashboardFilterKeyValues({ filters, dateRange });
const { data: filterValuesBySource, isFetching } = useDashboardFilterValues({
filters,
dateRange,
});
return (
<Group mt="sm">

View file

@ -1007,7 +1007,7 @@ const DBSearchPageFiltersComponent = ({
disableRowLimit: true,
source,
});
const newValues = newKeyVals[0].value;
const newValues = newKeyVals[0].value?.map(val => val.toString()) ?? [];
if (newValues.length > 0) {
setExtraFacets(prev => ({
...prev,

View file

@ -12,7 +12,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import * as sourceModule from '@/source';
import { useDashboardFilterKeyValues } from '../useDashboardFilterValues';
import { useDashboardFilterValues } from '../useDashboardFilterValues';
import * as useMetadataModule from '../useMetadata';
// Mock modules
@ -26,7 +26,7 @@ jest.mock('@hyperdx/common-utils/dist/core/materializedViews', () => ({
]),
}));
describe('useDashboardFilterKeyValues', () => {
describe('useDashboardFilterValues', () => {
let queryClient: QueryClient;
let wrapper: React.ComponentType<{ children: any }>;
let mockMetadata: jest.Mocked<Metadata>;
@ -96,12 +96,13 @@ describe('useDashboardFilterKeyValues', () => {
},
];
const mockKeyValues: Record<string, string[] | undefined> = {
const mockKeyValues: Record<string, string[] | number[] | undefined> = {
environment: ['production', 'staging', 'development'],
'service.name': ['frontend', 'backend', 'database'],
MetricName: ['CPU_Usage', 'Memory_Usage'],
status: ['200', '404', '500'],
log_level: ['info', 'error'],
SeverityNumber: [1, 2],
};
const mockDateRange: [Date, Date] = [
@ -121,7 +122,7 @@ describe('useDashboardFilterKeyValues', () => {
return Promise.resolve(
keys.map(key => ({
key,
value: mockKeyValues[key] ?? [],
value: (mockKeyValues[key] as string[]) ?? [],
})),
);
});
@ -150,6 +151,47 @@ describe('useDashboardFilterKeyValues', () => {
jest.restoreAllMocks();
});
it('should convert non-string key values to strings', async () => {
// Arrange
jest.spyOn(sourceModule, 'useSources').mockReturnValue({
data: mockSources,
isLoading: false,
} as any);
// Act
const { result } = renderHook(
() =>
useDashboardFilterValues({
filters: [
{
id: 'filterSevNumber',
type: 'QUERY_EXPRESSION',
name: 'SeverityNumber',
expression: 'SeverityNumber',
source: 'logs-source',
},
],
dateRange: mockDateRange,
}),
{ wrapper },
);
// Assert
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data).toEqual(
new Map([
[
'SeverityNumber',
{
values: ['1', '2'],
isLoading: false,
},
],
]),
);
});
it('should fetch key values for filters grouped by source', async () => {
// Arrange
jest.spyOn(sourceModule, 'useSources').mockReturnValue({
@ -160,7 +202,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: mockFilters,
dateRange: mockDateRange,
}),
@ -285,7 +327,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: sameSourceFilters,
dateRange: mockDateRange,
}),
@ -313,7 +355,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: [],
dateRange: mockDateRange,
}),
@ -335,7 +377,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: mockFilters,
dateRange: mockDateRange,
}),
@ -367,7 +409,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: filtersWithInvalidSource,
dateRange: mockDateRange,
}),
@ -395,7 +437,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: mockFilters,
dateRange: mockDateRange,
}),
@ -418,7 +460,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: [mockFilters[0]], // Only first filter (logs-source)
dateRange: mockDateRange,
}),
@ -469,7 +511,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result, rerender } = renderHook(
({ filters, dateRange }) =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters,
dateRange,
}),
@ -535,7 +577,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: multiFilters,
dateRange: mockDateRange,
}),
@ -623,7 +665,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: filtersForSameSource,
dateRange: mockDateRange,
}),
@ -705,7 +747,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: mockFilters.slice(0, 2), // Only first two filters
dateRange: mockDateRange,
}),
@ -766,7 +808,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act
const { result } = renderHook(
() =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters: mockFilters.slice(0, 2), // Only first two filters
dateRange: mockDateRange,
}),
@ -830,7 +872,7 @@ describe('useDashboardFilterKeyValues', () => {
// Act - Initial render
const { result, rerender } = renderHook(
({ filters, dateRange }) =>
useDashboardFilterKeyValues({
useDashboardFilterValues({
filters,
dateRange,
}),

View file

@ -112,7 +112,7 @@ function useOptimizedKeyValuesCalls({
};
}
export function useDashboardFilterKeyValues({
export function useDashboardFilterValues({
filters,
dateRange,
}: {
@ -185,7 +185,7 @@ export function useDashboardFilterKeyValues({
return data.map(({ key, value }) => [
key,
{
values: value,
values: value.map(v => v.toString()),
isLoading,
},
]);

View file

@ -197,8 +197,9 @@ describe('Metadata Integration Tests', () => {
expect(resultLimited[0].key).toBe('SeverityText');
expect(resultLimited[0].value).toHaveLength(2);
expect(
resultLimited[0].value.every(v =>
['info', 'error', 'warning'].includes(v),
resultLimited[0].value.every(
v =>
typeof v === 'string' && ['info', 'error', 'warning'].includes(v),
),
).toBeTruthy();
});

View file

@ -329,13 +329,13 @@ describe('Metadata', () => {
]);
});
it('should filter out falsy values from the response', async () => {
it('should filter out empty and nullish values from the response', async () => {
(mockClickhouseClient.query as jest.Mock).mockResolvedValue({
json: () =>
Promise.resolve({
data: [
{
param0: ['value1', null, '', 'value2', undefined],
param0: ['value1', null, '', 'value2', undefined, 0, 10],
},
],
}),
@ -348,7 +348,9 @@ describe('Metadata', () => {
source,
});
expect(result).toEqual([{ key: 'column1', value: ['value1', 'value2'] }]);
expect(result).toEqual([
{ key: 'column1', value: ['value1', 'value2', 0, 10] },
]);
});
it('should return an empty list when no keys are provided', async () => {

View file

@ -850,7 +850,7 @@ export class Metadata {
source:
| Omit<TSource, 'connection'> /* for overlap with ISource type */
| undefined;
}): Promise<{ key: string; value: string[] }[]> {
}): Promise<{ key: string; value: string[] | number[] }[]> {
const cacheKeyConfig = {
...pick(chartConfig, [
'connection',
@ -938,14 +938,16 @@ export class Metadata {
: undefined,
abort_signal: signal,
})
.then(res => res.json<any>());
.then(res => res.json<Record<string, string[] | number[]>>());
// TODO: Fix type issues mentioned in HDX-1548. value is not actually a
// string[], sometimes it's { [key: string]: string; }
return Object.entries(json?.data?.[0]).map(([key, value]) => ({
key: keys[parseInt(key.replace('param', ''))],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentional, see HDX-1548
value: (value as string[])?.filter(Boolean), // remove nulls
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
value: value?.filter(v => v != null && v !== '') as // remove nulls and empty strings
| string[]
| number[],
}));
},
);
@ -965,7 +967,7 @@ export class Metadata {
limit?: number;
disableRowLimit?: boolean;
signal?: AbortSignal;
}): Promise<{ key: string; value: string[] }[]> {
}): Promise<{ key: string; value: string[] | number[] }[]> {
const cacheKeyConfig = {
...pick(chartConfig, [
'connection',