mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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"]
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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.',
|
||||
|
@@ -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.',
|
||||
|
@@ -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,
|
||||
|
@@ -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)) {
|
||||
|
@@ -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 [];
|
||||
}
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -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)),
|
||||
|
@@ -0,0 +1,3 @@
|
||||
export const transformationsVariableSupport = () => {
|
||||
return (window as any)?.grafanaBootData?.settings?.featureToggles?.transformationsVariableSupport;
|
||||
};
|
@@ -136,4 +136,5 @@ export interface FeatureToggles {
|
||||
externalServiceAccounts?: boolean;
|
||||
alertingModifiedExport?: boolean;
|
||||
enableNativeHTTPHistogram?: boolean;
|
||||
transformationsVariableSupport?: boolean;
|
||||
}
|
||||
|
@@ -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': {
|
||||
|
@@ -823,5 +823,12 @@ var (
|
||||
FrontendOnly: false,
|
||||
Owner: hostedGrafanaTeam,
|
||||
},
|
||||
{
|
||||
Name: "transformationsVariableSupport",
|
||||
Description: "Allows using variables in transformations",
|
||||
FrontendOnly: true,
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaBiSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@@ -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
|
||||
|
|
@@ -478,4 +478,8 @@ const (
|
||||
// FlagEnableNativeHTTPHistogram
|
||||
// Enables native HTTP Histograms
|
||||
FlagEnableNativeHTTPHistogram = "enableNativeHTTPHistogram"
|
||||
|
||||
// FlagTransformationsVariableSupport
|
||||
// Allows using variables in transformations
|
||||
FlagTransformationsVariableSupport = "transformationsVariableSupport"
|
||||
)
|
||||
|
@@ -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>
|
||||
|
@@ -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),
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
@@ -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,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
@@ -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),
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
@@ -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(),
|
||||
];
|
||||
});
|
||||
|
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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[]) => {
|
||||
|
@@ -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(
|
||||
|
@@ -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,
|
||||
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
@@ -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) => {
|
||||
|
@@ -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;
|
||||
}
|
65
public/app/features/transformers/utils.test.ts
Normal file
65
public/app/features/transformers/utils.test.ts
Normal 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;
|
||||
});
|
||||
});
|
@@ -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;
|
||||
};
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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 {}
|
||||
}
|
||||
|
Reference in New Issue
Block a user