P2P Filter: Add adhoc filter option toggle (#110160)

* feat(ds): add adhoc filter option
This commit is contained in:
Ihor Yeromin
2025-09-03 12:51:27 +00:00
committed by GitHub
parent f59495c416
commit 1faaec2611
5 changed files with 277 additions and 10 deletions
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getDefaultTimeRange, LoadingState } from '@grafana/data';
import config from 'app/core/config';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
@@ -16,6 +17,7 @@ import { MIXED_DATASOURCE_NAME } from '../mixed/MixedDataSource';
import { DashboardQueryEditor, INVALID_PANEL_DESCRIPTION } from './DashboardQueryEditor';
import { SHARED_DASHBOARD_QUERY } from './constants';
import { DashboardDatasource } from './datasource';
import { DashboardQuery } from './types';
jest.mock('app/core/config', () => ({
...jest.requireActual('app/core/config'),
@@ -28,6 +30,9 @@ jest.mock('app/core/config', () => ({
},
},
},
featureToggles: {
dashboardDsAdHocFiltering: false, // Default to false, can be overridden in tests
},
}));
setupDataSources(mockDataSource({ isDefault: true }));
@@ -164,4 +169,62 @@ describe('DashboardQueryEditor', () => {
INVALID_PANEL_DESCRIPTION
);
});
describe('AdHoc Filters Toggle', () => {
beforeEach(() => {
// Reset only the specific mocks we need, not all mocks
mockOnChange.mockClear();
mockOnRunQueries.mockClear();
// Re-establish the dashboard mock in case it was cleared
jest.spyOn(getDashboardSrv(), 'getCurrent').mockImplementation(() => mockDashboard);
});
it('shows the AdHoc Filters toggle when feature toggle is enabled', async () => {
await act(async () => {
config.featureToggles.dashboardDsAdHocFiltering = true;
});
const query: DashboardQuery = { refId: 'A', panelId: 1, adHocFiltersEnabled: false };
await act(async () => {
render(
<DashboardQueryEditor
datasource={{} as DashboardDatasource}
query={query}
data={mockPanelData}
onChange={mockOnChange}
onRunQuery={mockOnRunQueries}
/>
);
});
const adhocFiltersToggle = await screen.findByText('AdHoc Filters');
expect(adhocFiltersToggle).toBeInTheDocument();
});
it('does not show the AdHoc Filters toggle when feature toggle is disabled', async () => {
await act(async () => {
config.featureToggles.dashboardDsAdHocFiltering = false;
});
const query: DashboardQuery = { refId: 'A', panelId: 1, adHocFiltersEnabled: false };
await act(async () => {
render(
<DashboardQueryEditor
datasource={{} as DashboardDatasource}
query={query}
data={mockPanelData}
onChange={mockOnChange}
onRunQuery={mockOnRunQueries}
/>
);
});
// Wait for any async operations to complete
await waitFor(() => {
expect(screen.queryByText('AdHoc Filters')).not.toBeInTheDocument();
});
});
});
});
@@ -108,6 +108,13 @@ export function DashboardQueryEditor({ data, query, onChange, onRunQuery }: Prop
[query, onUpdateQuery]
);
const onAdHocFiltersToggle = useCallback(() => {
onUpdateQuery({
...query,
adHocFiltersEnabled: !query.adHocFiltersEnabled,
});
}, [query, onUpdateQuery]);
const isMixedDSWithDashboardQueries = (panel: PanelModel) => {
return (
panel.datasource?.uid === MIXED_DATASOURCE_NAME &&
@@ -197,6 +204,16 @@ export function DashboardQueryEditor({ data, query, onChange, onRunQuery }: Prop
<InlineSwitch value={Boolean(query.withTransforms)} onChange={onTransformToggle} />
</Field>
)}
{config.featureToggles.dashboardDsAdHocFiltering && (
<Field
label="AdHoc Filters"
description="Apply --Dashboard-- data source AdHoc filters to this panel"
noMargin
>
<InlineSwitch value={Boolean(query.adHocFiltersEnabled)} onChange={onAdHocFiltersToggle} />
</Field>
)}
</Stack>
{loadingResults ? (
@@ -159,10 +159,10 @@ describe('DashboardDatasource', () => {
};
}
function createQueryRequest(filters: AdHocVariableFilter[], scene: SceneObject) {
function createQueryRequest(filters: AdHocVariableFilter[], scene: SceneObject, adHocFiltersEnabled?: boolean) {
return {
timezone: 'utc',
targets: [{ refId: 'A', panelId: 1 }],
targets: [{ refId: 'A', panelId: 1, adHocFiltersEnabled }],
requestId: '',
interval: '',
intervalMs: 0,
@@ -214,7 +214,7 @@ describe('DashboardDatasource', () => {
const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '=', value: 'John' }];
const observable = ds.query(createQueryRequest(filters, scene));
const observable = ds.query(createQueryRequest(filters, scene, true));
let result: DataQueryResponse | undefined;
observable.subscribe({ next: (data) => (result = data) });
@@ -264,6 +264,158 @@ describe('DashboardDatasource', () => {
expect(result?.data[0].length).toBe(3);
});
it('should respect per-panel adHocFiltersEnabled setting and not filter when disabled', async () => {
const testFrame = createTestFrame([
{ name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
{ name: 'age', type: FieldType.number, values: [25, 30, 35] },
]);
const scene = new SceneFlexLayout({
children: [
new SceneFlexItem({
body: new VizPanel({
key: getVizPanelKeyForPanelId(1),
$data: new SceneDataNode({
data: {
series: [testFrame],
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
},
}),
}),
}),
],
});
const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '=', value: 'John' }];
// Test with adHocFiltersEnabled explicitly set to false
const observable = ds.query(createQueryRequest(filters, scene, false));
let result: DataQueryResponse | undefined;
observable.subscribe({ next: (data) => (result = data) });
// Should return unfiltered data since per-panel setting is disabled
expect(result?.data[0].fields[0].values).toEqual(['John', 'Jane', 'Bob']);
expect(result?.data[0].fields[1].values).toEqual([25, 30, 35]);
expect(result?.data[0].length).toBe(3);
});
it('should not filter when adHocFiltersEnabled is undefined (default behavior)', async () => {
const testFrame = createTestFrame([
{ name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
{ name: 'age', type: FieldType.number, values: [25, 30, 35] },
]);
const scene = new SceneFlexLayout({
children: [
new SceneFlexItem({
body: new VizPanel({
key: getVizPanelKeyForPanelId(1),
$data: new SceneDataNode({
data: {
series: [testFrame],
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
},
}),
}),
}),
],
});
const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '=', value: 'John' }];
// Test with adHocFiltersEnabled undefined (should default to not filtering)
const observable = ds.query(createQueryRequest(filters, scene));
let result: DataQueryResponse | undefined;
observable.subscribe({ next: (data) => (result = data) });
// Should return unfiltered data since adHocFiltersEnabled is not set
expect(result?.data[0].fields[0].values).toEqual(['John', 'Jane', 'Bob']);
expect(result?.data[0].fields[1].values).toEqual([25, 30, 35]);
expect(result?.data[0].length).toBe(3);
});
it('should apply filtering when adHocFiltersEnabled is explicitly enabled', async () => {
const testFrame = createTestFrame([
{ name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
{ name: 'age', type: FieldType.number, values: [25, 30, 35] },
]);
const scene = new SceneFlexLayout({
children: [
new SceneFlexItem({
body: new VizPanel({
key: getVizPanelKeyForPanelId(1),
$data: new SceneDataNode({
data: {
series: [testFrame],
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
},
}),
}),
}),
],
});
const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '=', value: 'John' }];
// Test with adHocFiltersEnabled explicitly set to true
const observable = ds.query(createQueryRequest(filters, scene, true));
let result: DataQueryResponse | undefined;
observable.subscribe({ next: (data) => (result = data) });
// Should return filtered data since adHocFiltersEnabled is enabled
expect(result?.data[0].fields[0].values).toEqual(['John']);
expect(result?.data[0].fields[1].values).toEqual([25]);
expect(result?.data[0].length).toBe(1);
});
it('should apply not-equal filtering when adHocFiltersEnabled is enabled', async () => {
const testFrame = createTestFrame([
{ name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
{ name: 'age', type: FieldType.number, values: [25, 30, 35] },
]);
const scene = new SceneFlexLayout({
children: [
new SceneFlexItem({
body: new VizPanel({
key: getVizPanelKeyForPanelId(1),
$data: new SceneDataNode({
data: {
series: [testFrame],
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
},
}),
}),
}),
],
});
const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '!=', value: 'John' }];
// Test with adHocFiltersEnabled explicitly set to true
const observable = ds.query(createQueryRequest(filters, scene, true));
let result: DataQueryResponse | undefined;
observable.subscribe({ next: (data) => (result = data) });
// Should return filtered data excluding 'John'
expect(result?.data[0].fields[0].values).toEqual(['Jane', 'Bob']);
expect(result?.data[0].fields[1].values).toEqual([30, 35]);
expect(result?.data[0].length).toBe(2);
});
it('should apply multiple filters with AND logic through public API', async () => {
const testFrame = createTestFrame([
{ name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
@@ -293,7 +445,7 @@ describe('DashboardDatasource', () => {
{ key: 'status', operator: '=', value: 'active' },
];
const observable = ds.query(createQueryRequest(filters, scene));
const observable = ds.query(createQueryRequest(filters, scene, true));
let result: DataQueryResponse | undefined;
observable.subscribe({ next: (data) => (result = data) });
@@ -598,6 +750,7 @@ describe('DashboardDatasource', () => {
{ key: 'name', operator: '=', value: 'John' },
{ key: 'age', operator: '!=', value: '25' },
],
queries: [{ refId: 'A', panelId: 1, adHocFiltersEnabled: true }],
});
expect(result).toEqual([
@@ -606,6 +759,29 @@ describe('DashboardDatasource', () => {
]);
});
it('should return empty array when no query has adHocFiltersEnabled enabled', async () => {
const result = await ds.getDrilldownsApplicability({
filters: [
{ key: 'name', operator: '=', value: 'John' },
{ key: 'age', operator: '!=', value: '25' },
],
queries: [{ refId: 'A', panelId: 1, adHocFiltersEnabled: false }],
});
expect(result).toEqual([]);
});
it('should return empty array when queries is undefined', async () => {
const result = await ds.getDrilldownsApplicability({
filters: [
{ key: 'name', operator: '=', value: 'John' },
{ key: 'age', operator: '!=', value: '25' },
],
});
expect(result).toEqual([]);
});
it('should mark unsupported operators as not applicable with reason', async () => {
const result = await ds.getDrilldownsApplicability({
filters: [
@@ -613,6 +789,7 @@ describe('DashboardDatasource', () => {
{ key: 'age', operator: '<', value: '25' },
{ key: 'score', operator: '=~', value: 'pattern' },
],
queries: [{ refId: 'A', panelId: 1, adHocFiltersEnabled: true }],
});
expect(result).toEqual([
@@ -641,6 +818,7 @@ describe('DashboardDatasource', () => {
{ key: 'age', operator: '>', value: '25' },
{ key: 'status', operator: '!=', value: 'active' },
],
queries: [{ refId: 'A', panelId: 1, adHocFiltersEnabled: true }],
});
expect(result).toEqual([
@@ -128,10 +128,11 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> {
...field,
config: {
...field.config,
// Enable AdHoc filtering for string and numeric fields only when feature toggle is enabled
filterable: config.featureToggles.dashboardDsAdHocFiltering
? field.type === FieldType.string || field.type === FieldType.number
: field.config.filterable,
// Enable AdHoc filtering for string and numeric fields only when feature toggle and per-panel setting are enabled
filterable:
config.featureToggles.dashboardDsAdHocFiltering && query.adHocFiltersEnabled
? field.type === FieldType.string || field.type === FieldType.number
: field.config.filterable,
},
state: {
...field.state,
@@ -140,7 +141,7 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> {
};
});
if (!config.featureToggles.dashboardDsAdHocFiltering || filters.length === 0) {
if (!config.featureToggles.dashboardDsAdHocFiltering || !query.adHocFiltersEnabled || filters.length === 0) {
return [...series, ...annotations];
}
@@ -348,6 +349,13 @@ export class DashboardDatasource extends DataSourceApi<DashboardQuery> {
return [];
}
// Check if any query has adhoc filters enabled
const hasAdHocFiltersEnabled = options?.queries?.some((query) => query.adHocFiltersEnabled);
if (!hasAdHocFiltersEnabled) {
return [];
}
const filters = options?.filters || [];
return filters.map((filter): DrilldownsApplicability => {
@@ -4,6 +4,7 @@ export interface DashboardQuery extends DataQuery {
panelId?: number;
withTransforms?: boolean;
topic?: DataTopic;
adHocFiltersEnabled?: boolean;
}
export type ResultInfo = {