mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into bar-gauge-refactoring
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
51
packages/grafana-ui/src/components/Input/Input.test.tsx
Normal file
51
packages/grafana-ui/src/components/Input/Input.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
81
packages/grafana-ui/src/components/Input/Input.tsx
Normal file
81
packages/grafana-ui/src/components/Input/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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' },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './plugin';
|
||||
export * from './datasource';
|
||||
export * from './theme';
|
||||
export * from './threshold';
|
||||
export * from './input';
|
||||
|
||||
8
packages/grafana-ui/src/types/input.ts
Normal file
8
packages/grafana-ui/src/types/input.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface ValidationRule {
|
||||
rule: (valueToValidate: string) => boolean;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export interface ValidationEvents {
|
||||
[eventName: string]: ValidationRule[];
|
||||
}
|
||||
@@ -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>>;
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface PluginMeta {
|
||||
includes: PluginInclude[];
|
||||
|
||||
// Datasource-specific
|
||||
builtIn?: boolean;
|
||||
metrics?: boolean;
|
||||
tables?: boolean;
|
||||
logs?: boolean;
|
||||
|
||||
157
packages/grafana-ui/src/utils/displayValue.test.ts
Normal file
157
packages/grafana-ui/src/utils/displayValue.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
145
packages/grafana-ui/src/utils/displayValue.ts
Normal file
145
packages/grafana-ui/src/utils/displayValue.ts
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
24
packages/grafana-ui/src/utils/validate.ts
Normal file
24
packages/grafana-ui/src/utils/validate.ts
Normal 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];
|
||||
};
|
||||
Reference in New Issue
Block a user