Merge branch 'master' into bump-webpack
# Conflicts: # package.json # yarn.lock
This commit is contained in:
@@ -25,10 +25,10 @@
|
||||
"lodash": "^4.17.10",
|
||||
"moment": "^2.22.2",
|
||||
"papaparse": "^4.6.3",
|
||||
"react": "^16.6.3",
|
||||
"react": "^16.8.4",
|
||||
"react-color": "^2.17.0",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
"react-dom": "^16.6.3",
|
||||
"react-dom": "^16.8.4",
|
||||
"react-highlight-words": "0.11.0",
|
||||
"react-popper": "^1.3.0",
|
||||
"react-transition-group": "^2.2.1",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@types/lodash": "^4.14.119",
|
||||
"@types/node": "^10.12.18",
|
||||
"@types/papaparse": "^4.5.9",
|
||||
"@types/react": "^16.7.6",
|
||||
"@types/react": "^16.8.8",
|
||||
"@types/react-custom-scrollbars": "^4.0.5",
|
||||
"@types/react-test-renderer": "^16.0.3",
|
||||
"@types/react-transition-group": "^2.0.15",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { number, text } from '@storybook/addon-knobs';
|
||||
import { number, text, boolean } from '@storybook/addon-knobs';
|
||||
import { BarGauge } from './BarGauge';
|
||||
import { VizOrientation } from '../../types';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||
|
||||
@@ -15,6 +16,8 @@ const getKnobs = () => {
|
||||
threshold2Color: text('threshold2Color', 'red'),
|
||||
unit: text('unit', 'ms'),
|
||||
decimals: number('decimals', 1),
|
||||
horizontal: boolean('horizontal', false),
|
||||
lcd: boolean('lcd', false),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,7 +25,7 @@ const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module);
|
||||
|
||||
BarGaugeStories.addDecorator(withCenteredStory);
|
||||
|
||||
BarGaugeStories.add('Vertical, with basic thresholds', () => {
|
||||
BarGaugeStories.add('Simple with basic thresholds', () => {
|
||||
const {
|
||||
value,
|
||||
minValue,
|
||||
@@ -33,11 +36,13 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
|
||||
threshold2Value,
|
||||
unit,
|
||||
decimals,
|
||||
horizontal,
|
||||
lcd,
|
||||
} = getKnobs();
|
||||
|
||||
return renderComponentWithTheme(BarGauge, {
|
||||
width: 200,
|
||||
height: 400,
|
||||
width: 700,
|
||||
height: 700,
|
||||
value: value,
|
||||
minValue: minValue,
|
||||
maxValue: maxValue,
|
||||
@@ -45,6 +50,8 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
|
||||
prefix: '',
|
||||
postfix: '',
|
||||
decimals: decimals,
|
||||
orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical,
|
||||
displayMode: lcd ? 'lcd' : 'simple',
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: 'green' },
|
||||
{ index: 1, value: threshold1Value, color: threshold1Color },
|
||||
|
||||
@@ -11,16 +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,
|
||||
};
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
// Library
|
||||
import React, { PureComponent, CSSProperties } from 'react';
|
||||
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';
|
||||
}
|
||||
|
||||
/*
|
||||
* This visualization is still in POC state, needed more tests & better structure
|
||||
*/
|
||||
export class BarGauge extends PureComponent<Props> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
maxValue: 100,
|
||||
minValue: 0,
|
||||
value: 100,
|
||||
unit: 'none',
|
||||
value: {
|
||||
text: '100',
|
||||
numeric: 100,
|
||||
},
|
||||
displayMode: 'lcd',
|
||||
orientation: VizOrientation.Horizontal,
|
||||
thresholds: [],
|
||||
valueMappings: [],
|
||||
};
|
||||
|
||||
getNumericValue(): number {
|
||||
if (Number.isFinite(this.props.value as number)) {
|
||||
return this.props.value as number;
|
||||
render() {
|
||||
switch (this.props.displayMode) {
|
||||
case 'lcd':
|
||||
return this.renderRetroBars();
|
||||
case 'basic':
|
||||
case 'gradient':
|
||||
default:
|
||||
return this.renderBasicAndGradientBars();
|
||||
}
|
||||
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);
|
||||
@@ -57,41 +56,19 @@ export class BarGauge extends PureComponent<Props> {
|
||||
return {
|
||||
value: color,
|
||||
border: color,
|
||||
bar: tinycolor(color)
|
||||
.setAlpha(0.3)
|
||||
background: tinycolor(color)
|
||||
.setAlpha(0.15)
|
||||
.toRgbString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: getColorFromHexRgbOrName('gray', theme.type),
|
||||
bar: getColorFromHexRgbOrName('gray', theme.type),
|
||||
background: getColorFromHexRgbOrName('gray', theme.type),
|
||||
border: getColorFromHexRgbOrName('gray', theme.type),
|
||||
};
|
||||
}
|
||||
|
||||
getCellColor(positionValue: TimeSeriesValue): string {
|
||||
const { thresholds, theme, value } = this.props;
|
||||
const activeThreshold = getThresholdForValue(thresholds, positionValue);
|
||||
|
||||
if (activeThreshold !== null) {
|
||||
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)) {
|
||||
return tinycolor(color)
|
||||
.setAlpha(0.15)
|
||||
.toRgbString();
|
||||
} else {
|
||||
return tinycolor(color)
|
||||
.setAlpha(0.7)
|
||||
.toRgbString();
|
||||
}
|
||||
}
|
||||
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
getValueStyles(value: string, color: string, width: number): CSSProperties {
|
||||
const guess = width / (value.length * 1.1);
|
||||
const fontSize = Math.min(Math.max(guess, 14), 40);
|
||||
@@ -102,107 +79,205 @@ export class BarGauge extends PureComponent<Props> {
|
||||
};
|
||||
}
|
||||
|
||||
renderVerticalBar(valueFormatted: string, valuePercent: number) {
|
||||
/*
|
||||
* Return width or height depending on viz orientation
|
||||
* */
|
||||
get size() {
|
||||
const { height, width } = this.props;
|
||||
return this.isVertical ? height : width;
|
||||
}
|
||||
|
||||
const maxHeight = height * BAR_SIZE_RATIO;
|
||||
const barHeight = Math.max(valuePercent * maxHeight, 0);
|
||||
get isVertical() {
|
||||
return this.props.orientation === VizOrientation.Vertical;
|
||||
}
|
||||
|
||||
getBarGradient(maxSize: number): string {
|
||||
const { minValue, maxValue, thresholds, value } = this.props;
|
||||
const cssDirection = this.isVertical ? '0deg' : '90deg';
|
||||
|
||||
let gradient = '';
|
||||
let lastpos = 0;
|
||||
|
||||
for (let i = 0; i < thresholds.length; i++) {
|
||||
const threshold = thresholds[i];
|
||||
const color = getColorFromHexRgbOrName(threshold.color);
|
||||
const valuePercent = Math.min(threshold.value / (maxValue - minValue), 1);
|
||||
const pos = valuePercent * maxSize;
|
||||
const offset = Math.round(pos - (pos - lastpos) / 2);
|
||||
|
||||
if (gradient === '') {
|
||||
gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
|
||||
} else if (value.numeric < threshold.value) {
|
||||
break;
|
||||
} else {
|
||||
lastpos = pos;
|
||||
gradient += ` ${offset}px, ${color}`;
|
||||
}
|
||||
}
|
||||
|
||||
return gradient + ')';
|
||||
}
|
||||
|
||||
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 valueStyles = this.getValueStyles(valueFormatted, colors.value, width);
|
||||
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
|
||||
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
|
||||
const isBasic = displayMode === 'basic';
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
};
|
||||
|
||||
const barStyles: CSSProperties = {
|
||||
height: `${barHeight}px`,
|
||||
width: `${width}px`,
|
||||
backgroundColor: colors.bar,
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
borderRadius: '3px',
|
||||
};
|
||||
|
||||
if (this.isVertical) {
|
||||
// Custom styles for vertical orientation
|
||||
containerStyles.flexDirection = 'column';
|
||||
containerStyles.justifyContent = 'flex-end';
|
||||
barStyles.transition = 'height 1s';
|
||||
barStyles.height = `${barSize}px`;
|
||||
barStyles.width = `${width}px`;
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
barStyles.background = `${colors.background}`;
|
||||
barStyles.border = `1px solid ${colors.border}`;
|
||||
barStyles.boxShadow = `0 0 4px ${colors.border}`;
|
||||
} else {
|
||||
// Gradient styles
|
||||
barStyles.background = this.getBarGradient(maxSize);
|
||||
}
|
||||
} else {
|
||||
// Custom styles for horizontal orientation
|
||||
containerStyles.flexDirection = 'row-reverse';
|
||||
containerStyles.justifyContent = 'flex-end';
|
||||
containerStyles.alignItems = 'center';
|
||||
barStyles.transition = 'width 1s';
|
||||
barStyles.height = `${height}px`;
|
||||
barStyles.width = `${barSize}px`;
|
||||
barStyles.marginRight = '10px';
|
||||
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
barStyles.background = `${colors.background}`;
|
||||
barStyles.border = `1px solid ${colors.border}`;
|
||||
barStyles.boxShadow = `0 0 4px ${colors.border}`;
|
||||
} else {
|
||||
// Gradient styles
|
||||
barStyles.background = this.getBarGradient(maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyles}>
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
{value.text}
|
||||
</div>
|
||||
<div style={barStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderHorizontalBar(valueFormatted: string, valuePercent: number) {
|
||||
const { height, width } = this.props;
|
||||
getCellColor(positionValue: TimeSeriesValue): CellColors {
|
||||
const { thresholds, theme, value } = this.props;
|
||||
const activeThreshold = getThresholdForValue(thresholds, positionValue);
|
||||
|
||||
const maxWidth = width * BAR_SIZE_RATIO;
|
||||
const barWidth = Math.max(valuePercent * maxWidth, 0);
|
||||
const colors = this.getValueColors();
|
||||
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
|
||||
if (activeThreshold !== null) {
|
||||
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
|
||||
|
||||
valueStyles.marginLeft = '8px';
|
||||
// if we are past real value the cell is not "on"
|
||||
if (value === null || (positionValue !== null && positionValue > value.numeric)) {
|
||||
return {
|
||||
background: tinycolor(color)
|
||||
.setAlpha(0.15)
|
||||
.toRgbString(),
|
||||
border: 'transparent',
|
||||
isLit: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
background: tinycolor(color)
|
||||
.setAlpha(0.85)
|
||||
.toRgbString(),
|
||||
backgroundShade: tinycolor(color)
|
||||
.setAlpha(0.55)
|
||||
.toRgbString(),
|
||||
border: tinycolor(color)
|
||||
.setAlpha(0.9)
|
||||
.toRgbString(),
|
||||
isLit: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
return {
|
||||
background: 'gray',
|
||||
border: 'gray',
|
||||
};
|
||||
|
||||
const barStyles = {
|
||||
height: `${height}px`,
|
||||
width: `${barWidth}px`,
|
||||
backgroundColor: colors.bar,
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyles}>
|
||||
<div style={barStyles} />
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderHorizontalLCD(valueFormatted: string, valuePercent: number) {
|
||||
const { height, width, maxValue, minValue } = this.props;
|
||||
renderRetroBars(): ReactNode {
|
||||
const { height, width, maxValue, minValue, value } = this.props;
|
||||
|
||||
const valueRange = maxValue - minValue;
|
||||
const maxWidth = width * BAR_SIZE_RATIO;
|
||||
const cellSpacing = 4;
|
||||
const cellCount = 30;
|
||||
const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount;
|
||||
const maxSize = this.size * BAR_SIZE_RATIO;
|
||||
const cellSpacing = 5;
|
||||
const cellCount = maxSize / 20;
|
||||
const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
|
||||
const colors = this.getValueColors();
|
||||
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
|
||||
valueStyles.marginLeft = '8px';
|
||||
const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
|
||||
const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
if (this.isVertical) {
|
||||
containerStyles.flexDirection = 'column-reverse';
|
||||
containerStyles.alignItems = 'center';
|
||||
valueStyles.marginBottom = '20px';
|
||||
} else {
|
||||
containerStyles.flexDirection = 'row';
|
||||
containerStyles.alignItems = 'center';
|
||||
valueStyles.marginLeft = '20px';
|
||||
}
|
||||
|
||||
const cells: JSX.Element[] = [];
|
||||
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const currentValue = (valueRange / cellCount) * i;
|
||||
const cellColor = this.getCellColor(currentValue);
|
||||
const cellStyles: CSSProperties = {
|
||||
width: `${cellWidth}px`,
|
||||
backgroundColor: cellColor,
|
||||
marginRight: '4px',
|
||||
height: `${height}px`,
|
||||
borderRadius: '2px',
|
||||
};
|
||||
|
||||
if (cellColor.isLit) {
|
||||
cellStyles.boxShadow = `0 0 4px ${cellColor.border}`;
|
||||
cellStyles.backgroundImage = `radial-gradient(${cellColor.background} 10%, ${cellColor.backgroundShade})`;
|
||||
} else {
|
||||
cellStyles.backgroundColor = cellColor.background;
|
||||
}
|
||||
|
||||
if (this.isVertical) {
|
||||
cellStyles.height = `${cellSize}px`;
|
||||
cellStyles.width = `${width}px`;
|
||||
cellStyles.marginTop = `${cellSpacing}px`;
|
||||
} else {
|
||||
cellStyles.width = `${cellSize}px`;
|
||||
cellStyles.height = `${height}px`;
|
||||
cellStyles.marginRight = `${cellSpacing}px`;
|
||||
}
|
||||
|
||||
cells.push(<div style={cellStyles} />);
|
||||
}
|
||||
|
||||
@@ -210,30 +285,22 @@ export class BarGauge extends PureComponent<Props> {
|
||||
<div style={containerStyles}>
|
||||
{cells}
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
{value.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { maxValue, minValue, orientation, unit, decimals } = this.props;
|
||||
|
||||
const numericValue = this.getNumericValue();
|
||||
const valuePercent = Math.min(numericValue / (maxValue - minValue), 1);
|
||||
|
||||
const formatFunc = getValueFormat(unit);
|
||||
const valueFormatted = formatFunc(numericValue, decimals);
|
||||
const vertical = orientation === 'vertical';
|
||||
|
||||
return vertical
|
||||
? this.renderVerticalBar(valueFormatted, valuePercent)
|
||||
: this.renderHorizontalLCD(valueFormatted, valuePercent);
|
||||
}
|
||||
}
|
||||
|
||||
interface BarColors {
|
||||
value: string;
|
||||
bar: string;
|
||||
background: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
interface CellColors {
|
||||
background: string;
|
||||
backgroundShade?: string;
|
||||
border: string;
|
||||
isLit?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,353 +6,37 @@ exports[`Render BarGauge with basic options should render 1`] = `
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"display": "flex",
|
||||
"flexDirection": "row",
|
||||
"flexDirection": "row-reverse",
|
||||
"height": "300px",
|
||||
"justifyContent": "flex-end",
|
||||
"width": "300px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.7)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(126, 178, 109, 0.15)",
|
||||
"borderRadius": "2px",
|
||||
"height": "300px",
|
||||
"marginRight": "4px",
|
||||
"width": "4px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="bar-gauge__value"
|
||||
style={
|
||||
Object {
|
||||
"color": "#7EB26D",
|
||||
"fontSize": "27.272727272727263px",
|
||||
"marginLeft": "8px",
|
||||
"fontSize": "27.27272727272727px",
|
||||
}
|
||||
}
|
||||
>
|
||||
25
|
||||
</div>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"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",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
onConfirm(): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onConfirm } = this.props;
|
||||
let showConfirm;
|
||||
let showDeleteButton;
|
||||
|
||||
if (this.state.showConfirm) {
|
||||
showConfirm = 'show';
|
||||
showDeleteButton = 'hide';
|
||||
} else {
|
||||
showConfirm = 'hide';
|
||||
showDeleteButton = 'show';
|
||||
}
|
||||
const { onConfirm, disabled } = this.props;
|
||||
const showConfirmClass = this.state.showConfirm ? 'show' : 'hide';
|
||||
const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show';
|
||||
const disabledClass = disabled ? 'disabled btn-inverse' : '';
|
||||
const onClick = disabled ? () => {} : this.onClickDelete;
|
||||
|
||||
return (
|
||||
<span className="delete-button-container">
|
||||
<a className={'delete-button ' + showDeleteButton + ' btn btn-danger btn-small'} onClick={this.onClickDelete}>
|
||||
<a
|
||||
className={`delete-button ${showDeleteButtonClass} btn btn-danger btn-small ${disabledClass}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
<span className="confirm-delete-container">
|
||||
<span className={'confirm-delete ' + showConfirm}>
|
||||
<span className={`confirm-delete ${showConfirmClass}`}>
|
||||
<a className="btn btn-small" onClick={this.onClickCancel}>
|
||||
Cancel
|
||||
</a>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -25,6 +25,7 @@ const model: OptionProps<any> = {
|
||||
key: '',
|
||||
onClick: jest.fn(),
|
||||
onMouseOver: jest.fn(),
|
||||
onMouseMove: jest.fn(),
|
||||
tabIndex: 1,
|
||||
},
|
||||
label: 'Option label',
|
||||
|
||||
@@ -4,6 +4,7 @@ exports[`SelectOption renders correctly 1`] = `
|
||||
<div
|
||||
id=""
|
||||
onClick={[MockFunction]}
|
||||
onMouseMove={[MockFunction]}
|
||||
onMouseOver={[MockFunction]}
|
||||
tabIndex={1}
|
||||
>
|
||||
|
||||
@@ -40,8 +40,6 @@ export function makeDummyTable(columnCount: number, rowCount: number): TableData
|
||||
const suffix = (rowId + 1).toString();
|
||||
return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix);
|
||||
}),
|
||||
type: 'table',
|
||||
columnMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CellMeasurerCache,
|
||||
CellMeasurer,
|
||||
GridCellProps,
|
||||
Index,
|
||||
} from 'react-virtualized';
|
||||
import { Themeable } from '../../types/theme';
|
||||
|
||||
@@ -26,6 +27,7 @@ import { stringToJsRegex } from '../../utils/index';
|
||||
export interface Props extends Themeable {
|
||||
data: TableData;
|
||||
|
||||
minColumnWidth: number;
|
||||
showHeader: boolean;
|
||||
fixedHeader: boolean;
|
||||
fixedColumns: number;
|
||||
@@ -46,6 +48,7 @@ interface State {
|
||||
|
||||
interface ColumnRenderInfo {
|
||||
header: string;
|
||||
width: number;
|
||||
builder: TableCellBuilder;
|
||||
}
|
||||
|
||||
@@ -64,6 +67,7 @@ export class Table extends Component<Props, State> {
|
||||
fixedHeader: true,
|
||||
fixedColumns: 0,
|
||||
rotate: false,
|
||||
minColumnWidth: 150,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
@@ -76,7 +80,7 @@ export class Table extends Component<Props, State> {
|
||||
this.renderer = this.initColumns(props);
|
||||
this.measurer = new CellMeasurerCache({
|
||||
defaultHeight: 30,
|
||||
defaultWidth: 150,
|
||||
fixedWidth: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,7 +114,8 @@ export class Table extends Component<Props, State> {
|
||||
|
||||
/** Given the configuration, setup how each column gets rendered */
|
||||
initColumns(props: Props): ColumnRenderInfo[] {
|
||||
const { styles, data } = props;
|
||||
const { styles, data, width, minColumnWidth } = props;
|
||||
const columnWidth = Math.max(width / data.columns.length, minColumnWidth);
|
||||
|
||||
return data.columns.map((col, index) => {
|
||||
let title = col.text;
|
||||
@@ -131,6 +136,7 @@ export class Table extends Component<Props, State> {
|
||||
|
||||
return {
|
||||
header: title,
|
||||
width: columnWidth,
|
||||
builder: getCellBuilder(col, style, this.props),
|
||||
};
|
||||
});
|
||||
@@ -228,6 +234,10 @@ export class Table extends Component<Props, State> {
|
||||
);
|
||||
};
|
||||
|
||||
getColumnWidth = (col: Index): number => {
|
||||
return this.renderer[col.index].width;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props;
|
||||
const { data } = this.state;
|
||||
@@ -269,7 +279,7 @@ export class Table extends Component<Props, State> {
|
||||
rowCount={rowCount}
|
||||
overscanColumnCount={8}
|
||||
overscanRowCount={8}
|
||||
columnWidth={this.measurer.columnWidth}
|
||||
columnWidth={this.getColumnWidth}
|
||||
deferredMeasurementCache={this.measurer}
|
||||
cellRenderer={this.cellRenderer}
|
||||
rowHeight={this.measurer.rowHeight}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { ThresholdsEditor } from './ThresholdsEditor';
|
||||
|
||||
const ThresholdsEditorStories = storiesOf('UI/ThresholdsEditor', module);
|
||||
const thresholds = [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 50, color: 'red' }];
|
||||
|
||||
ThresholdsEditorStories.add('default', () => {
|
||||
return <ThresholdsEditor thresholds={[]} onChange={action('Thresholds changed')} />;
|
||||
});
|
||||
|
||||
ThresholdsEditorStories.add('with thresholds', () => {
|
||||
return <ThresholdsEditor thresholds={thresholds} onChange={action('Thresholds changed')} />;
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { ThresholdsEditor, Props } from './ThresholdsEditor';
|
||||
import { colors } from '../../utils';
|
||||
|
||||
const setup = (propOverrides?: Partial<Props>) => {
|
||||
const props: Props = {
|
||||
@@ -31,7 +32,7 @@ describe('Initialization', () => {
|
||||
it('should add a base threshold if missing', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +42,7 @@ describe('Add threshold', () => {
|
||||
|
||||
instance.onAddThreshold(0);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
|
||||
expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
|
||||
});
|
||||
|
||||
it('should add threshold', () => {
|
||||
@@ -50,41 +51,41 @@ describe('Add threshold', () => {
|
||||
instance.onAddThreshold(1);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold above a first', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
|
||||
thresholds: [{ index: 0, value: -Infinity, color: colors[0] }, { index: 1, value: 50, color: colors[2] }],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 75, color: colors[3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add another threshold between first and second index', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 75, color: colors[3] },
|
||||
],
|
||||
});
|
||||
|
||||
instance.onAddThreshold(2);
|
||||
|
||||
expect(instance.state.thresholds).toEqual([
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 2, value: 62.5, color: '#EF843C' },
|
||||
{ index: 3, value: 75, color: '#6ED0E0' },
|
||||
{ index: 0, value: -Infinity, color: colors[0] },
|
||||
{ index: 1, value: 50, color: colors[2] },
|
||||
{ index: 2, value: 62.5, color: colors[4] },
|
||||
{ index: 3, value: 75, color: colors[3] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -161,7 +162,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' },
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Threshold } from '../../types';
|
||||
import { ColorPicker } from '..';
|
||||
import { PanelOptionsGroup } from '..';
|
||||
import { colors } from '../../utils';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||
import { ThemeContext } from '../../themes';
|
||||
import { getColorFromHexRgbOrName } from '../../utils';
|
||||
|
||||
export interface Props {
|
||||
thresholds: Threshold[];
|
||||
@@ -166,7 +166,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
<div className="thresholds-row-input-inner-color">
|
||||
{threshold.color && (
|
||||
<div className="thresholds-row-input-inner-color-colorpicker">
|
||||
<ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
|
||||
<ColorPicker
|
||||
color={threshold.color}
|
||||
onChange={color => this.onChangeThresholdColor(threshold, color)}
|
||||
enableNamedColors={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+446
-3
@@ -1,7 +1,450 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render with base threshold 1`] = `
|
||||
<ContextConsumer>
|
||||
<Component />
|
||||
</ContextConsumer>
|
||||
<ThresholdsEditor
|
||||
onChange={
|
||||
[MockFunction] {
|
||||
"calls": Array [
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"color": "#7EB26D",
|
||||
"index": 0,
|
||||
"value": -Infinity,
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
Object {
|
||||
"isThrow": false,
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
thresholds={Array []}
|
||||
>
|
||||
<Component
|
||||
title="Thresholds"
|
||||
>
|
||||
<div
|
||||
className="panel-options-group"
|
||||
>
|
||||
<div
|
||||
className="panel-options-group__header"
|
||||
>
|
||||
<span
|
||||
className="panel-options-group__title"
|
||||
>
|
||||
Thresholds
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="panel-options-group__body"
|
||||
>
|
||||
<div
|
||||
className="thresholds"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row"
|
||||
key="0-0"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row-add-button"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="thresholds-row-color-indicator"
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "#7EB26D",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="thresholds-row-input"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row-input-inner"
|
||||
>
|
||||
<span
|
||||
className="thresholds-row-input-inner-arrow"
|
||||
/>
|
||||
<div
|
||||
className="thresholds-row-input-inner-color"
|
||||
>
|
||||
<div
|
||||
className="thresholds-row-input-inner-color-colorpicker"
|
||||
>
|
||||
<WithTheme(ColorPicker)
|
||||
color="#7EB26D"
|
||||
enableNamedColors={true}
|
||||
onChange={[Function]}
|
||||
>
|
||||
<ColorPicker
|
||||
color="#7EB26D"
|
||||
enableNamedColors={true}
|
||||
onChange={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"background": Object {
|
||||
"dropdown": "#1f1f20",
|
||||
"scrollbar": "#343436",
|
||||
"scrollbar2": "#343436",
|
||||
},
|
||||
"border": Object {
|
||||
"radius": Object {
|
||||
"lg": "5px",
|
||||
"md": "3px",
|
||||
"sm": "2px",
|
||||
},
|
||||
"width": Object {
|
||||
"sm": "1px",
|
||||
},
|
||||
},
|
||||
"breakpoints": Object {
|
||||
"lg": "992px",
|
||||
"md": "768px",
|
||||
"sm": "544px",
|
||||
"xl": "1200px",
|
||||
"xs": "0",
|
||||
},
|
||||
"colors": Object {
|
||||
"black": "#000000",
|
||||
"blue": "#33b5e5",
|
||||
"blueBase": "#3274d9",
|
||||
"blueFaint": "#041126",
|
||||
"blueLight": "#5794f2",
|
||||
"blueShade": "#1f60c4",
|
||||
"body": "#d8d9da",
|
||||
"bodyBg": "#161719",
|
||||
"brandDanger": "#e02f44",
|
||||
"brandPrimary": "#eb7b18",
|
||||
"brandSuccess": "#299c46",
|
||||
"brandWarning": "#eb7b18",
|
||||
"critical": "#e02f44",
|
||||
"dark1": "#141414",
|
||||
"dark10": "#424345",
|
||||
"dark2": "#161719",
|
||||
"dark3": "#1f1f20",
|
||||
"dark4": "#212124",
|
||||
"dark5": "#222426",
|
||||
"dark6": "#262628",
|
||||
"dark7": "#292a2d",
|
||||
"dark8": "#2f2f32",
|
||||
"dark9": "#343436",
|
||||
"gray1": "#555555",
|
||||
"gray2": "#8e8e8e",
|
||||
"gray3": "#b3b3b3",
|
||||
"gray4": "#d8d9da",
|
||||
"gray5": "#ececec",
|
||||
"gray6": "#f4f5f8",
|
||||
"gray7": "#fbfbfb",
|
||||
"grayBlue": "#212327",
|
||||
"greenBase": "#299c46",
|
||||
"greenShade": "#23843b",
|
||||
"headingColor": "#e3e3e3",
|
||||
"inputBlack": "#09090b",
|
||||
"link": "#e3e3e3",
|
||||
"linkDisabled": "#e3e3e3",
|
||||
"linkExternal": "#33b5e5",
|
||||
"linkHover": "#ffffff",
|
||||
"online": "#299c46",
|
||||
"orange": "#eb7b18",
|
||||
"pageBg": "#161719",
|
||||
"purple": "#9933cc",
|
||||
"queryGreen": "#74e680",
|
||||
"queryKeyword": "#66d9ef",
|
||||
"queryOrange": "#eb7b18",
|
||||
"queryPurple": "#fe85fc",
|
||||
"queryRed": "#e02f44",
|
||||
"red": "#d44a3a",
|
||||
"redBase": "#e02f44",
|
||||
"redShade": "#c4162a",
|
||||
"text": "#d8d9da",
|
||||
"textEmphasis": "#ececec",
|
||||
"textFaint": "#222426",
|
||||
"textStrong": "#ffffff",
|
||||
"textWeak": "#8e8e8e",
|
||||
"variable": "#32d1df",
|
||||
"warn": "#f79520",
|
||||
"white": "#ffffff",
|
||||
"yellow": "#ecbb13",
|
||||
},
|
||||
"name": "Grafana Dark",
|
||||
"panelPadding": Object {
|
||||
"horizontal": 10,
|
||||
"vertical": 5,
|
||||
},
|
||||
"spacing": Object {
|
||||
"d": "14px",
|
||||
"gutter": "30px",
|
||||
"lg": "24px",
|
||||
"md": "16px",
|
||||
"sm": "8px",
|
||||
"xl": "32px",
|
||||
"xs": "4px",
|
||||
"xxs": "2px",
|
||||
},
|
||||
"type": "dark",
|
||||
"typography": Object {
|
||||
"fontFamily": Object {
|
||||
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
|
||||
"sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
|
||||
},
|
||||
"heading": Object {
|
||||
"h1": "28px",
|
||||
"h2": "24px",
|
||||
"h3": "21px",
|
||||
"h4": "18px",
|
||||
"h5": "16px",
|
||||
"h6": "14px",
|
||||
},
|
||||
"lineHeight": Object {
|
||||
"lg": 1.5,
|
||||
"md": 1.3333333333333333,
|
||||
"sm": 1.1,
|
||||
"xs": 1,
|
||||
},
|
||||
"size": Object {
|
||||
"base": "13px",
|
||||
"lg": "18px",
|
||||
"md": "14px",
|
||||
"root": "14px",
|
||||
"sm": "12px",
|
||||
"xs": "10px",
|
||||
},
|
||||
"weight": Object {
|
||||
"light": 300,
|
||||
"regular": 400,
|
||||
"semibold": 500,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<PopperController
|
||||
content={
|
||||
<ColorPickerPopover
|
||||
color="#7EB26D"
|
||||
enableNamedColors={true}
|
||||
onChange={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"background": Object {
|
||||
"dropdown": "#1f1f20",
|
||||
"scrollbar": "#343436",
|
||||
"scrollbar2": "#343436",
|
||||
},
|
||||
"border": Object {
|
||||
"radius": Object {
|
||||
"lg": "5px",
|
||||
"md": "3px",
|
||||
"sm": "2px",
|
||||
},
|
||||
"width": Object {
|
||||
"sm": "1px",
|
||||
},
|
||||
},
|
||||
"breakpoints": Object {
|
||||
"lg": "992px",
|
||||
"md": "768px",
|
||||
"sm": "544px",
|
||||
"xl": "1200px",
|
||||
"xs": "0",
|
||||
},
|
||||
"colors": Object {
|
||||
"black": "#000000",
|
||||
"blue": "#33b5e5",
|
||||
"blueBase": "#3274d9",
|
||||
"blueFaint": "#041126",
|
||||
"blueLight": "#5794f2",
|
||||
"blueShade": "#1f60c4",
|
||||
"body": "#d8d9da",
|
||||
"bodyBg": "#161719",
|
||||
"brandDanger": "#e02f44",
|
||||
"brandPrimary": "#eb7b18",
|
||||
"brandSuccess": "#299c46",
|
||||
"brandWarning": "#eb7b18",
|
||||
"critical": "#e02f44",
|
||||
"dark1": "#141414",
|
||||
"dark10": "#424345",
|
||||
"dark2": "#161719",
|
||||
"dark3": "#1f1f20",
|
||||
"dark4": "#212124",
|
||||
"dark5": "#222426",
|
||||
"dark6": "#262628",
|
||||
"dark7": "#292a2d",
|
||||
"dark8": "#2f2f32",
|
||||
"dark9": "#343436",
|
||||
"gray1": "#555555",
|
||||
"gray2": "#8e8e8e",
|
||||
"gray3": "#b3b3b3",
|
||||
"gray4": "#d8d9da",
|
||||
"gray5": "#ececec",
|
||||
"gray6": "#f4f5f8",
|
||||
"gray7": "#fbfbfb",
|
||||
"grayBlue": "#212327",
|
||||
"greenBase": "#299c46",
|
||||
"greenShade": "#23843b",
|
||||
"headingColor": "#e3e3e3",
|
||||
"inputBlack": "#09090b",
|
||||
"link": "#e3e3e3",
|
||||
"linkDisabled": "#e3e3e3",
|
||||
"linkExternal": "#33b5e5",
|
||||
"linkHover": "#ffffff",
|
||||
"online": "#299c46",
|
||||
"orange": "#eb7b18",
|
||||
"pageBg": "#161719",
|
||||
"purple": "#9933cc",
|
||||
"queryGreen": "#74e680",
|
||||
"queryKeyword": "#66d9ef",
|
||||
"queryOrange": "#eb7b18",
|
||||
"queryPurple": "#fe85fc",
|
||||
"queryRed": "#e02f44",
|
||||
"red": "#d44a3a",
|
||||
"redBase": "#e02f44",
|
||||
"redShade": "#c4162a",
|
||||
"text": "#d8d9da",
|
||||
"textEmphasis": "#ececec",
|
||||
"textFaint": "#222426",
|
||||
"textStrong": "#ffffff",
|
||||
"textWeak": "#8e8e8e",
|
||||
"variable": "#32d1df",
|
||||
"warn": "#f79520",
|
||||
"white": "#ffffff",
|
||||
"yellow": "#ecbb13",
|
||||
},
|
||||
"name": "Grafana Dark",
|
||||
"panelPadding": Object {
|
||||
"horizontal": 10,
|
||||
"vertical": 5,
|
||||
},
|
||||
"spacing": Object {
|
||||
"d": "14px",
|
||||
"gutter": "30px",
|
||||
"lg": "24px",
|
||||
"md": "16px",
|
||||
"sm": "8px",
|
||||
"xl": "32px",
|
||||
"xs": "4px",
|
||||
"xxs": "2px",
|
||||
},
|
||||
"type": "dark",
|
||||
"typography": Object {
|
||||
"fontFamily": Object {
|
||||
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
|
||||
"sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
|
||||
},
|
||||
"heading": Object {
|
||||
"h1": "28px",
|
||||
"h2": "24px",
|
||||
"h3": "21px",
|
||||
"h4": "18px",
|
||||
"h5": "16px",
|
||||
"h6": "14px",
|
||||
},
|
||||
"lineHeight": Object {
|
||||
"lg": 1.5,
|
||||
"md": 1.3333333333333333,
|
||||
"sm": 1.1,
|
||||
"xs": 1,
|
||||
},
|
||||
"size": Object {
|
||||
"base": "13px",
|
||||
"lg": "18px",
|
||||
"md": "14px",
|
||||
"root": "14px",
|
||||
"sm": "12px",
|
||||
"xs": "10px",
|
||||
},
|
||||
"weight": Object {
|
||||
"light": 300,
|
||||
"regular": 400,
|
||||
"semibold": 500,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
hideAfter={300}
|
||||
>
|
||||
<ForwardRef(ColorPickerTrigger)
|
||||
color="#7EB26D"
|
||||
onClick={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
>
|
||||
<div
|
||||
onClick={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"background": "inherit",
|
||||
"border": "none",
|
||||
"borderRadius": 10,
|
||||
"color": "inherit",
|
||||
"cursor": "pointer",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundImage": "url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)",
|
||||
"border": "none",
|
||||
"float": "left",
|
||||
"height": 15,
|
||||
"margin": 0,
|
||||
"position": "relative",
|
||||
"width": 15,
|
||||
"zIndex": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "#7EB26D",
|
||||
"bottom": 0,
|
||||
"display": "block",
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ForwardRef(ColorPickerTrigger)>
|
||||
</PopperController>
|
||||
</ColorPicker>
|
||||
</WithTheme(ColorPicker)>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="thresholds-row-input-inner-value"
|
||||
>
|
||||
<input
|
||||
readOnly={true}
|
||||
type="text"
|
||||
value="Base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
</ThresholdsEditor>
|
||||
`;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -120,7 +120,7 @@ $headings-line-height: ${theme.typography.lineHeight.sm} !default;
|
||||
$border-width: ${theme.border.width.sm} !default;
|
||||
|
||||
$border-radius: ${theme.border.radius.md} !default;
|
||||
$border-radius-lg: ${theme.border.radius.lg}!default;
|
||||
$border-radius-lg: ${theme.border.radius.lg} !default;
|
||||
$border-radius-sm: ${theme.border.radius.sm} !default;
|
||||
|
||||
// Page
|
||||
@@ -191,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
|
||||
$btn-padding-x-xl: 21px !default;
|
||||
$btn-padding-y-xl: 11px !default;
|
||||
|
||||
|
||||
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
|
||||
|
||||
// sidemenu
|
||||
|
||||
@@ -51,27 +51,13 @@ export enum NullValueMode {
|
||||
export type TimeSeriesVMs = TimeSeriesVM[];
|
||||
|
||||
export interface Column {
|
||||
text: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
sort?: boolean;
|
||||
desc?: boolean;
|
||||
filterable?: boolean;
|
||||
text: string; // The column name
|
||||
type?: 'time' | 'number' | 'string' | 'object'; // not used anywhere? can we remove?
|
||||
filterable?: boolean; // currently only set by elasticsearch, and used in the table panel
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface TableData {
|
||||
columns: Column[];
|
||||
rows: any[];
|
||||
type: string;
|
||||
columnMap: any;
|
||||
}
|
||||
|
||||
export type SingleStatValue = number | string | null;
|
||||
|
||||
/*
|
||||
* So we can add meta info like tags & series name
|
||||
*/
|
||||
export interface SingleStatValueInfo {
|
||||
value: SingleStatValue;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './plugin';
|
||||
export * from './datasource';
|
||||
export * from './theme';
|
||||
export * from './threshold';
|
||||
export * from './input';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface ValidationRule {
|
||||
rule: (valueToValidate: string) => boolean;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export interface ValidationEvents {
|
||||
[eventName: string]: ValidationRule[];
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ComponentClass } from 'react';
|
||||
import { TimeSeries, LoadingState, TableData } from './data';
|
||||
import { LoadingState, TableData } from './data';
|
||||
import { TimeRange } from './time';
|
||||
import { ScopedVars } from './datasource';
|
||||
|
||||
export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string;
|
||||
|
||||
export interface PanelProps<T = any> {
|
||||
panelData: PanelData;
|
||||
data?: TableData[];
|
||||
timeRange: TimeRange;
|
||||
loading: LoadingState;
|
||||
options: T;
|
||||
@@ -16,23 +16,26 @@ export interface PanelProps<T = any> {
|
||||
replaceVariables: InterpolateFunction;
|
||||
}
|
||||
|
||||
export interface PanelData {
|
||||
timeSeries?: TimeSeries[];
|
||||
tableData?: TableData;
|
||||
}
|
||||
|
||||
export interface PanelEditorProps<T = any> {
|
||||
options: T;
|
||||
onOptionsChange: (options: T) => void;
|
||||
}
|
||||
|
||||
export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
|
||||
/**
|
||||
* Called before a panel is initalized
|
||||
*/
|
||||
export type PanelTypeChangedHook<TOptions = any> = (
|
||||
options: Partial<TOptions>,
|
||||
prevPluginId?: string,
|
||||
prevOptions?: any
|
||||
) => Partial<TOptions>;
|
||||
|
||||
export class ReactPanelPlugin<TOptions = any> {
|
||||
panel: ComponentClass<PanelProps<TOptions>>;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
defaults?: TOptions;
|
||||
preserveOptions?: PreservePanelOptionsHandler<TOptions>;
|
||||
|
||||
panelTypeChangedHook?: PanelTypeChangedHook<TOptions>;
|
||||
|
||||
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
|
||||
this.panel = panel;
|
||||
@@ -46,8 +49,12 @@ export class ReactPanelPlugin<TOptions = any> {
|
||||
this.defaults = defaults;
|
||||
}
|
||||
|
||||
setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
|
||||
this.preserveOptions = handler;
|
||||
/**
|
||||
* Called when the visualization changes.
|
||||
* Lets you keep whatever settings made sense in the previous panel
|
||||
*/
|
||||
setPanelTypeChangedHook(v: PanelTypeChangedHook<TOptions>) {
|
||||
this.panelTypeChangedHook = v;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface PluginMeta {
|
||||
includes: PluginInclude[];
|
||||
|
||||
// Datasource-specific
|
||||
builtIn?: boolean;
|
||||
metrics?: boolean;
|
||||
tables?: boolean;
|
||||
logs?: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
exports[`processTableData basic processing should generate a header and fix widths 1`] = `
|
||||
Object {
|
||||
"columnMap": Object {},
|
||||
"columns": Array [
|
||||
Object {
|
||||
"text": "Column 1",
|
||||
@@ -31,13 +30,11 @@ Object {
|
||||
null,
|
||||
],
|
||||
],
|
||||
"type": "table",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`processTableData basic processing should read header and two rows 1`] = `
|
||||
Object {
|
||||
"columnMap": Object {},
|
||||
"columns": Array [
|
||||
Object {
|
||||
"text": "a",
|
||||
@@ -61,6 +58,5 @@ Object {
|
||||
6,
|
||||
],
|
||||
],
|
||||
"type": "table",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from './processTimeSeries';
|
||||
export * from './singlestat';
|
||||
export * from './processTableData';
|
||||
export * from './valueFormats/valueFormats';
|
||||
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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseCSV } from './processTableData';
|
||||
import { parseCSV, toTableData } from './processTableData';
|
||||
|
||||
describe('processTableData', () => {
|
||||
describe('basic processing', () => {
|
||||
@@ -18,3 +18,41 @@ describe('processTableData', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTableData', () => {
|
||||
it('converts timeseries to table skipping nulls', () => {
|
||||
const input1 = {
|
||||
target: 'Field Name',
|
||||
datapoints: [[100, 1], [200, 2]],
|
||||
};
|
||||
const input2 = {
|
||||
// without target
|
||||
target: '',
|
||||
datapoints: [[100, 1], [200, 2]],
|
||||
};
|
||||
const data = toTableData([null, input1, input2, null, null]);
|
||||
expect(data.length).toBe(2);
|
||||
expect(data[0].columns[0].text).toBe(input1.target);
|
||||
expect(data[0].rows).toBe(input1.datapoints);
|
||||
|
||||
// Default name
|
||||
expect(data[1].columns[0].text).toEqual('Value');
|
||||
});
|
||||
|
||||
it('keeps tableData unchanged', () => {
|
||||
const input = {
|
||||
columns: [{ text: 'A' }, { text: 'B' }, { text: 'C' }],
|
||||
rows: [[100, 'A', 1], [200, 'B', 2], [300, 'C', 3]],
|
||||
};
|
||||
const data = toTableData([null, input, null, null]);
|
||||
expect(data.length).toBe(1);
|
||||
expect(data[0]).toBe(input);
|
||||
});
|
||||
|
||||
it('supports null values OK', () => {
|
||||
expect(toTableData([null, null, null, null])).toEqual([]);
|
||||
expect(toTableData(undefined)).toEqual([]);
|
||||
expect(toTableData((null as unknown) as any[])).toEqual([]);
|
||||
expect(toTableData([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import isNumber from 'lodash/isNumber';
|
||||
import Papa, { ParseError, ParseMeta } from 'papaparse';
|
||||
|
||||
// Types
|
||||
import { TableData, Column } from '../types';
|
||||
import { TableData, Column, TimeSeries } from '../types';
|
||||
|
||||
// Subset of all parse options
|
||||
export interface TableParseOptions {
|
||||
@@ -70,8 +70,6 @@ export function matchRowSizes(table: TableData): TableData {
|
||||
return {
|
||||
columns,
|
||||
rows: fixedRows,
|
||||
type: table.type,
|
||||
columnMap: table.columnMap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,8 +116,6 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
|
||||
return {
|
||||
columns: [],
|
||||
rows: [],
|
||||
type: 'table',
|
||||
columnMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,11 +126,48 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
|
||||
return matchRowSizes({
|
||||
columns: makeColumns(header),
|
||||
rows: results.data,
|
||||
type: 'table',
|
||||
columnMap: {},
|
||||
});
|
||||
}
|
||||
|
||||
function convertTimeSeriesToTableData(timeSeries: TimeSeries): TableData {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
text: timeSeries.target || 'Value',
|
||||
unit: timeSeries.unit,
|
||||
},
|
||||
{
|
||||
text: 'Time',
|
||||
type: 'time',
|
||||
unit: 'dateTimeAsIso',
|
||||
},
|
||||
],
|
||||
rows: timeSeries.datapoints,
|
||||
};
|
||||
}
|
||||
|
||||
export const isTableData = (data: any): data is TableData => data && data.hasOwnProperty('columns');
|
||||
|
||||
export const toTableData = (results?: any[]): TableData[] => {
|
||||
if (!results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return results
|
||||
.filter(d => !!d)
|
||||
.map(data => {
|
||||
if (data.hasOwnProperty('columns')) {
|
||||
return data as TableData;
|
||||
}
|
||||
if (data.hasOwnProperty('datapoints')) {
|
||||
return convertTimeSeriesToTableData(data);
|
||||
}
|
||||
// TODO, try to convert JSON to table?
|
||||
console.warn('Can not convert', data);
|
||||
throw new Error('Unsupported data format');
|
||||
});
|
||||
};
|
||||
|
||||
export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
|
||||
if (isNumber(sortIndex)) {
|
||||
const copy = {
|
||||
|
||||
@@ -4,17 +4,36 @@ import isNumber from 'lodash/isNumber';
|
||||
import { colors } from './colors';
|
||||
|
||||
// Types
|
||||
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
|
||||
import { TimeSeriesVMs, NullValueMode, TimeSeriesValue, TableData } from '../types';
|
||||
|
||||
interface Options {
|
||||
timeSeries: TimeSeries[];
|
||||
data: TableData[];
|
||||
xColumn?: number; // Time (or null to guess)
|
||||
yColumn?: number; // Value (or null to guess)
|
||||
nullValueMode: NullValueMode;
|
||||
}
|
||||
|
||||
export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
|
||||
const vmSeries = timeSeries.map((item, index) => {
|
||||
// NOTE: this should move to processTableData.ts
|
||||
// I left it as is so the merge changes are more clear.
|
||||
export function processTimeSeries({ data, xColumn, yColumn, nullValueMode }: Options): TimeSeriesVMs {
|
||||
const vmSeries = data.map((item, index) => {
|
||||
if (!isNumber(xColumn)) {
|
||||
xColumn = 1; // Default timeseries colum. TODO, find first time field!
|
||||
}
|
||||
if (!isNumber(yColumn)) {
|
||||
yColumn = 0; // TODO, find first non-time field
|
||||
}
|
||||
|
||||
// TODO? either % or throw error?
|
||||
if (xColumn >= item.columns.length) {
|
||||
throw new Error('invalid colum: ' + xColumn);
|
||||
}
|
||||
if (yColumn >= item.columns.length) {
|
||||
throw new Error('invalid colum: ' + yColumn);
|
||||
}
|
||||
|
||||
const colorIndex = index % colors.length;
|
||||
const label = item.target;
|
||||
const label = item.columns[yColumn].text;
|
||||
const result = [];
|
||||
|
||||
// stat defaults
|
||||
@@ -42,9 +61,9 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
|
||||
let previousValue = 0;
|
||||
let previousDeltaUp = true;
|
||||
|
||||
for (let i = 0; i < item.datapoints.length; i++) {
|
||||
currentValue = item.datapoints[i][0];
|
||||
currentTime = item.datapoints[i][1];
|
||||
for (let i = 0; i < item.rows.length; i++) {
|
||||
currentValue = item.rows[i][yColumn];
|
||||
currentTime = item.rows[i][xColumn];
|
||||
|
||||
if (typeof currentTime !== 'number') {
|
||||
continue;
|
||||
@@ -95,7 +114,7 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
|
||||
if (previousValue > currentValue) {
|
||||
// counter reset
|
||||
previousDeltaUp = false;
|
||||
if (i === item.datapoints.length - 1) {
|
||||
if (i === item.rows.length - 1) {
|
||||
// reset on last
|
||||
delta += currentValue;
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { PanelData, NullValueMode, SingleStatValueInfo } from '../types';
|
||||
import { processTimeSeries } from './processTimeSeries';
|
||||
|
||||
export interface SingleStatProcessingOptions {
|
||||
panelData: PanelData;
|
||||
stat: string;
|
||||
}
|
||||
|
||||
//
|
||||
// This is a temporary thing, waiting for a better data model and maybe unification between time series & table data
|
||||
//
|
||||
export function processSingleStatPanelData(options: SingleStatProcessingOptions): SingleStatValueInfo[] {
|
||||
const { panelData, stat } = options;
|
||||
|
||||
if (panelData.timeSeries) {
|
||||
const timeSeries = processTimeSeries({
|
||||
timeSeries: panelData.timeSeries,
|
||||
nullValueMode: NullValueMode.Null,
|
||||
});
|
||||
|
||||
return timeSeries.map((series, index) => {
|
||||
const value = stat !== 'name' ? series.stats[stat] : series.label;
|
||||
|
||||
return {
|
||||
value: value,
|
||||
};
|
||||
});
|
||||
} else if (panelData.tableData) {
|
||||
throw { message: 'Panel data not supported' };
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -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