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 <oscar.kilhed@grafana.com>

* 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 <victor.marin@grafana.com>
This commit is contained in:
Oscar Kilhed
2023-10-04 16:28:46 +02:00
committed by GitHub
parent 027028d9a0
commit 40cdb30336
33 changed files with 1020 additions and 97 deletions

View File

@@ -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"]

View File

@@ -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

View File

@@ -4,7 +4,7 @@ import { ValueMatcherID } from '../ids';
import { BasicValueMatcherOptions } from './types';
const isGreaterValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isGreaterValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
id: ValueMatcherID.greater,
name: 'Is greater',
description: 'Match when field value is greater than option.',
@@ -24,7 +24,7 @@ const isGreaterValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>>
getDefaultOptions: () => ({ value: 0 }),
};
const isGreaterOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isGreaterOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
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<BasicValueMatcherOptions<nu
getDefaultOptions: () => ({ value: 0 }),
};
const isLowerValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isLowerValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
id: ValueMatcherID.lower,
name: 'Is lower',
description: 'Match when field value is lower than option.',
@@ -64,7 +64,7 @@ const isLowerValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> =
getDefaultOptions: () => ({ value: 0 }),
};
const isLowerOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isLowerOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
id: ValueMatcherID.lowerOrEqual,
name: 'Is lower or equal',
description: 'Match when field value is lower or equal than option.',

View File

@@ -4,7 +4,7 @@ import { ValueMatcherID } from '../ids';
import { RangeValueMatcherOptions } from './types';
const isBetweenValueMatcher: ValueMatcherInfo<RangeValueMatcherOptions<number>> = {
const isBetweenValueMatcher: ValueMatcherInfo<RangeValueMatcherOptions> = {
id: ValueMatcherID.between,
name: 'Is between',
description: 'Match when field value is between given option values.',

View File

@@ -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<BasicValueMatcherOptions<string | number>> = {
id: ValueMatcherID.lower,
options: { value: 'thiswillinterpolateto6' },
};
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = {
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<BasicValueMatcherOptions<number | string>> = {
id: ValueMatcherID.lower,
options: { value: 'notinterpolating' },
};
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = {
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<BasicValueMatcherOptions<number>> = {
id: ValueMatcherID.lowerOrEqual,

View File

@@ -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<FilterByValueTransfor
return source.pipe(noopTransformer.operator({}, ctx));
}
const interpolatedFilters: FilterByValueFilter[] = [];
if (transformationsVariableSupport()) {
interpolatedFilters.push(
...filters.map((filter) => {
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<FilterByValueTransfor
for (const frame of data) {
const fieldIndexByName = groupFieldIndexByName(frame, data);
const matchers = createFilterValueMatchers(filters, fieldIndexByName);
let matchers;
if (transformationsVariableSupport()) {
matchers = createFilterValueMatchers(interpolatedFilters, fieldIndexByName);
} else {
matchers = createFilterValueMatchers(filters, fieldIndexByName);
}
for (let index = 0; index < frame.length; index++) {
if (rows.has(index)) {

View File

@@ -2,12 +2,13 @@ import { map } from 'rxjs/operators';
import { getDisplayProcessor } from '../../field';
import { createTheme, GrafanaTheme2 } from '../../themes';
import { DataFrameType, SynchronousDataTransformerInfo } from '../../types';
import { DataFrameType, DataTransformContext, SynchronousDataTransformerInfo } from '../../types';
import { DataFrame, Field, FieldConfig, FieldType } from '../../types/dataFrame';
import { roundDecimals } from '../../utils';
import { DataTransformerID } from './ids';
import { AlignedData, join } from './joinDataFrames';
import { transformationsVariableSupport } from './utils';
/**
* @internal
@@ -40,6 +41,12 @@ export const histogramBucketSizes = [
const histFilter = [null];
const histSort = (a: number, b: number) => 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<HistogramTransformerOptions> = {
export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTransformerInputs> = {
id: DataTransformerID.histogram,
name: 'Histogram',
description: 'Calculate a histogram from input data.',
@@ -85,11 +92,51 @@ export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTrans
operator: (options, ctx) => (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 [];
}

View File

@@ -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<LimitTransformerOptions> = {
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,
};
}

View File

@@ -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<SortByTransformerOptions> =
* 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)),

View File

@@ -0,0 +1,3 @@
export const transformationsVariableSupport = () => {
return (window as any)?.grafanaBootData?.settings?.featureToggles?.transformationsVariableSupport;
};

View File

@@ -136,4 +136,5 @@ export interface FeatureToggles {
externalServiceAccounts?: boolean;
alertingModifiedExport?: boolean;
enableNativeHTTPHistogram?: boolean;
transformationsVariableSupport?: boolean;
}

View File

@@ -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': {

View File

@@ -823,5 +823,12 @@ var (
FrontendOnly: false,
Owner: hostedGrafanaTeam,
},
{
Name: "transformationsVariableSupport",
Description: "Allows using variables in transformations",
FrontendOnly: true,
Stage: FeatureStageExperimental,
Owner: grafanaBiSquad,
},
}
)

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
117 externalServiceAccounts experimental @grafana/grafana-authnz-team true false false false
118 alertingModifiedExport experimental @grafana/alerting-squad false false false false
119 enableNativeHTTPHistogram experimental @grafana/hosted-grafana-team false false false false
120 transformationsVariableSupport experimental @grafana/grafana-bi-squad false false false true

View File

@@ -478,4 +478,8 @@ const (
// FlagEnableNativeHTTPHistogram
// Enables native HTTP Histograms
FlagEnableNativeHTTPHistogram = "enableNativeHTTPHistogram"
// FlagTransformationsVariableSupport
// Allows using variables in transformations
FlagTransformationsVariableSupport = "transformationsVariableSupport"
)

View File

@@ -96,7 +96,7 @@ export const FilterByValueFilterEditor = (props: Props) => {
onChange={onChangeMatcher}
/>
</div>
<div className="gf-form gf-form--grow gf-form-spacing">
<div className="gf-form gf-form-spacing">
<div className="gf-form-label">Value</div>
<editor.component field={field} options={filter.config.options ?? {}} onChange={onChangeMatcherOptions} />
</div>

View File

@@ -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<T = any>(
config: ValueMatcherEditorConfig
): React.FC<ValueMatcherUIProps<BasicValueMatcherOptions<T>>> {
): React.FC<ValueMatcherUIProps<BasicValueMatcherOptions>> {
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<HTMLInputElement>) => {
setInvalid(!validator(event.currentTarget.value));
@@ -37,16 +46,40 @@ export function basicMatcherEditor<T = any>(
[options, onChange, isInvalid, field, converter]
);
return (
<Input
className="flex-grow-1"
invalid={isInvalid}
defaultValue={String(options.value)}
placeholder="Value"
onChange={onChangeValue}
onBlur={onChangeOptions}
/>
const onChangeVariableValue = useCallback(
(value: string) => {
setInvalid(!validator(value));
onChange({
...options,
value: value,
});
},
[setInvalid, validator, onChange, options]
);
if (cfg.featureToggles.transformationsVariableSupport) {
return (
<SuggestionsInput
invalid={isInvalid}
value={value}
error={'Value needs to be an integer or a variable'}
onChange={onChangeVariableValue}
placeholder="Value or variable"
suggestions={variables}
></SuggestionsInput>
);
} else {
return (
<Input
className="flex-grow-1"
invalid={isInvalid}
defaultValue={String(options.value)}
placeholder="Value"
onChange={onChangeValue}
onBlur={onChangeOptions}
/>
);
}
};
}
@@ -55,52 +88,44 @@ export const getBasicValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<Basi
{
name: 'Is greater',
id: ValueMatcherID.greater,
component: basicMatcherEditor<number>({
validator: (value) => !isNaN(value),
component: basicMatcherEditor<string | number>({
validator: numberOrVariableValidator,
}),
},
{
name: 'Is greater or equal',
id: ValueMatcherID.greaterOrEqual,
component: basicMatcherEditor<number>({
validator: (value) => !isNaN(value),
component: basicMatcherEditor<string | number>({
validator: numberOrVariableValidator,
}),
},
{
name: 'Is lower',
id: ValueMatcherID.lower,
component: basicMatcherEditor<number>({
validator: (value) => !isNaN(value),
component: basicMatcherEditor<string | number>({
validator: numberOrVariableValidator,
}),
},
{
name: 'Is lower or equal',
id: ValueMatcherID.lowerOrEqual,
component: basicMatcherEditor<number>({
validator: (value) => !isNaN(value),
component: basicMatcherEditor<string | number>({
validator: numberOrVariableValidator,
}),
},
{
name: 'Is equal',
id: ValueMatcherID.equal,
component: basicMatcherEditor<any>({
component: basicMatcherEditor<string | number | boolean>({
validator: () => true,
}),
},
{
name: 'Is not equal',
id: ValueMatcherID.notEqual,
component: basicMatcherEditor<any>({
component: basicMatcherEditor<string | number | boolean>({
validator: () => true,
}),
},
{
name: 'Regex',
id: ValueMatcherID.regex,
component: basicMatcherEditor<string>({
validator: () => true,
converter: (value) => String(value),
}),
},
];
};

View File

@@ -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<T = any>(
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<HTMLInputElement>, prop: PropNames) => {
setInvalid({
@@ -44,6 +53,49 @@ export function rangeMatcherEditor<T = any>(
[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 (
<>
<SuggestionsInput
value={String(options.from)}
invalid={isInvalid.from}
error={'Value needs to be an integer or a variable'}
placeholder="From"
onChange={(val) => onChangeOptionsSuggestions(val, 'from')}
suggestions={variables}
/>
<div className="gf-form-label">and</div>
<SuggestionsInput
invalid={isInvalid.to}
error={'Value needs to be an integer or a variable'}
value={String(options.to)}
placeholder="To"
suggestions={variables}
onChange={(val) => onChangeOptionsSuggestions(val, 'to')}
/>
</>
);
}
return (
<>
<Input
@@ -73,10 +125,8 @@ export const getRangeValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<Rang
{
name: 'Is between',
id: ValueMatcherID.between,
component: rangeMatcherEditor<number>({
validator: (value) => {
return !isNaN(value);
},
component: rangeMatcherEditor<string | number>({
validator: numberOrVariableValidator,
}),
},
];

View File

@@ -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<ValueMatcherUIProps<BasicValueMatcherOptions<string>>> {
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<HTMLInputElement>) => {
setInvalid(!validator(event.currentTarget.value));
},
[setInvalid, validator]
);
const onChangeOptions = useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
if (isInvalid) {
return;
}
const { value } = event.currentTarget;
onChange({
...options,
value: converter(value, field),
});
},
[options, onChange, isInvalid, field, converter]
);
return (
<Input
className="flex-grow-1"
invalid={isInvalid}
defaultValue={String(options.value)}
placeholder="Value"
onChange={onChangeValue}
onBlur={onChangeOptions}
/>
);
};
}
export const getRegexValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<BasicValueMatcherOptions>> => {
return [
{
name: 'Regex',
id: ValueMatcherID.regex,
component: regexMatcherEditor({
validator: () => true,
converter: (value) => String(value),
}),
},
];
};

View File

@@ -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<ValueMatcherUIRegistryItem<any>>(() => {
return [...getBasicValueMatchersUI(), ...getNoopValueMatchersUI(), ...getRangeValueMatchersUI()];
return [
...getBasicValueMatchersUI(),
...getNoopValueMatchersUI(),
...getRangeValueMatchersUI(),
...getRegexValueMatchersUI(),
];
});

View File

@@ -29,7 +29,6 @@ export const HeatmapTransformerEditor = (props: TransformerUIProps<HeatmapTransf
if (!props.options.xBuckets?.mode) {
const opts = getDefaultOptions(supplier);
props.onChange({ ...opts, ...props.options });
console.log('geometry useEffect', opts);
}
});

View File

@@ -1,9 +1,13 @@
import React from 'react';
import React, { useState } from 'react';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { SelectableValue, StandardEditorProps, VariableOrigin } from '@grafana/data';
import { getTemplateSrv, config as cfg } from '@grafana/runtime';
import { HeatmapCalculationBucketConfig, HeatmapCalculationMode } from '@grafana/schema';
import { HorizontalGroup, Input, RadioButtonGroup, ScaleDistribution } from '@grafana/ui';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { numberOrVariableValidator } from '../../utils';
const modeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
{
label: 'Size',
@@ -26,6 +30,21 @@ const logModeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
];
export const AxisEditor = ({ value, onChange, item }: StandardEditorProps<HeatmapCalculationBucketConfig>) => {
const [isInvalid, setInvalid] = useState<boolean>(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 (
<HorizontalGroup>
<RadioButtonGroup
@@ -38,16 +57,27 @@ export const AxisEditor = ({ value, onChange, item }: StandardEditorProps<Heatma
});
}}
/>
<Input
value={value?.value ?? ''}
placeholder="Auto"
onChange={(v) => {
onChange({
...value,
value: v.currentTarget.value,
});
}}
/>
{cfg.featureToggles.transformationsVariableSupport ? (
<SuggestionsInput
invalid={isInvalid}
error={'Value needs to be an integer or a variable'}
value={value?.value ?? ''}
placeholder="Auto"
onChange={onValueChange}
suggestions={variables}
/>
) : (
<Input
value={value?.value ?? ''}
placeholder="Auto"
onChange={(v) => {
onChange({
...value,
value: v.currentTarget.value,
});
}}
/>
)}
</HorizontalGroup>
);
};

View File

@@ -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<HeatmapTransform
defaultOptions: {},
operator: (options, ctx) => (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[]) => {

View File

@@ -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<CalculateFieldTransformerOptions> {}
@@ -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<string[], string[]> {
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<DataFrame[], string[]> {
return (source) =>
source.pipe(

View File

@@ -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<HistogramTransformerOptions>) => {
}: TransformerUIProps<HistogramTransformerInputs>) => {
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 (
<div>
<InlineFieldRow>
<InlineField
labelWidth={labelWidth}
label={histogramFieldInfo.bucketSize.name}
tooltip={histogramFieldInfo.bucketSize.description}
>
<NumberInput value={bucketSize} placeholder="auto" onChange={onBucketSizeChanged} min={0} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField
labelWidth={labelWidth}
label={histogramFieldInfo.bucketOffset.name}
tooltip={histogramFieldInfo.bucketOffset.description}
>
<NumberInput value={bucketOffset} placeholder="none" onChange={onBucketOffsetChanged} min={0} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField
labelWidth={labelWidth}
label={histogramFieldInfo.combine.name}
tooltip={histogramFieldInfo.combine.description}
>
<InlineSwitch value={options.combine ?? false} onChange={onToggleCombine} />
</InlineField>
</InlineFieldRow>
</div>
);
}
return (
<div>
<InlineFieldRow>
<InlineField
invalid={isInvalid.bucketSize}
error={'Value needs to be an integer or a variable'}
labelWidth={labelWidth}
label={histogramFieldInfo.bucketSize.name}
tooltip={histogramFieldInfo.bucketSize.description}
>
<NumberInput value={options.bucketSize} placeholder="auto" onChange={onBucketSizeChanged} min={0} />
<SuggestionsInput
suggestions={variables}
value={options.bucketSize}
placeholder="auto"
onChange={onVariableBucketSizeChanged}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
@@ -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'}
>
<NumberInput value={options.bucketOffset} placeholder="none" onChange={onBucketOffsetChanged} min={0} />
<SuggestionsInput
suggestions={variables}
value={options.bucketOffset}
placeholder="none"
onChange={onVariableBucketOffsetChanged}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
@@ -81,7 +182,7 @@ export const HistogramTransformerEditor = ({
);
};
export const histogramTransformRegistryItem: TransformerRegistryItem<HistogramTransformerOptions> = {
export const histogramTransformRegistryItem: TransformerRegistryItem<HistogramTransformerInputs> = {
id: DataTransformerID.histogram,
editor: HistogramTransformerEditor,
transformation: standardTransformers.histogramTransformer,

View File

@@ -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<LimitTransformerOptions>) => {
const [isInvalid, setInvalid] = useState<boolean>(false);
const onSetLimit = useCallback(
(value: FormEvent<HTMLInputElement>) => {
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 (
<>
<InlineFieldRow>
<InlineField label="Limit" labelWidth={8}>
<Input
placeholder="Limit count"
pattern="[0-9]*"
value={options.limitField}
onChange={onSetLimit}
width={25}
/>
</InlineField>
</InlineFieldRow>
</>
);
}
return (
<>
<InlineFieldRow>
<InlineField label="Limit" labelWidth={8}>
<Input
placeholder="Limit count"
pattern="[0-9]*"
value={options.limitField}
onChange={onSetLimit}
width={25}
/>
</InlineField>
<SuggestionsInput
invalid={isInvalid}
error={'Value needs to be an integer or a variable'}
value={String(options.limitField)}
onChange={onSetVariableLimit}
placeholder="Value or variable"
suggestions={variables}
></SuggestionsInput>
</InlineFieldRow>
</>
);

View File

@@ -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<SortByTransformerOptions>) => {
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
<InlineFieldRow key={`${s.field}/${index}`}>
<InlineField label="Field" labelWidth={10} grow={true}>
<Select
options={fieldNames}
options={cfg.featureToggles.transformationsVariableSupport ? [...fieldNames, ...variables] : fieldNames}
value={s.field}
placeholder="Select field"
onChange={(v) => {

View File

@@ -0,0 +1,211 @@
import { css } from '@emotion/css';
import React, { FormEvent, useEffect, useRef, useState } from 'react';
import { Popper as ReactPopper } from 'react-popper';
import { GrafanaTheme2, VariableSuggestion } from '@grafana/data';
import { CustomScrollbar, FieldValidationMessage, Input, Portal, useTheme2 } from '@grafana/ui';
import { DataLinkSuggestions } from '@grafana/ui/src/components/DataLinks/DataLinkSuggestions';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
const ERROR_TOOLTIP_OFFSET = 8;
interface SuggestionsInputProps {
value?: string | number;
onChange: (url: string, callback?: () => void) => void;
suggestions: VariableSuggestion[];
placeholder?: string;
invalid?: boolean;
error?: string;
width?: number;
}
const getStyles = (theme: GrafanaTheme2, inputHeight: number) => {
return {
suggestionsWrapper: css({
boxShadow: theme.shadows.z2,
}),
errorTooltip: css({
position: 'absolute',
top: inputHeight + ERROR_TOOLTIP_OFFSET + 'px',
zIndex: theme.zIndex.tooltip,
}),
inputWrapper: css({
position: 'relative',
}),
// Wrapper with child selector needed.
// When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working
};
};
export const SuggestionsInput = ({
value = '',
onChange,
suggestions,
placeholder,
error,
invalid,
}: SuggestionsInputProps) => {
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [variableValue, setVariableValue] = useState<string>(value.toString());
const [scrollTop, setScrollTop] = useState(0);
const [inputHeight, setInputHeight] = useState<number>(0);
const [startPos, setStartPos] = useState<number>(0);
const theme = useTheme2();
const styles = getStyles(theme, inputHeight);
const inputRef = useRef<HTMLInputElement>(null);
// Used to get the height of the suggestion elements in order to scroll to them.
const activeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setScrollTop(getElementPosition(activeRef.current, suggestionsIndex));
}, [suggestionsIndex]);
const onVariableSelect = React.useCallback(
(item: VariableSuggestion, input = inputRef.current!) => {
const curPos = input.selectionStart!;
const x = input.value;
if (x[startPos - 1] === '$') {
input.value = x.slice(0, startPos) + item.value + x.slice(curPos);
} else {
input.value = x.slice(0, startPos) + '$' + item.value + x.slice(curPos);
}
setVariableValue(input.value);
setShowingSuggestions(false);
setSuggestionsIndex(0);
onChange(input.value);
},
[onChange, startPos]
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (!showingSuggestions) {
if (event.key === '$' || (event.key === ' ' && event.ctrlKey)) {
setStartPos(inputRef.current!.selectionStart || 0);
setShowingSuggestions(true);
return;
}
return;
}
switch (event.key) {
case 'Backspace':
case 'Escape':
case 'ArrowLeft':
case 'ArrowRight':
setShowingSuggestions(false);
return setSuggestionsIndex(0);
case 'Enter':
event.preventDefault();
return onVariableSelect(suggestions[suggestionsIndex]);
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
return setSuggestionsIndex((index) => modulo(index + direction, suggestions.length));
default:
return;
}
},
[showingSuggestions, suggestions, suggestionsIndex, onVariableSelect]
);
const onValueChanged = React.useCallback((event: FormEvent<HTMLInputElement>) => {
setVariableValue(event.currentTarget.value);
}, []);
const onBlur = React.useCallback(
(event: FormEvent<HTMLInputElement>) => {
onChange(event.currentTarget.value);
},
[onChange]
);
useEffect(() => {
setInputHeight(inputRef.current!.clientHeight);
}, []);
return (
<div className={styles.inputWrapper}>
{showingSuggestions && (
<Portal>
<ReactPopper
referenceElement={inputRef.current!}
placement="bottom-start"
modifiers={[
{
name: 'preventOverflow',
enabled: true,
options: {
rootBoundary: 'viewport',
},
},
{
name: 'arrow',
enabled: false,
},
{
name: 'offset',
options: {
offset: [0, 0],
},
},
]}
>
{({ ref, style, placement }) => {
return (
<div ref={ref} style={style} data-placement={placement} className={styles.suggestionsWrapper}>
<CustomScrollbar
scrollTop={scrollTop}
autoHeightMax="300px"
setScrollTop={({ scrollTop }) => setScrollTop(scrollTop)}
>
{/* This suggestion component has a specialized name,
but is rather generalistic in implementation,
so we're using it in transformations also.
We should probably rename this to something more general. */}
<DataLinkSuggestions
activeRef={activeRef}
suggestions={suggestions}
onSuggestionSelect={onVariableSelect}
onClose={() => setShowingSuggestions(false)}
activeIndex={suggestionsIndex}
/>
</CustomScrollbar>
</div>
);
}}
</ReactPopper>
</Portal>
)}
{invalid && error && (
<div className={styles.errorTooltip}>
<FieldValidationMessage>{error}</FieldValidationMessage>
</div>
)}
<Input
placeholder={placeholder}
invalid={invalid}
ref={inputRef}
value={variableValue}
onChange={onValueChanged}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
};
SuggestionsInput.displayName = 'SuggestionsInput';
function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) {
return (suggestionElement?.clientHeight ?? 0) * activeIndex;
}

View File

@@ -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;
});
});

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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<LinkModel<Field>>
getFieldLinks?: (exemplars: DataFrame, field: Field) => (config: ValueLinkConfig) => Array<LinkModel<Field>>,
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,

View File

@@ -52,7 +52,15 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(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 {}
}