From 1faaec26116b4c1129ed72b1d668fb3443a7c2c4 Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Wed, 3 Sep 2025 14:51:27 +0200 Subject: [PATCH] P2P Filter: Add adhoc filter option toggle (#110160) * feat(ds): add adhoc filter option --- .../dashboard/DashboardQueryEditor.test.tsx | 65 +++++- .../dashboard/DashboardQueryEditor.tsx | 17 ++ .../datasource/dashboard/datasource.test.ts | 186 +++++++++++++++++- .../datasource/dashboard/datasource.ts | 18 +- .../app/plugins/datasource/dashboard/types.ts | 1 + 5 files changed, 277 insertions(+), 10 deletions(-) diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx index 0b794a48061..2cbfa311d7b 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx @@ -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( + + ); + }); + + 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( + + ); + }); + + // Wait for any async operations to complete + await waitFor(() => { + expect(screen.queryByText('AdHoc Filters')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx index 4ce823746e2..e137069219d 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -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 )} + + {config.featureToggles.dashboardDsAdHocFiltering && ( + + + + )} {loadingResults ? ( diff --git a/public/app/plugins/datasource/dashboard/datasource.test.ts b/public/app/plugins/datasource/dashboard/datasource.test.ts index 3ecde52e9b6..55d7a12968d 100644 --- a/public/app/plugins/datasource/dashboard/datasource.test.ts +++ b/public/app/plugins/datasource/dashboard/datasource.test.ts @@ -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([ diff --git a/public/app/plugins/datasource/dashboard/datasource.ts b/public/app/plugins/datasource/dashboard/datasource.ts index c5efa221fc4..2386418231a 100644 --- a/public/app/plugins/datasource/dashboard/datasource.ts +++ b/public/app/plugins/datasource/dashboard/datasource.ts @@ -128,10 +128,11 @@ export class DashboardDatasource extends DataSourceApi { ...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 { }; }); - 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 { 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 => { diff --git a/public/app/plugins/datasource/dashboard/types.ts b/public/app/plugins/datasource/dashboard/types.ts index 867605b1d07..d55141c183a 100644 --- a/public/app/plugins/datasource/dashboard/types.ts +++ b/public/app/plugins/datasource/dashboard/types.ts @@ -4,6 +4,7 @@ export interface DashboardQuery extends DataQuery { panelId?: number; withTransforms?: boolean; topic?: DataTopic; + adHocFiltersEnabled?: boolean; } export type ResultInfo = {