Transformation: Add support for variables to ALL transformations (#100225)

* Transformation: generic support for variables, yay

* clean up value matcher editors

* clean

* clean up filterByValue

* more

* clean

* clean

* more!

* lint

* Revert "Transformation: generic support for variables, yay"

This reverts commit deec7c306b.

* Bump scenes to canary

* filterByValue - remove irrelevant tests

* filterByName tests update

* calculateField test update

* nit

* is that gonna work?

* Update grafana-data tscnig config to use grafana-ui's react table types

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
Co-authored-by: oscarkilhed <oscar.kilhed@grafana.com>
This commit is contained in:
Dominik Prokop
2025-02-17 13:07:11 +01:00
committed by GitHub
co-authored by Leon Sorokin oscarkilhed
parent f15c961a90
commit c9250c9135
32 changed files with 589 additions and 1014 deletions
@@ -46,7 +46,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `alertingInsights` | Show the new alerting insights landing page | Yes |
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
| `formatString` | Enable format string transformer | Yes |
| `transformationsVariableSupport` | Allows using variables in transformations | Yes |
| `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s | Yes |
| `recoveryThreshold` | Enables feature recovery threshold (aka hysteresis) for threshold server-side expression | Yes |
| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes |
@@ -1,8 +1,22 @@
import {
SceneDataNode,
SceneDataTransformer,
SceneDeactivationHandler,
SceneFlexItem,
SceneFlexLayout,
sceneGraph,
SceneObject,
SceneObjectBase,
SceneVariable,
SceneVariableSet,
TestVariable,
} from '@grafana/scenes';
import { DataTransformerConfig, LoadingState } from '@grafana/schema';
import { DataFrameView } from '../../dataframe/DataFrameView';
import { toDataFrame } from '../../dataframe/processDataFrame';
import { ScopedVars } from '../../types/ScopedVars';
import { FieldType } from '../../types/dataFrame';
import { DataTransformContext } from '../../types/transformations';
import { DataFrame, FieldType } from '../../types/dataFrame';
import { getDefaultTimeRange } from '../../types/time';
import { BinaryOperationID } from '../../utils/binaryOperators';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { UnaryOperationID } from '../../utils/unaryOperators';
@@ -565,51 +579,26 @@ describe('calculateField transformer w/ timeseries', () => {
replaceFields: true,
},
};
const context: DataTransformContext = {
interpolate: (target: string | undefined, scopedVars?: ScopedVars, format?: string | Function): string => {
if (!target) {
return '';
}
const variables: ScopedVars = {
var1: {
value: 'Test',
text: 'Test',
},
var2: {
value: 5,
text: '5',
},
__interval: {
value: 10000,
text: '10000',
},
};
for (const key in variables) {
if (target === `$${key}`) {
return variables[key]!.value + '';
}
}
return target;
},
};
await expect(transformDataFrame([cfg], [seriesA], context)).toEmitValuesWith((received) => {
const data = received[0];
const filtered = data[0];
const rows = new DataFrameView(filtered).toArray();
expect(rows).toMatchInlineSnapshot(`
[
{
"Test": 6,
"TheTime": 1000,
},
{
"Test": 105,
"TheTime": 2000,
},
]
`);
});
const data = setupTransformationScene(seriesA, cfg, [
new TestVariable({ name: 'var1', value: 'Test' }),
new TestVariable({ name: 'var2', value: 5 }),
]);
const filtered = data[0];
const rows = new DataFrameView(filtered).toArray();
expect(rows).toMatchInlineSnapshot(`
[
{
"Test": 6,
"TheTime": 1000,
},
{
"Test": 105,
"TheTime": 2000,
},
]
`);
});
it('calculates centered moving average on odd window size', async () => {
@@ -750,8 +739,6 @@ describe('calculateField transformer w/ timeseries', () => {
await expect(transformDataFrame([cfg], [series])).toEmitValuesWith((received) => {
const data = received[0][0];
//console.log(data.fields);
expect(data.fields.length).toEqual(2);
expect(data.fields[1].values).toEqual([1, 1, 3, 3.5, 4.5]);
});
@@ -1185,3 +1172,63 @@ describe('calculateField transformer w/ timeseries', () => {
});
});
});
function activateFullSceneTree(scene: SceneObject): SceneDeactivationHandler {
const deactivationHandlers: SceneDeactivationHandler[] = [];
// Important that variables are activated before other children
if (scene.state.$variables) {
deactivationHandlers.push(activateFullSceneTree(scene.state.$variables));
}
scene.forEachChild((child) => {
// For query runners which by default use the container width for maxDataPoints calculation we are setting a width.
// In real life this is done by the React component when VizPanel is rendered.
if ('setContainerWidth' in child) {
// @ts-expect-error
child.setContainerWidth(500);
}
deactivationHandlers.push(activateFullSceneTree(child));
});
deactivationHandlers.push(scene.activate());
return () => {
for (const handler of deactivationHandlers) {
handler();
}
};
}
function setupTransformationScene(
inputData: DataFrame,
cfg: DataTransformerConfig,
variables: SceneVariable[]
): DataFrame[] {
class TestSceneObject extends SceneObjectBase<{}> {}
const dataNode = new SceneDataNode({
data: {
state: LoadingState.Loading,
timeRange: getDefaultTimeRange(),
series: [inputData],
},
});
const transformationNode = new SceneDataTransformer({
transformations: [cfg],
});
const consumer = new TestSceneObject({
$data: transformationNode,
});
const scene = new SceneFlexLayout({
$data: dataNode,
$variables: new SceneVariableSet({ variables }),
children: [new SceneFlexItem({ body: consumer })],
});
activateFullSceneTree(scene);
return sceneGraph.getData(consumer).state.data?.series!;
}
@@ -139,10 +139,6 @@ export const calculateFieldTransformer: DataTransformerInfo<CalculateFieldTransf
? ensureColumnsTransformer.operator(null, ctx)
: noopTransformer.operator({}, ctx);
if (options.alias != null) {
options.alias = ctx.interpolate(options.alias);
}
return outerSource.pipe(
operator,
map((data) => {
@@ -573,7 +569,8 @@ function findFieldValuesWithNameOrConstant(
}
if (value.matcher && value.matcher.id === FieldMatcherID.byName) {
const name = ctx.interpolate(value.matcher.options ?? '');
const name = value.matcher.options ?? '';
for (const f of frame.fields) {
if (name === getFieldDisplayName(f, frame, allFrames)) {
if (f.type === FieldType.boolean) {
@@ -584,7 +581,7 @@ function findFieldValuesWithNameOrConstant(
}
}
const v = parseFloat(value.fixed ?? ctx.interpolate(value.matcher?.options ?? ''));
const v = parseFloat(value.fixed ?? value.matcher?.options ?? '');
if (!isNaN(v)) {
return new Array(frame.length).fill(v);
}
@@ -28,15 +28,15 @@ export const filterFieldsTransformer: DataTransformerInfo<FilterOptions> = {
}
if (typeof options.include?.options === 'string') {
options.include.options = ctx.interpolate(options.include?.options);
options.include.options = options.include?.options;
} else if (typeof options.include?.options?.pattern === 'string') {
options.include.options.pattern = ctx.interpolate(options.include?.options.pattern);
options.include.options.pattern = options.include?.options.pattern;
}
if (typeof options.exclude?.options === 'string') {
options.exclude.options = ctx.interpolate(options.exclude?.options);
options.exclude.options = options.exclude?.options;
} else if (typeof options.exclude?.options?.pattern === 'string') {
options.exclude.options.pattern = ctx.interpolate(options.exclude?.options.pattern);
options.exclude.options.pattern = options.exclude?.options.pattern;
}
return source.pipe(
@@ -1,6 +1,21 @@
import {
SceneDataNode,
SceneDataTransformer,
SceneDeactivationHandler,
SceneFlexItem,
SceneFlexLayout,
sceneGraph,
SceneObject,
SceneObjectBase,
SceneVariable,
SceneVariableSet,
TestVariable,
} from '@grafana/scenes';
import { DataTransformerConfig, LoadingState } from '@grafana/schema';
import { toDataFrame } from '../../dataframe/processDataFrame';
import { ScopedVars } from '../../types/ScopedVars';
import { FieldType } from '../../types/dataFrame';
import { DataFrame, FieldType } from '../../types/dataFrame';
import { getDefaultTimeRange } from '../../types/time';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { transformDataFrame } from '../transformDataFrame';
@@ -196,6 +211,7 @@ describe('filterByName transformer', () => {
expect(filtered.fields[0].name).toBe('B');
});
});
it('it can use a variable with multiple comma separated', async () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
@@ -207,31 +223,13 @@ describe('filterByName transformer', () => {
},
};
const ctx = {
interpolate: (target: string | undefined, scopedVars?: ScopedVars, format?: string | Function): string => {
if (!target) {
return '';
}
const variables: ScopedVars = {
var: {
value: 'B,D',
text: 'Test',
},
};
for (const key in variables) {
return target.replace(`$${key}`, variables[key]!.value);
}
return target;
},
};
await expect(transformDataFrame([cfg], [seriesWithNamesToMatch], ctx)).toEmitValuesWith((received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('B');
expect(filtered.fields[1].name).toBe('D');
});
const data = setupTransformationScene(seriesWithNamesToMatch, cfg, [
new TestVariable({ name: 'var', value: 'B,D' }),
]);
const filtered = data[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('B');
expect(filtered.fields[1].name).toBe('D');
});
it('it can use a variable with multiple comma separated values in {}', async () => {
@@ -245,31 +243,14 @@ describe('filterByName transformer', () => {
},
};
const ctx = {
interpolate: (target: string | undefined, scopedVars?: ScopedVars, format?: string | Function): string => {
if (!target) {
return '';
}
const variables: ScopedVars = {
var: {
value: '{B,D}',
text: 'Test',
},
};
for (const key in variables) {
return target.replace(`$${key}`, variables[key]!.value);
}
return target;
},
};
const data = setupTransformationScene(seriesWithNamesToMatch, cfg, [
new TestVariable({ name: 'var', value: 'B,D' }),
]);
await expect(transformDataFrame([cfg], [seriesWithNamesToMatch], ctx)).toEmitValuesWith((received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('B');
expect(filtered.fields[1].name).toBe('D');
});
const filtered = data[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('B');
expect(filtered.fields[1].name).toBe('D');
});
it('uses template variable substitution', async () => {
@@ -277,35 +258,78 @@ describe('filterByName transformer', () => {
id: DataTransformerID.filterFieldsByName,
options: {
include: {
pattern: '/^$var1/',
pattern: '/^$var/',
},
},
};
const ctx = {
interpolate: (target: string | undefined, scopedVars?: ScopedVars, format?: string | Function): string => {
if (!target) {
return '';
}
const variables: ScopedVars = {
var1: {
value: 'startsWith',
text: 'Test',
},
};
for (const key in variables) {
return target.replace(`$${key}`, variables[key]!.value);
}
return target;
},
};
const data = setupTransformationScene(seriesWithNamesToMatch, cfg, [
new TestVariable({ name: 'var', value: 'startsWith' }),
]);
await expect(transformDataFrame([cfg], [seriesWithNamesToMatch], ctx)).toEmitValuesWith((received) => {
const data = received[0];
const filtered = data[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('startsWithA');
});
const filtered = data[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('startsWithA');
});
});
});
function activateFullSceneTree(scene: SceneObject): SceneDeactivationHandler {
const deactivationHandlers: SceneDeactivationHandler[] = [];
// Important that variables are activated before other children
if (scene.state.$variables) {
deactivationHandlers.push(activateFullSceneTree(scene.state.$variables));
}
scene.forEachChild((child) => {
// For query runners which by default use the container width for maxDataPoints calculation we are setting a width.
// In real life this is done by the React component when VizPanel is rendered.
if ('setContainerWidth' in child) {
// @ts-expect-error
child.setContainerWidth(500);
}
deactivationHandlers.push(activateFullSceneTree(child));
});
deactivationHandlers.push(scene.activate());
return () => {
for (const handler of deactivationHandlers) {
handler();
}
};
}
export function setupTransformationScene(
inputData: DataFrame,
cfg: DataTransformerConfig,
variables: SceneVariable[]
): DataFrame[] {
class TestSceneObject extends SceneObjectBase<{}> {}
const dataNode = new SceneDataNode({
data: {
state: LoadingState.Loading,
timeRange: getDefaultTimeRange(),
series: [inputData],
},
});
const transformationNode = new SceneDataTransformer({
transformations: [cfg],
});
const consumer = new TestSceneObject({
$data: transformationNode,
});
const scene = new SceneFlexLayout({
$data: dataNode,
$variables: new SceneVariableSet({ variables }),
children: [new SceneFlexItem({ body: consumer })],
});
activateFullSceneTree(scene);
return sceneGraph.getData(consumer).state.data?.series!;
}
@@ -1,4 +1,4 @@
import { DataTransformContext, DataTransformerInfo, MatcherConfig } from '../../types/transformations';
import { DataTransformerInfo, MatcherConfig } from '../../types/transformations';
import { FieldMatcherID } from '../matchers/ids';
import { RegexpOrNamesMatcherOptions } from '../matchers/nameMatcher';
@@ -25,8 +25,8 @@ export const filterFieldsByNameTransformer: DataTransformerInfo<FilterFieldsByNa
source.pipe(
filterFieldsTransformer.operator(
{
include: getMatcherConfig(ctx, options.include, options.byVariable),
exclude: getMatcherConfig(ctx, options.exclude, options.byVariable),
include: getMatcherConfig(options.include, options.byVariable),
exclude: getMatcherConfig(options.exclude, options.byVariable),
},
ctx
)
@@ -35,7 +35,6 @@ export const filterFieldsByNameTransformer: DataTransformerInfo<FilterFieldsByNa
// Exported to share with other implementations, but not exported to `@grafana/data`
export const getMatcherConfig = (
ctx: DataTransformContext,
options?: RegexpOrNamesMatcherOptions,
byVariable?: boolean
): MatcherConfig | undefined => {
@@ -46,7 +45,8 @@ export const getMatcherConfig = (
const { names, pattern, variable } = options;
if (byVariable && variable) {
const stringOfNames = ctx.interpolate(variable);
const stringOfNames = variable;
if (/\{.*\}/.test(stringOfNames)) {
const namesFromString = stringOfNames.slice(1).slice(0, -1).split(',');
return { id: FieldMatcherID.byNames, options: { names: namesFromString } };
@@ -13,10 +13,6 @@ import {
FilterByValueType,
} from './filterByValue';
import { DataTransformerID } from './ids';
import * as utils from './utils';
const mockTransformationsVariableSupport = jest.spyOn(utils, 'transformationsVariableSupport');
mockTransformationsVariableSupport.mockReturnValue(false);
const seriesAWithSingleField = toDataFrame({
name: 'A',
@@ -260,139 +256,6 @@ describe('FilterByValue transformer', () => {
});
});
it('should interpolate dashboard variables', async () => {
mockTransformationsVariableSupport.mockReturnValue(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: {},
},
]);
});
});
it('should interpolate dashboard variables for regex matcher', async () => {
mockTransformationsVariableSupport.mockReturnValue(true);
const regex: MatcherConfig<BasicValueMatcherOptions<string | number>> = {
id: ValueMatcherID.regex,
options: { value: '.*thiswillinterpolateto6' },
};
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = {
id: DataTransformerID.filterByValue,
options: {
type: FilterByValueType.include,
match: FilterByValueMatch.all,
filters: [
{
fieldName: 'numbers',
config: regex,
},
],
},
};
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],
state: {},
},
{
name: 'numbers',
type: FieldType.number,
values: [6],
state: {},
},
]);
});
});
it('should not interpolate dashboard variables when feature toggle is off', async () => {
mockTransformationsVariableSupport.mockReturnValue(false);
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,
@@ -4,11 +4,9 @@ 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',
@@ -50,50 +48,6 @@ 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) {
let valueFrom = filter.config.options.from;
let valueTo = filter.config.options.to;
if (typeof filter.config.options.from === 'string') {
valueFrom = ctx.interpolate(valueFrom);
}
if (typeof filter.config.options.to === 'string') {
valueTo = ctx.interpolate(valueTo);
}
return {
...filter,
config: {
...filter.config,
options: {
...filter.config.options,
to: valueTo,
from: valueFrom,
},
},
};
} else if (filter.config.options.value) {
let value = filter.config.options.value;
if (typeof filter.config.options.value === 'string') {
value = ctx.interpolate(value);
}
return {
...filter,
config: { ...filter.config, options: { ...filter.config.options, value } },
};
}
return filter;
})
);
}
return source.pipe(
map((data) => {
if (data.length === 0) {
@@ -106,12 +60,7 @@ export const filterByValueTransformer: DataTransformerInfo<FilterByValueTransfor
const rows = new Set<number>();
const fieldIndexByName = groupFieldIndexByName(frame, data);
let matchers;
if (transformationsVariableSupport()) {
matchers = createFilterValueMatchers(interpolatedFilters, fieldIndexByName);
} else {
matchers = createFilterValueMatchers(filters, fieldIndexByName);
}
const matchers = createFilterValueMatchers(filters, fieldIndexByName);
for (let index = 0; index < frame.length; index++) {
if (rows.has(index)) {
@@ -65,9 +65,9 @@ export const groupingToMatrixTransformer: DataTransformerInfo<GroupingToMatrixTr
operator: (options: GroupingToMatrixTransformerOptions, ctx: DataTransformContext) => (source) =>
source.pipe(
map((data) => {
const columnFieldMatch = ctx.interpolate(options.columnField || DEFAULT_COLUMN_FIELD);
const rowFieldMatch = ctx.interpolate(options.rowField || DEFAULT_ROW_FIELD);
const valueFieldMatch = ctx.interpolate(options.valueField || DEFAULT_VALUE_FIELD);
const columnFieldMatch = options.columnField || DEFAULT_COLUMN_FIELD;
const rowFieldMatch = options.rowField || DEFAULT_ROW_FIELD;
const valueFieldMatch = options.valueField || DEFAULT_VALUE_FIELD;
const emptyValue = options.emptyValue || DEFAULT_EMPTY_VALUE;
// Accept only single queries
@@ -11,7 +11,6 @@ import { roundDecimals } from '../../utils/numbers';
import { DataTransformerID } from './ids';
import { AlignedData, join } from './joinDataFrames';
import { nullToValueField } from './nulls/nullToValue';
import { transformationsVariableSupport } from './utils';
/**
* @internal
@@ -112,9 +111,6 @@ export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTrans
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 {
@@ -127,9 +123,6 @@ export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTrans
}
if (options.bucketOffset) {
if (transformationsVariableSupport()) {
options.bucketOffset = ctx.interpolate(options.bucketOffset.toString());
}
if (typeof options.bucketOffset === 'string') {
bucketOffset = parseFloat(options.bucketOffset);
} else {
@@ -38,7 +38,7 @@ export const joinByFieldTransformer: SynchronousDataTransformerInfo<JoinByFieldO
return (data: DataFrame[]) => {
if (data.length > 1) {
if (options.byField && !joinBy) {
joinBy = fieldMatchers.get(FieldMatcherID.byName).get(ctx.interpolate(options.byField));
joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField);
}
const joined = joinDataFrames({ frames: data, joinBy, mode: options.mode });
if (joined) {
@@ -3,7 +3,6 @@ import { map } from 'rxjs/operators';
import { DataTransformerInfo } from '../../types/transformations';
import { DataTransformerID } from './ids';
import { transformationsVariableSupport } from './utils';
export interface LimitTransformerOptions {
limitField?: number | string;
@@ -25,11 +24,7 @@ export const limitTransformer: DataTransformerInfo<LimitTransformerOptions> = {
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);
}
limit = parseInt(options.limitField, 10);
} else {
limit = options.limitField;
}
@@ -6,7 +6,6 @@ import { DataFrame } from '../../types/dataFrame';
import { DataTransformContext, DataTransformerInfo } from '../../types/transformations';
import { DataTransformerID } from './ids';
import { transformationsVariableSupport } from './utils';
export interface SortByField {
field: string;
@@ -59,12 +58,7 @@ function attachFieldIndex(frame: DataFrame, sort: SortByField[], ctx: DataTransf
// 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)),
@@ -6,11 +6,6 @@ declare global {
grafanaBootData?: BootData;
}
}
export const transformationsVariableSupport = () => {
return window?.grafanaBootData?.settings?.featureToggles?.transformationsVariableSupport;
};
/**
* Retrieve the maximum number of fields in a series of a dataframe.
*/
@@ -105,7 +105,6 @@ export interface FeatureToggles {
enableNativeHTTPHistogram?: boolean;
disableClassicHTTPHistogram?: boolean;
formatString?: boolean;
transformationsVariableSupport?: boolean;
kubernetesPlaylists?: boolean;
kubernetesSnapshots?: boolean;
kubernetesDashboards?: boolean;
+7 -1
View File
@@ -8,5 +8,11 @@
},
"exclude": ["dist/**/*"],
"extends": "@grafana/tsconfig",
"include": ["src/**/*.ts*", "typings/jest", "../../public/app/types/jquery/*.ts", "../../public/app/types/*.d.ts"]
"include": [
"src/**/*.ts*",
"typings/jest",
"../../public/app/types/jquery/*.ts",
"../../public/app/types/*.d.ts",
"../grafana-ui/src/types/react-table-config.d.ts"
]
}
-8
View File
@@ -658,14 +658,6 @@ var (
Owner: grafanaDatavizSquad,
Expression: "true", // enabled by default
},
{
Name: "transformationsVariableSupport",
Description: "Allows using variables in transformations",
FrontendOnly: true,
Stage: FeatureStageGeneralAvailability,
Owner: grafanaDatavizSquad,
Expression: "true", // Enabled by default
},
{
Name: "kubernetesPlaylists",
Description: "Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s",
-1
View File
@@ -86,7 +86,6 @@ panelMonitoring,GA,@grafana/dataviz-squad,false,false,true
enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
formatString,GA,@grafana/dataviz-squad,false,false,true
transformationsVariableSupport,GA,@grafana/dataviz-squad,false,false,true
kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false
kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false
kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
86 enableNativeHTTPHistogram experimental @grafana/grafana-backend-services-squad false true false
87 disableClassicHTTPHistogram experimental @grafana/grafana-backend-services-squad false true false
88 formatString GA @grafana/dataviz-squad false false true
transformationsVariableSupport GA @grafana/dataviz-squad false false true
89 kubernetesPlaylists GA @grafana/grafana-app-platform-squad false true false
90 kubernetesSnapshots experimental @grafana/grafana-app-platform-squad false true false
91 kubernetesDashboards experimental @grafana/grafana-app-platform-squad false false true
-4
View File
@@ -355,10 +355,6 @@ const (
// Enable format string transformer
FlagFormatString = "formatString"
// FlagTransformationsVariableSupport
// Allows using variables in transformations
FlagTransformationsVariableSupport = "transformationsVariableSupport"
// FlagKubernetesPlaylists
// Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s
FlagKubernetesPlaylists = "kubernetesPlaylists"
@@ -3945,6 +3945,7 @@
"name": "transformationsVariableSupport",
"resourceVersion": "1720021873452",
"creationTimestamp": "2023-10-04T14:28:46Z",
"deletionTimestamp": "2025-02-06T20:19:53Z",
"annotations": {
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
}
@@ -1,52 +1,21 @@
import { useCallback, useState } from 'react';
import * as React from 'react';
import { ValueMatcherID, BasicValueMatcherOptions, VariableOrigin } from '@grafana/data';
import { getTemplateSrv, config as cfg } from '@grafana/runtime';
import { Input } from '@grafana/ui';
import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { numberOrVariableValidator } from '../../utils';
import { getVariableSuggestions, numberOrVariableValidator } from '../../utils';
import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types';
import { convertToType } from './utils';
export function basicMatcherEditor<T = any>(
config: ValueMatcherEditorConfig
): React.FC<ValueMatcherUIProps<BasicValueMatcherOptions>> {
return function Render({ options, onChange, field }) {
const { validator, converter = convertToType } = config;
return function Render({ options, onChange }) {
const { validator } = 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));
},
[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]
);
const onChangeVariableValue = useCallback(
(value: string) => {
setInvalid(!validator(value));
@@ -58,29 +27,16 @@ export function basicMatcherEditor<T = any>(
[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}
/>
);
}
return (
<SuggestionsInput
invalid={isInvalid}
value={value}
error={'Value needs to be a number or a variable'}
onChange={onChangeVariableValue}
placeholder="Value or variable"
suggestions={getVariableSuggestions()}
/>
);
};
}
@@ -1,59 +1,26 @@
import { useCallback, useState } from 'react';
import * as React from 'react';
import { ValueMatcherID, RangeValueMatcherOptions, VariableOrigin } from '@grafana/data';
import { getTemplateSrv, config as cfg } from '@grafana/runtime';
import { InlineLabel, Input } from '@grafana/ui';
import { ValueMatcherID, RangeValueMatcherOptions } from '@grafana/data';
import { InlineLabel } from '@grafana/ui';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { numberOrVariableValidator } from '../../utils';
import { getVariableSuggestions, numberOrVariableValidator } from '../../utils';
import { ValueMatcherEditorConfig, ValueMatcherUIProps, ValueMatcherUIRegistryItem } from './types';
import { convertToType } from './utils';
type PropNames = 'from' | 'to';
export function rangeMatcherEditor<T = any>(
config: ValueMatcherEditorConfig
): React.FC<ValueMatcherUIProps<RangeValueMatcherOptions<T>>> {
return function RangeMatcherEditor({ options, onChange, field }) {
return function RangeMatcherEditor({ options, onChange }) {
const { validator } = config;
const [isInvalid, setInvalid] = useState({
from: !validator(options.from),
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({
...isInvalid,
[prop]: !validator(event.currentTarget.value),
});
},
[setInvalid, validator, isInvalid]
);
const onChangeOptions = useCallback(
(event: React.FocusEvent<HTMLInputElement>, prop: PropNames) => {
if (isInvalid[prop]) {
return;
}
const { value } = event.currentTarget;
onChange({
...options,
[prop]: convertToType(value, field),
});
},
[options, onChange, isInvalid, field]
);
const onChangeOptionsSuggestions = useCallback(
(value: string, prop: PropNames) => {
const invalid = !validator(value);
@@ -74,45 +41,27 @@ export function rangeMatcherEditor<T = any>(
},
[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}
/>
<InlineLabel>and</InlineLabel>
<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')}
/>
</>
);
}
const suggestions = getVariableSuggestions();
return (
<>
<Input
invalid={isInvalid['from']}
defaultValue={String(options.from)}
<SuggestionsInput
value={String(options.from)}
invalid={isInvalid.from}
error={'Value needs to be a number or a variable'}
placeholder="From"
onChange={(event) => onChangeValue(event, 'from')}
onBlur={(event) => onChangeOptions(event, 'from')}
onChange={(val) => onChangeOptionsSuggestions(val, 'from')}
suggestions={suggestions}
/>
<InlineLabel>and</InlineLabel>
<Input
invalid={isInvalid['to']}
defaultValue={String(options.to)}
<SuggestionsInput
invalid={isInvalid.to}
error={'Value needs to be a number or a variable'}
value={String(options.to)}
placeholder="To"
onChange={(event) => onChangeValue(event, 'to')}
onBlur={(event) => onChangeOptions(event, 'to')}
suggestions={suggestions}
onChange={(val) => onChangeOptionsSuggestions(val, 'to')}
/>
</>
);
@@ -1,35 +1,21 @@
import { useCallback, useState } from 'react';
import * as React from 'react';
import { ValueMatcherID, BasicValueMatcherOptions, VariableOrigin } from '@grafana/data';
import { config as cfg, getTemplateSrv } from '@grafana/runtime';
import { Input } from '@grafana/ui';
import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data';
import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput';
import { getVariableSuggestions } from '../../utils';
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;
return function Render({ options, onChange }) {
const { validator } = 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));
},
[setInvalid, validator]
);
const onChangeVariableValue = useCallback(
(value: string) => {
setInvalid(!validator(value));
@@ -41,42 +27,13 @@ export function regexMatcherEditor(
[setInvalid, validator, onChange, options]
);
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]
);
if (cfg.featureToggles.transformationsVariableSupport) {
return (
<SuggestionsInput
invalid={isInvalid}
value={value}
onChange={onChangeVariableValue}
placeholder="Value or variable"
suggestions={variables}
/>
);
}
return (
<Input
className="flex-grow-1"
<SuggestionsInput
invalid={isInvalid}
defaultValue={String(options.value)}
placeholder="Value"
onChange={onChangeValue}
onBlur={onChangeOptions}
value={value}
onChange={onChangeVariableValue}
placeholder="Value or variable"
suggestions={getVariableSuggestions()}
/>
);
};
@@ -89,7 +46,6 @@ export const getRegexValueMatchersUI = (): Array<ValueMatcherUIRegistryItem<Basi
id: ValueMatcherID.regex,
component: regexMatcherEditor({
validator: () => true,
converter: (value) => String(value),
}),
},
];
@@ -16,7 +16,6 @@ import {
TimeRange,
} from '@grafana/data';
import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames';
import { config } from '@grafana/runtime';
import {
ScaleDistribution,
HeatmapCellLayout,
@@ -52,29 +51,7 @@ export const heatmapTransformer: SynchronousDataTransformerInfo<HeatmapTransform
isApplicableDescription:
'The Heatmap transformation requires fields with Heatmap compatible data. No fields with Heatmap data could be found.',
operator: (options, ctx) => (source) =>
source.pipe(
map((data) => {
if (config.featureToggles.transformationsVariableSupport) {
const optionsCopy = {
...options,
xBuckets: { ...options.xBuckets },
yBuckets: { ...options.yBuckets },
};
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);
}
})
),
source.pipe(map((data) => heatmapTransformer.transformer(options, ctx)(data))),
transformer: (options: HeatmapTransformerOptions) => {
return (data: DataFrame[]) => {
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useState } from 'react';
import * as React from 'react';
import { identity, of, OperatorFunction } from 'rxjs';
import { of, OperatorFunction } from 'rxjs';
import { map } from 'rxjs/operators';
import {
@@ -81,10 +81,8 @@ export const CalculateFieldTransformerEditor = (props: CalculateFieldTransformer
}, [input, configuredOptions]);
const getVariableNames = (): OperatorFunction<string[], string[]> => {
if (!cfg.featureToggles.transformationsVariableSupport) {
return identity;
}
const templateSrv = getTemplateSrv();
return (source) =>
source.pipe(
map((input) => {
@@ -6,19 +6,16 @@ import {
TransformerRegistryItem,
TransformerUIProps,
TransformerCategory,
VariableOrigin,
} from '@grafana/data';
import {
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 { getTransformationContent } from '../docs/getTransformationContent';
import { SuggestionsInput } from '../suggestionsInput/SuggestionsInput';
import { numberOrVariableValidator } from '../utils';
import { getVariableSuggestions, numberOrVariableValidator } from '../utils';
export const HistogramTransformerEditor = ({
input,
@@ -33,36 +30,6 @@ export const HistogramTransformerEditor = ({
bucketOffset: !numberOrVariableValidator(options.bucketOffset || ''),
});
const onBucketCountChanged = useCallback(
(val?: number) => {
onChange({
...options,
bucketCount: val,
});
},
[onChange, options]
);
const onBucketSizeChanged = useCallback(
(val?: number) => {
onChange({
...options,
bucketSize: val,
});
},
[onChange, options]
);
const onBucketOffsetChanged = useCallback(
(val?: number) => {
onChange({
...options,
bucketOffset: val,
});
},
[onChange, options]
);
const onVariableBucketCountChanged = useCallback(
(value: string) => {
setInvalid({ ...isInvalid, bucketCount: !numberOrVariableValidator(value) });
@@ -106,72 +73,7 @@ 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.bucketCount.name}
tooltip={histogramFieldInfo.bucketCount.description}
>
<NumberInput
value={options.bucketCount}
placeholder="Default: 30"
onChange={onBucketCountChanged}
min={0}
/>
</InlineField>
</InlineFieldRow>
<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>
);
}
const suggestions = getVariableSuggestions();
return (
<div>
@@ -184,7 +86,7 @@ export const HistogramTransformerEditor = ({
tooltip={histogramFieldInfo.bucketCount.description}
>
<SuggestionsInput
suggestions={variables}
suggestions={suggestions}
value={options.bucketCount}
placeholder="Default: 30"
onChange={onVariableBucketCountChanged}
@@ -200,7 +102,7 @@ export const HistogramTransformerEditor = ({
tooltip={histogramFieldInfo.bucketSize.description}
>
<SuggestionsInput
suggestions={variables}
suggestions={suggestions}
value={options.bucketSize}
placeholder="auto"
onChange={onVariableBucketSizeChanged}
@@ -216,7 +118,7 @@ export const HistogramTransformerEditor = ({
error={'Value needs to be an integer or a variable'}
>
<SuggestionsInput
suggestions={variables}
suggestions={suggestions}
value={options.bucketOffset}
placeholder="none"
onChange={onVariableBucketOffsetChanged}
@@ -1,4 +1,4 @@
import { FormEvent, useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import {
DataTransformerID,
@@ -6,29 +6,17 @@ 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 { InlineFieldRow } from '@grafana/ui';
import { getTransformationContent } from '../docs/getTransformationContent';
import { SuggestionsInput } from '../suggestionsInput/SuggestionsInput';
import { numberOrVariableValidator } from '../utils';
import { getVariableSuggestions, numberOrVariableValidator } from '../utils';
export const LimitTransformerEditor = ({ options, onChange }: TransformerUIProps<LimitTransformerOptions>) => {
const [isInvalid, setInvalid] = useState<boolean>(false);
const onSetLimit = useCallback(
(value: FormEvent<HTMLInputElement>) => {
onChange({
...options,
limitField: Number(value.currentTarget.value),
});
},
[onChange, options]
);
const onSetVariableLimit = useCallback(
(value: string) => {
setInvalid(!numberOrVariableValidator(value));
@@ -40,28 +28,6 @@ export const LimitTransformerEditor = ({ options, onChange }: TransformerUIProps
[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>
@@ -71,7 +37,7 @@ export const LimitTransformerEditor = ({ options, onChange }: TransformerUIProps
value={String(options.limitField)}
onChange={onSetVariableLimit}
placeholder="Value or variable"
suggestions={variables}
suggestions={getVariableSuggestions()}
></SuggestionsInput>
</InlineFieldRow>
</>
@@ -8,7 +8,7 @@ import {
TransformerCategory,
} from '@grafana/data';
import { SortByField, SortByTransformerOptions } from '@grafana/data/src/transformations/transformers/sortBy';
import { getTemplateSrv, config as cfg } from '@grafana/runtime';
import { getTemplateSrv } from '@grafana/runtime';
import { InlineField, InlineSwitch, InlineFieldRow, Select } from '@grafana/ui';
import { getTransformationContent } from '../docs/getTransformationContent';
@@ -36,7 +36,7 @@ export const SortByTransformerEditor = ({ input, options, onChange }: Transforme
<InlineFieldRow key={`${s.field}/${index}`}>
<InlineField label="Field" labelWidth={10} grow={true}>
<Select
options={cfg.featureToggles.transformationsVariableSupport ? [...fieldNames, ...variables] : fieldNames}
options={[...fieldNames, ...variables]}
value={s.field}
placeholder="Select field"
onChange={(v) => {
@@ -75,7 +75,7 @@ export const partitionByValuesTransformer: SynchronousDataTransformerInfo<Partit
source.pipe(map((data) => partitionByValuesTransformer.transformer(options, ctx)(data))),
transformer: (options: PartitionByValuesTransformerOptions, ctx: DataTransformContext) => {
const matcherConfig = getMatcherConfig(ctx, { names: options.fields });
const matcherConfig = getMatcherConfig({ names: options.fields });
if (!matcherConfig) {
return noopTransformer.transformer({}, ctx);
+1 -11
View File
@@ -1,5 +1,3 @@
import { config } from '@grafana/runtime';
import { numberOrVariableValidator } from './utils';
describe('validator', () => {
@@ -48,18 +46,10 @@ describe('validator', () => {
});
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;
});
});
+17 -3
View File
@@ -1,7 +1,15 @@
import { useMemo } from 'react';
import { DataFrame, getFieldDisplayName, TransformerCategory, SelectableValue, getTimeZones } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
DataFrame,
getFieldDisplayName,
TransformerCategory,
SelectableValue,
getTimeZones,
VariableOrigin,
VariableSuggestion,
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
export function useAllFieldNamesFromDataFrames(input: DataFrame[]): string[] {
return useMemo(() => {
@@ -56,7 +64,7 @@ export const numberOrVariableValidator = (value: string | number) => {
if (!Number.isNaN(Number(value))) {
return true;
}
if (/^\$[A-Za-z0-9_]+$/.test(value) && config.featureToggles.transformationsVariableSupport) {
if (/^\$[A-Za-z0-9_]+$/.test(value)) {
return true;
}
return false;
@@ -81,3 +89,9 @@ export function getTimezoneOptions(includeInternal: boolean) {
return timeZoneOptions;
}
export function getVariableSuggestions(): VariableSuggestion[] {
return getTemplateSrv()
.getVariables()
.map((v) => ({ value: v.name, label: v.label || v.name, origin: VariableOrigin.Template }));
}
+292 -274
View File
File diff suppressed because it is too large Load Diff