From 40cdb3033697e8d8e43d0ee0342f0d16fac7d633 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Wed, 4 Oct 2023 16:28:46 +0200 Subject: [PATCH] Transformations: Add support for dashboard variable in limit, sort by, filter by value, heatmap and histogram (#75372) * variables for filterforvalue * use datalinkinput for basic matcher * fix user select issue * heatmap transformation variable interpolation * clean code * interpolate sort by * add options interpolation in histogram transformation * interpolation for limit * Add suggestions UI to Filter by data value Transformation Co-authored-by: oscarkilhed * add validation for number/variable fields * Add variables to add field from calculation * Add validator to limit transformation * Refactor validator * Refactor suggestionInput styles * Add variable support in heatmap calculate options to be in sync with tranform * Refactor SuggestionsInput * Fix histogram, limit and filter by value matchers * clean up weird state ref * Only interpolate when the feature toggle is set * Add feature toggle to ui * Fix number of variable test * Fix issue with characters typed after opening suggestions still remains after selecting a suggestion * Clean up from review * Add more tests for numberOrVariableValidator --------- Co-authored-by: Victor Marin --- .betterer.results | 8 +- .../feature-toggles/index.md | 1 + .../matchers/valueMatchers/numericMatchers.ts | 8 +- .../matchers/valueMatchers/rangeMatchers.ts | 2 +- .../transformers/filterByValue.test.ts | 99 ++++++++ .../transformers/filterByValue.ts | 50 ++++- .../transformations/transformers/histogram.ts | 55 ++++- .../src/transformations/transformers/limit.ts | 24 +- .../transformations/transformers/sortBy.ts | 19 +- .../src/transformations/transformers/utils.ts | 3 + .../src/types/featureToggles.gen.ts | 1 + .../DataLinks/DataLinkSuggestions.tsx | 1 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../FilterByValueFilterEditor.tsx | 2 +- .../ValueMatchers/BasicMatcherEditor.tsx | 83 ++++--- .../ValueMatchers/RangeMatcherEditor.tsx | 60 ++++- .../ValueMatchers/RegexMatcherEditor.tsx | 63 ++++++ .../ValueMatchers/valueMatchersUI.ts | 8 +- .../HeatmapTransformerEditor.tsx | 1 - .../calculateHeatmap/editor/AxisEditor.tsx | 54 ++++- .../transformers/calculateHeatmap/heatmap.ts | 25 ++- .../CalculateFieldTransformerEditor.tsx | 18 +- .../editors/HistogramTransformerEditor.tsx | 113 +++++++++- .../editors/LimitTransformerEditor.tsx | 59 ++++- .../editors/SortByTransformerEditor.tsx | 5 +- .../suggestionsInput/SuggestionsInput.tsx | 211 ++++++++++++++++++ .../app/features/transformers/utils.test.ts | 65 ++++++ public/app/features/transformers/utils.ts | 14 ++ .../plugins/panel/heatmap/HeatmapPanel.tsx | 12 +- public/app/plugins/panel/heatmap/fields.ts | 31 ++- public/app/plugins/panel/heatmap/module.tsx | 10 +- 33 files changed, 1020 insertions(+), 97 deletions(-) create mode 100644 packages/grafana-data/src/transformations/transformers/utils.ts create mode 100644 public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx create mode 100644 public/app/features/transformers/suggestionsInput/SuggestionsInput.tsx create mode 100644 public/app/features/transformers/utils.test.ts diff --git a/.betterer.results b/.betterer.results index b16051014c5..927ce516602 100644 --- a/.betterer.results +++ b/.betterer.results @@ -275,6 +275,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "packages/grafana-data/src/transformations/transformers/utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -5225,9 +5229,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"] ], "public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 90295652945..a7418ed2963 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -143,6 +143,7 @@ Experimental features might be changed or removed without prior notice. | `httpSLOLevels` | Adds SLO level to http request metrics | | `alertingModifiedExport` | Enables using UI for provisioned rules modification and export | | `enableNativeHTTPHistogram` | Enables native HTTP Histograms | +| `transformationsVariableSupport` | Allows using variables in transformations | ## Development feature toggles diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts index be8873175a3..3f23a28ffcb 100644 --- a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts @@ -4,7 +4,7 @@ import { ValueMatcherID } from '../ids'; import { BasicValueMatcherOptions } from './types'; -const isGreaterValueMatcher: ValueMatcherInfo> = { +const isGreaterValueMatcher: ValueMatcherInfo = { id: ValueMatcherID.greater, name: 'Is greater', description: 'Match when field value is greater than option.', @@ -24,7 +24,7 @@ const isGreaterValueMatcher: ValueMatcherInfo> getDefaultOptions: () => ({ value: 0 }), }; -const isGreaterOrEqualValueMatcher: ValueMatcherInfo> = { +const isGreaterOrEqualValueMatcher: ValueMatcherInfo = { id: ValueMatcherID.greaterOrEqual, name: 'Is greater or equal', description: 'Match when field value is greater than or equal to option.', @@ -44,7 +44,7 @@ const isGreaterOrEqualValueMatcher: ValueMatcherInfo ({ value: 0 }), }; -const isLowerValueMatcher: ValueMatcherInfo> = { +const isLowerValueMatcher: ValueMatcherInfo = { id: ValueMatcherID.lower, name: 'Is lower', description: 'Match when field value is lower than option.', @@ -64,7 +64,7 @@ const isLowerValueMatcher: ValueMatcherInfo> = getDefaultOptions: () => ({ value: 0 }), }; -const isLowerOrEqualValueMatcher: ValueMatcherInfo> = { +const isLowerOrEqualValueMatcher: ValueMatcherInfo = { id: ValueMatcherID.lowerOrEqual, name: 'Is lower or equal', description: 'Match when field value is lower or equal than option.', diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts index a2f051945e4..c467e5aa993 100644 --- a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts @@ -4,7 +4,7 @@ import { ValueMatcherID } from '../ids'; import { RangeValueMatcherOptions } from './types'; -const isBetweenValueMatcher: ValueMatcherInfo> = { +const isBetweenValueMatcher: ValueMatcherInfo = { id: ValueMatcherID.between, name: 'Is between', description: 'Match when field value is between given option values.', diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts index 37e836f38e2..06db191240e 100644 --- a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts @@ -13,6 +13,18 @@ import { } from './filterByValue'; import { DataTransformerID } from './ids'; +let transformationSupport = false; + +jest.mock('./utils', () => { + const actual = jest.requireActual('./utils'); + return { + ...actual, + transformationsVariableSupport: () => { + return transformationSupport; + }, + }; +}); + const seriesAWithSingleField = toDataFrame({ name: 'A', length: 7, @@ -109,6 +121,93 @@ describe('FilterByValue transformer', () => { }); }); + it('should interpolate dashboard variables', async () => { + transformationSupport = true; + + const lower: MatcherConfig> = { + id: ValueMatcherID.lower, + options: { value: 'thiswillinterpolateto6' }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.exclude, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lower, + }, + ], + }, + }; + + const ctxmock = { interpolate: jest.fn(() => '6') }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField], ctxmock)).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: [6000, 7000], + state: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: [6, 7], + state: {}, + }, + ]); + }); + transformationSupport = false; + }); + + it('should not interpolate dashboard variables when feature toggle is off', async () => { + const lower: MatcherConfig> = { + id: ValueMatcherID.lower, + options: { value: 'notinterpolating' }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.exclude, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lower, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: [1000, 2000, 3000, 4000, 5000, 6000, 7000], + state: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: [1, 2, 3, 4, 5, 6, 7], + state: {}, + }, + ]); + }); + }); + it('should match any condition', async () => { const lowerOrEqual: MatcherConfig> = { id: ValueMatcherID.lowerOrEqual, diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.ts index 0e6f47e9b7b..766c98fcc2d 100644 --- a/packages/grafana-data/src/transformations/transformers/filterByValue.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.ts @@ -4,9 +4,11 @@ import { getFieldDisplayName } from '../../field/fieldState'; import { DataFrame, Field } from '../../types/dataFrame'; import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; import { getValueMatcher } from '../matchers'; +import { ValueMatcherID } from '../matchers/ids'; import { DataTransformerID } from './ids'; import { noopTransformer } from './noop'; +import { transformationsVariableSupport } from './utils'; export enum FilterByValueType { exclude = 'exclude', @@ -48,6 +50,46 @@ export const filterByValueTransformer: DataTransformerInfo { + if (filter.config.id === ValueMatcherID.between) { + const interpolatedFrom = ctx.interpolate(filter.config.options.from); + const interpolatedTo = ctx.interpolate(filter.config.options.to); + + const newFilter = { + ...filter, + config: { + ...filter.config, + options: { + ...filter.config.options, + to: interpolatedTo, + from: interpolatedFrom, + }, + }, + }; + + return newFilter; + } else if (filter.config.id === ValueMatcherID.regex) { + // Due to colliding syntaxes, interpolating regex filters will cause issues. + return filter; + } else if (filter.config.options.value) { + const interpolatedValue = ctx.interpolate(filter.config.options.value); + const newFilter = { + ...filter, + config: { ...filter.config, options: { ...filter.config.options, value: interpolatedValue } }, + }; + newFilter.config.options.value! = interpolatedValue; + return newFilter; + } + + return filter; + }) + ); + } + return source.pipe( map((data) => { if (!Array.isArray(data) || data.length === 0) { @@ -58,7 +100,13 @@ export const filterByValueTransformer: DataTransformerInfo a - b; +export interface HistogramTransformerInputs { + bucketSize?: string | number; + bucketOffset?: string | number; + combine?: boolean; +} + /** * @alpha */ @@ -74,7 +81,7 @@ export const histogramFieldInfo = { /** * @alpha */ -export const histogramTransformer: SynchronousDataTransformerInfo = { +export const histogramTransformer: SynchronousDataTransformerInfo = { id: DataTransformerID.histogram, name: 'Histogram', description: 'Calculate a histogram from input data.', @@ -85,11 +92,51 @@ export const histogramTransformer: SynchronousDataTransformerInfo (source) => source.pipe(map((data) => histogramTransformer.transformer(options, ctx)(data))), - transformer: (options: HistogramTransformerOptions) => (data: DataFrame[]) => { + transformer: (options: HistogramTransformerInputs, ctx: DataTransformContext) => (data: DataFrame[]) => { if (!Array.isArray(data) || data.length === 0) { return data; } - const hist = buildHistogram(data, options); + + let bucketSize, + bucketOffset: number | undefined = undefined; + + if (options.bucketSize) { + if (transformationsVariableSupport()) { + options.bucketSize = ctx.interpolate(options.bucketSize.toString()); + } + if (typeof options.bucketSize === 'string') { + bucketSize = parseFloat(options.bucketSize); + } else { + bucketSize = options.bucketSize; + } + + if (isNaN(bucketSize)) { + bucketSize = undefined; + } + } + + if (options.bucketOffset) { + if (transformationsVariableSupport()) { + options.bucketOffset = ctx.interpolate(options.bucketOffset.toString()); + } + if (typeof options.bucketOffset === 'string') { + bucketOffset = parseFloat(options.bucketOffset); + } else { + bucketOffset = options.bucketOffset; + } + + if (isNaN(bucketOffset)) { + bucketOffset = undefined; + } + } + + const interpolatedOptions: HistogramTransformerOptions = { + bucketSize: bucketSize, + bucketOffset: bucketOffset, + combine: options.combine, + }; + + const hist = buildHistogram(data, interpolatedOptions); if (hist == null) { return []; } diff --git a/packages/grafana-data/src/transformations/transformers/limit.ts b/packages/grafana-data/src/transformations/transformers/limit.ts index 5a2f5639639..2aa4d4c0fa2 100644 --- a/packages/grafana-data/src/transformations/transformers/limit.ts +++ b/packages/grafana-data/src/transformations/transformers/limit.ts @@ -3,9 +3,10 @@ import { map } from 'rxjs/operators'; import { DataTransformerInfo } from '../../types'; import { DataTransformerID } from './ids'; +import { transformationsVariableSupport } from './utils'; export interface LimitTransformerOptions { - limitField?: number; + limitField?: number | string; } const DEFAULT_LIMIT_FIELD = 10; @@ -18,21 +19,32 @@ export const limitTransformer: DataTransformerInfo = { limitField: DEFAULT_LIMIT_FIELD, }, - operator: (options) => (source) => + operator: (options, ctx) => (source) => source.pipe( map((data) => { - const limitFieldMatch = options.limitField || DEFAULT_LIMIT_FIELD; + let limit = DEFAULT_LIMIT_FIELD; + if (options.limitField !== undefined) { + if (typeof options.limitField === 'string') { + if (transformationsVariableSupport()) { + limit = parseInt(ctx.interpolate(options.limitField), 10); + } else { + limit = parseInt(options.limitField, 10); + } + } else { + limit = options.limitField; + } + } return data.map((frame) => { - if (frame.length > limitFieldMatch) { + if (frame.length > limit) { return { ...frame, fields: frame.fields.map((f) => { return { ...f, - values: f.values.slice(0, limitFieldMatch), + values: f.values.slice(0, limit), }; }), - length: limitFieldMatch, + length: limit, }; } diff --git a/packages/grafana-data/src/transformations/transformers/sortBy.ts b/packages/grafana-data/src/transformations/transformers/sortBy.ts index e425bf31cbc..7cd99acce36 100644 --- a/packages/grafana-data/src/transformations/transformers/sortBy.ts +++ b/packages/grafana-data/src/transformations/transformers/sortBy.ts @@ -3,9 +3,10 @@ import { map } from 'rxjs/operators'; import { sortDataFrame } from '../../dataframe'; import { getFieldDisplayName } from '../../field'; import { DataFrame } from '../../types'; -import { DataTransformerInfo } from '../../types/transformations'; +import { DataTransformContext, DataTransformerInfo } from '../../types/transformations'; import { DataTransformerID } from './ids'; +import { transformationsVariableSupport } from './utils'; export interface SortByField { field: string; @@ -31,20 +32,20 @@ export const sortByTransformer: DataTransformerInfo = * Return a modified copy of the series. If the transform is not or should not * be applied, just return the input series */ - operator: (options) => (source) => + operator: (options, ctx) => (source) => source.pipe( map((data) => { if (!Array.isArray(data) || data.length === 0 || !options?.sort?.length) { return data; } - return sortDataFrames(data, options.sort); + return sortDataFrames(data, options.sort, ctx); }) ), }; -export function sortDataFrames(data: DataFrame[], sort: SortByField[]): DataFrame[] { +export function sortDataFrames(data: DataFrame[], sort: SortByField[], ctx: DataTransformContext): DataFrame[] { return data.map((frame) => { - const s = attachFieldIndex(frame, sort); + const s = attachFieldIndex(frame, sort, ctx); if (s.length && s[0].index != null) { return sortDataFrame(frame, s[0].index, s[0].desc); } @@ -52,12 +53,18 @@ export function sortDataFrames(data: DataFrame[], sort: SortByField[]): DataFram }); } -function attachFieldIndex(frame: DataFrame, sort: SortByField[]): SortByField[] { +function attachFieldIndex(frame: DataFrame, sort: SortByField[], ctx: DataTransformContext): SortByField[] { return sort.map((s) => { if (s.index != null) { // null or undefined return s; } + if (transformationsVariableSupport()) { + return { + ...s, + index: frame.fields.findIndex((f) => ctx.interpolate(s.field) === getFieldDisplayName(f, frame)), + }; + } return { ...s, index: frame.fields.findIndex((f) => s.field === getFieldDisplayName(f, frame)), diff --git a/packages/grafana-data/src/transformations/transformers/utils.ts b/packages/grafana-data/src/transformations/transformers/utils.ts new file mode 100644 index 00000000000..64f762200fb --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/utils.ts @@ -0,0 +1,3 @@ +export const transformationsVariableSupport = () => { + return (window as any)?.grafanaBootData?.settings?.featureToggles?.transformationsVariableSupport; +}; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a3f181c3601..f9fc9655152 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -136,4 +136,5 @@ export interface FeatureToggles { externalServiceAccounts?: boolean; alertingModifiedExport?: boolean; enableNativeHTTPHistogram?: boolean; + transformationsVariableSupport?: boolean; } diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx index 2e6c1fc1855..472d69eee7e 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -31,6 +31,7 @@ const getStyles = (theme: GrafanaTheme2) => { item: css({ background: 'none', padding: '2px 8px', + userSelect: 'none', color: theme.colors.text.primary, cursor: 'pointer', '&:hover': { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c41abfb6ff3..d2fdfc3838c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -823,5 +823,12 @@ var ( FrontendOnly: false, Owner: hostedGrafanaTeam, }, + { + Name: "transformationsVariableSupport", + Description: "Allows using variables in transformations", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaBiSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 18eff8b92a5..c52d3030f7c 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -117,3 +117,4 @@ cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,false,false,false, externalServiceAccounts,experimental,@grafana/grafana-authnz-team,true,false,false,false alertingModifiedExport,experimental,@grafana/alerting-squad,false,false,false,false enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,false,false +transformationsVariableSupport,experimental,@grafana/grafana-bi-squad,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 5b88d2a8172..1c5a647d2cc 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -478,4 +478,8 @@ const ( // FlagEnableNativeHTTPHistogram // Enables native HTTP Histograms FlagEnableNativeHTTPHistogram = "enableNativeHTTPHistogram" + + // FlagTransformationsVariableSupport + // Allows using variables in transformations + FlagTransformationsVariableSupport = "transformationsVariableSupport" ) diff --git a/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx index 9b7343610c7..fdfd2e1b154 100644 --- a/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx +++ b/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx @@ -96,7 +96,7 @@ export const FilterByValueFilterEditor = (props: Props) => { onChange={onChangeMatcher} /> -
+
Value
diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx index e86bae96704..e80b2cbe2be 100644 --- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx @@ -1,19 +1,28 @@ import React, { useCallback, useState } from 'react'; -import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data'; +import { ValueMatcherID, BasicValueMatcherOptions, VariableOrigin } from '@grafana/data'; +import { getTemplateSrv, config as cfg } from '@grafana/runtime'; import { Input } from '@grafana/ui'; +import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput'; +import { numberOrVariableValidator } from '../../utils'; + import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; import { convertToType } from './utils'; export function basicMatcherEditor( config: ValueMatcherEditorConfig -): React.FC>> { +): React.FC> { return function Render({ options, onChange, field }) { const { validator, converter = convertToType } = config; const { value } = options; const [isInvalid, setInvalid] = useState(!validator(value)); + const templateSrv = getTemplateSrv(); + const variables = templateSrv.getVariables().map((v) => { + return { value: v.name, label: v.label || v.name, origin: VariableOrigin.Template }; + }); + const onChangeValue = useCallback( (event: React.FormEvent) => { setInvalid(!validator(event.currentTarget.value)); @@ -37,16 +46,40 @@ export function basicMatcherEditor( [options, onChange, isInvalid, field, converter] ); - return ( - + const onChangeVariableValue = useCallback( + (value: string) => { + setInvalid(!validator(value)); + onChange({ + ...options, + value: value, + }); + }, + [setInvalid, validator, onChange, options] ); + + if (cfg.featureToggles.transformationsVariableSupport) { + return ( + + ); + } else { + return ( + + ); + } }; } @@ -55,52 +88,44 @@ export const getBasicValueMatchersUI = (): Array({ - validator: (value) => !isNaN(value), + component: basicMatcherEditor({ + validator: numberOrVariableValidator, }), }, { name: 'Is greater or equal', id: ValueMatcherID.greaterOrEqual, - component: basicMatcherEditor({ - validator: (value) => !isNaN(value), + component: basicMatcherEditor({ + validator: numberOrVariableValidator, }), }, { name: 'Is lower', id: ValueMatcherID.lower, - component: basicMatcherEditor({ - validator: (value) => !isNaN(value), + component: basicMatcherEditor({ + validator: numberOrVariableValidator, }), }, { name: 'Is lower or equal', id: ValueMatcherID.lowerOrEqual, - component: basicMatcherEditor({ - validator: (value) => !isNaN(value), + component: basicMatcherEditor({ + validator: numberOrVariableValidator, }), }, { name: 'Is equal', id: ValueMatcherID.equal, - component: basicMatcherEditor({ + component: basicMatcherEditor({ validator: () => true, }), }, { name: 'Is not equal', id: ValueMatcherID.notEqual, - component: basicMatcherEditor({ + component: basicMatcherEditor({ validator: () => true, }), }, - { - name: 'Regex', - id: ValueMatcherID.regex, - component: basicMatcherEditor({ - validator: () => true, - converter: (value) => String(value), - }), - }, ]; }; diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx index fc64c0b64a1..1276d5f65f4 100644 --- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx @@ -1,8 +1,12 @@ import React, { useCallback, useState } from 'react'; -import { ValueMatcherID, RangeValueMatcherOptions } from '@grafana/data'; +import { ValueMatcherID, RangeValueMatcherOptions, VariableOrigin } from '@grafana/data'; +import { getTemplateSrv, config as cfg } from '@grafana/runtime'; import { Input } from '@grafana/ui'; +import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput'; +import { numberOrVariableValidator } from '../../utils'; + import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; import { convertToType } from './utils'; @@ -18,6 +22,11 @@ export function rangeMatcherEditor( to: !validator(options.to), }); + const templateSrv = getTemplateSrv(); + const variables = templateSrv.getVariables().map((v) => { + return { value: v.name, label: v.label || v.name, origin: VariableOrigin.Template }; + }); + const onChangeValue = useCallback( (event: React.FormEvent, prop: PropNames) => { setInvalid({ @@ -44,6 +53,49 @@ export function rangeMatcherEditor( [options, onChange, isInvalid, field] ); + const onChangeOptionsSuggestions = useCallback( + (value: string, prop: PropNames) => { + const invalid = !validator(value); + + setInvalid({ + ...isInvalid, + [prop]: invalid, + }); + + if (invalid) { + return; + } + + onChange({ + ...options, + [prop]: value, + }); + }, + [options, onChange, isInvalid, setInvalid, validator] + ); + if (cfg.featureToggles.transformationsVariableSupport) { + return ( + <> + onChangeOptionsSuggestions(val, 'from')} + suggestions={variables} + /> +
and
+ onChangeOptionsSuggestions(val, 'to')} + /> + + ); + } return ( <> ({ - validator: (value) => { - return !isNaN(value); - }, + component: rangeMatcherEditor({ + validator: numberOrVariableValidator, }), }, ]; diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx new file mode 100644 index 00000000000..5c15e25bf56 --- /dev/null +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx @@ -0,0 +1,63 @@ +import React, { useCallback, useState } from 'react'; + +import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data'; +import { Input } from '@grafana/ui'; + +import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types'; +import { convertToType } from './utils'; + +export function regexMatcherEditor( + config: ValueMatcherEditorConfig +): React.FC>> { + return function Render({ options, onChange, field }) { + const { validator, converter = convertToType } = config; + const { value } = options; + const [isInvalid, setInvalid] = useState(!validator(value)); + const onChangeValue = useCallback( + (event: React.FormEvent) => { + setInvalid(!validator(event.currentTarget.value)); + }, + [setInvalid, validator] + ); + + const onChangeOptions = useCallback( + (event: React.FocusEvent) => { + if (isInvalid) { + return; + } + + const { value } = event.currentTarget; + + onChange({ + ...options, + value: converter(value, field), + }); + }, + [options, onChange, isInvalid, field, converter] + ); + + return ( + + ); + }; +} + +export const getRegexValueMatchersUI = (): Array> => { + return [ + { + name: 'Regex', + id: ValueMatcherID.regex, + component: regexMatcherEditor({ + validator: () => true, + converter: (value) => String(value), + }), + }, + ]; +}; diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts index d1c7e994c9e..52c2cbf3b36 100644 --- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts @@ -3,8 +3,14 @@ import { Registry } from '@grafana/data'; import { getBasicValueMatchersUI } from './BasicMatcherEditor'; import { getNoopValueMatchersUI } from './NoopMatcherEditor'; import { getRangeValueMatchersUI } from './RangeMatcherEditor'; +import { getRegexValueMatchersUI } from './RegexMatcherEditor'; import { ValueMatcherUIRegistryItem } from './types'; export const valueMatchersUI = new Registry>(() => { - return [...getBasicValueMatchersUI(), ...getNoopValueMatchersUI(), ...getRangeValueMatchersUI()]; + return [ + ...getBasicValueMatchersUI(), + ...getNoopValueMatchersUI(), + ...getRangeValueMatchersUI(), + ...getRegexValueMatchersUI(), + ]; }); diff --git a/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx b/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx index c47c5fc0cf1..1870cbd2d5b 100644 --- a/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx +++ b/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx @@ -29,7 +29,6 @@ export const HeatmapTransformerEditor = (props: TransformerUIProps> = [ { label: 'Size', @@ -26,6 +30,21 @@ const logModeOptions: Array> = [ ]; export const AxisEditor = ({ value, onChange, item }: StandardEditorProps) => { + const [isInvalid, setInvalid] = useState(false); + + const onValueChange = (bucketValue: string) => { + setInvalid(!numberOrVariableValidator(bucketValue)); + onChange({ + ...value, + value: bucketValue, + }); + }; + + const templateSrv = getTemplateSrv(); + const variables = templateSrv.getVariables().map((v) => { + return { value: v.name, label: v.label || v.name, origin: VariableOrigin.Template }; + }); + return ( - { - onChange({ - ...value, - value: v.currentTarget.value, - }); - }} - /> + {cfg.featureToggles.transformationsVariableSupport ? ( + + ) : ( + { + onChange({ + ...value, + value: v.currentTarget.value, + }); + }} + /> + )} ); }; diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index bf797298d0d..32b5b2c6007 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -16,6 +16,7 @@ import { parseDuration, } from '@grafana/data'; import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames'; +import { config } from '@grafana/runtime'; import { ScaleDistribution, HeatmapCellLayout, @@ -37,7 +38,29 @@ export const heatmapTransformer: SynchronousDataTransformerInfo (source) => - source.pipe(map((data) => heatmapTransformer.transformer(options, ctx)(data))), + source.pipe( + map((data) => { + if (config.featureToggles.transformationsVariableSupport) { + const optionsCopy = { + ...options, + xBuckets: { ...options.xBuckets } ?? undefined, + yBuckets: { ...options.yBuckets } ?? undefined, + }; + + if (optionsCopy.xBuckets?.value) { + optionsCopy.xBuckets.value = ctx.interpolate(optionsCopy.xBuckets.value); + } + + if (optionsCopy.yBuckets?.value) { + optionsCopy.yBuckets.value = ctx.interpolate(optionsCopy.yBuckets.value); + } + + return heatmapTransformer.transformer(optionsCopy, ctx)(data); + } else { + return heatmapTransformer.transformer(options, ctx)(data); + } + }) + ), transformer: (options: HeatmapTransformerOptions) => { return (data: DataFrame[]) => { diff --git a/public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx index f5ebd50df1b..930dee5239e 100644 --- a/public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx +++ b/public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx @@ -1,6 +1,6 @@ import { defaults } from 'lodash'; import React, { ChangeEvent } from 'react'; -import { of, OperatorFunction } from 'rxjs'; +import { identity, of, OperatorFunction } from 'rxjs'; import { map } from 'rxjs/operators'; import { @@ -26,6 +26,7 @@ import { IndexOptions, ReduceOptions, } from '@grafana/data/src/transformations/transformers/calculateField'; +import { getTemplateSrv, config as cfg } from '@grafana/runtime'; import { FilterPill, HorizontalGroup, Input, LegacyForms, Select, StatsPicker } from '@grafana/ui'; interface CalculateFieldTransformerEditorProps extends TransformerUIProps {} @@ -76,6 +77,7 @@ export class CalculateFieldTransformerEditor extends React.PureComponent< .pipe( standardTransformers.ensureColumnsTransformer.operator(null, ctx), this.extractAllNames(), + this.getVariableNames(), this.extractNamesAndSelected(configuredOptions) ) .subscribe(({ selected, names }) => { @@ -83,6 +85,20 @@ export class CalculateFieldTransformerEditor extends React.PureComponent< }); } + private getVariableNames(): OperatorFunction { + if (!cfg.featureToggles.transformationsVariableSupport) { + return identity; + } + const templateSrv = getTemplateSrv(); + return (source) => + source.pipe( + map((input) => { + input.push(...templateSrv.getVariables().map((v) => '$' + v.name)); + return input; + }) + ); + } + private extractAllNames(): OperatorFunction { return (source) => source.pipe( diff --git a/public/app/features/transformers/editors/HistogramTransformerEditor.tsx b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx index 03ef3ababaf..4c79d933b1a 100644 --- a/public/app/features/transformers/editors/HistogramTransformerEditor.tsx +++ b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { DataTransformerID, @@ -6,21 +6,31 @@ import { TransformerRegistryItem, TransformerUIProps, TransformerCategory, + VariableOrigin, } from '@grafana/data'; import { - HistogramTransformerOptions, histogramFieldInfo, + HistogramTransformerInputs, } from '@grafana/data/src/transformations/transformers/histogram'; +import { getTemplateSrv, config as cfg } from '@grafana/runtime'; import { InlineField, InlineFieldRow, InlineSwitch } from '@grafana/ui'; import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; +import { SuggestionsInput } from '../suggestionsInput/SuggestionsInput'; +import { numberOrVariableValidator } from '../utils'; + export const HistogramTransformerEditor = ({ input, options, onChange, -}: TransformerUIProps) => { +}: TransformerUIProps) => { const labelWidth = 18; + const [isInvalid, setInvalid] = useState({ + bucketSize: !numberOrVariableValidator(options.bucketSize || ''), + bucketOffset: !numberOrVariableValidator(options.bucketOffset || ''), + }); + const onBucketSizeChanged = useCallback( (val?: number) => { onChange({ @@ -41,6 +51,30 @@ export const HistogramTransformerEditor = ({ [onChange, options] ); + const onVariableBucketSizeChanged = useCallback( + (value: string) => { + setInvalid({ ...isInvalid, bucketSize: !numberOrVariableValidator(value) }); + + onChange({ + ...options, + bucketSize: value, + }); + }, + [onChange, options, isInvalid, setInvalid] + ); + + const onVariableBucketOffsetChanged = useCallback( + (value: string) => { + setInvalid({ ...isInvalid, bucketOffset: !numberOrVariableValidator(value) }); + + onChange({ + ...options, + bucketOffset: value, + }); + }, + [onChange, options, isInvalid, setInvalid] + ); + const onToggleCombine = useCallback(() => { onChange({ ...options, @@ -48,15 +82,75 @@ export const HistogramTransformerEditor = ({ }); }, [onChange, options]); + const templateSrv = getTemplateSrv(); + const variables = templateSrv.getVariables().map((v) => { + return { value: v.name, label: v.label || v.name, origin: VariableOrigin.Template }; + }); + + if (!cfg.featureToggles.transformationsVariableSupport) { + let bucketSize; + if (typeof options.bucketSize === 'string') { + bucketSize = parseInt(options.bucketSize, 10); + } else { + bucketSize = options.bucketSize; + } + + let bucketOffset; + if (typeof options.bucketOffset === 'string') { + bucketOffset = parseInt(options.bucketOffset, 10); + } else { + bucketOffset = options.bucketOffset; + } + + return ( +
+ + + + + + + + + + + + + + + +
+ ); + } + return (
- + @@ -64,8 +158,15 @@ export const HistogramTransformerEditor = ({ labelWidth={labelWidth} label={histogramFieldInfo.bucketOffset.name} tooltip={histogramFieldInfo.bucketOffset.description} + invalid={isInvalid.bucketOffset} + error={'Value needs to be an integer or a variable'} > - + @@ -81,7 +182,7 @@ export const HistogramTransformerEditor = ({ ); }; -export const histogramTransformRegistryItem: TransformerRegistryItem = { +export const histogramTransformRegistryItem: TransformerRegistryItem = { id: DataTransformerID.histogram, editor: HistogramTransformerEditor, transformation: standardTransformers.histogramTransformer, diff --git a/public/app/features/transformers/editors/LimitTransformerEditor.tsx b/public/app/features/transformers/editors/LimitTransformerEditor.tsx index 35ba5f7d6fb..0c6b771ee4c 100644 --- a/public/app/features/transformers/editors/LimitTransformerEditor.tsx +++ b/public/app/features/transformers/editors/LimitTransformerEditor.tsx @@ -1,4 +1,4 @@ -import React, { FormEvent, useCallback } from 'react'; +import React, { FormEvent, useCallback, useState } from 'react'; import { DataTransformerID, @@ -6,11 +6,18 @@ import { TransformerRegistryItem, TransformerUIProps, TransformerCategory, + VariableOrigin, } from '@grafana/data'; import { LimitTransformerOptions } from '@grafana/data/src/transformations/transformers/limit'; +import { getTemplateSrv, config as cfg } from '@grafana/runtime'; import { InlineField, InlineFieldRow, Input } from '@grafana/ui'; +import { SuggestionsInput } from '../suggestionsInput/SuggestionsInput'; +import { numberOrVariableValidator } from '../utils'; + export const LimitTransformerEditor = ({ options, onChange }: TransformerUIProps) => { + const [isInvalid, setInvalid] = useState(false); + const onSetLimit = useCallback( (value: FormEvent) => { onChange({ @@ -21,18 +28,50 @@ export const LimitTransformerEditor = ({ options, onChange }: TransformerUIProps [onChange, options] ); + const onSetVariableLimit = useCallback( + (value: string) => { + setInvalid(!numberOrVariableValidator(value)); + onChange({ + ...options, + limitField: value, + }); + }, + [onChange, options] + ); + + const templateSrv = getTemplateSrv(); + const variables = templateSrv.getVariables().map((v) => { + return { value: v.name, label: v.label || v.name, origin: VariableOrigin.Template }; + }); + + if (!cfg.featureToggles.transformationsVariableSupport) { + return ( + <> + + + + + + + ); + } return ( <> - - - + ); diff --git a/public/app/features/transformers/editors/SortByTransformerEditor.tsx b/public/app/features/transformers/editors/SortByTransformerEditor.tsx index ff595611e27..6c227f948ea 100644 --- a/public/app/features/transformers/editors/SortByTransformerEditor.tsx +++ b/public/app/features/transformers/editors/SortByTransformerEditor.tsx @@ -8,12 +8,15 @@ import { TransformerCategory, } from '@grafana/data'; import { SortByField, SortByTransformerOptions } from '@grafana/data/src/transformations/transformers/sortBy'; +import { getTemplateSrv, config as cfg } from '@grafana/runtime'; import { InlineField, InlineSwitch, InlineFieldRow, Select } from '@grafana/ui'; import { useAllFieldNamesFromDataFrames } from '../utils'; export const SortByTransformerEditor = ({ input, options, onChange }: TransformerUIProps) => { const fieldNames = useAllFieldNamesFromDataFrames(input).map((item: string) => ({ label: item, value: item })); + const templateSrv = getTemplateSrv(); + const variables = templateSrv.getVariables().map((v) => ({ label: '$' + v.name, value: '$' + v.name })); // Only supports single sort for now const onSortChange = useCallback( @@ -32,7 +35,7 @@ export const SortByTransformerEditor = ({ input, options, onChange }: Transforme +
+ ); +}; + +SuggestionsInput.displayName = 'SuggestionsInput'; + +function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) { + return (suggestionElement?.clientHeight ?? 0) * activeIndex; +} diff --git a/public/app/features/transformers/utils.test.ts b/public/app/features/transformers/utils.test.ts new file mode 100644 index 00000000000..9fd153638af --- /dev/null +++ b/public/app/features/transformers/utils.test.ts @@ -0,0 +1,65 @@ +import { config } from '@grafana/runtime'; + +import { numberOrVariableValidator } from './utils'; + +describe('validator', () => { + it('validates a positive number', () => { + expect(numberOrVariableValidator(1)).toBe(true); + }); + + it('validates a negative number', () => { + expect(numberOrVariableValidator(-1)).toBe(true); + }); + + it('validates zero', () => { + expect(numberOrVariableValidator(0)).toBe(true); + }); + + it('validates a float', () => { + expect(numberOrVariableValidator(1.2)).toBe(true); + }); + + it('validates a negative float', () => { + expect(numberOrVariableValidator(1.2)).toBe(true); + }); + + it('validates a string that is a positive integer', () => { + expect(numberOrVariableValidator('1')).toBe(true); + }); + + it('validats a string that is a negative integer', () => { + expect(numberOrVariableValidator('-1')).toBe(true); + }); + + it('validats a string that is zero', () => { + expect(numberOrVariableValidator('0')).toBe(true); + }); + + it('validats a string that is a float', () => { + expect(numberOrVariableValidator('1.2')).toBe(true); + }); + + it('validats a string that is a negative float', () => { + expect(numberOrVariableValidator('-1.2')).toBe(true); + }); + + it('fails a string that is not a number', () => { + expect(numberOrVariableValidator('foo')).toBe(false); + }); + + it('validates a string that has a variable', () => { + config.featureToggles.transformationsVariableSupport = true; + expect(numberOrVariableValidator('$foo')).toBe(true); + config.featureToggles.transformationsVariableSupport = false; + }); + it('fails a string that has a variable if the feature flag is disabled', () => { + config.featureToggles.transformationsVariableSupport = false; + expect(numberOrVariableValidator('$foo')).toBe(false); + config.featureToggles.transformationsVariableSupport = true; + }); + it('fails a string that has multiple variables', () => { + config.featureToggles.transformationsVariableSupport = true; + expect(numberOrVariableValidator('$foo$asd')).toBe(false); + config.featureToggles.transformationsVariableSupport = false; + }); +}); diff --git a/public/app/features/transformers/utils.ts b/public/app/features/transformers/utils.ts index 33b3f9344dd..23c927ba7f6 100644 --- a/public/app/features/transformers/utils.ts +++ b/public/app/features/transformers/utils.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { DataFrame, getFieldDisplayName, TransformerCategory } from '@grafana/data'; +import { config } from '@grafana/runtime'; export function useAllFieldNamesFromDataFrames(input: DataFrame[]): string[] { return useMemo(() => { @@ -47,3 +48,16 @@ export const categoriesLabels: { [K in TransformerCategory]: string } = { reformat: 'Reformat', reorderAndRename: 'Reorder and rename', }; + +export const numberOrVariableValidator = (value: string | number) => { + if (typeof value === 'number') { + return true; + } + if (!Number.isNaN(Number(value))) { + return true; + } + if (/^\$[A-Za-z0-9_]+$/.test(value) && config.featureToggles.transformationsVariableSupport) { + return true; + } + return false; +}; diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index dac87caa2f8..174733c64a8 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -68,11 +68,19 @@ export const HeatmapPanel = ({ const info = useMemo(() => { try { - return prepareHeatmapData(data.series, data.annotations, options, palette, theme, getFieldLinksSupplier); + return prepareHeatmapData( + data.series, + data.annotations, + options, + palette, + theme, + getFieldLinksSupplier, + replaceVariables + ); } catch (ex) { return { warning: `${ex}` }; } - }, [data.series, data.annotations, options, palette, theme, getFieldLinksSupplier]); + }, [data.series, data.annotations, options, palette, theme, getFieldLinksSupplier, replaceVariables]); const facets = useMemo(() => { let exemplarsXFacet: number[] = []; // "Time" field diff --git a/public/app/plugins/panel/heatmap/fields.ts b/public/app/plugins/panel/heatmap/fields.ts index 2da54d1f2b3..0529ec3fce4 100644 --- a/public/app/plugins/panel/heatmap/fields.ts +++ b/public/app/plugins/panel/heatmap/fields.ts @@ -7,11 +7,13 @@ import { formattedValueToString, getDisplayProcessor, GrafanaTheme2, + InterpolateFunction, LinkModel, outerJoinDataFrames, ValueFormatter, ValueLinkConfig, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { HeatmapCellLayout } from '@grafana/schema'; import { calculateHeatmapFromData, @@ -68,7 +70,8 @@ export function prepareHeatmapData( options: Options, palette: string[], theme: GrafanaTheme2, - getFieldLinks?: (exemplars: DataFrame, field: Field) => (config: ValueLinkConfig) => Array> + getFieldLinks?: (exemplars: DataFrame, field: Field) => (config: ValueLinkConfig) => Array>, + replaceVariables?: InterpolateFunction ): HeatmapData { if (!frames?.length) { return {}; @@ -85,6 +88,32 @@ export function prepareHeatmapData( } if (options.calculate) { + if (config.featureToggles.transformationsVariableSupport) { + const optionsCopy = { + ...options, + calculation: { + xBuckets: { ...options.calculation?.xBuckets } ?? undefined, + yBuckets: { ...options.calculation?.yBuckets } ?? undefined, + }, + }; + + if (optionsCopy.calculation?.xBuckets?.value && replaceVariables !== undefined) { + optionsCopy.calculation.xBuckets.value = replaceVariables(optionsCopy.calculation.xBuckets.value); + } + + if (optionsCopy.calculation?.yBuckets?.value && replaceVariables !== undefined) { + optionsCopy.calculation.yBuckets.value = replaceVariables(optionsCopy.calculation.yBuckets.value); + } + + return getDenseHeatmapData( + calculateHeatmapFromData(frames, optionsCopy.calculation ?? {}), + exemplars, + optionsCopy, + palette, + theme + ); + } + return getDenseHeatmapData( calculateHeatmapFromData(frames, options.calculation ?? {}), exemplars, diff --git a/public/app/plugins/panel/heatmap/module.tsx b/public/app/plugins/panel/heatmap/module.tsx index 2fb79d39655..47f94f60fc6 100644 --- a/public/app/plugins/panel/heatmap/module.tsx +++ b/public/app/plugins/panel/heatmap/module.tsx @@ -52,7 +52,15 @@ export const plugin = new PanelPlugin(HeatmapPanel) // NOTE: this feels like overkill/expensive just to assert if we have an ordinal y // can probably simplify without doing full dataprep const palette = quantizeScheme(opts.color, config.theme2); - const v = prepareHeatmapData(context.data, undefined, opts, palette, config.theme2); + const v = prepareHeatmapData( + context.data, + undefined, + opts, + palette, + config.theme2, + undefined, + context.replaceVariables + ); isOrdinalY = readHeatmapRowsCustomMeta(v.heatmap).yOrdinalDisplay != null; } catch {} }