From 1034177b7002467f6ba8645154d809af46205d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 6 Oct 2023 11:45:08 +0200 Subject: [PATCH] QueryVariable: Support for queries that contain "$__searchFilter" (#395) --- docusaurus/docs/variables.tsx | 1 + packages/scenes-app/src/demos/variables.tsx | 33 ++++++++ .../src/variables/VariableDependencyConfig.ts | 11 +-- .../components/VariableValueSelect.tsx | 21 ++++- packages/scenes/src/variables/constants.ts | 1 + packages/scenes/src/variables/utils.ts | 10 +++ .../variables/variants/MultiValueVariable.ts | 5 ++ .../variants/query/QueryVariable.test.tsx | 76 +++++++++++++------ .../variants/query/QueryVariable.tsx | 48 ++++++++---- .../query/createQueryVariableRunner.ts | 22 ++++-- .../variants/query/toMetricFindValues.ts | 4 + 11 files changed, 177 insertions(+), 55 deletions(-) diff --git a/docusaurus/docs/variables.tsx b/docusaurus/docs/variables.tsx index 920921bed..fad20b44d 100644 --- a/docusaurus/docs/variables.tsx +++ b/docusaurus/docs/variables.tsx @@ -18,6 +18,7 @@ export function getVariablesScene() { }, query: { query: 'label_values(prometheus_http_requests_total,handler)', + refId: 'A', }, }); diff --git a/packages/scenes-app/src/demos/variables.tsx b/packages/scenes-app/src/demos/variables.tsx index b4993f293..f29e41472 100644 --- a/packages/scenes-app/src/demos/variables.tsx +++ b/packages/scenes-app/src/demos/variables.tsx @@ -18,6 +18,7 @@ import { DataSourceVariable, SceneQueryRunner, TextBoxVariable, + QueryVariable, } from '@grafana/scenes'; import { getQueryRunnerWithRandomWalkQuery } from './utils'; @@ -143,6 +144,38 @@ export function getVariablesDemo(defaults: SceneAppPageState) { }); }, }), + new SceneAppPage({ + title: 'Search filter', + url: `${defaults.url}/search`, + getScene: () => { + return new EmbeddedScene({ + controls: [new VariableValueSelectors({})], + $variables: new SceneVariableSet({ + variables: [ + new QueryVariable({ + name: 'server', + query: { query: 'A.$__searchFilter', refId: 'A' }, + datasource: { uid: 'gdev-testdata' }, + }), + ], + }), + body: new SceneFlexLayout({ + direction: 'column', + children: [ + new SceneFlexItem({ + body: PanelBuilders.text() + .setTitle('Variable with search filter') + .setOption( + 'content', + 'This is a very old messy feature that allows data sources to filter down the options in a query variable dropdown based on what the user has typed in. Only implemented by very few data sources (Graphite, SQL, Datadog)' + ) + .build(), + }), + ], + }), + }); + }, + }), ], }); } diff --git a/packages/scenes/src/variables/VariableDependencyConfig.ts b/packages/scenes/src/variables/VariableDependencyConfig.ts index 2b809a7c8..48602f8e0 100644 --- a/packages/scenes/src/variables/VariableDependencyConfig.ts +++ b/packages/scenes/src/variables/VariableDependencyConfig.ts @@ -2,6 +2,7 @@ import { SceneObject, SceneObjectState } from '../core/types'; import { VARIABLE_REGEX } from './constants'; import { SceneVariable, SceneVariableDependencyConfigLike } from './types'; +import { safeStringifyValue } from './utils'; interface VariableDependencyConfigOptions { /** @@ -152,13 +153,3 @@ export class VariableDependencyConfig implement } } } - -const safeStringifyValue = (value: unknown) => { - try { - return JSON.stringify(value, null); - } catch (error) { - console.error(error); - } - - return ''; -}; diff --git a/packages/scenes/src/variables/components/VariableValueSelect.tsx b/packages/scenes/src/variables/components/VariableValueSelect.tsx index af6f6798f..2a342aa8a 100644 --- a/packages/scenes/src/variables/components/VariableValueSelect.tsx +++ b/packages/scenes/src/variables/components/VariableValueSelect.tsx @@ -1,7 +1,7 @@ import { isArray } from 'lodash'; import React from 'react'; -import { MultiSelect, Select } from '@grafana/ui'; +import { InputActionMeta, MultiSelect, Select } from '@grafana/ui'; import { SceneComponentProps } from '../../core/types'; import { MultiValueVariable } from '../variants/MultiValueVariable'; @@ -10,6 +10,14 @@ import { VariableValue, VariableValueSingle } from '../types'; export function VariableValueSelect({ model }: SceneComponentProps) { const { value, key } = model.useState(); + const onInputChange = model.onSearchChange + ? (value: string, meta: InputActionMeta) => { + if (meta.action === 'input-change') { + model.onSearchChange!(value); + } + } + : undefined; + return ( id={key} @@ -18,6 +26,7 @@ export function VariableValueSelect({ model }: SceneComponentProps { model.changeValueTo(newValue.value!, newValue.label!); @@ -30,6 +39,14 @@ export function VariableValueSelectMulti({ model }: SceneComponentProps { + if (meta.action === 'input-change') { + model.onSearchChange!(value); + } + } + : undefined; + return ( id={key} @@ -41,7 +58,7 @@ export function VariableValueSelectMulti({ model }: SceneComponentProps {}} + onInputChange={onInputChange} onChange={(newValue) => { model.changeValueTo( newValue.map((v) => v.value!), diff --git a/packages/scenes/src/variables/constants.ts b/packages/scenes/src/variables/constants.ts index 93a79efff..351bf29ff 100644 --- a/packages/scenes/src/variables/constants.ts +++ b/packages/scenes/src/variables/constants.ts @@ -13,3 +13,4 @@ export const AUTO_VARIABLE_VALUE = '$__auto'; * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} */ export const VARIABLE_REGEX = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; +export const SEARCH_FILTER_VARIABLE = '__searchFilter'; diff --git a/packages/scenes/src/variables/utils.ts b/packages/scenes/src/variables/utils.ts index 12f3b8339..7bf00e112 100644 --- a/packages/scenes/src/variables/utils.ts +++ b/packages/scenes/src/variables/utils.ts @@ -8,3 +8,13 @@ export function isVariableValueEqual(a: VariableValue | null | undefined, b: Var return isEqual(a, b); } + +export function safeStringifyValue(value: unknown) { + try { + return JSON.stringify(value, null); + } catch (error) { + console.error(error); + } + + return ''; +} diff --git a/packages/scenes/src/variables/variants/MultiValueVariable.ts b/packages/scenes/src/variables/variants/MultiValueVariable.ts index 0307a0f11..c82ed137f 100644 --- a/packages/scenes/src/variables/variants/MultiValueVariable.ts +++ b/packages/scenes/src/variables/variants/MultiValueVariable.ts @@ -244,6 +244,11 @@ export abstract class MultiValueVariable diff --git a/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx b/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx index 8fe8554ec..31a05753d 100644 --- a/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx +++ b/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx @@ -1,4 +1,5 @@ import { lastValueFrom, of } from 'rxjs'; +import React from 'react'; import { DataQueryRequest, @@ -10,6 +11,7 @@ import { PanelData, PluginType, ScopedVars, + StandardVariableQuery, StandardVariableSupport, toDataFrame, toUtc, @@ -20,19 +22,28 @@ import { SceneTimeRange } from '../../../core/SceneTimeRange'; import { QueryVariable } from './QueryVariable'; import { QueryRunner, RunnerArgs, setCreateQueryVariableRunnerFactory } from './createQueryVariableRunner'; +import { EmbeddedScene } from '../../../components/EmbeddedScene'; +import { SceneVariableSet } from '../../sets/SceneVariableSet'; +import { VariableValueSelectors } from '../../components/VariableValueSelectors'; +import { SceneCanvasText } from '../../../components/SceneCanvasText'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { setRunRequest } from '@grafana/runtime'; const runRequestMock = jest.fn().mockReturnValue( of({ state: LoadingState.Done, series: [ toDataFrame({ - fields: [{ name: 'text', type: FieldType.string, values: ['A', 'AB', 'C'] }], + fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }], }), ], timeRange: getDefaultTimeRange(), }) ); +setRunRequest(runRequestMock); + const getDataSourceMock = jest.fn(); const fakeDsMock: DataSourceApi = { @@ -83,7 +94,9 @@ class FakeQueryRunner implements QueryRunner { public constructor(private datasource: DataSourceApi, private _runRequest: jest.Mock) {} public getTarget(variable: QueryVariable) { - return (this.datasource.variables as StandardVariableSupport).toDataQuery(variable.state.query); + return (this.datasource.variables as StandardVariableSupport).toDataQuery( + variable.state.query as StandardVariableQuery + ); } public runRequest(args: RunnerArgs, request: DataQueryRequest) { return this._runRequest( @@ -111,20 +124,6 @@ describe('QueryVariable', () => { }); }); - describe('When no data source is provided', () => { - it('Should default to empty options and empty value', async () => { - const variable = new QueryVariable({ - name: 'test', - }); - - await lastValueFrom(variable.validateAndUpdate()); - - expect(variable.state.value).toEqual(''); - expect(variable.state.text).toEqual(''); - expect(variable.state.options).toEqual([]); - }); - }); - describe('Issuing variable query', () => { const originalNow = Date.now; beforeEach(() => { @@ -151,9 +150,9 @@ describe('QueryVariable', () => { variable.validateAndUpdate().subscribe({ next: () => { expect(variable.state.options).toEqual([ - { label: 'A', value: 'A' }, - { label: 'AB', value: 'AB' }, - { label: 'C', value: 'C' }, + { label: 'val1', value: 'val1' }, + { label: 'val2', value: 'val2' }, + { label: 'val11', value: 'val11' }, ]); expect(variable.state.loading).toEqual(false); done(); @@ -260,15 +259,48 @@ describe('QueryVariable', () => { name: 'test', datasource: { uid: 'fake-std', type: 'fake-std' }, query: 'query', - regex: '/^A/', + regex: '/^val1/', }); await lastValueFrom(variable.validateAndUpdate()); expect(variable.state.options).toEqual([ - { label: 'A', value: 'A' }, - { label: 'AB', value: 'AB' }, + { label: 'val1', value: 'val1' }, + { label: 'val11', value: 'val11' }, ]); }); }); + + describe('Query with __searchFilter', () => { + beforeEach(() => { + runRequestMock.mockClear(); + setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); + }); + + it('Should trigger new query and show new options', async () => { + const variable = new QueryVariable({ + name: 'server', + datasource: null, + query: 'A.$__searchFilter', + }); + + const scene = new EmbeddedScene({ + $variables: new SceneVariableSet({ variables: [variable] }), + controls: [new VariableValueSelectors({})], + body: new SceneCanvasText({ text: 'hello' }), + }); + + render(); + + const select = await screen.findByRole('combobox'); + await userEvent.click(select); + await userEvent.type(select, 'muu!'); + + // wait for debounce + await new Promise((r) => setTimeout(r, 500)); + + expect(runRequestMock).toBeCalledTimes(2); + expect(runRequestMock.mock.calls[1][1].scopedVars.__searchFilter.value).toEqual('muu!'); + }); + }); }); diff --git a/packages/scenes/src/variables/variants/query/QueryVariable.tsx b/packages/scenes/src/variables/variants/query/QueryVariable.tsx index 322b9f259..d5c264467 100644 --- a/packages/scenes/src/variables/variants/query/QueryVariable.tsx +++ b/packages/scenes/src/variables/variants/query/QueryVariable.tsx @@ -1,11 +1,9 @@ -import { Observable, of, filter, take, mergeMap, catchError, throwError, from } from 'rxjs'; +import { Observable, of, filter, take, mergeMap, catchError, throwError, from, lastValueFrom } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { CoreApp, - DataQuery, DataQueryRequest, - DataSourceRef, getDefaultTimeRange, LoadingState, PanelData, @@ -25,11 +23,16 @@ import { createQueryVariableRunner } from './createQueryVariableRunner'; import { metricNamesToVariableValues } from './utils'; import { toMetricFindValues } from './toMetricFindValues'; import { getDataSource } from '../../../utils/getDataSource'; +import { safeStringifyValue } from '../../utils'; +import { DataQuery, DataSourceRef } from '@grafana/schema'; +import { SEARCH_FILTER_VARIABLE } from '../../constants'; +import { DataQueryExtended } from '../../../querying/SceneQueryRunner'; +import { debounce } from 'lodash'; export interface QueryVariableState extends MultiValueVariableState { type: 'query'; datasource: DataSourceRef | null; - query: any; + query: string | DataQueryExtended; regex: string; refresh: VariableRefresh; sort: VariableSort; @@ -46,10 +49,10 @@ export class QueryVariable extends MultiValueVariable { name: '', value: '', text: '', - query: '', options: [], datasource: null, regex: '', + query: { refId: 'A' }, refresh: VariableRefresh.onDashboardLoad, sort: VariableSort.alphabeticalAsc, ...initialState, @@ -71,9 +74,9 @@ export class QueryVariable extends MultiValueVariable { mergeMap((ds) => { const runner = createQueryVariableRunner(ds); const target = runner.getTarget(this); - const request = this.getRequest(target); + const request = this.getRequest(target, args.searchFilter); - return runner.runRequest({ variable: this }, request).pipe( + return runner.runRequest({ variable: this, searchFilter: args.searchFilter }, request).pipe( filter((data) => data.state === LoadingState.Done || data.state === LoadingState.Error), // we only care about done or error for now take(1), // take the first result, using first caused a bug where it in some situations throw an uncaught error because of no results had been received yet mergeMap((data: PanelData) => { @@ -101,16 +104,15 @@ export class QueryVariable extends MultiValueVariable { ); } - private getRequest(target: DataQuery) { - // TODO: add support for search filter - // const { searchFilter } = this.state.searchFilter; - // const searchFilterScope = { searchFilter: { text: searchFilter, value: searchFilter } }; - // const searchFilterAsVars = searchFilter ? searchFilterScope : {}; + private getRequest(target: DataQuery | string, searchFilter?: string) { const scopedVars: ScopedVars = { - // ...searchFilterAsVars, __sceneObject: { text: '__sceneObject', value: this }, }; + if (searchFilter) { + scopedVars.__searchFilter = { value: searchFilter, text: searchFilter }; + } + const range = this.state.refresh === VariableRefresh.onTimeRangeChanged ? sceneGraph.getTimeRange(this).state.value @@ -123,14 +125,34 @@ export class QueryVariable extends MultiValueVariable { range, interval: '', intervalMs: 0, + // @ts-ignore targets: [target], scopedVars, startTime: Date.now(), }; + return request; } + onSearchChange = (searchFilter: string) => { + if (!containsSearchFilter(this.state.query)) { + return; + } + + this._updateOptionsBasedOnSearchFilter(searchFilter); + }; + + private _updateOptionsBasedOnSearchFilter = debounce(async (searchFilter: string) => { + const result = await lastValueFrom(this.getValueOptions({ searchFilter })); + this.setState({ options: result, loading: false }); + }, 400); + public static Component = ({ model }: SceneComponentProps) => { return renderSelectForVariable(model); }; } + +function containsSearchFilter(query: string | DataQuery) { + const str = safeStringifyValue(query); + return str.indexOf(SEARCH_FILTER_VARIABLE); +} diff --git a/packages/scenes/src/variables/variants/query/createQueryVariableRunner.ts b/packages/scenes/src/variables/variants/query/createQueryVariableRunner.ts index 2f3eeef40..bf75359b1 100644 --- a/packages/scenes/src/variables/variants/query/createQueryVariableRunner.ts +++ b/packages/scenes/src/variables/variants/query/createQueryVariableRunner.ts @@ -1,6 +1,13 @@ import { from, mergeMap, Observable, of } from 'rxjs'; -import { DataQueryRequest, DataSourceApi, getDefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; +import { + DataQueryRequest, + DataSourceApi, + getDefaultTimeRange, + LoadingState, + PanelData, + StandardVariableQuery, +} from '@grafana/data'; import { getRunRequest } from '@grafana/runtime'; import { hasCustomVariableSupport, hasLegacyVariableSupport, hasStandardVariableSupport } from './guards'; @@ -14,7 +21,7 @@ export interface RunnerArgs { } export interface QueryRunner { - getTarget: (variable: QueryVariable) => DataQuery; + getTarget: (variable: QueryVariable) => DataQuery | string; runRequest: (args: RunnerArgs, request: DataQueryRequest) => Observable; } @@ -53,7 +60,7 @@ class LegacyQueryRunner implements QueryRunner { throw new Error("Couldn't create a target with supplied arguments."); } - public runRequest({ variable }: RunnerArgs, request: DataQueryRequest) { + public runRequest({ variable, searchFilter }: RunnerArgs, request: DataQueryRequest) { if (!hasLegacyVariableSupport(this.datasource)) { return getEmptyMetricFindValueObservable(); } @@ -66,8 +73,7 @@ class LegacyQueryRunner implements QueryRunner { name: variable.state.name, type: variable.state.type, }, - // TODO: add support for search filter - // searchFilter + searchFilter, }) ).pipe( mergeMap((values) => { @@ -137,7 +143,7 @@ export function setCreateQueryVariableRunnerFactory(fn: (datasource: DataSourceA /** * Fixes old legacy query string models and adds refId if missing */ -function ensureVariableQueryModelIsADataQuery(variable: QueryVariable) { +function ensureVariableQueryModelIsADataQuery(variable: QueryVariable): StandardVariableQuery { const query = variable.state.query; // Turn into query object if it's just a string @@ -147,8 +153,8 @@ function ensureVariableQueryModelIsADataQuery(variable: QueryVariable) { // Add potentially missing refId if (query.refId == null) { - return { ...variable.state.query, refId: `variable-${variable.state.name}` }; + return { ...query, refId: `variable-${variable.state.name}` } as StandardVariableQuery; } - return variable.state.query; + return variable.state.query as StandardVariableQuery; } diff --git a/packages/scenes/src/variables/variants/query/toMetricFindValues.ts b/packages/scenes/src/variables/variants/query/toMetricFindValues.ts index a869abf70..f9bc1a3b8 100644 --- a/packages/scenes/src/variables/variants/query/toMetricFindValues.ts +++ b/packages/scenes/src/variables/variants/query/toMetricFindValues.ts @@ -21,6 +21,10 @@ export function toMetricFindValues(): OperatorFunction