mirror of https://github.com/grafana/grafana
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>
pull/33946/head
parent
72c9d806fd
commit
6d4376c16d
@ -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 interface ValueMap extends BaseValueMap<Record<string, ValueMappingResult>> { |
||||
type: MappingType.ValueToText; |
||||
} |
||||
|
||||
export interface RangeMapOptions { |
||||
from: number | null; // changed from string
|
||||
to: number | null; |
||||
result: ValueMappingResult; |
||||
} |
||||
|
||||
export type ValueMapping = ValueMap | RangeMap; |
||||
export interface RangeMap extends BaseValueMap<RangeMapOptions> { |
||||
type: MappingType.RangeToText; |
||||
} |
||||
|
||||
export interface ValueMap extends BaseMap { |
||||
value: string; |
||||
export interface SpecialValueOptions { |
||||
match: SpecialValueMatch; |
||||
result: ValueMappingResult; |
||||
} |
||||
|
||||
export interface RangeMap extends BaseMap { |
||||
from: string; |
||||
to: string; |
||||
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; |
||||
|
@ -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); |
||||
} |
@ -1,104 +1,88 @@ |
||||
import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types'; |
||||
|
||||
type TimeSeriesValue = string | number | null; |
||||
|
||||
const addValueToTextMappingText = ( |
||||
allValueMappings: ValueMapping[], |
||||
valueToTextMapping: ValueMap, |
||||
value: TimeSeriesValue |
||||
) => { |
||||
if (valueToTextMapping.value === undefined) { |
||||
return allValueMappings; |
||||
} |
||||
|
||||
if (value === null && isNullValueMap(valueToTextMapping)) { |
||||
return allValueMappings.concat(valueToTextMapping); |
||||
} |
||||
|
||||
let valueAsNumber, valueToTextMappingAsNumber; |
||||
|
||||
if (isNumeric(value as string) && isNumeric(valueToTextMapping.value)) { |
||||
valueAsNumber = parseFloat(value as string); |
||||
valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string); |
||||
|
||||
if (valueAsNumber === valueToTextMappingAsNumber) { |
||||
return allValueMappings.concat(valueToTextMapping); |
||||
import { ValueMapping, MappingType, ValueMappingResult, SpecialValueMatch } from '../types'; |
||||
|
||||
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 result = vm.options[value]; |
||||
if (result) { |
||||
return result; |
||||
} |
||||
|
||||
break; |
||||
|
||||
case MappingType.RangeToText: |
||||
if (value == null) { |
||||
continue; |
||||
} |
||||
|
||||
const valueAsNumber = parseFloat(value as string); |
||||
if (isNaN(valueAsNumber)) { |
||||
continue; |
||||
} |
||||
|
||||
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); |
||||
} |
||||
|
@ -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,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: '', |
||||
}; |
||||
|
||||
const id = Math.max(...value.map((v) => v.id), 0) + 1; |
||||
|
||||
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))); |
||||
}; |
||||
export const ValueMappingsEditor = React.memo(({ value, onChange }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
const [isEditorOpen, setIsEditorOpen] = useState(false); |
||||
const onCloseEditor = useCallback(() => { |
||||
setIsEditorOpen(false); |
||||
}, [setIsEditorOpen]); |
||||
|
||||
const rows = useMemo(() => buildEditRowModels(value), [value]); |
||||
|
||||
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; |
||||
} |
Loading…
Reference in new issue