ValueMapping: Support for mapping text to color, boolean values, NaN and Null. Improved UI for value mapping. (#33820)

* alternative mapping editor

* alternative mapping editor

* values updating

* UI updates

* remove empty operators

* fix types

* horizontal

* New value mapping model and migration

* DataSource: show the uid in edit url, not the local id (#33818)

* update mapping model object

* Update to UI

* fixing ts issues

* Editing starting to work

* adding missing thing

* Update display processor to use color from value mapping

* Range maps now work

* Working on unit tests for modal editor

* Updated

* Adding new NullToText mapping type

* Added null to text UI

* add color from old threshold config

* Added migration for overrides, added Type column

* Added compact view model with color edit capability

* [Alerting]: store encrypted receiver secure settings (#33832)

* [Alerting]: Store secure settings encrypted

* Move encryption to the API handler

* CloudMonitoring: Migrate config editor from angular to react (#33645)

* fix broken config ctrl

* replace angular config with react config editor

* remove not used code

* add extra linebreak

* add noopener to link

* only test jwt props that we actually need

* Elasticsearch: automatically set date_histogram field based on data source configuration (#33840)

* Docs: delete from high availability docs references to removed configurations related to session storage (#33827)

* docs: delete from high availability docs references to removed configurations related to session storage

* docs: remove session storage mention and focus on the auth token implementation

* fix postgres to have precision of ms (#33853)

* Use ids for enterprise nav model items (#33854)

* Alerting: Disable dash alerting if NG enabled (#33794)

* Scuemata: Add grafana-cli cue schema validation to CI (#33798)

* Add scuemata validation in CI

* Fixes according to reviewer's comments

* Ensure http client has no timeout (#33856)

* Redact sensitive values before logging them (#33829)

* use a common way to redact sensitive values before logging them

* fix panic on missing testCase.err, simplify require checks

* fix a silly typo

* combine readConfig and buildConnectionString methods, as they are closely related

* Tempo: Search for Traces by querying Loki directly from Tempo (#33308)

* Loki query from Tempo UI

- add query type selector to tempo
- introduce linkedDatasource concept that runs queries on behalf of another datasource
- Tempo uses Loki's query field and Loki's derived fields to find a trace matcher
- Tempo uses the trace-to-logs mechanism to determine which dataource is linked

Loki data loads successfully via tempo

Extracted result transformers

Skip null values

Show trace on list id click

Query type selector

Use linked field trace regexp

* Review feedback

* Add isolation level db configuration parameter (#33830)

* add isolation level db configuration parameter

* add isolation_level to default.ini and sample.ini

* add note that only mysql supports isolation levels for now

* mention isolation_level in the documentation

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* Drawer: fixes title overflowing its container (#33857)

* Timeline: move grafana/ui elements to the panel folder (#33803)

* revendor loki with new Tripperware (#33858)

* live: move connection endpoint to api scope, fixes #33861 (#33863)

* OAuth: Add support for empty scopes (#32129)

* add parameter empty_scopes to override scope parameter with empty value and thus be able to authenticate against IdPs without scopes. Issue #27503

Update docs/sources/auth/generic-oauth.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* updated check according to feedback

* Update generic-oauth.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* Prometheus: Fix exemplars hover disappearing and broken link (#33866)

* Revert "Tooltip: eliminate flickering when repaint can't keep up (#33609)"

This reverts commit e159985aa2.

* Fix exemplar linking

Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>

* Removed content as per MarcusE's suggestion in https://github.com/grafana/grafana/issues/33822. (#33870)

* Fixed grammar usage. (#33871)

* Explore: Wrap each panel in separate error boundary (#33868)

* New Panel: Histogram (#33752)

* Sanitize PromLink button (#33874)

* Refactor and unify option creation between new visualizations (#33867)

* Refactor and unify option creation between new visualizations

* move to grafana/ui

* move to grafana/ui

* resolve duplicate scale config

* more imports

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>

* Live: do not show connection warning when on the login page (#33865)

* enforce receivers align with backend type when posting AM config (#33877)

* special values

* merge fix

* Document `hide_version` flag (#33670)

Unauthenticated users can be barred from being shown the current Grafana server version since https://github.com/grafana/grafana/pull/24919

* GraphNG: always use "x" as scaleKey for x axis (#33884)

* Timeline: add support for strings & booleans (#33882)

* Chore(deps): Bump hosted-git-info from 2.8.5 to 2.8.9 (#33886)

Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.5 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.5...v2.8.9)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* merge with torkel

* add empty special character

* Fixed centered text in special value match select

* fixed unit tests

* Updated snapshot

* Update dashboard page

* updated snapshot

* Fix more unit tests

* Fixed test

* Updates

* Added back tests

* Fixed doc issue

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: Sofia Papagiannaki <papagian@users.noreply.github.com>
Co-authored-by: Erik Sundell <erik.sundell@grafana.com>
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
Co-authored-by: Daniel dos Santos Pereira <danield1591998@gmail.com>
Co-authored-by: ying-jeanne <74549700+ying-jeanne@users.noreply.github.com>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
Co-authored-by: Kyle Brandt <kyle@grafana.com>
Co-authored-by: Dimitris Sotirakis <dimitrios.sotirakis@grafana.com>
Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
Co-authored-by: Serge Zaitsev <serge.zaitsev@grafana.com>
Co-authored-by: David <david.kaltschmidt@gmail.com>
Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
Co-authored-by: Uchechukwu Obasi <obasiuche62@gmail.com>
Co-authored-by: Owen Diehl <ow.diehl@gmail.com>
Co-authored-by: Alexander Emelin <frvzmb@gmail.com>
Co-authored-by: jvoeller <48791711+jvoeller@users.noreply.github.com>
Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com>
Co-authored-by: Tristan Deloche <tde@hey.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Torkel Ödegaard 2021-05-11 17:10:23 +02:00 committed by GitHub
parent 72c9d806fd
commit 6d4376c16d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1352 additions and 550 deletions

View File

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

View File

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

View File

@ -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<Field>;
@ -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 {

View File

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

View File

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

View File

@ -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<T> {
type: MappingType;
options: T;
}
export type ValueMapping = ValueMap | RangeMap;
export interface ValueMap extends BaseMap {
value: string;
export interface ValueMap extends BaseValueMap<Record<string, ValueMappingResult>> {
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<RangeMapOptions> {
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<SpecialValueOptions> {
type: MappingType.SpecialValue;
}
export type ValueMapping = ValueMap | RangeMap | SpecialValueMap;

View File

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

View File

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

View File

@ -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: '<NULL>', 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('<NULL>');
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: '<NULL>', 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('<NULL>');
});
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);
});
});

View File

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

View File

@ -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,
},
}
}
/>

View File

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

View File

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

View File

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

View File

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

View File

@ -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<SelectableValue<MappingType>> = [
{ value: MappingType.ValueToText, label: 'Value' },
{ value: MappingType.RangeToText, label: 'Range' },
];
export const MappingRow: React.FC<Props> = ({ 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<HTMLInputElement>) => {
if (e.key === 'Enter') {
handler(e.currentTarget.value);
}
};
const renderRow = () => {
if (type === MappingType.RangeToText) {
return (
<>
<HorizontalGroup>
<Field label="From">
<Input
type="number"
defaultValue={(valueMapping as RangeMap).from!}
onBlur={(e) => onMappingFromChange(e.currentTarget.value)}
onKeyDown={onKeyDown(onMappingFromChange)}
/>
</Field>
<Field label="To">
<Input
type="number"
defaultValue={(valueMapping as RangeMap).to}
onBlur={(e) => onMappingToChange(e.currentTarget.value)}
onKeyDown={onKeyDown(onMappingToChange)}
/>
</Field>
</HorizontalGroup>
<Field label="Text">
<Input
defaultValue={valueMapping.text}
onBlur={(e) => onMappingTextChange(e.currentTarget.value)}
onKeyDown={onKeyDown(onMappingTextChange)}
/>
</Field>
</>
);
}
return (
<>
<Field label="Value">
<Input
defaultValue={(valueMapping as ValueMap).value}
onBlur={(e) => onMappingValueChange(e.currentTarget.value)}
onKeyDown={onKeyDown(onMappingValueChange)}
/>
</Field>
<Field label="Text">
<Input
defaultValue={valueMapping.text}
onBlur={(e) => onMappingTextChange(e.currentTarget.value)}
onKeyDown={onKeyDown(onMappingTextChange)}
/>
</Field>
</>
);
};
const label = (
<HorizontalGroup justify="space-between" align="center">
<Label>Mapping type</Label>
<IconButton name="times" onClick={onRemove} aria-label="ValueMappingsEditor remove button" />
</HorizontalGroup>
);
return (
<div>
<Field label={label}>
<RadioButtonGroup
options={MAPPING_OPTIONS}
value={type}
onChange={(type) => {
onMappingTypeChange(type!);
}}
/>
</Field>
{renderRow()}
</div>
);
};

View File

@ -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<HTMLInputElement | null>(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<HTMLInputElement>) => {
update((mapping) => {
mapping.key = event.currentTarget.value;
});
};
const onChangeText = (event: React.FormEvent<HTMLInputElement>) => {
update((mapping) => {
mapping.result.text = event.currentTarget.value;
});
};
const onChangeFrom = (event: React.FormEvent<HTMLInputElement>) => {
update((mapping) => {
mapping.from = parseFloat(event.currentTarget.value);
});
};
const onChangeTo = (event: React.FormEvent<HTMLInputElement>) => {
update((mapping) => {
mapping.to = parseFloat(event.currentTarget.value);
});
};
const onChangeSpecialMatch = (sel: SelectableValue<SpecialValueMatch>) => {
update((mapping) => {
mapping.specialMatch = sel.value;
});
};
const specialMatchOptions: Array<SelectableValue<SpecialValueMatch>> = [
{ 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 (
<Draggable draggableId={`mapping-${index}`} index={index}>
{(provided) => (
<tr ref={provided.innerRef} {...provided.draggableProps}>
<td>
<div {...provided.dragHandleProps} className={styles.dragHandle}>
<Icon name="draggabledots" size="lg" />
</div>
</td>
<td className={styles.typeColumn}>{mapping.type}</td>
<td>
{mapping.type === MappingType.ValueToText && (
<Input
ref={inputRef}
type="text"
value={key ?? ''}
onChange={onUpdateMatchValue}
placeholder="Exact value to match"
/>
)}
{mapping.type === MappingType.RangeToText && (
<div className={styles.rangeInputWrapper}>
<Input
type="number"
value={mapping.from ?? ''}
placeholder="Range start"
onChange={onChangeFrom}
prefix="From"
/>
<Input
type="number"
value={mapping.to ?? ''}
placeholder="Range end"
onChange={onChangeTo}
prefix="To"
/>
</div>
)}
{mapping.type === MappingType.SpecialValue && (
<Select
value={specialMatchOptions.find((v) => v.value === mapping.specialMatch)}
options={specialMatchOptions}
onChange={onChangeSpecialMatch}
/>
)}
</td>
<td>
<Input type="text" value={result.text ?? ''} onChange={onChangeText} placeholder="Display text" />
</td>
<td className={styles.textAlignCenter}>
{result.color && (
<HorizontalGroup spacing="sm" justify="center">
<ColorPicker color={result.color} onChange={onChangeColor} enableNamedColors={true} />
<IconButton name="times" onClick={onClearColor} tooltip="Remove color" tooltipPlacement="top" />
</HorizontalGroup>
)}
{!result.color && (
<ColorPicker color={'gray'} onChange={onChangeColor} enableNamedColors={true}>
{(props) => (
<LinkButton variant="primary" fill="text" onClick={props.showColorPicker} ref={props.ref} size="sm">
Set color
</LinkButton>
)}
</ColorPicker>
)}
</td>
<td className={styles.textAlignCenter}>
<HorizontalGroup spacing="sm">
<IconButton name="trash-alt" onClick={() => onRemove(index)} data-testid="remove-value-mapping" />
</HorizontalGroup>
</td>
</tr>
)}
</Draggable>
);
}
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',
}),
});

View File

@ -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 <ValueMappingsEditor value={[]} onChange={action('Mapping changed')} />;
};
export function Example() {
const [mappings, setMappings] = useState<ValueMapping[]>([
{
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 <ValueMappingsEditor value={mappings} onChange={setMappings} />;
}

View File

@ -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(<ValueMappingsEditor {...props} />);
render(<ValueMappingsEditor {...props} />);
};
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();
});
});

View File

@ -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<Props> = ({ 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) => (
<MappingRow
key={`${valueMapping.text}-${index}`}
valueMapping={valueMapping}
onUpdate={onMappingChange}
onRemove={() => onRemove(index)}
/>
))}
<Button
size="sm"
icon="plus"
onClick={onAdd}
aria-label="ValueMappingsEditor add mapping button"
variant="secondary"
>
Add value mapping
<VerticalGroup>
<table className={styles.compactTable}>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex.toString()}>
<td>
{row.type === MappingType.ValueToText && row.key}
{row.type === MappingType.RangeToText && (
<span>
[{row.from} - {row.to}]
</span>
)}
{row.type === MappingType.SpecialValue && row.specialMatch}
</td>
<td>
<Icon name="arrow-right" />
</td>
<td>{row.result.text}</td>
<td>
{row.result.color && (
<ColorPicker
color={row.result.color}
onChange={(color) => onChangeColor(color, rowIndex)}
enableNamedColors={true}
/>
)}
</td>
</tr>
))}
</tbody>
</table>
<Button variant="secondary" size="sm" fullWidth onClick={() => setIsEditorOpen(true)}>
{rows.length > 0 && <span>Edit value mappings</span>}
{rows.length === 0 && <span>Add value mappings</span>}
</Button>
</>
<Modal isOpen={isEditorOpen} title="Value mappings" onDismiss={onCloseEditor} className={styles.modal}>
<ValueMappingsEditorModal value={value} onChange={onChange} onClose={onCloseEditor} />
</Modal>
</VerticalGroup>
);
};
});
ValueMappingsEditor.displayName = 'ValueMappingsEditor';
export const getStyles = (theme: GrafanaTheme2) => ({
modal: css({
width: '980px',
}),
compactTable: css({
width: '100%',
'tbody td': {
padding: theme.spacing(0.5),
},
}),
});

View File

@ -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(<ValueMappingsEditorModal {...props} />);
};
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,
},
},
},
]);
});
});

View File

@ -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<ValueMappingEditRowModel[]>([]);
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 (
<>
<table className={styles.editTable}>
<thead>
<tr>
<th style={{ width: '1%' }}></th>
<th style={{ width: '1%' }}>Type</th>
<th style={{ width: '40%' }}>Match</th>
<th>Display text</th>
<th>Color</th>
<th style={{ width: '1%' }}></th>
</tr>
</thead>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-field-mappings" direction="vertical">
{(provided) => (
<tbody ref={provided.innerRef} {...provided.droppableProps}>
{rows.map((row, index) => (
<ValueMappingEditRow
key={index.toString()}
mapping={row}
index={index}
onChange={onChangeMapping}
onRemove={onRemoveRow}
/>
))}
{provided.placeholder}
</tbody>
)}
</Droppable>
</DragDropContext>
</table>
<HorizontalGroup>
<Button variant="secondary" icon="plus" onClick={onAddValueMap} data-testid="add value map">
Value map
</Button>
<Button variant="secondary" icon="plus" onClick={onAddRangeMap} data-testid="add range map">
Range map
</Button>
<Button variant="secondary" icon="plus" onClick={onAddSpecialValueMap} data-testid="add special map">
Special value map
</Button>
</HorizontalGroup>
<Modal.ButtonRow>
<Button variant="secondary" fill="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={onUpdate}>
Update
</Button>
</Modal.ButtonRow>
</>
);
}
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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import { MutableDataFrame } from '@grafana/data';
jest.mock('react-redux', () => ({
useSelector: jest.fn(() => undefined),
connect: jest.fn((v) => v),
}));
function renderTraceView() {

View File

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