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