Merge branch 'master' into bar-gauge-refactoring

This commit is contained in:
Torkel Ödegaard
2019-03-19 13:40:02 +01:00
111 changed files with 3412 additions and 661 deletions

View File

@@ -11,17 +11,15 @@ jest.mock('jquery', () => ({
const setup = (propOverrides?: object) => {
const props: Props = {
maxValue: 100,
valueMappings: [],
minValue: 0,
prefix: '',
suffix: '',
displayMode: 'basic',
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
unit: 'none',
height: 300,
width: 300,
value: 25,
decimals: 0,
value: {
text: '25',
numeric: 25,
},
theme: getTheme(),
orientation: VizOrientation.Horizontal,
};

View File

@@ -3,26 +3,21 @@ import React, { PureComponent, CSSProperties, ReactNode } from 'react';
import tinycolor from 'tinycolor2';
// Utils
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
import { getColorFromHexRgbOrName, getThresholdForValue, DisplayValue } from '../../utils';
// Types
import { Themeable, TimeSeriesValue, Threshold, ValueMapping, VizOrientation } from '../../types';
import { Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types';
const BAR_SIZE_RATIO = 0.8;
export interface Props extends Themeable {
height: number;
unit: string;
width: number;
thresholds: Threshold[];
valueMappings: ValueMapping[];
value: TimeSeriesValue;
value: DisplayValue;
maxValue: number;
minValue: number;
orientation: VizOrientation;
prefix?: string;
suffix?: string;
decimals?: number;
displayMode: 'basic' | 'lcd' | 'gradient';
}
@@ -30,44 +25,30 @@ export class BarGauge extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
maxValue: 100,
minValue: 0,
value: 100,
unit: 'none',
displayMode: 'basic',
value: {
text: '100',
numeric: 100,
},
displayMode: 'lcd',
orientation: VizOrientation.Horizontal,
thresholds: [],
valueMappings: [],
};
render() {
const { maxValue, minValue, unit, decimals, displayMode } = this.props;
const numericValue = this.getNumericValue();
const valuePercent = Math.min(numericValue / (maxValue - minValue), 1);
const formatFunc = getValueFormat(unit);
const valueFormatted = formatFunc(numericValue, decimals);
switch (displayMode) {
switch (this.props.displayMode) {
case 'lcd':
return this.renderRetroBars(valueFormatted, valuePercent);
return this.renderRetroBars();
case 'basic':
case 'gradient':
default:
return this.renderBasicAndGradientBars(valueFormatted, valuePercent);
return this.renderBasicAndGradientBars();
}
}
getNumericValue(): number {
if (Number.isFinite(this.props.value as number)) {
return this.props.value as number;
}
return 0;
}
getValueColors(): BarColors {
const { thresholds, theme, value } = this.props;
const activeThreshold = getThresholdForValue(thresholds, value);
const activeThreshold = getThresholdForValue(thresholds, value.numeric);
if (activeThreshold !== null) {
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
@@ -111,9 +92,8 @@ export class BarGauge extends PureComponent<Props> {
}
getBarGradient(maxSize: number): string {
const { minValue, maxValue, thresholds } = this.props;
const { minValue, maxValue, thresholds, value } = this.props;
const cssDirection = this.isVertical ? '0deg' : '90deg';
const currentValue = this.getNumericValue();
let gradient = '';
let lastpos = 0;
@@ -127,7 +107,7 @@ export class BarGauge extends PureComponent<Props> {
if (gradient === '') {
gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
} else if (currentValue < threshold.value) {
} else if (value.numeric < threshold.value) {
break;
} else {
lastpos = pos;
@@ -135,18 +115,18 @@ export class BarGauge extends PureComponent<Props> {
}
}
console.log(gradient);
return gradient + ')';
}
renderBasicAndGradientBars(valueFormatted: string, valuePercent: number): ReactNode {
const { height, width, displayMode } = this.props;
renderBasicAndGradientBars(): ReactNode {
const { height, width, displayMode, maxValue, minValue, value } = this.props;
const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1);
const maxSize = this.size * BAR_SIZE_RATIO;
const barSize = Math.max(valuePercent * maxSize, 0);
const colors = this.getValueColors();
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
const valueStyles = this.getValueStyles(valueFormatted, colors.value, spaceForText);
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
const isBasic = displayMode === 'basic';
const containerStyles: CSSProperties = {
@@ -199,7 +179,7 @@ export class BarGauge extends PureComponent<Props> {
return (
<div style={containerStyles}>
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
{value.text}
</div>
<div style={barStyles} />
</div>
@@ -214,7 +194,7 @@ export class BarGauge extends PureComponent<Props> {
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
// if we are past real value the cell is not "on"
if (value === null || (positionValue !== null && positionValue > value)) {
if (value === null || (positionValue !== null && positionValue > value.numeric)) {
return {
background: tinycolor(color)
.setAlpha(0.15)
@@ -244,8 +224,8 @@ export class BarGauge extends PureComponent<Props> {
};
}
renderRetroBars(valueFormatted: string, valuePercent: number): ReactNode {
const { height, width, maxValue, minValue } = this.props;
renderRetroBars(): ReactNode {
const { height, width, maxValue, minValue, value } = this.props;
const valueRange = maxValue - minValue;
const maxSize = this.size * BAR_SIZE_RATIO;
@@ -254,7 +234,7 @@ export class BarGauge extends PureComponent<Props> {
const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
const colors = this.getValueColors();
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
const valueStyles = this.getValueStyles(valueFormatted, colors.value, spaceForText);
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
const containerStyles: CSSProperties = {
width: `${width}px`,
@@ -305,7 +285,7 @@ export class BarGauge extends PureComponent<Props> {
<div style={containerStyles}>
{cells}
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
{value.text}
</div>
</div>
);

View File

@@ -27,10 +27,13 @@ exports[`Render BarGauge with basic options should render 1`] = `
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.3)",
"borderRight": "1px solid #7EB26D",
"background": "rgba(126, 178, 109, 0.15)",
"border": "1px solid #7EB26D",
"borderRadius": "3px",
"boxShadow": "0 0 4px #7EB26D",
"height": "300px",
"marginRight": "10px",
"transition": "width 1s",
"width": "60px",
}
}

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Gauge, Props } from './Gauge';
import { ValueMapping, MappingType } from '../../types';
import { getTheme } from '../../themes';
jest.mock('jquery', () => ({
@@ -12,19 +11,16 @@ jest.mock('jquery', () => ({
const setup = (propOverrides?: object) => {
const props: Props = {
maxValue: 100,
valueMappings: [],
minValue: 0,
prefix: '',
showThresholdMarkers: true,
showThresholdLabels: false,
suffix: '',
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
unit: 'none',
stat: 'avg',
height: 300,
width: 300,
value: 25,
decimals: 0,
value: {
text: '25',
numeric: 25,
},
theme: getTheme(),
};
@@ -39,38 +35,6 @@ const setup = (propOverrides?: object) => {
};
};
describe('Get font color', () => {
it('should get first threshold color when only one threshold', () => {
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
expect(instance.getFontColor(49)).toEqual('#7EB26D');
});
it('should get the threshold color if value is same as a threshold', () => {
const { instance } = setup({
thresholds: [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
],
});
expect(instance.getFontColor(50)).toEqual('#EAB839');
});
it('should get the nearest threshold color between thresholds', () => {
const { instance } = setup({
thresholds: [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
],
});
expect(instance.getFontColor(55)).toEqual('#EAB839');
});
});
describe('Get thresholds formatted', () => {
it('should return first thresholds color for min and max', () => {
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
@@ -98,51 +62,3 @@ describe('Get thresholds formatted', () => {
]);
});
});
describe('Format value', () => {
it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = [];
const value = 'N/A';
const { instance } = setup({ valueMappings });
const result = instance.formatValue(value);
expect(result).toEqual('N/A');
});
it('should return formatted value if there are no value mappings', () => {
const valueMappings: ValueMapping[] = [];
const value = '6';
const { instance } = setup({ valueMappings, decimals: 1 });
const result = instance.formatValue(value);
expect(result).toEqual('6.0');
});
it('should return formatted value if there are no matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
const { instance } = setup({ valueMappings, decimals: 1 });
const result = instance.formatValue(value);
expect(result).toEqual('10.0');
});
it('should return mapped value if there are matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '11';
const { instance } = setup({ valueMappings, decimals: 1 });
const result = instance.formatValue(value);
expect(result).toEqual('1-20');
});
});

View File

@@ -1,28 +1,20 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
import { ValueMapping, Threshold, GrafanaThemeType } from '../../types';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
import { Threshold, GrafanaThemeType } from '../../types';
import { getColorFromHexRgbOrName } from '../../utils';
import { Themeable } from '../../index';
type GaugeValue = string | number | null;
import { DisplayValue } from '../../utils/displayValue';
export interface Props extends Themeable {
decimals?: number | null;
height: number;
valueMappings: ValueMapping[];
maxValue: number;
minValue: number;
prefix: string;
thresholds: Threshold[];
showThresholdMarkers: boolean;
showThresholdLabels: boolean;
stat: string;
suffix: string;
unit: string;
width: number;
value: number;
value: DisplayValue;
}
const FONT_SCALE = 1;
@@ -32,15 +24,10 @@ export class Gauge extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
maxValue: 100,
valueMappings: [],
minValue: 0,
prefix: '',
showThresholdMarkers: true,
showThresholdLabels: false,
suffix: '',
thresholds: [],
unit: 'none',
stat: 'avg',
};
componentDidMount() {
@@ -51,39 +38,6 @@ export class Gauge extends PureComponent<Props> {
this.draw();
}
formatValue(value: GaugeValue) {
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
if (isNaN(value as number)) {
return value;
}
if (valueMappings.length > 0) {
const valueMappedValue = getMappedValue(valueMappings, value);
if (valueMappedValue) {
return `${prefix && prefix + ' '}${valueMappedValue.text}${suffix && ' ' + suffix}`;
}
}
const formatFunc = getValueFormat(unit);
const formattedValue = formatFunc(value as number, decimals);
const handleNoValueValue = formattedValue || 'no value';
return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
}
getFontColor(value: GaugeValue): string {
const { thresholds, theme } = this.props;
const activeThreshold = getThresholdForValue(thresholds, value);
if (activeThreshold !== null) {
return getColorFromHexRgbOrName(activeThreshold.color, theme.type);
}
return '';
}
getFormattedThresholds() {
const { maxValue, minValue, thresholds, theme } = this.props;
@@ -112,15 +66,13 @@ export class Gauge extends PureComponent<Props> {
draw() {
const { maxValue, minValue, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props;
const formattedValue = this.formatValue(value) as string;
const dimension = Math.min(width, height * 1.3);
const backgroundColor = theme.type === GrafanaThemeType.Light ? 'rgb(230,230,230)' : theme.colors.dark3;
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
const thresholdMarkersWidth = gaugeWidth / 5;
const fontSize =
Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1);
const fontSize = Math.min(dimension / 5, 100) * (value.text !== null ? this.getFontScale(value.text.length) : 1);
const thresholdLabelFontSize = fontSize / 2.5;
const options: any = {
@@ -149,9 +101,9 @@ export class Gauge extends PureComponent<Props> {
width: thresholdMarkersWidth,
},
value: {
color: this.getFontColor(value),
color: value.color,
formatter: () => {
return formattedValue;
return value.text;
},
font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
},
@@ -160,7 +112,7 @@ export class Gauge extends PureComponent<Props> {
},
};
const plotSeries = { data: [[0, value]] };
const plotSeries = { data: [[0, value.numeric]] };
try {
$.plot(this.canvasElement, [plotSeries], options);

View File

@@ -0,0 +1,51 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { shallow } from 'enzyme';
import { Input } from './Input';
import { EventsWithValidation } from '../../utils';
import { ValidationEvents } from '../../types';
const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
const testBlurValidation: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: (value: string) => {
return !value || value.length < 3;
},
errorMessage: TEST_ERROR_MESSAGE,
},
],
};
describe('Input', () => {
it('renders correctly', () => {
const tree = renderer.create(<Input />).toJSON();
expect(tree).toMatchSnapshot();
});
it('should validate with error onBlur', () => {
const wrapper = shallow(<Input validationEvents={testBlurValidation} />);
const evt = {
persist: jest.fn,
target: {
value: 'I can not be more than 2 chars',
},
};
wrapper.find('input').simulate('blur', evt);
expect(wrapper.state('error')).toBe(TEST_ERROR_MESSAGE);
});
it('should validate without error onBlur', () => {
const wrapper = shallow(<Input validationEvents={testBlurValidation} />);
const evt = {
persist: jest.fn,
target: {
value: 'Hi',
},
};
wrapper.find('input').simulate('blur', evt);
expect(wrapper.state('error')).toBe(null);
});
});

View File

@@ -0,0 +1,81 @@
import React, { PureComponent, ChangeEvent } from 'react';
import classNames from 'classnames';
import { validate, EventsWithValidation, hasValidationEvent } from '../../utils';
import { ValidationEvents, ValidationRule } from '../../types';
export enum InputStatus {
Invalid = 'invalid',
Valid = 'valid',
}
interface Props extends React.HTMLProps<HTMLInputElement> {
validationEvents?: ValidationEvents;
hideErrorMessage?: boolean;
// Override event props and append status as argument
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
onChange?: (event: React.ChangeEvent<HTMLInputElement>, status?: InputStatus) => void;
}
export class Input extends PureComponent<Props> {
static defaultProps = {
className: '',
};
state = {
error: null,
};
get status() {
return this.state.error ? InputStatus.Invalid : InputStatus.Valid;
}
get isInvalid() {
return this.status === InputStatus.Invalid;
}
validatorAsync = (validationRules: ValidationRule[]) => {
return (evt: ChangeEvent<HTMLInputElement>) => {
const errors = validate(evt.target.value, validationRules);
this.setState(prevState => {
return { ...prevState, error: errors ? errors[0] : null };
});
};
};
populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => {
const inputElementProps = { ...restProps };
if (!validationEvents) {
return inputElementProps;
}
Object.keys(EventsWithValidation).forEach(eventName => {
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) {
inputElementProps[eventName] = async (evt: ChangeEvent<HTMLInputElement>) => {
evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) {
await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
}
if (restProps[eventName]) {
restProps[eventName].apply(null, [evt, this.status]);
}
};
}
});
return inputElementProps;
};
render() {
const { validationEvents, className, hideErrorMessage, ...restProps } = this.props;
const { error } = this.state;
const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className);
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
return (
<div className="our-custom-wrapper-class">
<input {...inputElementProps} className={inputClassName} />
{error && !hideErrorMessage && <span>{error}</span>}
</div>
);
}
}

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Input renders correctly 1`] = `
<div
className="our-custom-wrapper-class"
>
<input
className="gf-form-input"
/>
</div>
`;

View File

@@ -161,7 +161,7 @@ describe('change threshold value', () => {
});
describe('on blur threshold value', () => {
it.only('should resort rows and update indexes', () => {
it('should resort rows and update indexes', () => {
const { instance } = setup();
const thresholds = [
{ index: 0, value: -Infinity, color: '#7EB26D' },

View File

@@ -25,6 +25,7 @@ export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
export { Switch } from './Switch/Switch';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export { UnitPicker } from './UnitPicker/UnitPicker';
export { Input, InputStatus } from './Input/Input';
// Visualizations
export { Gauge } from './Gauge/Gauge';

View File

@@ -5,3 +5,4 @@ export * from './plugin';
export * from './datasource';
export * from './theme';
export * from './threshold';
export * from './input';

View File

@@ -0,0 +1,8 @@
export interface ValidationRule {
rule: (valueToValidate: string) => boolean;
errorMessage: string;
}
export interface ValidationEvents {
[eventName: string]: ValidationRule[];
}

View File

@@ -30,10 +30,10 @@ export interface PanelEditorProps<T = any> {
* Called before a panel is initalized
*/
export type PanelTypeChangedHook<TOptions = any> = (
options: TOptions,
options: Partial<TOptions>,
prevPluginId?: string,
prevOptions?: any
) => TOptions;
) => Partial<TOptions>;
export class ReactPanelPlugin<TOptions = any> {
panel: ComponentClass<PanelProps<TOptions>>;

View File

@@ -91,6 +91,7 @@ export interface PluginMeta {
includes: PluginInclude[];
// Datasource-specific
builtIn?: boolean;
metrics?: boolean;
tables?: boolean;
logs?: boolean;

View File

@@ -0,0 +1,157 @@
import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, DisplayValue } from './displayValue';
import { MappingType, ValueMapping } from '../types/panel';
function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) {
processors.forEach(processor => {
const value = processor(input);
expect(value.text).toEqual(match.text);
if (match.hasOwnProperty('numeric')) {
expect(value.numeric).toEqual(match.numeric);
}
});
}
describe('Process simple display values', () => {
// Don't test float values here since the decimal formatting changes
const processors = [
// Without options, this shortcuts to a much easier implementation
getDisplayProcessor(),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ color: '#FFF' }),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ unit: 'locale' }),
];
it('support null', () => {
assertSame(null, processors, { text: '', numeric: NaN });
});
it('support undefined', () => {
assertSame(undefined, processors, { text: '', numeric: NaN });
});
it('support NaN', () => {
assertSame(NaN, processors, { text: 'NaN', numeric: NaN });
});
it('Integer', () => {
assertSame(3, processors, { text: '3', numeric: 3 });
});
it('Text to number', () => {
assertSame('3', processors, { text: '3', numeric: 3 });
});
it('Simple String', () => {
assertSame('hello', processors, { text: 'hello', numeric: NaN });
});
it('empty array', () => {
assertSame([], processors, { text: '', numeric: NaN });
});
it('array of text', () => {
assertSame(['a', 'b', 'c'], processors, { text: 'a,b,c', numeric: NaN });
});
it('array of numbers', () => {
assertSame([1, 2, 3], processors, { text: '1,2,3', numeric: NaN });
});
it('empty object', () => {
assertSame({}, processors, { text: '[object Object]', numeric: NaN });
});
it('boolean true', () => {
assertSame(true, processors, { text: 'true', numeric: 1 });
});
it('boolean false', () => {
assertSame(false, processors, { text: 'false', numeric: 0 });
});
});
describe('Processor with more configs', () => {
it('support prefix & suffix', () => {
const processor = getDisplayProcessor({
prefix: 'AA_',
suffix: '_ZZ',
});
expect(processor('XXX').text).toEqual('AA_XXX_ZZ');
});
});
describe('Get color from threshold', () => {
it('should get first threshold color when only one threshold', () => {
const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }];
expect(getColorFromThreshold(49, thresholds)).toEqual('#7EB26D');
});
it('should get the threshold color if value is same as a threshold', () => {
const thresholds = [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
];
expect(getColorFromThreshold(50, thresholds)).toEqual('#EAB839');
});
it('should get the nearest threshold color between thresholds', () => {
const thresholds = [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
];
expect(getColorFromThreshold(55, thresholds)).toEqual('#EAB839');
});
});
describe('Format value', () => {
it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = [];
const value = 'N/A';
const instance = getDisplayProcessor({ mappings: valueMappings });
const result = instance(value);
expect(result.text).toEqual('N/A');
});
it('should return formatted value if there are no value mappings', () => {
const valueMappings: ValueMapping[] = [];
const value = '6';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
const result = instance(value);
expect(result.text).toEqual('6.0');
});
it('should return formatted value if there are no matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
const result = instance(value);
expect(result.text).toEqual('10.0');
});
it('should return mapped value if there are matching value mappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '11';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
expect(instance(value).text).toEqual('1-20');
});
});

View File

@@ -0,0 +1,145 @@
import { ValueMapping, Threshold } from '../types';
import _ from 'lodash';
import { getValueFormat, DecimalCount } from './valueFormats/valueFormats';
import { getMappedValue } from './valueMappings';
import { GrafanaTheme, GrafanaThemeType } from '../types';
import { getColorFromHexRgbOrName } from './namedColorsPalette';
import moment from 'moment';
export interface DisplayValue {
text: string; // Show in the UI
numeric: number; // Use isNaN to check if it is a real number
color?: string; // color based on configs or Threshold
}
export interface DisplayValueOptions {
unit?: string;
decimals?: DecimalCount;
scaledDecimals?: DecimalCount;
dateFormat?: string; // If set try to convert numbers to date
color?: string;
mappings?: ValueMapping[];
thresholds?: Threshold[];
prefix?: string;
suffix?: string;
// Alternative to empty string
noValue?: string;
// Context
isUtc?: boolean;
theme?: GrafanaTheme; // Will pick 'dark' if not defined
}
export type DisplayProcessor = (value: any) => DisplayValue;
export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProcessor {
if (options && !_.isEmpty(options)) {
const formatFunc = getValueFormat(options.unit || 'none');
return (value: any) => {
const { prefix, suffix, mappings, thresholds, theme } = options;
let color = options.color;
let text = _.toString(value);
let numeric = toNumber(value);
let shouldFormat = true;
if (mappings && mappings.length > 0) {
const mappedValue = getMappedValue(mappings, value);
if (mappedValue) {
text = mappedValue.text;
const v = toNumber(text);
if (!isNaN(v)) {
numeric = v;
}
shouldFormat = false;
}
}
if (options.dateFormat) {
const date = toMoment(value, numeric, options.dateFormat);
if (date.isValid()) {
text = date.format(options.dateFormat);
shouldFormat = false;
}
}
if (!isNaN(numeric)) {
if (shouldFormat && !_.isBoolean(value)) {
text = formatFunc(numeric, options.decimals, options.scaledDecimals, options.isUtc);
}
if (thresholds && thresholds.length > 0) {
color = getColorFromThreshold(numeric, thresholds, theme);
}
}
if (!text) {
text = options.noValue ? options.noValue : '';
}
if (prefix) {
text = prefix + text;
}
if (suffix) {
text = text + suffix;
}
return { text, numeric, color };
};
}
return toStringProcessor;
}
function toMoment(value: any, numeric: number, format: string): moment.Moment {
if (!isNaN(numeric)) {
const v = moment(numeric);
if (v.isValid()) {
return v;
}
}
const v = moment(value, format);
if (v.isValid) {
return v;
}
return moment(value); // moment will try to parse the format
}
/** Will return any value as a number or NaN */
function toNumber(value: any): number {
if (typeof value === 'number') {
return value;
}
if (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) };
}
export function getColorFromThreshold(value: number, thresholds: Threshold[], theme?: GrafanaTheme): string {
const themeType = theme ? theme.type : GrafanaThemeType.Dark;
if (thresholds.length === 1) {
return getColorFromHexRgbOrName(thresholds[0].color, themeType);
}
const atThreshold = thresholds.filter(threshold => value === threshold.value)[0];
if (atThreshold) {
return getColorFromHexRgbOrName(atThreshold.color, themeType);
}
const belowThreshold = thresholds.filter(threshold => value > threshold.value);
if (belowThreshold.length > 0) {
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
return getColorFromHexRgbOrName(nearestThreshold.color, themeType);
}
// Use the first threshold as the default color
return getColorFromHexRgbOrName(thresholds[0].color, themeType);
}

View File

@@ -5,5 +5,7 @@ export * from './colors';
export * from './namedColorsPalette';
export * from './thresholds';
export * from './string';
export * from './displayValue';
export * from './deprecationWarning';
export { getMappedValue } from './valueMappings';
export * from './validate';

View File

@@ -0,0 +1,24 @@
import { ValidationRule, ValidationEvents } from '../types/input';
export enum EventsWithValidation {
onBlur = 'onBlur',
onFocus = 'onFocus',
onChange = 'onChange',
}
export const validate = (value: string, validationRules: ValidationRule[]) => {
const errors = validationRules.reduce(
(acc, currRule) => {
if (!currRule.rule(value)) {
return acc.concat(currRule.errorMessage);
}
return acc;
},
[] as string[]
);
return errors.length > 0 ? errors : null;
};
export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents | undefined) => {
return validationEvents && validationEvents[event];
};