diff --git a/docs/sources/panels/field-options/standard-field-options.md b/docs/sources/panels/field-options/standard-field-options.md index efafe65c279..943381f9668 100644 --- a/docs/sources/panels/field-options/standard-field-options.md +++ b/docs/sources/panels/field-options/standard-field-options.md @@ -121,9 +121,12 @@ For more information and instructions, refer to [Thresholds]({{< relref "../thre ## Value mapping -Lets you set rules that translate a field value or range of values into explicit text. You can add more than one value mapping. +Value mappings come in different types. -- **Mapping type -** Click an option. - - **Value -** Enter a value. If the field value is greater than or equal to the value, then the **Text** is displayed. - - **From** and **To -** Enter a range. If the field value is between or equal to the values in the range, then the **Text** is displayed. -- **Text -** Text that is displayed if the conditions are met in a field. This field accepts variables. +* **Range** maps numerical ranges to a display text and color. +* **Value** maps text values to a color or different display text. +* **Special** maps special values like `Null`, `NaN` and boolean values like `true` and `false` to a display text and color. + +The display text and color are both optional. If you only want to assign colors to text values you can leave the display text empty and the original value will be used for display. + +Values mapped via value mappings will skip the unit formatting. This means that a text value mapped to a numerical value will not be formatted using the configured unit. diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts index 19847215ca8..4a612ce0b06 100644 --- a/packages/grafana-data/src/field/displayProcessor.test.ts +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -135,30 +135,40 @@ describe('Format value', () => { it('should return formatted value if there are no matching value mappings', () => { const valueMappings: ValueMapping[] = [ - { id: 0, text: 'elva', type: MappingType.ValueToText, value: '11' }, - { id: 1, text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, + { type: MappingType.ValueToText, options: { '11': { text: 'elva' } } }, + { type: MappingType.RangeToText, options: { from: 1, to: 9, result: { text: '1-9' } } }, ]; - const value = '10'; - const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); - const result = instance(value); + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + const result = instance('10'); expect(result.text).toEqual('10.0'); }); it('should return mapped value if there are matching value mappings', () => { const valueMappings: ValueMapping[] = [ - { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, + { type: MappingType.ValueToText, options: { '11': { text: 'elva' } } }, + { type: MappingType.RangeToText, options: { from: 1, to: 9, result: { text: '1-9' } } }, ]; - const value = '11'; - const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); - expect(instance(value).text).toEqual('1-20'); + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + const result = instance('11'); + + expect(result.text).toEqual('elva'); + }); + + it('should return value with color if mapping has color', () => { + const valueMappings: ValueMapping[] = [{ type: MappingType.ValueToText, options: { Low: { color: 'red' } } }]; + + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + const result = instance('Low'); + + expect(result.text).toEqual('Low'); + expect(result.color).toEqual('#F2495C'); }); it('should return mapped value and leave numeric value in tact if value mapping maps to empty string', () => { - const valueMappings: ValueMapping[] = [{ id: 1, text: '', type: MappingType.ValueToText, value: '1' }]; + const valueMappings: ValueMapping[] = [{ type: MappingType.ValueToText, options: { '1': { text: '' } } }]; const value = '1'; const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); @@ -167,7 +177,7 @@ describe('Format value', () => { }); it('should not map 1kW to the value for 1W', () => { - const valueMappings: ValueMapping[] = [{ id: 0, text: 'mapped', type: MappingType.ValueToText, value: '1' }]; + const valueMappings: ValueMapping[] = [{ type: MappingType.ValueToText, options: { '1': { text: 'mapped' } } }]; const value = '1000'; const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings, unit: 'watt' }); diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index b43e05ed53a..de283e96790 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -5,11 +5,13 @@ import { toString, toNumber as _toNumber, isEmpty, isBoolean } from 'lodash'; import { Field, FieldType } from '../types/dataFrame'; import { DisplayProcessor, DisplayValue } from '../types/displayValue'; import { getValueFormat } from '../valueFormats/valueFormats'; -import { getMappedValue } from '../utils/valueMappings'; +import { getValueMappingResult } from '../utils/valueMappings'; import { dateTime } from '../datetime'; import { KeyValue, TimeZone } from '../types'; import { getScaleCalculator } from './scale'; import { GrafanaTheme2 } from '../themes/types'; +import { anyToNumber } from '../utils/anyToNumber'; +import { getColorForTheme } from '../utils/namedColorsPalette'; interface DisplayProcessorOptions { field: Partial; @@ -62,20 +64,24 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP } let text = toString(value); - let numeric = isStringUnit ? NaN : toNumber(value); + let numeric = isStringUnit ? NaN : anyToNumber(value); let prefix: string | undefined = undefined; let suffix: string | undefined = undefined; + let color: string | undefined = undefined; + let percent: number | undefined = undefined; + let shouldFormat = true; if (mappings && mappings.length > 0) { - const mappedValue = getMappedValue(mappings, value); + const mappingResult = getValueMappingResult(mappings, value); - if (mappedValue) { - text = mappedValue.text; - const v = isStringUnit ? NaN : toNumber(text); + if (mappingResult) { + if (mappingResult.text != null) { + text = mappingResult.text; + } - if (!isNaN(v)) { - numeric = v; + if (mappingResult.color != null) { + color = getColorForTheme(mappingResult.color, options.theme.v1); } shouldFormat = false; @@ -91,8 +97,10 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP } // Return the value along with scale info - if (text) { - return { text, numeric, prefix, suffix, ...scaleFunc(numeric) }; + if (color === undefined) { + const scaleResult = scaleFunc(numeric); + color = scaleResult.color; + percent = scaleResult.percent; } } @@ -104,26 +112,18 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP } } - return { text, numeric, prefix, suffix, ...scaleFunc(-Infinity) }; + if (!color) { + const scaleResult = scaleFunc(-Infinity); + color = scaleResult.color; + percent = scaleResult.percent; + } + + return { text, numeric, prefix, suffix, color, percent }; }; } -/** Will return any value as a number or NaN */ -function toNumber(value: any): number { - if (typeof value === 'number') { - return value; - } - if (value === '' || value === null || value === undefined || Array.isArray(value)) { - return NaN; // lodash calls them 0 - } - if (typeof value === 'boolean') { - return value ? 1 : 0; - } - return _toNumber(value); -} - function toStringProcessor(value: any): DisplayValue { - return { text: toString(value), numeric: toNumber(value) }; + return { text: toString(value), numeric: anyToNumber(value) }; } export function getRawDisplayProcessor(): DisplayProcessor { diff --git a/packages/grafana-data/src/field/fieldDisplay.test.ts b/packages/grafana-data/src/field/fieldDisplay.test.ts index f286de0fa4c..373f587d429 100644 --- a/packages/grafana-data/src/field/fieldDisplay.test.ts +++ b/packages/grafana-data/src/field/fieldDisplay.test.ts @@ -2,7 +2,7 @@ import { merge } from 'lodash'; import { getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay'; import { toDataFrame } from '../dataframe/processDataFrame'; import { ReducerID } from '../transformations/fieldReducer'; -import { MappingType } from '../types'; +import { MappingType, SpecialValueMatch, ValueMapping } from '../types'; import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; import { createTheme } from '../themes'; @@ -100,11 +100,11 @@ describe('FieldDisplay', () => { defaults: { mappings: [ { - id: 1, - operator: '', - text: mapEmptyToText, - type: MappingType.ValueToText, - value: 'null', + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: mapEmptyToText }, + }, }, ], }, @@ -123,11 +123,11 @@ describe('FieldDisplay', () => { overrides: { mappings: [ { - id: 1, - operator: '', - text: mapEmptyToText, - type: MappingType.ValueToText, - value: 'null', + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: mapEmptyToText }, + }, }, ], }, @@ -152,13 +152,12 @@ describe('FieldDisplay', () => { describe('Value mapping', () => { it('should apply value mapping', () => { - const mappingConfig = [ + const mappingConfig: ValueMapping[] = [ { - id: 1, - operator: '', - text: 'Value mapped to text', type: MappingType.ValueToText, - value: '1', + options: { + '1': { text: 'Value mapped to text' }, + }, }, ]; const options = createDisplayOptions({ @@ -173,17 +172,17 @@ describe('FieldDisplay', () => { const result = getFieldDisplayValues(options); expect(result[0].display.text).toEqual('Value mapped to text'); }); + it('should apply range value mapping', () => { const mappedValue = 'Range mapped to text'; - const mappingConfig = [ + const mappingConfig: ValueMapping[] = [ { - id: 1, - operator: '', - text: mappedValue, type: MappingType.RangeToText, - value: 1, - from: '1', - to: '3', + options: { + from: 1, + to: 3, + result: { text: mappedValue }, + }, }, ]; const options = createDisplayOptions({ diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 4b6f2b356f4..c822f360265 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -663,10 +663,6 @@ describe('applyRawFieldOverrides', () => { suffix: undefined, text: '1599045551050', percent: expect.any(Number), - threshold: { - color: 'red', - value: 80, - }, }); expect(getDisplayValue(frames, frameIndex, 1)).toEqual({ @@ -676,10 +672,6 @@ describe('applyRawFieldOverrides', () => { prefix: undefined, suffix: undefined, text: '3.142', - threshold: { - color: 'green', - value: null, - }, }); expect(getDisplayValue(frames, frameIndex, 2)).toEqual({ @@ -689,10 +681,6 @@ describe('applyRawFieldOverrides', () => { prefix: undefined, suffix: undefined, text: '0', - threshold: { - color: 'green', - value: null, - }, }); expect(getDisplayValue(frames, frameIndex, 3)).toEqual({ @@ -702,7 +690,6 @@ describe('applyRawFieldOverrides', () => { prefix: undefined, suffix: undefined, text: '0', - threshold: expect.anything(), }); expect(getDisplayValue(frames, frameIndex, 4)).toEqual({ @@ -712,7 +699,6 @@ describe('applyRawFieldOverrides', () => { prefix: undefined, suffix: undefined, text: 'A - string', - threshold: expect.anything(), }); expect(getDisplayValue(frames, frameIndex, 5)).toEqual({ @@ -722,7 +708,6 @@ describe('applyRawFieldOverrides', () => { prefix: undefined, suffix: undefined, text: '2020-09-02 11:19:11', - threshold: expect.anything(), }); }; diff --git a/packages/grafana-data/src/types/valueMapping.ts b/packages/grafana-data/src/types/valueMapping.ts index 3a2b61a0703..f371d42de69 100644 --- a/packages/grafana-data/src/types/valueMapping.ts +++ b/packages/grafana-data/src/types/valueMapping.ts @@ -1,21 +1,50 @@ export enum MappingType { - ValueToText = 1, - RangeToText = 2, + ValueToText = 'value', // was 1 + RangeToText = 'range', // was 2 + SpecialValue = 'special', } -interface BaseMap { - id: number; // this could/should just be the array index - text: string; // the final display value +export interface ValueMappingResult { + text?: string; + color?: string; + index?: number; +} + +interface BaseValueMap { type: MappingType; + options: T; } -export type ValueMapping = ValueMap | RangeMap; - -export interface ValueMap extends BaseMap { - value: string; +export interface ValueMap extends BaseValueMap> { + type: MappingType.ValueToText; } -export interface RangeMap extends BaseMap { - from: string; - to: string; +export interface RangeMapOptions { + from: number | null; // changed from string + to: number | null; + result: ValueMappingResult; } + +export interface RangeMap extends BaseValueMap { + type: MappingType.RangeToText; +} + +export interface SpecialValueOptions { + match: SpecialValueMatch; + result: ValueMappingResult; +} + +export enum SpecialValueMatch { + True = 'true', + False = 'false', + Null = 'null', + NaN = 'nan', + NullAndNaN = 'null+nan', + Empty = 'empty', +} + +export interface SpecialValueMap extends BaseValueMap { + type: MappingType.SpecialValue; +} + +export type ValueMapping = ValueMap | RangeMap | SpecialValueMap; diff --git a/packages/grafana-data/src/utils/anyToNumber.ts b/packages/grafana-data/src/utils/anyToNumber.ts new file mode 100644 index 00000000000..cf480d4d3c9 --- /dev/null +++ b/packages/grafana-data/src/utils/anyToNumber.ts @@ -0,0 +1,18 @@ +import { toNumber } from 'lodash'; + +/** Will return any value as a number or NaN */ +export function anyToNumber(value: any): number { + if (typeof value === 'number') { + return value; + } + + if (value === '' || value === null || value === undefined || Array.isArray(value)) { + return NaN; // lodash calls them 0 + } + + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + + return toNumber(value); +} diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 7912bf435b8..2e9c892b968 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -12,10 +12,10 @@ export * from './series'; export * from './binaryOperators'; export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders'; export { arrayUtils }; -export { getMappedValue } from './valueMappings'; export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; export { locationUtil } from './location'; export { urlUtil, UrlQueryMap, UrlQueryValue, serializeStateToUrlParam } from './url'; export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks'; export { DocsId } from './docs'; export { makeClassES5Compatible } from './makeClassES5Compatible'; +export { anyToNumber } from './anyToNumber'; diff --git a/packages/grafana-data/src/utils/valueMappings.test.ts b/packages/grafana-data/src/utils/valueMappings.test.ts index e9d04959725..f6fe0bee6d2 100644 --- a/packages/grafana-data/src/utils/valueMappings.test.ts +++ b/packages/grafana-data/src/utils/valueMappings.test.ts @@ -1,121 +1,134 @@ -import { getMappedValue, isNumeric } from './valueMappings'; -import { ValueMapping, MappingType } from '../types'; +import { getValueMappingResult, isNumeric } from './valueMappings'; +import { ValueMapping, MappingType, SpecialValueMatch } from '../types'; + +const testSet1: ValueMapping[] = [ + { + type: MappingType.ValueToText, + options: { '11': { text: 'elva' } }, + }, + { + type: MappingType.RangeToText, + options: { + from: 1, + to: 9, + result: { text: '1-9' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 8, + to: 12, + result: { text: '8-12' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: 'it is null' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.NaN, + result: { text: 'it is nan' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.True, + result: { text: 'it is true' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.False, + result: { text: 'it is false' }, + }, + }, +]; describe('Format value with value mappings', () => { - it('should return undefined with no valuemappings', () => { + it('should return null with no valuemappings', () => { const valueMappings: ValueMapping[] = []; const value = '10'; - expect(getMappedValue(valueMappings, value)).toBeUndefined(); + expect(getValueMappingResult(valueMappings, value)).toBeNull(); }); - it('should return undefined with no matching valuemappings', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, text: 'elva', type: MappingType.ValueToText, value: '11' }, - { id: 1, text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, - ]; - const value = '10'; - - expect(getMappedValue(valueMappings, value)).toBeUndefined(); + it('should return null with no matching valuemappings', () => { + const value = '100'; + expect(getValueMappingResult(testSet1, value)).toBeNull(); }); - it('should return first matching mapping with lowest id', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, text: 'tio', type: MappingType.ValueToText, value: '10' }, - ]; - const value = '10'; - - expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); + it('should return match result with string value match', () => { + const value = '11'; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: 'elva' }); }); - it('should return if value is null and value to text mapping value is null', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, text: '', type: MappingType.ValueToText, value: 'null' }, - ]; + it('should return match result with number value', () => { + const value = 11; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: 'elva' }); + }); + + it('should return match result for null value', () => { const value = null; - - expect(getMappedValue(valueMappings, value).text).toEqual(''); + expect(getValueMappingResult(testSet1, value)).toEqual({ text: 'it is null' }); }); - it('should return if value is null and range to text mapping from and to is null', () => { + it('should return match result for undefined value', () => { + const value = undefined; + expect(getValueMappingResult(testSet1, value as any)).toEqual({ text: 'it is null' }); + }); + + it('should return match result for nan value', () => { + const value = Number.NaN; + expect(getValueMappingResult(testSet1, value as any)).toEqual({ text: 'it is nan' }); + }); + + it('should return range mapping that matches first', () => { + const value = '9'; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: '1-9' }); + }); + + it('should return correct range mapping result', () => { + const value = '12'; + expect(getValueMappingResult(testSet1, value)).toEqual({ text: '8-12' }); + }); + + it.each` + value | expected + ${'2/0/12'} | ${{ text: 'mapped value 1' }} + ${'2/1/12'} | ${null} + ${'2:0'} | ${{ text: 'mapped value 3' }} + ${'2:1'} | ${null} + ${'20whatever'} | ${{ text: 'mapped value 2' }} + ${'20whateve'} | ${null} + ${'20'} | ${null} + ${'00020.4'} | ${null} + ${'192.168.1.1'} | ${{ text: 'mapped value ip' }} + ${'192'} | ${null} + ${'192.168'} | ${null} + ${'192.168.1'} | ${null} + ${9.9} | ${{ text: 'OK' }} + `('numeric-like text mapping, value:${value', ({ value, expected }) => { const valueMappings: ValueMapping[] = [ - { id: 0, text: '', type: MappingType.RangeToText, from: 'null', to: 'null' }, - { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, + { + type: MappingType.ValueToText, + options: { + '2/0/12': { text: 'mapped value 1' }, + '20whatever': { text: 'mapped value 2' }, + '2:0': { text: 'mapped value 3' }, + '192.168.1.1': { text: 'mapped value ip' }, + '9.9': { text: 'OK' }, + }, + }, ]; - const value = null; - - expect(getMappedValue(valueMappings, value).text).toEqual(''); - }); - - it('should return rangeToText mapping where value equals to', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' }, - { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, - ]; - const value = '10'; - - expect(getMappedValue(valueMappings, value).text).toEqual('1-10'); - }); - - it('should return rangeToText mapping where value equals from', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' }, - { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, - ]; - const value = '10'; - - expect(getMappedValue(valueMappings, value).text).toEqual('10-20'); - }); - - it('should return rangeToText mapping where value is between from and to', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, - ]; - const value = '10'; - - expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); - }); - - describe('text mapping', () => { - it('should map value text to mapping', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, text: 'ELVA', type: MappingType.ValueToText, value: 'elva' }, - ]; - - const value = 'elva'; - - expect(getMappedValue(valueMappings, value).text).toEqual('ELVA'); - }); - - it.each` - value | expected - ${'2/0/12'} | ${{ id: 1, text: 'mapped value 1', type: MappingType.ValueToText, value: '2/0/12' }} - ${'2/1/12'} | ${undefined} - ${'2:0'} | ${{ id: 3, text: 'mapped value 3', type: MappingType.ValueToText, value: '2:0' }} - ${'2:1'} | ${undefined} - ${'20whatever'} | ${{ id: 2, text: 'mapped value 2', type: MappingType.ValueToText, value: '20whatever' }} - ${'20whateve'} | ${undefined} - ${'20'} | ${undefined} - ${'00020.4'} | ${undefined} - ${'192.168.1.1'} | ${{ id: 4, text: 'mapped value ip', type: MappingType.ValueToText, value: '192.168.1.1' }} - ${'192'} | ${undefined} - ${'192.168'} | ${undefined} - ${'192.168.1'} | ${undefined} - ${'9.90'} | ${{ id: 5, text: 'OK', type: MappingType.ValueToText, value: '9.9' }} - `('numeric-like text mapping, value:${value', ({ value, expected }) => { - const valueMappings: ValueMapping[] = [ - { id: 1, text: 'mapped value 1', type: MappingType.ValueToText, value: '2/0/12' }, - { id: 2, text: 'mapped value 2', type: MappingType.ValueToText, value: '20whatever' }, - { id: 3, text: 'mapped value 3', type: MappingType.ValueToText, value: '2:0' }, - { id: 4, text: 'mapped value ip', type: MappingType.ValueToText, value: '192.168.1.1' }, - { id: 5, text: 'OK', type: MappingType.ValueToText, value: '9.9' }, - ]; - expect(getMappedValue(valueMappings, value)).toEqual(expected); - }); + expect(getValueMappingResult(valueMappings, value)).toEqual(expected); }); }); diff --git a/packages/grafana-data/src/utils/valueMappings.ts b/packages/grafana-data/src/utils/valueMappings.ts index 3839683c615..35f56dd1f9c 100644 --- a/packages/grafana-data/src/utils/valueMappings.ts +++ b/packages/grafana-data/src/utils/valueMappings.ts @@ -1,104 +1,88 @@ -import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types'; +import { ValueMapping, MappingType, ValueMappingResult, SpecialValueMatch } from '../types'; -type TimeSeriesValue = string | number | null; +export function getValueMappingResult(valueMappings: ValueMapping[], value: any): ValueMappingResult | null { + for (const vm of valueMappings) { + switch (vm.type) { + case MappingType.ValueToText: + if (value == null) { + continue; + } -const addValueToTextMappingText = ( - allValueMappings: ValueMapping[], - valueToTextMapping: ValueMap, - value: TimeSeriesValue -) => { - if (valueToTextMapping.value === undefined) { - return allValueMappings; - } + const result = vm.options[value]; + if (result) { + return result; + } - if (value === null && isNullValueMap(valueToTextMapping)) { - return allValueMappings.concat(valueToTextMapping); - } + break; - let valueAsNumber, valueToTextMappingAsNumber; + case MappingType.RangeToText: + if (value == null) { + continue; + } - if (isNumeric(value as string) && isNumeric(valueToTextMapping.value)) { - valueAsNumber = parseFloat(value as string); - valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string); + const valueAsNumber = parseFloat(value as string); + if (isNaN(valueAsNumber)) { + continue; + } - if (valueAsNumber === valueToTextMappingAsNumber) { - return allValueMappings.concat(valueToTextMapping); + const isNumFrom = !isNaN(vm.options.from!); + if (isNumFrom && valueAsNumber < vm.options.from!) { + continue; + } + + const isNumTo = !isNaN(vm.options.to!); + if (isNumTo && valueAsNumber > vm.options.to!) { + continue; + } + + return vm.options.result; + + case MappingType.SpecialValue: + switch (vm.options.match) { + case SpecialValueMatch.Null: { + if (value == null) { + return vm.options.result; + } + break; + } + case SpecialValueMatch.NaN: { + if (isNaN(value as any)) { + return vm.options.result; + } + break; + } + case SpecialValueMatch.NullAndNaN: { + if (isNaN(value as any) || value == null) { + return vm.options.result; + } + break; + } + case SpecialValueMatch.True: { + if (value === true || value === 'true') { + return vm.options.result; + } + break; + } + case SpecialValueMatch.False: { + if (value === false || value === 'false') { + return vm.options.result; + } + break; + } + case SpecialValueMatch.Empty: { + if (value === '') { + return vm.options.result; + } + break; + } + } } - return allValueMappings; } - if (value === valueToTextMapping.value) { - return allValueMappings.concat(valueToTextMapping); - } - - return allValueMappings; -}; - -const addRangeToTextMappingText = ( - allValueMappings: ValueMapping[], - rangeToTextMapping: RangeMap, - value: TimeSeriesValue -) => { - if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) { - return allValueMappings; - } - - if ( - value === null && - rangeToTextMapping.from && - rangeToTextMapping.to && - rangeToTextMapping.from.toLowerCase() === 'null' && - rangeToTextMapping.to.toLowerCase() === 'null' - ) { - return allValueMappings.concat(rangeToTextMapping); - } - - const valueAsNumber = parseFloat(value as string); - const fromAsNumber = parseFloat(rangeToTextMapping.from as string); - const toAsNumber = parseFloat(rangeToTextMapping.to as string); - - if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) { - return allValueMappings; - } - - if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) { - return allValueMappings.concat(rangeToTextMapping); - } - - return allValueMappings; -}; - -const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => { - const allFormattedValueMappings = valueMappings.reduce((allValueMappings, valueMapping) => { - if (valueMapping.type === MappingType.ValueToText) { - allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value); - } else if (valueMapping.type === MappingType.RangeToText) { - allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value); - } - - return allValueMappings; - }, [] as ValueMapping[]); - - allFormattedValueMappings.sort((t1, t2) => { - return t1.id - t2.id; - }); - - return allFormattedValueMappings; -}; - -export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => { - return getAllFormattedValueMappings(valueMappings, value)[0]; -}; - -const isNullValueMap = (mapping: ValueMap): boolean => { - if (!mapping || !mapping.value) { - return false; - } - return mapping.value.toLowerCase() === 'null'; -}; + return null; +} // Ref https://stackoverflow.com/a/58550111 - export function isNumeric(num: any) { return (typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) && !isNaN(num as number); } diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index cb2c15faf80..be671326cee 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -46,10 +46,6 @@ exports[`BarGauge Render with basic options should render 1`] = ` "prefix": undefined, "suffix": undefined, "text": "25", - "threshold": Object { - "color": "green", - "value": -Infinity, - }, } } /> diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index 2f0090caaab..ea4896a7346 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -151,7 +151,8 @@ export const getButtonStyles = (props: StyleProps) => { margin: ${theme.spacing(0, 1, 0, 0.5)}; `, icon: css` - margin: ${theme.spacing(0, (iconOnly ? -padding : padding) / 2, 0, -(padding / 2))}; + margin-right: ${theme.spacing((iconOnly ? -padding : padding) / 2)}; + margin-left: ${theme.spacing(-padding / 2)}; `, content: css` display: flex; diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx index cacece751f1..5b5d09f8146 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx @@ -98,7 +98,7 @@ const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme2, size: RadioBut color: ${textColor}; padding: ${theme.spacing(0, padding)}; border-radius: ${theme.shape.borderRadius()}; - background: transparent; + background: ${theme.colors.background.primary}; cursor: pointer; z-index: 1; flex: ${fullWidth ? `1 0 0` : 'none'}; diff --git a/packages/grafana-ui/src/components/Layout/Layout.tsx b/packages/grafana-ui/src/components/Layout/Layout.tsx index 9fe091d428f..3a96c47440b 100644 --- a/packages/grafana-ui/src/components/Layout/Layout.tsx +++ b/packages/grafana-ui/src/components/Layout/Layout.tsx @@ -133,6 +133,7 @@ const getStyles = stylesFactory( margin-bottom: ${marginCompensation}; `, childWrapper: css` + label: layoutChildrenWrapper; margin-bottom: ${orientation === Orientation.Horizontal && !wrap ? 0 : finalSpacing}; margin-right: ${orientation === Orientation.Horizontal ? finalSpacing : 0}; display: flex; @@ -152,6 +153,7 @@ const getContainerStyles = stylesFactory((theme: GrafanaTheme, padding?: Spacing const marginSize = (margin && margin !== 'none' && theme.spacing[margin]) || 0; return { wrapper: css` + label: container; margin: ${marginSize}; padding: ${paddingSize}; `, diff --git a/packages/grafana-ui/src/components/Select/SingleValue.tsx b/packages/grafana-ui/src/components/Select/SingleValue.tsx index b3d57fb5c7e..e95278fbfbe 100644 --- a/packages/grafana-ui/src/components/Select/SingleValue.tsx +++ b/packages/grafana-ui/src/components/Select/SingleValue.tsx @@ -17,7 +17,6 @@ const getStyles = (theme: GrafanaTheme2) => { text-overflow: ellipsis; box-sizing: border-box; max-width: 100%; - /* padding-right: 40px; */ `; const container = css` width: 16px; diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx deleted file mode 100644 index 9c845f6412b..00000000000 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import { HorizontalGroup } from '../Layout/Layout'; -import { IconButton, Label, RadioButtonGroup } from '../index'; -import { Field } from '../Forms/Field'; -import { Input } from '../Input/Input'; -import { MappingType, RangeMap, SelectableValue, ValueMap, ValueMapping } from '@grafana/data'; - -export interface Props { - valueMapping: ValueMapping; - onUpdate: (value: ValueMapping) => void; - onRemove: () => void; -} - -const MAPPING_OPTIONS: Array> = [ - { value: MappingType.ValueToText, label: 'Value' }, - { value: MappingType.RangeToText, label: 'Range' }, -]; - -export const MappingRow: React.FC = ({ valueMapping, onUpdate, onRemove }) => { - const { type } = valueMapping; - - const onMappingValueChange = (value: string) => { - onUpdate({ ...valueMapping, value: value }); - }; - - const onMappingFromChange = (value: string) => { - onUpdate({ ...valueMapping, from: value }); - }; - - const onMappingToChange = (value: string) => { - onUpdate({ ...valueMapping, to: value }); - }; - - const onMappingTextChange = (value: string) => { - onUpdate({ ...valueMapping, text: value }); - }; - - const onMappingTypeChange = (mappingType: MappingType) => { - onUpdate({ ...valueMapping, type: mappingType }); - }; - - const onKeyDown = (handler: (value: string) => void) => (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handler(e.currentTarget.value); - } - }; - - const renderRow = () => { - if (type === MappingType.RangeToText) { - return ( - <> - - - onMappingFromChange(e.currentTarget.value)} - onKeyDown={onKeyDown(onMappingFromChange)} - /> - - - onMappingToChange(e.currentTarget.value)} - onKeyDown={onKeyDown(onMappingToChange)} - /> - - - - - onMappingTextChange(e.currentTarget.value)} - onKeyDown={onKeyDown(onMappingTextChange)} - /> - - - ); - } - - return ( - <> - - onMappingValueChange(e.currentTarget.value)} - onKeyDown={onKeyDown(onMappingValueChange)} - /> - - - - onMappingTextChange(e.currentTarget.value)} - onKeyDown={onKeyDown(onMappingTextChange)} - /> - - - ); - }; - - const label = ( - - - - - ); - return ( -
- - { - onMappingTypeChange(type!); - }} - /> - - {renderRow()} -
- ); -}; diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingEditRow.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingEditRow.tsx new file mode 100644 index 00000000000..3c3578908f9 --- /dev/null +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingEditRow.tsx @@ -0,0 +1,204 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { Input } from '../Input/Input'; +import { GrafanaTheme2, MappingType, SpecialValueMatch, SelectableValue, ValueMappingResult } from '@grafana/data'; +import { Draggable } from 'react-beautiful-dnd'; +import { Icon } from '../Icon/Icon'; +import { ColorPicker } from '../ColorPicker/ColorPicker'; +import { LinkButton } from '../Button'; +import { HorizontalGroup } from '../Layout/Layout'; +import { IconButton } from '../IconButton/IconButton'; +import { useStyles2 } from '../../themes/ThemeContext'; +import { css } from '@emotion/css'; +import { Select } from '../Select/Select'; + +export interface ValueMappingEditRowModel { + type: MappingType; + from?: number; + to?: number; + key?: string; + isNew?: boolean; + specialMatch?: SpecialValueMatch; + result: ValueMappingResult; +} + +interface Props { + mapping: ValueMappingEditRowModel; + index: number; + onChange: (index: number, mapping: ValueMappingEditRowModel) => void; + onRemove: (index: number) => void; +} + +export function ValueMappingEditRow({ mapping, index, onChange, onRemove }: Props) { + const { key, result } = mapping; + const styles = useStyles2(getStyles); + const inputRef = useRef(null); + + const update = useCallback( + (fn: (item: ValueMappingEditRowModel) => void) => { + const copy = { + ...mapping, + result: { + ...mapping.result, + }, + }; + fn(copy); + onChange(index, copy); + }, + [mapping, index, onChange] + ); + + useEffect(() => { + if (inputRef.current && mapping.isNew) { + inputRef.current.focus(); + update((mapping) => { + mapping.isNew = false; + }); + } + }, [mapping, inputRef, update]); + + const onChangeColor = (color: string) => { + update((mapping) => { + mapping.result.color = color; + }); + }; + + const onClearColor = () => { + update((mapping) => { + mapping.result.color = undefined; + }); + }; + + const onUpdateMatchValue = (event: React.FormEvent) => { + update((mapping) => { + mapping.key = event.currentTarget.value; + }); + }; + + const onChangeText = (event: React.FormEvent) => { + update((mapping) => { + mapping.result.text = event.currentTarget.value; + }); + }; + + const onChangeFrom = (event: React.FormEvent) => { + update((mapping) => { + mapping.from = parseFloat(event.currentTarget.value); + }); + }; + + const onChangeTo = (event: React.FormEvent) => { + update((mapping) => { + mapping.to = parseFloat(event.currentTarget.value); + }); + }; + + const onChangeSpecialMatch = (sel: SelectableValue) => { + update((mapping) => { + mapping.specialMatch = sel.value; + }); + }; + + const specialMatchOptions: Array> = [ + { label: 'Null', value: SpecialValueMatch.Null, description: 'Matches null and undefined values' }, + { label: 'NaN', value: SpecialValueMatch.NaN, description: 'Matches against Number.NaN (not a number)' }, + { label: 'Null + NaN', value: SpecialValueMatch.NullAndNaN, description: 'Matches null, undefined and NaN' }, + { label: 'True', value: SpecialValueMatch.True, description: 'Boolean true values' }, + { label: 'False', value: SpecialValueMatch.False, description: 'Boolean false values' }, + { label: 'Empty', value: SpecialValueMatch.Empty, description: 'Empty string' }, + ]; + + return ( + + {(provided) => ( + + +
+ +
+ + {mapping.type} + + {mapping.type === MappingType.ValueToText && ( + + )} + {mapping.type === MappingType.RangeToText && ( +
+ + +
+ )} + {mapping.type === MappingType.SpecialValue && ( + + + + {result.color && ( + + + + + )} + {!result.color && ( + + {(props) => ( + + Set color + + )} + + )} + + + + onRemove(index)} data-testid="remove-value-mapping" /> + + + + )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + dragHandle: css({ + cursor: 'grab', + }), + rangeInputWrapper: css({ + display: 'flex', + '> div:first-child': { + marginRight: theme.spacing(2), + }, + }), + typeColumn: css({ + textTransform: 'capitalize', + textAlign: 'center', + }), + textAlignCenter: css({ + textAlign: 'center', + }), +}); diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx index dda84c86840..7596233ecc8 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx @@ -1,12 +1,36 @@ -import React from 'react'; -import { action } from '@storybook/addon-actions'; +import React, { useState } from 'react'; import { ValueMappingsEditor } from './ValueMappingsEditor'; +import { MappingType, ValueMapping } from '@grafana/data'; export default { title: 'Pickers and Editors/ValueMappingsEditor', component: ValueMappingsEditor, }; -export const basic = () => { - return ; -}; +export function Example() { + const [mappings, setMappings] = useState([ + { + type: MappingType.ValueToText, + options: { + LowLow: { color: 'red' }, + Low: { text: 'not good', color: 'orange' }, + Ok: { text: 'all good', color: 'green' }, + NoColor: { text: 'Unknown' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 10, + to: 15, + result: { + index: 5, + text: 'bad', + color: 'red', + }, + }, + }, + ]); + + return ; +} diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx index b3c23eb2c91..998b5fe4efb 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { mount } from 'enzyme'; - +import { render, screen } from '@testing-library/react'; import { ValueMappingsEditor, Props } from './ValueMappingsEditor'; import { MappingType } from '@grafana/data'; @@ -12,63 +11,32 @@ const setup = (spy?: any, propOverrides?: object) => { } }, value: [ - { id: 1, type: MappingType.ValueToText, value: '20', text: 'Ok' }, - { id: 2, type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' }, + { + type: MappingType.ValueToText, + options: { + '20': { text: 'Ok' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 21, + to: 30, + result: { text: 'Meh' }, + }, + }, ], }; Object.assign(props, propOverrides); - return mount(); + render(); }; describe('Render', () => { it('should render component', () => { - expect(setup).not.toThrow(); - }); -}); - -describe('On remove mapping', () => { - it('Should remove mapping at index 0', () => { - const onChangeSpy = jest.fn(); - const wrapper = setup(onChangeSpy); - const remove = wrapper.find('button[aria-label="ValueMappingsEditor remove button"]'); - remove.at(0).simulate('click'); - - expect(onChangeSpy).toBeCalledWith([{ id: 2, type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' }]); - }); - - it('should remove mapping at index 1', () => { - const onChangeSpy = jest.fn(); - const wrapper = setup(onChangeSpy); - - const remove = wrapper.find('button[aria-label="ValueMappingsEditor remove button"]'); - remove.at(1).simulate('click'); - - expect(onChangeSpy).toBeCalledWith([{ id: 1, type: MappingType.ValueToText, value: '20', text: 'Ok' }]); - }); -}); - -describe('Next id to add', () => { - it('should be 3', () => { - const onChangeSpy = jest.fn(); - const wrapper = setup(onChangeSpy); - - const add = wrapper.find('*[aria-label="ValueMappingsEditor add mapping button"]'); - add.at(0).simulate('click'); - - expect(onChangeSpy).toBeCalledWith([ - { id: 1, type: MappingType.ValueToText, value: '20', text: 'Ok' }, - { id: 2, type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' }, - { id: 3, type: MappingType.ValueToText, from: '', to: '', text: '' }, - ]); - }); - - it('should default to id 1', () => { - const onChangeSpy = jest.fn(); - const wrapper = setup(onChangeSpy, { value: [] }); - const add = wrapper.find('*[aria-label="ValueMappingsEditor add mapping button"]'); - add.at(0).simulate('click'); - expect(onChangeSpy).toBeCalledWith([{ id: 1, type: MappingType.ValueToText, from: '', to: '', text: '' }]); + setup(); + const button = screen.getByText('Edit value mappings'); + expect(button).toBeInTheDocument(); }); }); diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx index 581171ca94a..60a360b2e7a 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx @@ -1,60 +1,90 @@ -import React from 'react'; -import { MappingType, ValueMapping } from '@grafana/data'; +import React, { useCallback, useMemo, useState } from 'react'; +import { GrafanaTheme2, MappingType, ValueMapping } from '@grafana/data'; import { Button } from '../Button/Button'; -import { MappingRow } from './MappingRow'; +import { Modal } from '../Modal/Modal'; +import { useStyles2 } from '../../themes'; +import { css } from '@emotion/css'; +import { buildEditRowModels, editModelToSaveModel, ValueMappingsEditorModal } from './ValueMappingsEditorModal'; +import { Icon } from '../Icon/Icon'; +import { VerticalGroup } from '../Layout/Layout'; +import { ColorPicker } from '../ColorPicker/ColorPicker'; export interface Props { value: ValueMapping[]; onChange: (valueMappings: ValueMapping[]) => void; } -export const ValueMappingsEditor: React.FC = ({ value, onChange, children }) => { - const onAdd = () => { - const defaultMapping = { - type: MappingType.ValueToText, - from: '', - to: '', - text: '', - }; +export const ValueMappingsEditor = React.memo(({ value, onChange }: Props) => { + const styles = useStyles2(getStyles); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const onCloseEditor = useCallback(() => { + setIsEditorOpen(false); + }, [setIsEditorOpen]); - const id = Math.max(...value.map((v) => v.id), 0) + 1; + const rows = useMemo(() => buildEditRowModels(value), [value]); - onChange([ - ...value, - { - id, - ...defaultMapping, - }, - ]); - }; - - const onRemove = (index: number) => { - onChange(value.filter((_, i) => i !== index)); - }; - - const onMappingChange = (update: ValueMapping) => { - onChange(value.map((item) => (item.id === update.id ? update : item))); - }; + const onChangeColor = useCallback( + (color: string, index: number) => { + rows[index].result.color = color; + onChange(editModelToSaveModel(rows)); + }, + [rows, onChange] + ); return ( - <> - {value.map((valueMapping, index) => ( - onRemove(index)} - /> - ))} - - + + + + ); -}; +}); + +ValueMappingsEditor.displayName = 'ValueMappingsEditor'; + +export const getStyles = (theme: GrafanaTheme2) => ({ + modal: css({ + width: '980px', + }), + compactTable: css({ + width: '100%', + 'tbody td': { + padding: theme.spacing(0.5), + }, + }), +}); diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.test.tsx new file mode 100644 index 00000000000..daac4a6f6f4 --- /dev/null +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ValueMappingsEditorModal, Props } from './ValueMappingsEditorModal'; +import { MappingType } from '@grafana/data'; + +const setup = (spy?: any, propOverrides?: object) => { + const props: Props = { + onClose: jest.fn(), + onChange: (mappings: any) => { + if (spy) { + spy(mappings); + } + }, + value: [ + { + type: MappingType.ValueToText, + options: { + '20': { + text: 'Ok', + index: 0, + }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 21, + to: 30, + result: { + text: 'Meh', + index: 1, + }, + }, + }, + ], + }; + + Object.assign(props, propOverrides); + + render(); +}; + +describe('Render', () => { + it('should render component', () => { + setup(); + }); +}); + +describe('On remove mapping', () => { + it('Should remove mapping at index 0', () => { + const onChangeSpy = jest.fn(); + setup(onChangeSpy); + + screen.getAllByTestId('remove-value-mapping')[0].click(); + screen.getByText('Update').click(); + + expect(onChangeSpy).toBeCalledWith([ + { + type: MappingType.RangeToText, + options: { + from: 21, + to: 30, + result: { + text: 'Meh', + index: 0, + }, + }, + }, + ]); + }); +}); + +describe('When adding and updating value mapp', () => { + it('should be 3', async () => { + const onChangeSpy = jest.fn(); + setup(onChangeSpy); + + fireEvent.click(screen.getByTestId('add value map')); + + const input = (await screen.findAllByPlaceholderText('Exact value to match'))[1]; + + fireEvent.change(input, { target: { value: 'New' } }); + fireEvent.change(screen.getAllByPlaceholderText('Display text')[2], { target: { value: 'display' } }); + fireEvent.click(screen.getByText('Update')); + + expect(onChangeSpy).toBeCalledWith([ + { + type: MappingType.ValueToText, + options: { + '20': { + text: 'Ok', + index: 0, + }, + New: { + text: 'display', + index: 2, + }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 21, + to: 30, + result: { + text: 'Meh', + index: 1, + }, + }, + }, + ]); + }); +}); + +describe('When adding and updating range map', () => { + it('should add new range map', async () => { + const onChangeSpy = jest.fn(); + setup(onChangeSpy, { value: [] }); + + fireEvent.click(screen.getByTestId('add range map')); + + fireEvent.change(screen.getByPlaceholderText('Range start'), { target: { value: '10' } }); + fireEvent.change(screen.getByPlaceholderText('Range end'), { target: { value: '20' } }); + fireEvent.change(screen.getByPlaceholderText('Display text'), { target: { value: 'display' } }); + + fireEvent.click(screen.getByText('Update')); + + expect(onChangeSpy).toBeCalledWith([ + { + type: MappingType.RangeToText, + options: { + from: 10, + to: 20, + result: { + text: 'display', + index: 0, + }, + }, + }, + ]); + }); +}); diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.tsx new file mode 100644 index 00000000000..7882f99e59a --- /dev/null +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditorModal.tsx @@ -0,0 +1,246 @@ +import React, { useEffect, useState } from 'react'; +import { GrafanaTheme2, MappingType, SpecialValueMatch, ValueMapping } from '@grafana/data'; +import { Button } from '../Button/Button'; +import { Modal } from '../Modal/Modal'; +import { useStyles2 } from '../../themes'; +import { ValueMappingEditRow, ValueMappingEditRowModel } from './ValueMappingEditRow'; +import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; +import { HorizontalGroup } from '../Layout/Layout'; +import { css } from '@emotion/css'; + +export interface Props { + value: ValueMapping[]; + onChange: (valueMappings: ValueMapping[]) => void; + onClose: () => void; +} + +export function ValueMappingsEditorModal({ value, onChange, onClose }: Props) { + const styles = useStyles2(getStyles); + const [rows, updateRows] = useState([]); + + useEffect(() => { + updateRows(buildEditRowModels(value)); + }, [value]); + + const onDragEnd = (result: DropResult) => { + if (!value || !result.destination) { + return; + } + + const copy = [...rows]; + const element = copy[result.source.index]; + copy.splice(result.source.index, 1); + copy.splice(result.destination.index, 0, element); + updateRows(copy); + }; + + const onChangeMapping = (index: number, row: ValueMappingEditRowModel) => { + const newList = [...rows]; + newList.splice(index, 1, row); + updateRows(newList); + }; + + const onRemoveRow = (index: number) => { + const newList = [...rows]; + newList.splice(index, 1); + updateRows(newList); + }; + + const onAddValueMap = () => { + updateRows([ + ...rows, + { + type: MappingType.ValueToText, + isNew: true, + result: {}, + }, + ]); + }; + + const onAddRangeMap = () => { + updateRows([ + ...rows, + { + type: MappingType.RangeToText, + isNew: true, + result: {}, + }, + ]); + }; + + const onAddSpecialValueMap = () => { + updateRows([ + ...rows, + { + type: MappingType.SpecialValue, + specialMatch: SpecialValueMatch.Null, + result: {}, + }, + ]); + }; + + const onUpdate = () => { + onChange(editModelToSaveModel(rows)); + onClose(); + }; + + return ( + <> + + + + + + + + + + + + + + {(provided) => ( + + {rows.map((row, index) => ( + + ))} + {provided.placeholder} + + )} + + +
TypeMatchDisplay textColor
+ + + + + + + + + + + ); +} + +export const getStyles = (theme: GrafanaTheme2) => ({ + editTable: css({ + width: '100%', + marginBottom: theme.spacing(2), + + 'thead th': { + textAlign: 'center', + }, + + 'tbody tr:hover': { + background: theme.colors.action.hover, + }, + + ' th, td': { + padding: theme.spacing(1), + }, + }), +}); + +export function editModelToSaveModel(rows: ValueMappingEditRowModel[]) { + const mappings: ValueMapping[] = []; + const valueMaps: ValueMapping = { + type: MappingType.ValueToText, + options: {}, + }; + + rows.forEach((item, index) => { + const result = { + ...item.result, + index, + }; + + switch (item.type) { + case MappingType.ValueToText: + if (item.key != null) { + valueMaps.options[item.key] = result; + } + break; + case MappingType.RangeToText: + if (item.from != null && item.to != null) { + mappings.push({ + type: item.type, + options: { + from: item.from, + to: item.to, + result, + }, + }); + } + break; + case MappingType.SpecialValue: + mappings.push({ + type: item.type, + options: { + match: item.specialMatch!, + result, + }, + }); + } + }); + + if (Object.keys(valueMaps.options).length > 0) { + mappings.unshift(valueMaps); + } + return mappings; +} + +export function buildEditRowModels(value: ValueMapping[]) { + const editRows: ValueMappingEditRowModel[] = []; + + for (const mapping of value) { + switch (mapping.type) { + case MappingType.ValueToText: + for (const key of Object.keys(mapping.options)) { + editRows.push({ + type: mapping.type, + result: mapping.options[key], + key, + }); + } + break; + case MappingType.RangeToText: + editRows.push({ + type: mapping.type, + result: mapping.options.result, + from: mapping.options.from ?? 0, + to: mapping.options.to ?? 0, + }); + break; + case MappingType.SpecialValue: + editRows.push({ + type: mapping.type, + result: mapping.options.result, + specialMatch: mapping.options.match ?? SpecialValueMatch.Null, + }); + } + } + + // Sort by index + editRows.sort((a, b) => { + return (a.result.index ?? 0) > (b.result.index ?? 0) ? 1 : -1; + }); + + return editRows; +} diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap index 3d36ea0fbc3..77f9ce30b7f 100644 --- a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -65,7 +65,9 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -201,7 +203,9 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -307,7 +311,9 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -435,7 +441,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -571,7 +579,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -677,7 +687,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -787,7 +799,9 @@ exports[`DashboardPage When dashboard has editview url state should render setti }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { diff --git a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap index 7d476fda810..2556cd86e4f 100644 --- a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap +++ b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap @@ -112,7 +112,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -147,7 +149,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -182,7 +186,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -217,7 +223,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -277,7 +285,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -373,7 +383,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -408,7 +420,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -443,7 +457,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -478,7 +494,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -538,7 +556,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -634,7 +654,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -669,7 +691,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -704,7 +728,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -739,7 +765,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -799,7 +827,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -895,7 +925,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -930,7 +962,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -965,7 +999,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -1000,7 +1036,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { @@ -1060,7 +1098,9 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` }, }, "fieldConfig": Object { - "defaults": Object {}, + "defaults": Object { + "mappings": undefined, + }, "overrides": Array [], }, "gridPos": Object { diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index e6454af8978..32a17bc5651 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -3,7 +3,7 @@ import { DashboardModel } from '../state/DashboardModel'; import { PanelModel } from '../state/PanelModel'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; import { expect } from 'test/lib/common'; -import { DataLinkBuiltInVars } from '@grafana/data'; +import { DataLinkBuiltInVars, MappingType } from '@grafana/data'; import { VariableHide } from '../../variables/types'; import { config } from 'app/core/config'; import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; @@ -1069,6 +1069,143 @@ describe('DashboardModel', () => { }); }); + describe('when migrating old value mapping model', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + id: 1, + type: 'timeseries', + fieldConfig: { + defaults: { + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + mappings: [ + { + id: 0, + text: '1', + type: 1, + value: 'up', + }, + { + id: 1, + text: 'BAD', + type: 1, + value: 'down', + }, + { + from: '0', + id: 2, + text: 'below 30', + to: '30', + type: 2, + }, + { + from: '30', + id: 3, + text: '100', + to: '100', + type: 2, + }, + { + type: 1, + value: 'null', + text: 'it is null', + }, + ], + }, + overrides: [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'mappings', + value: [ + { + id: 0, + text: 'OverrideText', + type: 1, + value: 'up', + }, + ], + }, + ], + }, + ], + }, + }, + ], + }); + }); + + it('should migrate value mapping model', () => { + expect(model.panels[0].fieldConfig.defaults.mappings).toEqual([ + { + type: MappingType.ValueToText, + options: { + down: { text: 'BAD', color: undefined }, + up: { text: '1', color: 'green' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 0, + to: 30, + result: { text: 'below 30' }, + }, + }, + { + type: MappingType.RangeToText, + options: { + from: 30, + to: 100, + result: { text: '100', color: 'red' }, + }, + }, + { + type: MappingType.SpecialValue, + options: { + match: 'null', + result: { text: 'it is null', color: undefined }, + }, + }, + ]); + + expect(model.panels[0].fieldConfig.overrides).toEqual([ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'mappings', + value: [ + { + type: MappingType.ValueToText, + options: { + up: { text: 'OverrideText' }, + }, + }, + ], + }, + ], + }, + ]); + }); + }); + describe('when migrating tooltipOptions to tooltip', () => { it('should rename options.tooltipOptions to options.tooltip', () => { const model = new DashboardModel({ diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index f0a71adb8a7..3d037d064c2 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -9,10 +9,16 @@ import { DashboardModel } from './DashboardModel'; import { DataLink, DataLinkBuiltInVars, + MappingType, + SpecialValueMatch, PanelPlugin, standardEditorsRegistry, standardFieldConfigEditorRegistry, + ThresholdsConfig, urlUtil, + ValueMap, + ValueMapping, + getActiveThreshold, } from '@grafana/data'; // Constants import { @@ -611,6 +617,7 @@ export class DashboardMigrator { } if (oldVersion < 30) { + panelUpgrades.push(upgradeValueMappingsForPanel); panelUpgrades.push(migrateTooltipOptions); } @@ -890,6 +897,81 @@ function migrateSinglestat(panel: PanelModel) { } } +function upgradeValueMappingsForPanel(panel: PanelModel) { + const fieldConfig = panel.fieldConfig; + if (!fieldConfig) { + return; + } + + fieldConfig.defaults.mappings = upgradeValueMappings(fieldConfig.defaults.mappings, fieldConfig.defaults.thresholds); + + for (const override of fieldConfig.overrides) { + for (const prop of override.properties) { + if (prop.id === 'mappings') { + prop.value = upgradeValueMappings(prop.value); + } + } + } +} + +function upgradeValueMappings(oldMappings: any, thresholds?: ThresholdsConfig): ValueMapping[] | undefined { + if (!oldMappings) { + return undefined; + } + + const valueMaps: ValueMap = { type: MappingType.ValueToText, options: {} }; + const newMappings: ValueMapping[] = []; + + for (const old of oldMappings) { + // Use the color we would have picked from thesholds + let color: string | undefined = undefined; + const numeric = parseFloat(old.text); + if (thresholds && !isNaN(numeric)) { + const level = getActiveThreshold(numeric, thresholds.steps); + if (level && level.color) { + color = level.color; + } + } + + switch (old.type) { + case 1: // MappingType.ValueToText: + if (old.value != null) { + if (old.value === 'null') { + newMappings.push({ + type: MappingType.SpecialValue, + options: { + match: SpecialValueMatch.Null, + result: { text: old.text, color }, + }, + }); + } else { + valueMaps.options[String(old.value)] = { + text: old.text, + color, + }; + } + } + break; + case 2: // MappingType.RangeToText: + newMappings.push({ + type: MappingType.RangeToText, + options: { + from: +old.from, + to: +old.to, + result: { text: old.text, color }, + }, + }); + break; + } + } + + if (Object.keys(valueMaps.options).length > 0) { + newMappings.unshift(valueMaps); + } + + return newMappings; +} + function migrateTooltipOptions(panel: PanelModel) { if (panel.type === 'timeseries' || panel.type === 'xychart') { if (panel.options.tooltipOptions) { diff --git a/public/app/features/explore/TraceView/TraceView.test.tsx b/public/app/features/explore/TraceView/TraceView.test.tsx index 1484fc2192e..46bcd174946 100644 --- a/public/app/features/explore/TraceView/TraceView.test.tsx +++ b/public/app/features/explore/TraceView/TraceView.test.tsx @@ -10,6 +10,7 @@ import { MutableDataFrame } from '@grafana/data'; jest.mock('react-redux', () => ({ useSelector: jest.fn(() => undefined), + connect: jest.fn((v) => v), })); function renderTraceView() { diff --git a/public/app/plugins/panel/timeline/utils.ts b/public/app/plugins/panel/timeline/utils.ts index 8ad8598a2b2..59a25f1c0b7 100644 --- a/public/app/plugins/panel/timeline/utils.ts +++ b/public/app/plugins/panel/timeline/utils.ts @@ -2,7 +2,6 @@ import React from 'react'; import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types'; import { DataFrame, - FieldColorModeId, FieldConfig, formattedValueToString, getFieldDisplayName, @@ -74,8 +73,7 @@ export const preparePlotConfigBuilder: PrepConfig = ({ const colorLookup = (seriesIdx: number, valueIdx: number, value: any) => { const field = frame.fields[seriesIdx]; - const mode = field.config?.color?.mode; - if (mode && field.display && (mode === FieldColorModeId.Thresholds || mode.startsWith('continuous-'))) { + if (field.display) { const disp = field.display(value); // will apply color modes if (disp.color) { return disp.color;