mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
72c9d806fd
commit
6d4376c16d
@ -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.
|
||||
|
@ -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' });
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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({
|
||||
|
@ -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(),
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
18
packages/grafana-data/src/utils/anyToNumber.ts
Normal file
18
packages/grafana-data/src/utils/anyToNumber.ts
Normal 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);
|
||||
}
|
@ -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';
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
@ -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;
|
||||
|
@ -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'};
|
||||
|
@ -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};
|
||||
`,
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
}),
|
||||
});
|
@ -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} />;
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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({
|
||||
|
@ -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) {
|
||||
|
@ -10,6 +10,7 @@ import { MutableDataFrame } from '@grafana/data';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(() => undefined),
|
||||
connect: jest.fn((v) => v),
|
||||
}));
|
||||
|
||||
function renderTraceView() {
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user