mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
dd313f7754
commit
275dc94161
13 changed files with 445 additions and 232 deletions
7
.changeset/lemon-gifts-suffer.md
Normal file
7
.changeset/lemon-gifts-suffer.md
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue