Merge branch 'master' into bump-webpack

# Conflicts:
#	package.json
#	yarn.lock
This commit is contained in:
Johannes Schill
2019-03-20 09:43:33 +01:00
216 changed files with 7282 additions and 2841 deletions
+3 -3
View File
@@ -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>
@@ -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
+3 -17
View File
@@ -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;
}
+1
View File
@@ -5,3 +5,4 @@ export * from './plugin';
export * from './datasource';
export * from './theme';
export * from './threshold';
export * from './input';
+8
View File
@@ -0,0 +1,8 @@
export interface ValidationRule {
rule: (valueToValidate: string) => boolean;
errorMessage: string;
}
export interface ValidationEvents {
[eventName: string]: ValidationRule[];
}
+18 -11
View File
@@ -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;
}
}
+1
View File
@@ -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);
}
+3 -1
View File
@@ -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 [];
}
+24
View File
@@ -0,0 +1,24 @@
import { ValidationRule, ValidationEvents } from '../types/input';
export enum EventsWithValidation {
onBlur = 'onBlur',
onFocus = 'onFocus',
onChange = 'onChange',
}
export const validate = (value: string, validationRules: ValidationRule[]) => {
const errors = validationRules.reduce(
(acc, currRule) => {
if (!currRule.rule(value)) {
return acc.concat(currRule.errorMessage);
}
return acc;
},
[] as string[]
);
return errors.length > 0 ? errors : null;
};
export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents | undefined) => {
return validationEvents && validationEvents[event];
};