diff --git a/packages/scenes-app/src/demos/index.ts b/packages/scenes-app/src/demos/index.ts index 68481f906..bb9d10b20 100644 --- a/packages/scenes-app/src/demos/index.ts +++ b/packages/scenes-app/src/demos/index.ts @@ -41,6 +41,7 @@ import { getMlDemo } from './ml'; import { getSceneGraphEventsDemo } from './sceneGraphEvents'; import { getSeriesLimitTest } from './seriesLimit'; import { getScopesDemo } from './scopesDemo'; +import { getVariableWithObjectValuesDemo } from './variableWithObjectValuesDemo'; export interface DemoDescriptor { title: string; @@ -305,5 +306,11 @@ export function getDemos(): DemoDescriptor[] { getPage: getScopesDemo, getSourceCodeModule: () => import('!!raw-loader!../demos/scopesDemo'), }, + { + title: 'Variables with object values', + description: '', + getPage: getVariableWithObjectValuesDemo, + getSourceCodeModule: () => import('!!raw-loader!../demos/variableWithObjectValuesDemo.tsx'), + }, ].sort((a, b) => a.title.localeCompare(b.title)); } diff --git a/packages/scenes-app/src/demos/variableWithObjectValuesDemo.tsx b/packages/scenes-app/src/demos/variableWithObjectValuesDemo.tsx new file mode 100644 index 000000000..032a92931 --- /dev/null +++ b/packages/scenes-app/src/demos/variableWithObjectValuesDemo.tsx @@ -0,0 +1,52 @@ +import { + CustomVariable, + EmbeddedScene, + PanelBuilders, + SceneAppPage, + SceneAppPageState, + SceneFlexItem, + SceneFlexLayout, + SceneVariableSet, + VariableValueSelectors, +} from '@grafana/scenes'; +import { getEmbeddedSceneDefaults } from './utils'; + +export function getVariableWithObjectValuesDemo(defaults: SceneAppPageState) { + return new SceneAppPage({ + ...defaults, + getScene: () => { + return new EmbeddedScene({ + ...getEmbeddedSceneDefaults(), + controls: [new VariableValueSelectors({})], + $variables: new SceneVariableSet({ + variables: [ + new CustomVariable({ + name: 'envs', + label: 'Environments', + isMulti: true, + includeAll: true, + valuesFormat: 'json', + query: ` +[ + { "value": "1", "text": "Development", "aws_environment": "development", "azure_environment": "dev" }, + { "value": "2", "text": "Staging", "aws_environment": "staging", "azure_environment": "stg" }, + { "value": "3", "text": "Production", "aws_environment": "prod", "azure_environment": "prd" } +] +`, + }), + ], + }), + body: new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: PanelBuilders.text().setTitle('AWS').setOption('content', '${envs.aws_environment}').build(), + }), + new SceneFlexItem({ + body: PanelBuilders.text().setTitle('Azure').setOption('content', '${envs.azure_environment}').build(), + }), + ], + }), + }); + }, + }); +} diff --git a/packages/scenes/src/variables/groupby/GroupByVariable.tsx b/packages/scenes/src/variables/groupby/GroupByVariable.tsx index 7798fa6a1..198c18949 100644 --- a/packages/scenes/src/variables/groupby/GroupByVariable.tsx +++ b/packages/scenes/src/variables/groupby/GroupByVariable.tsx @@ -257,7 +257,7 @@ export class GroupByVariable extends MultiValueVariable { /** * Allows clearing the value of the variable to an empty value. Overrides default behavior of a MultiValueVariable */ - public getDefaultMultiState(options: VariableValueOption[]): { value: VariableValueSingle[]; text: string[] } { + public getDefaultMultiState(options: VariableValueOption[]) { return { value: [], text: [] }; } } diff --git a/packages/scenes/src/variables/types.ts b/packages/scenes/src/variables/types.ts index 02af46e1f..743551d0d 100644 --- a/packages/scenes/src/variables/types.ts +++ b/packages/scenes/src/variables/types.ts @@ -72,10 +72,12 @@ export interface CustomVariableValue { } export interface ValidateAndUpdateResult {} +export interface VariableValueOptionProperties extends Record {} export interface VariableValueOption { label: string; value: VariableValueSingle; group?: string; + properties?: VariableValueOptionProperties; } export interface SceneVariableSetState extends SceneObjectState { diff --git a/packages/scenes/src/variables/variants/CustomVariable.test.ts b/packages/scenes/src/variables/variants/CustomVariable.test.ts index b03735b62..59d06e044 100644 --- a/packages/scenes/src/variables/variants/CustomVariable.test.ts +++ b/packages/scenes/src/variables/variants/CustomVariable.test.ts @@ -1,8 +1,8 @@ import { lastValueFrom } from 'rxjs'; -import { CustomVariable } from './CustomVariable'; import { TestScene } from '../TestScene'; import { SceneVariableSet } from '../sets/SceneVariableSet'; +import { CustomVariable } from './CustomVariable'; describe('CustomVariable', () => { describe('When empty query is provided', () => { @@ -280,4 +280,44 @@ label-3 : value-3,`, expect(B.state.options[2].value).toBe('value1'); }); }); + + describe('JSON options provider', () => { + it('Can provide object values (JSON is an array of objects)', async () => { + const variable = new CustomVariable({ + name: 'test', + isMulti: false, + valuesFormat: 'json', + query: ` +[ + { "value": "test", "text": "Test", "location": "US" }, + { "value": "prod", "text": "Prod", "location": "EU" } +] + `, + value: 'prod', + text: 'Prod', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.getValue()).toEqual('prod'); + expect(variable.getValue('location')).toEqual('EU'); + }); + + it('Can provide non-object values (JSON is an array of strings)', async () => { + const variable = new CustomVariable({ + name: 'test', + isMulti: false, + valuesFormat: 'json', + query: `["test", "prod"]`, + value: 'prod', + text: 'prod', + options: [], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toBe('prod'); + expect(variable.state.text).toBe('prod'); + }); + }); }); diff --git a/packages/scenes/src/variables/variants/CustomVariable.tsx b/packages/scenes/src/variables/variants/CustomVariable.tsx index 948b2311d..bb2e652a9 100644 --- a/packages/scenes/src/variables/variants/CustomVariable.tsx +++ b/packages/scenes/src/variables/variants/CustomVariable.tsx @@ -11,6 +11,7 @@ import React from 'react'; export interface CustomVariableState extends MultiValueVariableState { query: string; + valuesFormat?: 'csv' | 'json'; } export class CustomVariable extends MultiValueVariable { @@ -22,6 +23,7 @@ export class CustomVariable extends MultiValueVariable { super({ type: 'custom', query: '', + valuesFormat: 'csv', value: '', text: '', options: [], @@ -30,11 +32,13 @@ export class CustomVariable extends MultiValueVariable { }); } - public getValueOptions(args: VariableGetOptionsArgs): Observable { - const interpolated = sceneGraph.interpolate(this, this.state.query); - const match = interpolated.match(/(?:\\,|[^,])+/g) ?? []; + // We expose this publicly as we also need it outside the variable + // The interpolate flag is needed since we don't always want to get the interpolated options + public transformCsvStringToOptions(str: string, interpolate = true): VariableValueOption[] { + str = interpolate ? sceneGraph.interpolate(this, str) : str; + const match = str.match(/(?:\\,|[^,])+/g) ?? []; - const options = match.map((text) => { + return match.map((text) => { text = text.replace(/\\,/g, ','); const textMatch = /^\s*(.+)\s:\s(.+)$/g.exec(text) ?? []; if (textMatch.length === 3) { @@ -44,6 +48,38 @@ export class CustomVariable extends MultiValueVariable { return { label: text.trim(), value: text.trim() }; } }); + } + + private transformJsonToOptions(json: string) { + const parsedOptions = JSON.parse(json); + + if (!Array.isArray(parsedOptions)) { + throw new Error('Query must be a JSON array'); + } + + if (typeof parsedOptions[0] === 'string') { + return parsedOptions.map((value) => ({ label: value.trim(), value: value.trim() })); + } + + if (typeof parsedOptions[0] !== 'object' || parsedOptions[0] === null) { + throw new Error('Query must be a JSON array of strings or objects'); + } + + const valueProp = 'value'; + const textProp = 'text'; + + return parsedOptions.map((o) => ({ + value: String(o[valueProp]).trim(), + label: String(o[textProp] || o[valueProp])?.trim(), + properties: o, + })); + } + + public getValueOptions(args: VariableGetOptionsArgs): Observable { + const options = + this.state.valuesFormat === 'csv' + ? this.transformCsvStringToOptions(this.state.query) + : this.transformJsonToOptions(this.state.query); if (!options.length) { this.skipNextValidation = true; diff --git a/packages/scenes/src/variables/variants/MultiValueVariable.test.ts b/packages/scenes/src/variables/variants/MultiValueVariable.test.ts index 31022b03a..1df167957 100644 --- a/packages/scenes/src/variables/variants/MultiValueVariable.test.ts +++ b/packages/scenes/src/variables/variants/MultiValueVariable.test.ts @@ -749,22 +749,72 @@ describe('MultiValueVariable', () => { }); describe('multi prop / object support', () => { - it('Can have object values', async () => { - const variable = new TestVariable({ - name: 'test', - value: 'A', - text: 'A', - delayMs: 0, - skipUrlSync: true, - optionsToReturn: [ - { label: 'Test', value: 'test', properties: { id: 'test', display: 'Test', location: 'US' } }, - { label: 'Prod', value: 'pod', properties: { id: 'prod', display: 'Prod', location: 'EU' } }, - ], + describe('isMulti = false', () => { + it('Can have object values', async () => { + const variable = new TestVariable({ + name: 'test', + delayMs: 0, + skipUrlSync: true, + value: 'prod', + text: 'Prod', + optionsToReturn: [ + { label: 'Test', value: 'test', properties: { id: 'test', display: 'Test', location: 'US' } }, + { label: 'Prod', value: 'prod', properties: { id: 'prod', display: 'Prod', location: 'EU' } }, + ], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.getValue()).toEqual('prod'); + expect(variable.getValue('location')).toEqual('EU'); + }); + }); + + describe('isMulti = true', () => { + it('Can have object values', async () => { + const variable = new TestVariable({ + name: 'test', + delayMs: 0, + skipUrlSync: true, + value: ['prod', 'stag'], + text: 'Prod + Staging', + optionsToReturn: [ + { label: 'Test', value: 'test', properties: { id: 'test', display: 'Test', location: 'US' } }, + { label: 'Stag', value: 'stag', properties: { id: 'stag', display: 'Stag', location: 'SG' } }, + { label: 'Prod', value: 'prod', properties: { id: 'prod', display: 'Prod', location: 'EU' } }, + ], + isMulti: true, + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.getValue()).toEqual(['prod', 'stag']); + expect(variable.getValue('location')).toEqual(['EU', 'SG']); + }); + + describe('value=$__all', () => { + it('Can have object values', async () => { + const variable = new TestVariable({ + name: 'test', + delayMs: 0, + skipUrlSync: true, + value: ALL_VARIABLE_VALUE, + text: ALL_VARIABLE_TEXT, + optionsToReturn: [ + { label: 'Test', value: 'test', properties: { id: 'test', display: 'Test', location: 'US' } }, + { label: 'Stag', value: 'stag', properties: { id: 'stag', display: 'Stag', location: 'SG' } }, + { label: 'Prod', value: 'prod', properties: { id: 'prod', display: 'Prod', location: 'EU' } }, + ], + isMulti: true, + includeAll: true, + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.getValue()).toEqual(['test', 'stag', 'prod']); + expect(variable.getValue('location')).toEqual(['US', 'SG', 'EU']); + }); }); - - await lastValueFrom(variable.validateAndUpdate()); - - expect(variable.getValue('location')).toEqual('US'); }); }); }); diff --git a/packages/scenes/src/variables/variants/MultiValueVariable.ts b/packages/scenes/src/variables/variants/MultiValueVariable.ts index 21f0138de..05e2435f0 100644 --- a/packages/scenes/src/variables/variants/MultiValueVariable.ts +++ b/packages/scenes/src/variables/variants/MultiValueVariable.ts @@ -21,12 +21,12 @@ import { VariableFormatID } from '@grafana/schema'; import { SceneVariableSet } from '../sets/SceneVariableSet'; import { setBaseClassState } from '../../utils/utils'; import { VARIABLE_VALUE_CHANGED_INTERACTION } from '../../behaviors/SceneRenderProfiler'; + import { getQueryController } from '../../core/sceneGraph/getQueryController'; export interface MultiValueVariableState extends SceneVariableState { value: VariableValue; // old current.text text: VariableValue; // old current.value - valueProperties?: Record; options: VariableValueOption[]; allowCustomValue?: boolean; isMulti?: boolean; @@ -183,13 +183,11 @@ export abstract class MultiValueVariable x.value); + value = this.state.options.map((o) => o.value); } if (fieldPath != null) { @@ -233,11 +230,18 @@ export abstract class MultiValueVariable= 0 && index < value.length) { return value[index]; } + + const accesor = this.getFieldAccessor(fieldPath); + return value.map((v) => { + const o = this.state.options.find((o) => o.value === v); + return o ? accesor(o.properties) : v; + }); } - if (this.state.valueProperties) { - const accessor = this.getFieldAccessor(fieldPath); - return accessor(this.state.valueProperties); + const accesor = this.getFieldAccessor(fieldPath); + const o = this.state.options.find((o) => o.value === value); + if (o) { + return accesor(o.properties); } } @@ -274,7 +278,7 @@ export abstract class MultiValueVariable 0) { - return { value: [options[0].value], text: [options[0].label] }; + return { value: [options[0].value], text: [options[0].label], properties: [options[0].properties] }; } else { return { value: [], text: [] }; } @@ -414,14 +418,14 @@ function findOptionMatchingCurrent( ) { let textMatch: VariableValueOption | undefined; - for (const item of options) { - if (item.value === currentValue) { - return item; + for (const o of options) { + if (o.value === currentValue) { + return o; } // No early return here as want to continue to look a value match - if (item.label === currentText) { - textMatch = item; + if (o.label === currentText) { + textMatch = o; } } diff --git a/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx b/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx index 546695f97..e9e5473fd 100644 --- a/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx +++ b/packages/scenes/src/variables/variants/query/QueryVariable.test.tsx @@ -124,429 +124,480 @@ describe.each(['11.1.2', '11.1.1'])('QueryVariable', (v) => { config.buildInfo.version = v; }); - describe('When empty query is provided', () => { - it('Should default to empty query', async () => { - const variable = new QueryVariable({ name: 'test' }); + describe(`Version ${v}`, () => { + describe('When empty query is provided', () => { + it('Should default to empty query', async () => { + const variable = new QueryVariable({ name: 'test' }); - await lastValueFrom(variable.validateAndUpdate()); - - expect(variable.state.query).toEqual(''); - expect(variable.state.value).toEqual(''); - expect(variable.state.text).toEqual(''); - expect(variable.state.options).toEqual([]); - }); + await lastValueFrom(variable.validateAndUpdate()); - it('Should default to empty options and empty value', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake', type: 'fake' }, - query: '', + expect(variable.state.query).toEqual(''); + expect(variable.state.value).toEqual(''); + expect(variable.state.text).toEqual(''); + expect(variable.state.options).toEqual([]); }); - 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(() => { - setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); - }); + it('Should default to empty options and empty value', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake', type: 'fake' }, + query: '', + }); - beforeEach(() => { - Date.now = jest.fn(() => 60000); - }); + await lastValueFrom(variable.validateAndUpdate()); - afterEach(() => { - Date.now = originalNow; - runRequestMock.mockClear(); - getDataSourceMock.mockClear(); + expect(variable.state.value).toEqual(''); + expect(variable.state.text).toEqual(''); + expect(variable.state.options).toEqual([]); + }); }); - it('Should resolve variable options via provided runner', (done) => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', + describe('Issuing variable query', () => { + const originalNow = Date.now; + beforeEach(() => { + setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); }); - variable.validateAndUpdate().subscribe({ - next: () => { - expect(variable.state.options).toEqual([ - { label: 'val1', value: 'val1' }, - { label: 'val2', value: 'val2' }, - { label: 'val11', value: 'val11' }, - ]); - expect(variable.state.loading).toEqual(false); - done(); - }, + beforeEach(() => { + Date.now = jest.fn(() => 60000); }); - expect(variable.state.loading).toEqual(true); - }); - - it('Should pass variable scene object when resolving data source and via request scoped vars', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', + afterEach(() => { + Date.now = originalNow; + runRequestMock.mockClear(); + getDataSourceMock.mockClear(); }); - await lastValueFrom(variable.validateAndUpdate()); - - const getDataSourceCall = getDataSourceMock.mock.calls[0]; - const runRequestCall = runRequestMock.mock.calls[0]; - - expect((runRequestCall[1].scopedVars.__sceneObject.value as SafeSerializableSceneObject).valueOf()).toEqual( - variable - ); - expect((getDataSourceCall[1].__sceneObject.value as SafeSerializableSceneObject).valueOf()).toEqual(variable); - }); - - describe('when refresh on dashboard load set', () => { - it('Should issue variable query with current time range', async () => { + it('Should resolve variable options via provided runner', (done) => { const variable = new QueryVariable({ name: 'test', datasource: { uid: 'fake-std', type: 'fake-std' }, query: 'query', }); - const scene = new EmbeddedScene({ - $timeRange: new SceneTimeRange({ from: 'now-5m', to: 'now' }), - $variables: new SceneVariableSet({ variables: [variable] }), - body: new SceneCanvasText({ text: 'hello' }), + variable.validateAndUpdate().subscribe({ + next: () => { + expect(variable.state.options).toEqual([ + { label: 'val1', value: 'val1' }, + { label: 'val2', value: 'val2' }, + { label: 'val11', value: 'val11' }, + ]); + expect(variable.state.loading).toEqual(false); + done(); + }, }); - scene.activate(); - await lastValueFrom(variable.validateAndUpdate()); - - const call = runRequestMock.mock.calls[0]; - expect(call[1].range.raw.from).toEqual('now-5m'); - expect(call[1].range.raw.to).toEqual('now'); + expect(variable.state.loading).toEqual(true); }); - it('Should not issue variable query when the closest time range changes if refresh on dahboard load is set', async () => { - const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); + describe('When properties are received', () => { + it('Should provide object values', async () => { + setCreateQueryVariableRunnerFactory( + () => + new FakeQueryRunner( + fakeDsMock, + jest.fn().mockReturnValue( + of({ + state: LoadingState.Done, + series: [ + toDataFrame({ + fields: [ + { + name: 'value', + type: FieldType.string, + values: ['test', 'prod'], + }, + { + name: 'text', + type: FieldType.string, + values: ['Test', 'Prod'], + }, + { + name: 'location', + type: FieldType.string, + values: ['US', 'EU'], + }, + ], + }), + ], + timeRange: getDefaultTimeRange(), + }) + ) + ) + ); + + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.getValue('location')).toEqual('US'); + expect(variable.getValue()).toEqual('test'); + }); + }); + it('Should pass variable scene object when resolving data source and via request scoped vars', async () => { const variable = new QueryVariable({ name: 'test', datasource: { uid: 'fake-std', type: 'fake-std' }, query: 'query', - refresh: VariableRefresh.onDashboardLoad, - $timeRange: timeRange, }); - variable.activate(); - await lastValueFrom(variable.validateAndUpdate()); - expect(runRequestMock).toBeCalledTimes(1); - const call1 = runRequestMock.mock.calls[0]; + const getDataSourceCall = getDataSourceMock.mock.calls[0]; + const runRequestCall = runRequestMock.mock.calls[0]; - // Uses default time range - expect(call1[1].range.raw).toEqual({ - from: 'now-1h', - to: 'now', - }); + expect((runRequestCall[1].scopedVars.__sceneObject.value as SafeSerializableSceneObject).valueOf()).toEqual( + variable + ); + expect((getDataSourceCall[1].__sceneObject.value as SafeSerializableSceneObject).valueOf()).toEqual(variable); + }); - timeRange.onTimeRangeChange({ - from: toUtc('2020-01-01'), - to: toUtc('2020-01-02'), - raw: { from: toUtc('2020-01-01'), to: toUtc('2020-01-02') }, + describe('when refresh on dashboard load set', () => { + it('Should issue variable query with current time range', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + }); + + const scene = new EmbeddedScene({ + $timeRange: new SceneTimeRange({ from: 'now-5m', to: 'now' }), + $variables: new SceneVariableSet({ variables: [variable] }), + body: new SceneCanvasText({ text: 'hello' }), + }); + scene.activate(); + + await lastValueFrom(variable.validateAndUpdate()); + + const call = runRequestMock.mock.calls[0]; + expect(call[1].range.raw.from).toEqual('now-5m'); + expect(call[1].range.raw.to).toEqual('now'); }); - await Promise.resolve(); + it('Should not issue variable query when the closest time range changes if refresh on dahboard load is set', async () => { + const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); - expect(runRequestMock).toBeCalledTimes(1); - }); - }); - }); + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + refresh: VariableRefresh.onDashboardLoad, + $timeRange: timeRange, + }); + + variable.activate(); - describe('When ds is null', () => { - beforeEach(() => { - setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); + await lastValueFrom(variable.validateAndUpdate()); + + expect(runRequestMock).toBeCalledTimes(1); + const call1 = runRequestMock.mock.calls[0]; + + // Uses default time range + expect(call1[1].range.raw).toEqual({ + from: 'now-1h', + to: 'now', + }); + + timeRange.onTimeRangeChange({ + from: toUtc('2020-01-01'), + to: toUtc('2020-01-02'), + raw: { from: toUtc('2020-01-01'), to: toUtc('2020-01-02') }, + }); + + await Promise.resolve(); + + expect(runRequestMock).toBeCalledTimes(1); + }); + }); }); - it('should get options for default ds', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: null, - query: 'query', - regex: '/^A/', + describe('When ds is null', () => { + beforeEach(() => { + setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); }); - await lastValueFrom(variable.validateAndUpdate()); + it('should get options for default ds', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: null, + query: 'query', + regex: '/^A/', + }); - expect(runRequestMock).toBeCalledTimes(1); - }); - }); + await lastValueFrom(variable.validateAndUpdate()); - describe('When regex provided', () => { - beforeEach(() => { - setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); + expect(runRequestMock).toBeCalledTimes(1); + }); }); - it('should return options that match regex', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - regex: '/^val1/', + describe('When regex provided', () => { + beforeEach(() => { + setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); }); - await lastValueFrom(variable.validateAndUpdate()); + it('should return options that match regex', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + regex: '/^val1/', + }); - expect(variable.state.options).toEqual([ - { label: 'val1', value: 'val1' }, - { label: 'val11', value: 'val11' }, - ]); - }); - }); + await lastValueFrom(variable.validateAndUpdate()); - describe('When sort is provided', () => { - beforeEach(() => { - setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); + expect(variable.state.options).toEqual([ + { label: 'val1', value: 'val1' }, + { label: 'val11', value: 'val11' }, + ]); + }); }); - it('should return options order by natural sort Desc', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - sort: 8, + describe('When sort is provided', () => { + beforeEach(() => { + setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); }); - await lastValueFrom(variable.validateAndUpdate()); + it('should return options order by natural sort Desc', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + sort: 8, + }); - expect(variable.state.options).toEqual([ - { label: 'val11', value: 'val11' }, - { label: 'val2', value: 'val2' }, - { label: 'val1', value: 'val1' }, - ]); - }); + await lastValueFrom(variable.validateAndUpdate()); - it('should return options order by natural sort Asc', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - sort: 7, + expect(variable.state.options).toEqual([ + { label: 'val11', value: 'val11' }, + { label: 'val2', value: 'val2' }, + { label: 'val1', value: 'val1' }, + ]); }); - await lastValueFrom(variable.validateAndUpdate()); + it('should return options order by natural sort Asc', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + sort: 7, + }); - expect(variable.state.options).toEqual([ - { label: 'val1', value: 'val1' }, - { label: 'val2', value: 'val2' }, - { label: 'val11', value: 'val11' }, - ]); - }); + await lastValueFrom(variable.validateAndUpdate()); - it('should return options order by alphabeticalAsc', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - sort: 1, + expect(variable.state.options).toEqual([ + { label: 'val1', value: 'val1' }, + { label: 'val2', value: 'val2' }, + { label: 'val11', value: 'val11' }, + ]); }); - await lastValueFrom(variable.validateAndUpdate()); + it('should return options order by alphabeticalAsc', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + sort: 1, + }); - expect(variable.state.options).toEqual([ - { label: 'val1', value: 'val1' }, - { label: 'val11', value: 'val11' }, - { label: 'val2', value: 'val2' }, - ]); - }); - }); + await lastValueFrom(variable.validateAndUpdate()); - describe('Query with __searchFilter', () => { - beforeEach(() => { - runRequestMock.mockClear(); - setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); + expect(variable.state.options).toEqual([ + { label: 'val1', value: 'val1' }, + { label: 'val11', value: 'val11' }, + { label: 'val2', value: 'val2' }, + ]); + }); }); - it('Should trigger new query and show new options', async () => { - const variable = new QueryVariable({ - name: 'server', - datasource: null, - query: 'A.$__searchFilter', + describe('Query with __searchFilter', () => { + beforeEach(() => { + runRequestMock.mockClear(); + setCreateQueryVariableRunnerFactory(() => new FakeQueryRunner(fakeDsMock, runRequestMock)); }); - const scene = new EmbeddedScene({ - $variables: new SceneVariableSet({ variables: [variable] }), - controls: [new VariableValueSelectors({})], - body: new SceneCanvasText({ text: 'hello' }), - }); + it('Should trigger new query and show new options', async () => { + const variable = new QueryVariable({ + name: 'server', + datasource: null, + query: 'A.$__searchFilter', + }); - render(); + const scene = new EmbeddedScene({ + $variables: new SceneVariableSet({ variables: [variable] }), + controls: [new VariableValueSelectors({})], + body: new SceneCanvasText({ text: 'hello' }), + }); - await act(() => new Promise((r) => setTimeout(r, 10))); + render(); - const select = await screen.findByRole('combobox'); + await act(() => new Promise((r) => setTimeout(r, 10))); - await userEvent.click(select); - await userEvent.type(select, 'muu!'); + const select = await screen.findByRole('combobox'); - // wait for debounce - await act(() => new Promise((r) => setTimeout(r, 500))); + await userEvent.click(select); + await userEvent.type(select, 'muu!'); - expect(runRequestMock).toBeCalledTimes(2); - expect(runRequestMock.mock.calls[1][1].scopedVars.__searchFilter.value).toEqual('muu!'); - }); + // wait for debounce + await act(() => new Promise((r) => setTimeout(r, 500))); - it('Should not trigger new query whern __searchFilter is not present', async () => { - const variable = new QueryVariable({ - name: 'server', - datasource: null, - query: 'A.*', + expect(runRequestMock).toBeCalledTimes(2); + expect(runRequestMock.mock.calls[1][1].scopedVars.__searchFilter.value).toEqual('muu!'); }); - const scene = new EmbeddedScene({ - $variables: new SceneVariableSet({ variables: [variable] }), - controls: [new VariableValueSelectors({})], - body: new SceneCanvasText({ text: 'hello' }), - }); + it('Should not trigger new query whern __searchFilter is not present', async () => { + const variable = new QueryVariable({ + name: 'server', + datasource: null, + query: 'A.*', + }); - render(); + const scene = new EmbeddedScene({ + $variables: new SceneVariableSet({ variables: [variable] }), + controls: [new VariableValueSelectors({})], + body: new SceneCanvasText({ text: 'hello' }), + }); + + render(); - await act(() => new Promise((r) => setTimeout(r, 10))); + await act(() => new Promise((r) => setTimeout(r, 10))); - const select = await screen.findByRole('combobox'); - await userEvent.click(select); - await userEvent.type(select, 'muu!'); + 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)); + // wait for debounce + await new Promise((r) => setTimeout(r, 500)); - expect(runRequestMock).toBeCalledTimes(1); + expect(runRequestMock).toBeCalledTimes(1); + }); }); - }); - describe('When static options are provided', () => { - it('Should prepend static options to the query results when no static options order is provided', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - staticOptions: [ + describe('When static options are provided', () => { + it('Should prepend static options to the query results when no static options order is provided', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + staticOptions: [ + { label: 'A', value: 'A' }, + { label: 'B', value: 'B' }, + { label: 'C', value: 'C' }, + ], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.options).toEqual([ { label: 'A', value: 'A' }, { label: 'B', value: 'B' }, { label: 'C', value: 'C' }, - ], + { label: 'val1', value: 'val1' }, + { label: 'val2', value: 'val2' }, + { label: 'val11', value: 'val11' }, + ]); }); - await lastValueFrom(variable.validateAndUpdate()); + it('Should prepend static options to the query results when static order "before" is provided"', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + staticOptions: [ + { label: 'A', value: 'A' }, + { label: 'B', value: 'B' }, + { label: 'C', value: 'C' }, + ], + staticOptionsOrder: 'before', + }); - expect(variable.state.options).toEqual([ - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - { label: 'C', value: 'C' }, - { label: 'val1', value: 'val1' }, - { label: 'val2', value: 'val2' }, - { label: 'val11', value: 'val11' }, - ]); - }); + await lastValueFrom(variable.validateAndUpdate()); - it('Should prepend static options to the query results when static order "before" is provided"', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - staticOptions: [ + expect(variable.state.options).toEqual([ { label: 'A', value: 'A' }, { label: 'B', value: 'B' }, { label: 'C', value: 'C' }, - ], - staticOptionsOrder: 'before', + { label: 'val1', value: 'val1' }, + { label: 'val2', value: 'val2' }, + { label: 'val11', value: 'val11' }, + ]); }); - await lastValueFrom(variable.validateAndUpdate()); + it('Should append static options to the query results when static order "after" is provided', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + staticOptions: [ + { label: 'A', value: 'A' }, + { label: 'B', value: 'B' }, + { label: 'C', value: 'C' }, + ], + staticOptionsOrder: 'after', + }); - expect(variable.state.options).toEqual([ - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - { label: 'C', value: 'C' }, - { label: 'val1', value: 'val1' }, - { label: 'val2', value: 'val2' }, - { label: 'val11', value: 'val11' }, - ]); - }); + await lastValueFrom(variable.validateAndUpdate()); - it('Should append static options to the query results when static order "after" is provided', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - staticOptions: [ + expect(variable.state.options).toEqual([ + { label: 'val1', value: 'val1' }, + { label: 'val2', value: 'val2' }, + { label: 'val11', value: 'val11' }, { label: 'A', value: 'A' }, { label: 'B', value: 'B' }, { label: 'C', value: 'C' }, - ], - staticOptionsOrder: 'after', + ]); }); - await lastValueFrom(variable.validateAndUpdate()); + it('Should sort static options and query results when static order "sorted" is provided', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + sort: VariableSort.alphabeticalAsc, + staticOptions: [ + { label: 'A', value: 'A' }, + { label: 'B', value: 'B' }, + { label: 'val12', value: 'val12' }, + ], + staticOptionsOrder: 'sorted', + }); - expect(variable.state.options).toEqual([ - { label: 'val1', value: 'val1' }, - { label: 'val2', value: 'val2' }, - { label: 'val11', value: 'val11' }, - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - { label: 'C', value: 'C' }, - ]); - }); + await lastValueFrom(variable.validateAndUpdate()); - it('Should sort static options and query results when static order "sorted" is provided', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - sort: VariableSort.alphabeticalAsc, - staticOptions: [ + expect(variable.state.options).toEqual([ { label: 'A', value: 'A' }, { label: 'B', value: 'B' }, + { label: 'val1', value: 'val1' }, + { label: 'val11', value: 'val11' }, { label: 'val12', value: 'val12' }, - ], - staticOptionsOrder: 'sorted', + { label: 'val2', value: 'val2' }, + ]); }); - await lastValueFrom(variable.validateAndUpdate()); + it('Should deduplicate options if both query results and static options have the same value, preferring static option', async () => { + const variable = new QueryVariable({ + name: 'test', + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + staticOptions: [ + { label: 'A', value: 'A' }, + { label: 'val3', value: 'val11' }, + ], + }); - expect(variable.state.options).toEqual([ - { label: 'A', value: 'A' }, - { label: 'B', value: 'B' }, - { label: 'val1', value: 'val1' }, - { label: 'val11', value: 'val11' }, - { label: 'val12', value: 'val12' }, - { label: 'val2', value: 'val2' }, - ]); - }); + await lastValueFrom(variable.validateAndUpdate()); - it('Should deduplicate options if both query results and static options have the same value, preferring static option', async () => { - const variable = new QueryVariable({ - name: 'test', - datasource: { uid: 'fake-std', type: 'fake-std' }, - query: 'query', - staticOptions: [ + expect(variable.state.options).toEqual([ { label: 'A', value: 'A' }, { label: 'val3', value: 'val11' }, - ], + { label: 'val1', value: 'val1' }, + { label: 'val2', value: 'val2' }, + ]); }); - - await lastValueFrom(variable.validateAndUpdate()); - - expect(variable.state.options).toEqual([ - { label: 'A', value: 'A' }, - { label: 'val3', value: 'val11' }, - { label: 'val1', value: 'val1' }, - { label: 'val2', value: 'val2' }, - ]); }); }); }); diff --git a/packages/scenes/src/variables/variants/query/toMetricFindValues.test.ts b/packages/scenes/src/variables/variants/query/toMetricFindValues.test.ts index ae97f6405..cd95fbf9c 100644 --- a/packages/scenes/src/variables/variants/query/toMetricFindValues.test.ts +++ b/packages/scenes/src/variables/variants/query/toMetricFindValues.test.ts @@ -3,100 +3,246 @@ import { of } from 'rxjs'; import { toMetricFindValues } from './toMetricFindValues'; describe('toMetricFindValues', () => { - const frameWithTextField = toDataFrame({ - fields: [{ name: 'text', type: FieldType.string, values: ['A', 'B', 'C'] }], - }); - const frameWithValueField = toDataFrame({ - fields: [{ name: 'value', type: FieldType.string, values: ['A', 'B', 'C'] }], - }); - const frameWithTextAndValueField = toDataFrame({ - fields: [ - { name: 'text', type: FieldType.string, values: ['TA', 'TB', 'TC'] }, - { name: 'value', type: FieldType.string, values: ['VA', 'VB', 'VC'] }, - ], - }); - const frameWithAStringField = toDataFrame({ - fields: [{ name: 'label', type: FieldType.string, values: ['A', 'B', 'C'] }], - }); - const frameWithExpandableField = toDataFrame({ - fields: [ - { name: 'label', type: FieldType.string, values: ['A', 'B', 'C'] }, - { name: 'expandable', type: FieldType.boolean, values: [true, false, true] }, - ], - }); - - // it.each wouldn't work here as we need the done callback - [ - { series: null, expected: [] }, - { series: undefined, expected: [] }, - { series: [], expected: [] }, - { series: [{ text: '' }], expected: [{ text: '' }] }, - { series: [{ value: '' }], expected: [{ value: '' }] }, - { - series: [frameWithTextField], - expected: [ - { text: 'A', value: 'A' }, - { text: 'B', value: 'B' }, - { text: 'C', value: 'C' }, - ], - }, - { - series: [frameWithValueField], - expected: [ - { text: 'A', value: 'A' }, - { text: 'B', value: 'B' }, - { text: 'C', value: 'C' }, - ], - }, - { - series: [frameWithTextAndValueField], - expected: [ - { text: 'TA', value: 'VA' }, - { text: 'TB', value: 'VB' }, - { text: 'TC', value: 'VC' }, - ], - }, - { - series: [frameWithAStringField], - expected: [ - { text: 'A', value: 'A' }, - { text: 'B', value: 'B' }, - { text: 'C', value: 'C' }, + describe('series without properties', () => { + const frameWithTextField = toDataFrame({ + fields: [{ name: 'text', type: FieldType.string, values: ['A', 'B', 'C'] }], + }); + const frameWithValueField = toDataFrame({ + fields: [{ name: 'value', type: FieldType.string, values: ['A', 'B', 'C'] }], + }); + const frameWithTextAndValueField = toDataFrame({ + fields: [ + { name: 'text', type: FieldType.string, values: ['TA', 'TB', 'TC'] }, + { name: 'value', type: FieldType.string, values: ['VA', 'VB', 'VC'] }, ], - }, - { - series: [frameWithExpandableField], - expected: [ - { text: 'A', value: 'A', expandable: true }, - { text: 'B', value: 'B', expandable: false }, - { text: 'C', value: 'C', expandable: true }, + }); + const frameWithAStringField = toDataFrame({ + fields: [{ name: 'label', type: FieldType.string, values: ['A', 'B', 'C'] }], + }); + const frameWithExpandableField = toDataFrame({ + fields: [ + { name: 'label', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'expandable', type: FieldType.boolean, values: [true, false, true] }, ], - }, - ].map((scenario) => { - it(`when called with series:${JSON.stringify(scenario.series, null, 0)}`, async () => { - const { series, expected } = scenario; - const panelData: any = { series }; - const observable = of(panelData).pipe(toMetricFindValues()); + }); + + // it.each wouldn't work here as we need the done callback + [ + { series: null, expected: [] }, + { series: undefined, expected: [] }, + { series: [], expected: [] }, + { series: [{ text: '' }], expected: [{ text: '' }] }, + { series: [{ value: '' }], expected: [{ value: '' }] }, + { + series: [frameWithTextField], + expected: [ + { text: 'A', value: 'A' }, + { text: 'B', value: 'B' }, + { text: 'C', value: 'C' }, + ], + }, + { + series: [frameWithValueField], + expected: [ + { text: 'A', value: 'A' }, + { text: 'B', value: 'B' }, + { text: 'C', value: 'C' }, + ], + }, + { + series: [frameWithTextAndValueField], + expected: [ + { text: 'TA', value: 'VA' }, + { text: 'TB', value: 'VB' }, + { text: 'TC', value: 'VC' }, + ], + }, + { + series: [frameWithAStringField], + expected: [ + { text: 'A', value: 'A' }, + { text: 'B', value: 'B' }, + { text: 'C', value: 'C' }, + ], + }, + { + series: [frameWithExpandableField], + expected: [ + { text: 'A', value: 'A', expandable: true }, + { text: 'B', value: 'B', expandable: false }, + { text: 'C', value: 'C', expandable: true }, + ], + }, + ].forEach((scenario) => { + it(`when called with series:${JSON.stringify(scenario.series, null, 0)}`, async () => { + const { series, expected } = scenario; + const panelData: any = { series }; + const observable = of(panelData).pipe(toMetricFindValues()); - await expect(observable).toEmitValuesWith((received) => { - const value = received[0]; - expect(value).toEqual(expected); + await expect(observable).toEmitValuesWith((received) => { + const value = received[0]; + expect(value).toEqual(expected); + }); + }); + }); + + describe('when called with no string fields', () => { + it('then the observable throws', async () => { + const frameWithTimeField = toDataFrame({ + fields: [{ name: 'time', type: FieldType.time, values: [1, 2, 3] }], + }); + + const panelData: any = { series: [frameWithTimeField] }; + const observable = of(panelData).pipe(toMetricFindValues()); + + await expect(observable).toEmitValuesWith((received) => { + const value = received[0]; + expect(value).toEqual(new Error("Couldn't find any field of type string in the results")); + }); }); }); }); - describe('when called without metric find values and string fields', () => { - it('then the observable throws', async () => { - const frameWithTimeField = toDataFrame({ - fields: [{ name: 'time', type: FieldType.time, values: [1, 2, 3] }], + describe('series with properties', () => { + const frameWithPropertiesField = toDataFrame({ + fields: [ + { + name: 'id', + type: FieldType.string, + values: ['dev', 'staging', 'prod'], + }, + { + name: 'display_name', + type: FieldType.string, + values: ['Development', 'Staging', 'Production'], + }, + { + name: 'location', + type: FieldType.string, + values: ['US', 'SG', 'EU'], + }, + ], + }); + const frameWithValueAndPropertiesField = toDataFrame({ + fields: [...frameWithPropertiesField.fields, { name: 'value', type: FieldType.string, values: ['1', '2', '3'] }], + }); + const frameWithValueTextAndPropertiesField = toDataFrame({ + fields: [ + ...frameWithValueAndPropertiesField.fields, + { name: 'text', type: FieldType.string, values: ['Dev', 'Stag', 'Prod'] }, + ], + }); + const frameWithPropertiesAndExpandableField = toDataFrame({ + fields: [ + ...frameWithPropertiesField.fields, + { name: 'expandable', type: FieldType.boolean, values: [true, false, true] }, + ], + }); + + [ + { + series: [frameWithPropertiesField], + valueProp: 'id', + textProp: 'display_name', + expected: [ + { text: 'Development', value: 'dev', properties: { id: 'dev', display_name: 'Development', location: 'US' } }, + { text: 'Staging', value: 'staging', properties: { id: 'staging', display_name: 'Staging', location: 'SG' } }, + { text: 'Production', value: 'prod', properties: { id: 'prod', display_name: 'Production', location: 'EU' } }, + ], + }, + { + series: [frameWithPropertiesField], + valueProp: 'id', + expected: [ + { text: 'dev', value: 'dev', properties: { id: 'dev', display_name: 'Development', location: 'US' } }, + { text: 'staging', value: 'staging', properties: { id: 'staging', display_name: 'Staging', location: 'SG' } }, + { text: 'prod', value: 'prod', properties: { id: 'prod', display_name: 'Production', location: 'EU' } }, + ], + }, + { + series: [frameWithPropertiesField], + textProp: 'display_name', + expected: [ + { + text: 'Development', + value: 'Development', + properties: { id: 'dev', display_name: 'Development', location: 'US' }, + }, + { text: 'Staging', value: 'Staging', properties: { id: 'staging', display_name: 'Staging', location: 'SG' } }, + { + text: 'Production', + value: 'Production', + properties: { id: 'prod', display_name: 'Production', location: 'EU' }, + }, + ], + }, + { + series: [frameWithPropertiesAndExpandableField], + valueProp: 'id', + textProp: 'display_name', + expected: [ + { + text: 'Development', + value: 'dev', + properties: { id: 'dev', display_name: 'Development', location: 'US' }, + expandable: true, + }, + { + text: 'Staging', + value: 'staging', + properties: { id: 'staging', display_name: 'Staging', location: 'SG' }, + expandable: false, + }, + { + text: 'Production', + value: 'prod', + properties: { id: 'prod', display_name: 'Production', location: 'EU' }, + expandable: true, + }, + ], + }, + { + series: [frameWithValueAndPropertiesField], + valueProp: 'id', + textProp: 'display_name', + expected: [ + { text: 'Development', value: '1', properties: { id: 'dev', display_name: 'Development', location: 'US' } }, + { text: 'Staging', value: '2', properties: { id: 'staging', display_name: 'Staging', location: 'SG' } }, + { text: 'Production', value: '3', properties: { id: 'prod', display_name: 'Production', location: 'EU' } }, + ], + }, + { + series: [frameWithValueTextAndPropertiesField], + valueProp: 'id', + textProp: 'display_name', + expected: [ + { text: 'Dev', value: '1', properties: { id: 'dev', display_name: 'Development', location: 'US' } }, + { text: 'Stag', value: '2', properties: { id: 'staging', display_name: 'Staging', location: 'SG' } }, + { text: 'Prod', value: '3', properties: { id: 'prod', display_name: 'Production', location: 'EU' } }, + ], + }, + ].forEach((scenario) => { + it(`when called with series:${JSON.stringify(scenario.series, null, 0)}`, async () => { + const { series, valueProp, textProp, expected } = scenario; + const panelData: any = { series }; + const observable = of(panelData).pipe(toMetricFindValues(valueProp, textProp)); + + await expect(observable).toEmitValuesWith((received) => { + const value = received[0]; + expect(value).toEqual(expected); + }); }); + }); - const panelData: any = { series: [frameWithTimeField] }; - const observable = of(panelData).pipe(toMetricFindValues()); + describe('when called with no string fields', () => { + it('then the observable throws', async () => { + const panelData: any = { series: [frameWithPropertiesField] }; + const observable = of(panelData).pipe(toMetricFindValues(undefined, undefined)); - await expect(observable).toEmitValuesWith((received) => { - const value = received[0]; - expect(value).toEqual(new Error("Couldn't find any field of type string in the results.")); + await expect(observable).toEmitValuesWith((received) => { + const value = received[0]; + expect(value).toEqual(new Error('Properties found in series but missing valueProp and textProp')); + }); }); }); }); diff --git a/packages/scenes/src/variables/variants/query/toMetricFindValues.ts b/packages/scenes/src/variables/variants/query/toMetricFindValues.ts index 324d6c3e9..d7b55b88b 100644 --- a/packages/scenes/src/variables/variants/query/toMetricFindValues.ts +++ b/packages/scenes/src/variables/variants/query/toMetricFindValues.ts @@ -1,14 +1,22 @@ import { + DataFrame, FieldType, getFieldDisplayName, + getProcessedDataFrames, isDataFrame, MetricFindValue, PanelData, - getProcessedDataFrames, } from '@grafana/data'; import { map, OperatorFunction } from 'rxjs'; -export function toMetricFindValues(): OperatorFunction { +interface MetricFindValueWithOptionalProperties extends MetricFindValue { + properties?: Record; +} + +export function toMetricFindValues( + valueProp?: string, + textProp?: string +): OperatorFunction { return (source) => source.pipe( map((panelData) => { @@ -25,68 +33,59 @@ export function toMetricFindValues(): OperatorFunction { + acc[p.name] = frame.fields[p.index].values.get(index); + return acc; + }, {} as Record); + + metrics.push({ + value: + value || + (valueProp && properties[valueProp as string]) || + text || + (textProp && properties[textProp as string]), + text: + text || + (textProp && properties[textProp as string]) || + value || + (valueProp && properties[valueProp as string]), + properties, + expandable, + }); } } @@ -95,6 +94,58 @@ export function toMetricFindValues(): OperatorFunction; + expandable: number; +}; + +function findFieldsIndices(frames: DataFrame[]): Indices { + const indices: Indices = { + value: -1, + text: -1, + properties: [], + expandable: -1, + }; + + for (const frame of getProcessedDataFrames(frames)) { + for (let index = 0; index < frame.fields.length; index++) { + const field = frame.fields[index]; + const fieldName = getFieldDisplayName(field, frame, frames).toLowerCase(); + + if (field.type === FieldType.string) { + if (fieldName === 'value') { + if (indices.value === -1) { + indices.value = index; + } + continue; + } + + if (fieldName === 'text') { + if (indices.text === -1) { + indices.text = index; + } + continue; + } + + indices.properties.push({ name: fieldName, index }); + continue; + } + + if ( + fieldName === 'expandable' && + (field.type === FieldType.boolean || field.type === FieldType.number) && + indices.expandable === -1 + ) { + indices.expandable = index; + } + } + } + + return indices; +} + function areMetricFindValues(data: any[]): data is MetricFindValue[] { if (!data) { return false; diff --git a/packages/scenes/src/variables/variants/query/utils.ts b/packages/scenes/src/variables/variants/query/utils.ts index bfc73c150..f1b89ec89 100644 --- a/packages/scenes/src/variables/variants/query/utils.ts +++ b/packages/scenes/src/variables/variants/query/utils.ts @@ -51,7 +51,7 @@ export function metricNamesToVariableValues(variableRegEx: string, sort: Variabl } } - options.push({ label: text, value: value }); + options.push({ label: text, value: value, properties: item.properties }); } options = uniqBy(options, 'value');