merge master
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { number, text } from '@storybook/addon-knobs';
|
||||
import { BarGauge } from './BarGauge';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||
|
||||
const getKnobs = () => {
|
||||
return {
|
||||
value: number('value', 70),
|
||||
minValue: number('minValue', 0),
|
||||
maxValue: number('maxValue', 100),
|
||||
threshold1Value: number('threshold1Value', 40),
|
||||
threshold1Color: text('threshold1Color', 'orange'),
|
||||
threshold2Value: number('threshold2Value', 60),
|
||||
threshold2Color: text('threshold2Color', 'red'),
|
||||
unit: text('unit', 'ms'),
|
||||
decimals: number('decimals', 1),
|
||||
};
|
||||
};
|
||||
|
||||
const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module);
|
||||
|
||||
BarGaugeStories.addDecorator(withCenteredStory);
|
||||
|
||||
BarGaugeStories.add('Vertical, with basic thresholds', () => {
|
||||
const {
|
||||
value,
|
||||
minValue,
|
||||
maxValue,
|
||||
threshold1Color,
|
||||
threshold2Color,
|
||||
threshold1Value,
|
||||
threshold2Value,
|
||||
unit,
|
||||
decimals,
|
||||
} = getKnobs();
|
||||
|
||||
return renderComponentWithTheme(BarGauge, {
|
||||
width: 200,
|
||||
height: 400,
|
||||
value: value,
|
||||
minValue: minValue,
|
||||
maxValue: maxValue,
|
||||
unit: unit,
|
||||
prefix: '',
|
||||
postfix: '',
|
||||
decimals: decimals,
|
||||
thresholds: [
|
||||
{ index: 0, value: -Infinity, color: 'green' },
|
||||
{ index: 1, value: threshold1Value, color: threshold1Color },
|
||||
{ index: 1, value: threshold2Value, color: threshold2Color },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { BarGauge, Props } from './BarGauge';
|
||||
import { VizOrientation } from '../../types';
|
||||
import { getTheme } from '../../themes';
|
||||
|
||||
jest.mock('jquery', () => ({
|
||||
plot: jest.fn(),
|
||||
}));
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
maxValue: 100,
|
||||
valueMappings: [],
|
||||
minValue: 0,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
|
||||
unit: 'none',
|
||||
height: 300,
|
||||
width: 300,
|
||||
value: 25,
|
||||
decimals: 0,
|
||||
theme: getTheme(),
|
||||
orientation: VizOrientation.Horizontal,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<BarGauge {...props} />);
|
||||
const instance = wrapper.instance() as BarGauge;
|
||||
|
||||
return {
|
||||
instance,
|
||||
wrapper,
|
||||
};
|
||||
};
|
||||
|
||||
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.getValueColors().value).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: 10, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(instance.getValueColors().value).toEqual('#EAB839');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Render BarGauge with basic options', () => {
|
||||
it('should render', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// Library
|
||||
import React, { PureComponent, CSSProperties } from 'react';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
// Utils
|
||||
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
|
||||
|
||||
// Types
|
||||
import { Themeable, TimeSeriesValue, Threshold, ValueMapping, 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;
|
||||
maxValue: number;
|
||||
minValue: number;
|
||||
orientation: VizOrientation;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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',
|
||||
orientation: VizOrientation.Horizontal,
|
||||
thresholds: [],
|
||||
valueMappings: [],
|
||||
};
|
||||
|
||||
getNumericValue(): number {
|
||||
if (Number.isFinite(this.props.value as number)) {
|
||||
return this.props.value as number;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
getValueColors(): BarColors {
|
||||
const { thresholds, theme, value } = this.props;
|
||||
|
||||
const activeThreshold = getThresholdForValue(thresholds, value);
|
||||
|
||||
if (activeThreshold !== null) {
|
||||
const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
|
||||
|
||||
return {
|
||||
value: color,
|
||||
border: color,
|
||||
bar: tinycolor(color)
|
||||
.setAlpha(0.3)
|
||||
.toRgbString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: getColorFromHexRgbOrName('gray', theme.type),
|
||||
bar: 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);
|
||||
|
||||
return {
|
||||
color: color,
|
||||
fontSize: fontSize + 'px',
|
||||
};
|
||||
}
|
||||
|
||||
renderVerticalBar(valueFormatted: string, valuePercent: number) {
|
||||
const { height, width } = this.props;
|
||||
|
||||
const maxHeight = height * BAR_SIZE_RATIO;
|
||||
const barHeight = Math.max(valuePercent * maxHeight, 0);
|
||||
const colors = this.getValueColors();
|
||||
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width);
|
||||
|
||||
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}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyles}>
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
</div>
|
||||
<div style={barStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderHorizontalBar(valueFormatted: string, valuePercent: number) {
|
||||
const { height, width } = this.props;
|
||||
|
||||
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));
|
||||
|
||||
valueStyles.marginLeft = '8px';
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const valueRange = maxValue - minValue;
|
||||
const maxWidth = width * BAR_SIZE_RATIO;
|
||||
const cellSpacing = 4;
|
||||
const cellCount = 30;
|
||||
const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount;
|
||||
const colors = this.getValueColors();
|
||||
const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
|
||||
valueStyles.marginLeft = '8px';
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
cells.push(<div style={cellStyles} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyles}>
|
||||
{cells}
|
||||
<div className="bar-gauge__value" style={valueStyles}>
|
||||
{valueFormatted}
|
||||
</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;
|
||||
border: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.bar-gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bar-gauge__value {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render BarGauge with basic options should render 1`] = `
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"display": "flex",
|
||||
"flexDirection": "row",
|
||||
"height": "300px",
|
||||
"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",
|
||||
}
|
||||
}
|
||||
>
|
||||
25
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -50,7 +50,16 @@ ColorPickerStories.add('Series color picker', () => {
|
||||
color={selectedColor}
|
||||
onChange={color => updateSelectedColor(color)}
|
||||
>
|
||||
<div style={{ color: selectedColor, cursor: 'pointer' }}>Open color picker</div>
|
||||
{({ ref, showColorPicker, hideColorPicker }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseLeave={hideColorPicker}
|
||||
onClick={showColorPicker}
|
||||
style={{ color: selectedColor, cursor: 'pointer' }}
|
||||
>
|
||||
Open color picker
|
||||
</div>
|
||||
)}
|
||||
</SeriesColorPicker>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { ColorPickerTrigger } from './ColorPickerTrigger';
|
||||
|
||||
describe('ColorPicker', () => {
|
||||
it('renders ColorPickerTrigger component by default', () => {
|
||||
expect(
|
||||
renderer.create(<ColorPicker color="#EAB839" onChange={() => {}} />).root.findByType(ColorPickerTrigger)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders custom trigger when supplied', () => {
|
||||
const div = renderer
|
||||
.create(
|
||||
<ColorPicker color="#EAB839" onChange={() => {}}>
|
||||
{() => <div>Custom trigger</div>}
|
||||
</ColorPicker>
|
||||
)
|
||||
.root.findByType('div');
|
||||
expect(div.children[0]).toBe('Custom trigger');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { Component, createRef } from 'react';
|
||||
import { omit } from 'lodash';
|
||||
import { PopperController } from '../Tooltip/PopperController';
|
||||
import { Popper } from '../Tooltip/Popper';
|
||||
import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover';
|
||||
@@ -6,14 +7,29 @@ import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
||||
|
||||
import { withTheme } from '../../themes/ThemeContext';
|
||||
import { ColorPickerTrigger } from './ColorPickerTrigger';
|
||||
|
||||
/**
|
||||
* If you need custom trigger for the color picker you can do that with a render prop pattern and supply a function
|
||||
* as a child. You will get show/hide function which you can map to desired interaction (like onClick or onMouseLeave)
|
||||
* and a ref which needs to be passed to an HTMLElement for correct positioning. If you want to use class or functional
|
||||
* component as a custom trigger you will need to forward the reference to first HTMLElement child.
|
||||
*/
|
||||
type ColorPickerTriggerRenderer = (props: {
|
||||
// This should be a React.RefObject<HTMLElement> but due to how object refs are defined you cannot downcast from that
|
||||
// to a specific type like React.RefObject<HTMLDivElement> even though it would be fine in runtime.
|
||||
ref: React.RefObject<any>;
|
||||
showColorPicker: () => void;
|
||||
hideColorPicker: () => void;
|
||||
}) => React.ReactNode;
|
||||
|
||||
export const colorPickerFactory = <T extends ColorPickerProps>(
|
||||
popover: React.ComponentType<T>,
|
||||
displayName = 'ColorPicker'
|
||||
) => {
|
||||
return class ColorPicker extends Component<T, any> {
|
||||
return class ColorPicker extends Component<T & { children?: ColorPickerTriggerRenderer }, any> {
|
||||
static displayName = displayName;
|
||||
pickerTriggerRef = createRef<HTMLDivElement>();
|
||||
pickerTriggerRef = createRef<any>();
|
||||
|
||||
onColorChange = (color: string) => {
|
||||
const { onColorChange, onChange } = this.props;
|
||||
@@ -23,11 +39,11 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme, children } = this.props;
|
||||
const popoverElement = React.createElement(popover, {
|
||||
...this.props,
|
||||
...omit(this.props, 'children'),
|
||||
onChange: this.onColorChange,
|
||||
});
|
||||
const { theme, children } = this.props;
|
||||
|
||||
return (
|
||||
<PopperController content={popoverElement} hideAfter={300}>
|
||||
@@ -45,27 +61,21 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
||||
)}
|
||||
|
||||
{children ? (
|
||||
React.cloneElement(children as JSX.Element, {
|
||||
// Children have a bit weird type due to intersection used in the definition so we need to cast here,
|
||||
// but the definition is correct and should not allow to pass a children that does not conform to
|
||||
// ColorPickerTriggerRenderer type.
|
||||
(children as ColorPickerTriggerRenderer)({
|
||||
ref: this.pickerTriggerRef,
|
||||
onClick: showPopper,
|
||||
onMouseLeave: hidePopper,
|
||||
showColorPicker: showPopper,
|
||||
hideColorPicker: hidePopper,
|
||||
})
|
||||
) : (
|
||||
<div
|
||||
<ColorPickerTrigger
|
||||
ref={this.pickerTriggerRef}
|
||||
onClick={showPopper}
|
||||
onMouseLeave={hidePopper}
|
||||
className="sp-replacer sp-light"
|
||||
>
|
||||
<div className="sp-preview">
|
||||
<div
|
||||
className="sp-preview-inner"
|
||||
style={{
|
||||
backgroundColor: getColorFromHexRgbOrName(this.props.color || '#000000', theme.type),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
color={getColorFromHexRgbOrName(this.props.color || '#000000', theme.type)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,8 +17,8 @@ export interface ColorPickerProps extends Themeable {
|
||||
*/
|
||||
onColorChange?: ColorPickerChangeHandler;
|
||||
enableNamedColors?: boolean;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export interface Props<T> extends ColorPickerProps, PopperContentProps {
|
||||
customPickers?: T;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
interface ColorPickerTriggerProps {
|
||||
onClick: () => void;
|
||||
onMouseLeave: () => void;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const ColorPickerTrigger = forwardRef(function ColorPickerTrigger(
|
||||
props: ColorPickerTriggerProps,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={props.onClick}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
background: 'inherit',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
padding: 0,
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 15,
|
||||
height: 15,
|
||||
border: 'none',
|
||||
margin: 0,
|
||||
float: 'left',
|
||||
zIndex: 0,
|
||||
backgroundImage:
|
||||
// tslint:disable-next-line:max-line-length
|
||||
'url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: props.color,
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -161,59 +161,6 @@ $arrowSize: 15px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sp-replacer {
|
||||
background: inherit;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sp-replacer:hover,
|
||||
.sp-replacer.sp-active {
|
||||
border-color: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sp-container {
|
||||
border-radius: 0;
|
||||
background-color: $dropdownBackground;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sp-palette-container,
|
||||
.sp-picker-container {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sp-dd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sp-preview {
|
||||
position: relative;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: none;
|
||||
margin: 0;
|
||||
float: left;
|
||||
z-index: 0;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
|
||||
}
|
||||
|
||||
.sp-preview-inner,
|
||||
.sp-alpha-inner,
|
||||
.sp-thumb-inner {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.gf-color-picker__body {
|
||||
padding-bottom: $arrowSize;
|
||||
padding-left: 6px;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import $ from 'jquery';
|
||||
import { getMappedValue } from '../../utils/valueMappings';
|
||||
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||
import { Themeable, GrafanaThemeType } from '../../types/theme';
|
||||
import { ValueMapping, Threshold, BasicGaugeColor } from '../../types/panel';
|
||||
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
|
||||
|
||||
type TimeSeriesValue = string | number | null;
|
||||
import { ValueMapping, Threshold, GrafanaThemeType } from '../../types';
|
||||
import { getMappedValue } from '../../utils/valueMappings';
|
||||
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
|
||||
import { Themeable } from '../../index';
|
||||
|
||||
type GaugeValue = string | number | null;
|
||||
|
||||
export interface Props extends Themeable {
|
||||
decimals?: number | null;
|
||||
@@ -51,7 +51,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
formatValue(value: TimeSeriesValue) {
|
||||
formatValue(value: GaugeValue) {
|
||||
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
|
||||
|
||||
if (isNaN(value as number)) {
|
||||
@@ -72,26 +72,16 @@ export class Gauge extends PureComponent<Props> {
|
||||
return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
|
||||
}
|
||||
|
||||
getFontColor(value: TimeSeriesValue) {
|
||||
getFontColor(value: GaugeValue): string {
|
||||
const { thresholds, theme } = this.props;
|
||||
|
||||
if (thresholds.length === 1) {
|
||||
return getColorFromHexRgbOrName(thresholds[0].color, theme.type);
|
||||
const activeThreshold = getThresholdForValue(thresholds, value);
|
||||
|
||||
if (activeThreshold !== null) {
|
||||
return getColorFromHexRgbOrName(activeThreshold.color, theme.type);
|
||||
}
|
||||
|
||||
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||
if (atThreshold) {
|
||||
return getColorFromHexRgbOrName(atThreshold.color, theme.type);
|
||||
}
|
||||
|
||||
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||
|
||||
if (belowThreshold.length > 0) {
|
||||
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
|
||||
return getColorFromHexRgbOrName(nearestThreshold.color, theme.type);
|
||||
}
|
||||
|
||||
return BasicGaugeColor.Red;
|
||||
return '';
|
||||
}
|
||||
|
||||
getFormattedThresholds() {
|
||||
@@ -183,19 +173,15 @@ export class Gauge extends PureComponent<Props> {
|
||||
const { height, width } = this.props;
|
||||
|
||||
return (
|
||||
<div className="singlestat-panel">
|
||||
<div
|
||||
style={{
|
||||
height: `${height * 0.9}px`,
|
||||
width: `${Math.min(width, height * 1.3)}px`,
|
||||
top: '10px',
|
||||
margin: 'auto',
|
||||
}}
|
||||
ref={element => (this.canvasElement = element)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: `${Math.min(height, width * 1.3)}px`,
|
||||
width: `${Math.min(width, height * 1.3)}px`,
|
||||
top: '10px',
|
||||
margin: 'auto',
|
||||
}}
|
||||
ref={element => (this.canvasElement = element)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Gauge;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { Table } from './Table';
|
||||
import { getTheme } from '../../themes';
|
||||
|
||||
import { migratedTestTable, migratedTestStyles, simpleTable } from './examples';
|
||||
import { ScopedVars, TableData, GrafanaThemeType } from '../../types/index';
|
||||
import { withFullSizeStory } from '../../utils/storybook/withFullSizeStory';
|
||||
import { number, boolean } from '@storybook/addon-knobs';
|
||||
|
||||
const replaceVariables = (value: string, scopedVars?: ScopedVars) => {
|
||||
if (scopedVars) {
|
||||
// For testing variables replacement in link
|
||||
for (const key in scopedVars) {
|
||||
const val = scopedVars[key];
|
||||
value = value.replace('$' + key, val.value);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export function columnIndexToLeter(column: number) {
|
||||
const A = 'A'.charCodeAt(0);
|
||||
const c1 = Math.floor(column / 26);
|
||||
const c2 = column % 26;
|
||||
if (c1 > 0) {
|
||||
return String.fromCharCode(A + c1 - 1) + String.fromCharCode(A + c2);
|
||||
}
|
||||
return String.fromCharCode(A + c2);
|
||||
}
|
||||
|
||||
export function makeDummyTable(columnCount: number, rowCount: number): TableData {
|
||||
return {
|
||||
columns: Array.from(new Array(columnCount), (x, i) => {
|
||||
return {
|
||||
text: columnIndexToLeter(i),
|
||||
};
|
||||
}),
|
||||
rows: Array.from(new Array(rowCount), (x, rowId) => {
|
||||
const suffix = (rowId + 1).toString();
|
||||
return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix);
|
||||
}),
|
||||
type: 'table',
|
||||
columnMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
storiesOf('Alpha/Table', module)
|
||||
.add('Basic Table', () => {
|
||||
// NOTE: This example does not seem to survice rotate &
|
||||
// Changing fixed headers... but the next one does?
|
||||
// perhaps `simpleTable` is static and reused?
|
||||
|
||||
const showHeader = boolean('Show Header', true);
|
||||
const fixedHeader = boolean('Fixed Header', true);
|
||||
const fixedColumns = number('Fixed Columns', 0, { min: 0, max: 50, step: 1, range: false });
|
||||
const rotate = boolean('Rotate', false);
|
||||
|
||||
return withFullSizeStory(Table, {
|
||||
styles: [],
|
||||
data: simpleTable,
|
||||
replaceVariables,
|
||||
showHeader,
|
||||
fixedHeader,
|
||||
fixedColumns,
|
||||
rotate,
|
||||
theme: getTheme(GrafanaThemeType.Light),
|
||||
});
|
||||
})
|
||||
.add('Variable Size', () => {
|
||||
const columnCount = number('Column Count', 15, { min: 2, max: 50, step: 1, range: false });
|
||||
const rowCount = number('Row Count', 20, { min: 0, max: 100, step: 1, range: false });
|
||||
|
||||
const showHeader = boolean('Show Header', true);
|
||||
const fixedHeader = boolean('Fixed Header', true);
|
||||
const fixedColumns = number('Fixed Columns', 1, { min: 0, max: 50, step: 1, range: false });
|
||||
const rotate = boolean('Rotate', false);
|
||||
|
||||
return withFullSizeStory(Table, {
|
||||
styles: [],
|
||||
data: makeDummyTable(columnCount, rowCount),
|
||||
replaceVariables,
|
||||
showHeader,
|
||||
fixedHeader,
|
||||
fixedColumns,
|
||||
rotate,
|
||||
theme: getTheme(GrafanaThemeType.Light),
|
||||
});
|
||||
})
|
||||
.add('Test Config (migrated)', () => {
|
||||
return withFullSizeStory(Table, {
|
||||
styles: migratedTestStyles,
|
||||
data: migratedTestTable,
|
||||
replaceVariables,
|
||||
showHeader: true,
|
||||
rotate: true,
|
||||
theme: getTheme(GrafanaThemeType.Light),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
import React, { Component, ReactElement } from 'react';
|
||||
import {
|
||||
SortDirectionType,
|
||||
SortIndicator,
|
||||
MultiGrid,
|
||||
CellMeasurerCache,
|
||||
CellMeasurer,
|
||||
GridCellProps,
|
||||
} from 'react-virtualized';
|
||||
import { Themeable } from '../../types/theme';
|
||||
|
||||
import { sortTableData } from '../../utils/processTableData';
|
||||
|
||||
import { TableData, InterpolateFunction } from '@grafana/ui';
|
||||
import {
|
||||
TableCellBuilder,
|
||||
ColumnStyle,
|
||||
getCellBuilder,
|
||||
TableCellBuilderOptions,
|
||||
simpleCellBuilder,
|
||||
} from './TableCellBuilder';
|
||||
import { stringToJsRegex } from '../../utils/index';
|
||||
|
||||
export interface Props extends Themeable {
|
||||
data: TableData;
|
||||
|
||||
showHeader: boolean;
|
||||
fixedHeader: boolean;
|
||||
fixedColumns: number;
|
||||
rotate: boolean;
|
||||
styles: ColumnStyle[];
|
||||
|
||||
replaceVariables: InterpolateFunction;
|
||||
width: number;
|
||||
height: number;
|
||||
isUTC?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
sortBy?: number;
|
||||
sortDirection?: SortDirectionType;
|
||||
data: TableData;
|
||||
}
|
||||
|
||||
interface ColumnRenderInfo {
|
||||
header: string;
|
||||
builder: TableCellBuilder;
|
||||
}
|
||||
|
||||
interface DataIndex {
|
||||
column: number;
|
||||
row: number; // -1 is the header!
|
||||
}
|
||||
|
||||
export class Table extends Component<Props, State> {
|
||||
renderer: ColumnRenderInfo[];
|
||||
measurer: CellMeasurerCache;
|
||||
scrollToTop = false;
|
||||
|
||||
static defaultProps = {
|
||||
showHeader: true,
|
||||
fixedHeader: true,
|
||||
fixedColumns: 0,
|
||||
rotate: false,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
data: props.data,
|
||||
};
|
||||
|
||||
this.renderer = this.initColumns(props);
|
||||
this.measurer = new CellMeasurerCache({
|
||||
defaultHeight: 30,
|
||||
defaultWidth: 150,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
const { data, styles, showHeader } = this.props;
|
||||
const { sortBy, sortDirection } = this.state;
|
||||
const dataChanged = data !== prevProps.data;
|
||||
const configsChanged =
|
||||
showHeader !== prevProps.showHeader ||
|
||||
this.props.rotate !== prevProps.rotate ||
|
||||
this.props.fixedColumns !== prevProps.fixedColumns ||
|
||||
this.props.fixedHeader !== prevProps.fixedHeader;
|
||||
|
||||
// Reset the size cache
|
||||
if (dataChanged || configsChanged) {
|
||||
this.measurer.clearAll();
|
||||
}
|
||||
|
||||
// Update the renderer if options change
|
||||
// We only *need* do to this if the header values changes, but this does every data update
|
||||
if (dataChanged || styles !== prevProps.styles) {
|
||||
this.renderer = this.initColumns(this.props);
|
||||
}
|
||||
|
||||
// Update the data when data or sort changes
|
||||
if (dataChanged || sortBy !== prevState.sortBy || sortDirection !== prevState.sortDirection) {
|
||||
this.scrollToTop = true;
|
||||
this.setState({ data: sortTableData(data, sortBy, sortDirection === 'DESC') });
|
||||
}
|
||||
}
|
||||
|
||||
/** Given the configuration, setup how each column gets rendered */
|
||||
initColumns(props: Props): ColumnRenderInfo[] {
|
||||
const { styles, data } = props;
|
||||
|
||||
return data.columns.map((col, index) => {
|
||||
let title = col.text;
|
||||
let style: ColumnStyle | null = null; // ColumnStyle
|
||||
|
||||
// Find the style based on the text
|
||||
for (let i = 0; i < styles.length; i++) {
|
||||
const s = styles[i];
|
||||
const regex = stringToJsRegex(s.pattern);
|
||||
if (title.match(regex)) {
|
||||
style = s;
|
||||
if (s.alias) {
|
||||
title = title.replace(regex, s.alias);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
header: title,
|
||||
builder: getCellBuilder(col, style, this.props),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
doSort = (columnIndex: number) => {
|
||||
let sort: any = this.state.sortBy;
|
||||
let dir = this.state.sortDirection;
|
||||
if (sort !== columnIndex) {
|
||||
dir = 'DESC';
|
||||
sort = columnIndex;
|
||||
} else if (dir === 'DESC') {
|
||||
dir = 'ASC';
|
||||
} else {
|
||||
sort = null;
|
||||
}
|
||||
this.setState({ sortBy: sort, sortDirection: dir });
|
||||
};
|
||||
|
||||
/** Converts the grid coordinates to TableData coordinates */
|
||||
getCellRef = (rowIndex: number, columnIndex: number): DataIndex => {
|
||||
const { showHeader, rotate } = this.props;
|
||||
const rowOffset = showHeader ? -1 : 0;
|
||||
|
||||
if (rotate) {
|
||||
return { column: rowIndex, row: columnIndex + rowOffset };
|
||||
} else {
|
||||
return { column: columnIndex, row: rowIndex + rowOffset };
|
||||
}
|
||||
};
|
||||
|
||||
onCellClick = (rowIndex: number, columnIndex: number) => {
|
||||
const { row, column } = this.getCellRef(rowIndex, columnIndex);
|
||||
if (row < 0) {
|
||||
this.doSort(column);
|
||||
} else {
|
||||
const values = this.state.data.rows[row];
|
||||
const value = values[column];
|
||||
console.log('CLICK', value, row);
|
||||
}
|
||||
};
|
||||
|
||||
headerBuilder = (cell: TableCellBuilderOptions): ReactElement<'div'> => {
|
||||
const { data, sortBy, sortDirection } = this.state;
|
||||
const { columnIndex, rowIndex, style } = cell.props;
|
||||
const { column } = this.getCellRef(rowIndex, columnIndex);
|
||||
|
||||
let col = data.columns[column];
|
||||
const sorting = sortBy === column;
|
||||
if (!col) {
|
||||
col = {
|
||||
text: '??' + columnIndex + '???',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gf-table-header" style={style} onClick={() => this.onCellClick(rowIndex, columnIndex)}>
|
||||
{col.text}
|
||||
{sorting && <SortIndicator sortDirection={sortDirection} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
getTableCellBuilder = (column: number): TableCellBuilder => {
|
||||
const render = this.renderer[column];
|
||||
if (render && render.builder) {
|
||||
return render.builder;
|
||||
}
|
||||
return simpleCellBuilder; // the default
|
||||
};
|
||||
|
||||
cellRenderer = (props: GridCellProps): React.ReactNode => {
|
||||
const { rowIndex, columnIndex, key, parent } = props;
|
||||
const { row, column } = this.getCellRef(rowIndex, columnIndex);
|
||||
const { data } = this.state;
|
||||
|
||||
const isHeader = row < 0;
|
||||
const rowData = isHeader ? data.columns : data.rows[row];
|
||||
const value = rowData ? rowData[column] : '';
|
||||
const builder = isHeader ? this.headerBuilder : this.getTableCellBuilder(column);
|
||||
|
||||
return (
|
||||
<CellMeasurer cache={this.measurer} columnIndex={columnIndex} key={key} parent={parent} rowIndex={rowIndex}>
|
||||
{builder({
|
||||
value,
|
||||
row: rowData,
|
||||
column: data.columns[column],
|
||||
table: this,
|
||||
props,
|
||||
})}
|
||||
</CellMeasurer>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props;
|
||||
const { data } = this.state;
|
||||
|
||||
let columnCount = data.columns.length;
|
||||
let rowCount = data.rows.length + (showHeader ? 1 : 0);
|
||||
|
||||
let fixedColumnCount = Math.min(fixedColumns, columnCount);
|
||||
let fixedRowCount = showHeader && fixedHeader ? 1 : 0;
|
||||
|
||||
if (rotate) {
|
||||
const temp = columnCount;
|
||||
columnCount = rowCount;
|
||||
rowCount = temp;
|
||||
|
||||
fixedRowCount = 0;
|
||||
fixedColumnCount = Math.min(fixedColumns, rowCount) + (showHeader && fixedHeader ? 1 : 0);
|
||||
}
|
||||
|
||||
// Called after sort or the data changes
|
||||
const scroll = this.scrollToTop ? 1 : -1;
|
||||
const scrollToRow = rotate ? -1 : scroll;
|
||||
const scrollToColumn = rotate ? scroll : -1;
|
||||
if (this.scrollToTop) {
|
||||
this.scrollToTop = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiGrid
|
||||
{
|
||||
...this.state /** Force MultiGrid to update when data changes */
|
||||
}
|
||||
{
|
||||
...this.props /** Force MultiGrid to update when data changes */
|
||||
}
|
||||
scrollToRow={scrollToRow}
|
||||
columnCount={columnCount}
|
||||
scrollToColumn={scrollToColumn}
|
||||
rowCount={rowCount}
|
||||
overscanColumnCount={8}
|
||||
overscanRowCount={8}
|
||||
columnWidth={this.measurer.columnWidth}
|
||||
deferredMeasurementCache={this.measurer}
|
||||
cellRenderer={this.cellRenderer}
|
||||
rowHeight={this.measurer.rowHeight}
|
||||
width={width}
|
||||
height={height}
|
||||
fixedColumnCount={fixedColumnCount}
|
||||
fixedRowCount={fixedRowCount}
|
||||
classNameTopLeftGrid="gf-table-fixed-column"
|
||||
classNameBottomLeftGrid="gf-table-fixed-column"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Table;
|
||||
@@ -0,0 +1,291 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { GridCellProps } from 'react-virtualized';
|
||||
import { Table, Props } from './Table';
|
||||
import moment from 'moment';
|
||||
import { ValueFormatter } from '../../utils/index';
|
||||
import { GrafanaTheme } from '../../types/theme';
|
||||
import { getValueFormat, getColorFromHexRgbOrName, Column } from '@grafana/ui';
|
||||
import { InterpolateFunction } from '../../types/panel';
|
||||
|
||||
export interface TableCellBuilderOptions {
|
||||
value: any;
|
||||
column?: Column;
|
||||
row?: any[];
|
||||
table?: Table;
|
||||
className?: string;
|
||||
props: GridCellProps;
|
||||
}
|
||||
|
||||
export type TableCellBuilder = (cell: TableCellBuilderOptions) => ReactElement<'div'>;
|
||||
|
||||
/** Simplest cell that just spits out the value */
|
||||
export const simpleCellBuilder: TableCellBuilder = (cell: TableCellBuilderOptions) => {
|
||||
const { props, value, className } = cell;
|
||||
const { style } = props;
|
||||
|
||||
return (
|
||||
<div style={style} className={'gf-table-cell ' + className}>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ***************************************************************************
|
||||
// HERE BE DRAGONS!!!
|
||||
// ***************************************************************************
|
||||
//
|
||||
// The following code has been migrated blindy two times from the angular
|
||||
// table panel. I don't understand all the options nor do I know if they
|
||||
// are correct!
|
||||
//
|
||||
// ***************************************************************************
|
||||
|
||||
// Made to match the existing (untyped) settings in the angular table
|
||||
export interface ColumnStyle {
|
||||
pattern: string;
|
||||
|
||||
alias?: string;
|
||||
colorMode?: 'cell' | 'value';
|
||||
colors?: any[];
|
||||
decimals?: number;
|
||||
thresholds?: any[];
|
||||
type?: 'date' | 'number' | 'string' | 'hidden';
|
||||
unit?: string;
|
||||
dateFormat?: string;
|
||||
sanitize?: boolean; // not used in react
|
||||
mappingType?: any;
|
||||
valueMaps?: any;
|
||||
rangeMaps?: any;
|
||||
|
||||
link?: any;
|
||||
linkUrl?: any;
|
||||
linkTooltip?: any;
|
||||
linkTargetBlank?: boolean;
|
||||
|
||||
preserveFormat?: boolean;
|
||||
}
|
||||
|
||||
// private mapper:ValueMapper,
|
||||
// private style:ColumnStyle,
|
||||
// private theme:GrafanaTheme,
|
||||
// private column:Column,
|
||||
// private replaceVariables: InterpolateFunction,
|
||||
// private fmt?:ValueFormatter) {
|
||||
|
||||
export function getCellBuilder(schema: Column, style: ColumnStyle | null, props: Props): TableCellBuilder {
|
||||
if (!style) {
|
||||
return simpleCellBuilder;
|
||||
}
|
||||
|
||||
if (style.type === 'hidden') {
|
||||
// TODO -- for hidden, we either need to:
|
||||
// 1. process the Table and remove hidden fields
|
||||
// 2. do special math to pick the right column skipping hidden fields
|
||||
throw new Error('hidden not supported!');
|
||||
}
|
||||
|
||||
if (style.type === 'date') {
|
||||
return new CellBuilderWithStyle(
|
||||
(v: any) => {
|
||||
if (v === undefined || v === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (_.isArray(v)) {
|
||||
v = v[0];
|
||||
}
|
||||
let date = moment(v);
|
||||
if (false) {
|
||||
// TODO?????? this.props.isUTC) {
|
||||
date = date.utc();
|
||||
}
|
||||
return date.format(style.dateFormat);
|
||||
},
|
||||
style,
|
||||
props.theme,
|
||||
schema,
|
||||
props.replaceVariables
|
||||
).build;
|
||||
}
|
||||
|
||||
if (style.type === 'string') {
|
||||
return new CellBuilderWithStyle(
|
||||
(v: any) => {
|
||||
if (_.isArray(v)) {
|
||||
v = v.join(', ');
|
||||
}
|
||||
return v;
|
||||
},
|
||||
style,
|
||||
props.theme,
|
||||
schema,
|
||||
props.replaceVariables
|
||||
).build;
|
||||
// TODO!!!! all the mapping stuff!!!!
|
||||
}
|
||||
|
||||
if (style.type === 'number') {
|
||||
const valueFormatter = getValueFormat(style.unit || schema.unit || 'none');
|
||||
return new CellBuilderWithStyle(
|
||||
(v: any) => {
|
||||
if (v === null || v === void 0) {
|
||||
return '-';
|
||||
}
|
||||
return v;
|
||||
},
|
||||
style,
|
||||
props.theme,
|
||||
schema,
|
||||
props.replaceVariables,
|
||||
valueFormatter
|
||||
).build;
|
||||
}
|
||||
|
||||
return simpleCellBuilder;
|
||||
}
|
||||
|
||||
type ValueMapper = (value: any) => any;
|
||||
|
||||
// Runs the value through a formatter and adds colors to the cell properties
|
||||
class CellBuilderWithStyle {
|
||||
constructor(
|
||||
private mapper: ValueMapper,
|
||||
private style: ColumnStyle,
|
||||
private theme: GrafanaTheme,
|
||||
private column: Column,
|
||||
private replaceVariables: InterpolateFunction,
|
||||
private fmt?: ValueFormatter
|
||||
) {
|
||||
//
|
||||
console.log('COLUMN', column.text, theme);
|
||||
}
|
||||
|
||||
getColorForValue = (value: any): string | null => {
|
||||
const { thresholds, colors } = this.style;
|
||||
if (!thresholds || !colors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = thresholds.length; i > 0; i--) {
|
||||
if (value >= thresholds[i - 1]) {
|
||||
return getColorFromHexRgbOrName(colors[i], this.theme.type);
|
||||
}
|
||||
}
|
||||
return getColorFromHexRgbOrName(_.first(colors), this.theme.type);
|
||||
};
|
||||
|
||||
build = (cell: TableCellBuilderOptions) => {
|
||||
let { props } = cell;
|
||||
let value = this.mapper(cell.value);
|
||||
|
||||
if (_.isNumber(value)) {
|
||||
if (this.fmt) {
|
||||
value = this.fmt(value, this.style.decimals);
|
||||
}
|
||||
|
||||
// For numeric values set the color
|
||||
const { colorMode } = this.style;
|
||||
if (colorMode) {
|
||||
const color = this.getColorForValue(Number(value));
|
||||
if (color) {
|
||||
if (colorMode === 'cell') {
|
||||
props = {
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
backgroundColor: color,
|
||||
color: 'white',
|
||||
},
|
||||
};
|
||||
} else if (colorMode === 'value') {
|
||||
props = {
|
||||
...props,
|
||||
style: {
|
||||
...props.style,
|
||||
color: color,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cellClasses = [];
|
||||
if (this.style.preserveFormat) {
|
||||
cellClasses.push('table-panel-cell-pre');
|
||||
}
|
||||
|
||||
if (this.style.link) {
|
||||
// Render cell as link
|
||||
const { row } = cell;
|
||||
|
||||
const scopedVars: any = {};
|
||||
if (row) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
scopedVars[`__cell_${i}`] = { value: row[i] };
|
||||
}
|
||||
}
|
||||
scopedVars['__cell'] = { value: value };
|
||||
|
||||
const cellLink = this.replaceVariables(this.style.linkUrl, scopedVars, encodeURIComponent);
|
||||
const cellLinkTooltip = this.replaceVariables(this.style.linkTooltip, scopedVars);
|
||||
const cellTarget = this.style.linkTargetBlank ? '_blank' : '';
|
||||
|
||||
cellClasses.push('table-panel-cell-link');
|
||||
value = (
|
||||
<a
|
||||
href={cellLink}
|
||||
target={cellTarget}
|
||||
data-link-tooltip
|
||||
data-original-title={cellLinkTooltip}
|
||||
data-placement="right"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ??? I don't think this will still work!
|
||||
if (this.column.filterable) {
|
||||
cellClasses.push('table-panel-cell-filterable');
|
||||
value = (
|
||||
<>
|
||||
{value}
|
||||
<span>
|
||||
<a
|
||||
className="table-panel-filter-link"
|
||||
data-link-tooltip
|
||||
data-original-title="Filter out value"
|
||||
data-placement="bottom"
|
||||
data-row={props.rowIndex}
|
||||
data-column={props.columnIndex}
|
||||
data-operator="!="
|
||||
>
|
||||
<i className="fa fa-search-minus" />
|
||||
</a>
|
||||
<a
|
||||
className="table-panel-filter-link"
|
||||
data-link-tooltip
|
||||
data-original-title="Filter for value"
|
||||
data-placement="bottom"
|
||||
data-row={props.rowIndex}
|
||||
data-column={props.columnIndex}
|
||||
data-operator="="
|
||||
>
|
||||
<i className="fa fa-search-plus" />
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let className;
|
||||
if (cellClasses.length) {
|
||||
className = cellClasses.join(' ');
|
||||
}
|
||||
|
||||
return simpleCellBuilder({ value, props, className });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// .ReactVirtualized__Table {
|
||||
// }
|
||||
|
||||
// .ReactVirtualized__Table__Grid {
|
||||
// }
|
||||
|
||||
.ReactVirtualized__Table__headerRow {
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: left;
|
||||
}
|
||||
.ReactVirtualized__Table__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
border-bottom: 2px solid $body-bg;
|
||||
}
|
||||
|
||||
.ReactVirtualized__Table__headerTruncatedText {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ReactVirtualized__Table__headerColumn,
|
||||
.ReactVirtualized__Table__rowColumn {
|
||||
margin-right: 10px;
|
||||
min-width: 0px;
|
||||
}
|
||||
|
||||
.ReactVirtualized__Table__headerColumn:first-of-type,
|
||||
.ReactVirtualized__Table__rowColumn:first-of-type {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.ReactVirtualized__Table__sortableHeaderColumn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ReactVirtualized__Table__sortableHeaderIconContainer {
|
||||
align-items: center;
|
||||
}
|
||||
.ReactVirtualized__Table__sortableHeaderIcon {
|
||||
flex: 0 0 24px;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.gf-table-header {
|
||||
padding: 3px 10px;
|
||||
|
||||
background: $list-item-bg;
|
||||
border-top: 2px solid $body-bg;
|
||||
border-bottom: 2px solid $body-bg;
|
||||
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.gf-table-cell {
|
||||
padding: 3px 10px;
|
||||
|
||||
background: $page-gradient;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
border-right: 2px solid $body-bg;
|
||||
border-bottom: 2px solid $body-bg;
|
||||
}
|
||||
|
||||
.gf-table-fixed-column {
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { TableData } from '../../types/data';
|
||||
import { ColumnStyle } from './TableCellBuilder';
|
||||
|
||||
import { getColorDefinitionByName } from '@grafana/ui';
|
||||
|
||||
const SemiDarkOrange = getColorDefinitionByName('semi-dark-orange');
|
||||
|
||||
export const migratedTestTable = {
|
||||
type: 'table',
|
||||
columns: [
|
||||
{ text: 'Time' },
|
||||
{ text: 'Value' },
|
||||
{ text: 'Colored' },
|
||||
{ text: 'Undefined' },
|
||||
{ text: 'String' },
|
||||
{ text: 'United', unit: 'bps' },
|
||||
{ text: 'Sanitized' },
|
||||
{ text: 'Link' },
|
||||
{ text: 'Array' },
|
||||
{ text: 'Mapping' },
|
||||
{ text: 'RangeMapping' },
|
||||
{ text: 'MappingColored' },
|
||||
{ text: 'RangeMappingColored' },
|
||||
],
|
||||
rows: [[1388556366666, 1230, 40, undefined, '', '', 'my.host.com', 'host1', ['value1', 'value2'], 1, 2, 1, 2]],
|
||||
} as TableData;
|
||||
|
||||
export const migratedTestStyles: ColumnStyle[] = [
|
||||
{
|
||||
pattern: 'Time',
|
||||
type: 'date',
|
||||
alias: 'Timestamp',
|
||||
},
|
||||
{
|
||||
pattern: '/(Val)ue/',
|
||||
type: 'number',
|
||||
unit: 'ms',
|
||||
decimals: 3,
|
||||
alias: '$1',
|
||||
},
|
||||
{
|
||||
pattern: 'Colored',
|
||||
type: 'number',
|
||||
unit: 'none',
|
||||
decimals: 1,
|
||||
colorMode: 'value',
|
||||
thresholds: [50, 80],
|
||||
colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'],
|
||||
},
|
||||
{
|
||||
pattern: 'String',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
pattern: 'String',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
pattern: 'United',
|
||||
type: 'number',
|
||||
unit: 'ms',
|
||||
decimals: 2,
|
||||
},
|
||||
{
|
||||
pattern: 'Sanitized',
|
||||
type: 'string',
|
||||
sanitize: true,
|
||||
},
|
||||
{
|
||||
pattern: 'Link',
|
||||
type: 'string',
|
||||
link: true,
|
||||
linkUrl: '/dashboard?param=$__cell¶m_1=$__cell_1¶m_2=$__cell_2',
|
||||
linkTooltip: '$__cell $__cell_1 $__cell_6',
|
||||
linkTargetBlank: true,
|
||||
},
|
||||
{
|
||||
pattern: 'Array',
|
||||
type: 'number',
|
||||
unit: 'ms',
|
||||
decimals: 3,
|
||||
},
|
||||
{
|
||||
pattern: 'Mapping',
|
||||
type: 'string',
|
||||
mappingType: 1,
|
||||
valueMaps: [
|
||||
{
|
||||
value: '1',
|
||||
text: 'on',
|
||||
},
|
||||
{
|
||||
value: '0',
|
||||
text: 'off',
|
||||
},
|
||||
{
|
||||
value: 'HELLO WORLD',
|
||||
text: 'HELLO GRAFANA',
|
||||
},
|
||||
{
|
||||
value: 'value1, value2',
|
||||
text: 'value3, value4',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: 'RangeMapping',
|
||||
type: 'string',
|
||||
mappingType: 2,
|
||||
rangeMaps: [
|
||||
{
|
||||
from: '1',
|
||||
to: '3',
|
||||
text: 'on',
|
||||
},
|
||||
{
|
||||
from: '3',
|
||||
to: '6',
|
||||
text: 'off',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: 'MappingColored',
|
||||
type: 'string',
|
||||
mappingType: 1,
|
||||
valueMaps: [
|
||||
{
|
||||
value: '1',
|
||||
text: 'on',
|
||||
},
|
||||
{
|
||||
value: '0',
|
||||
text: 'off',
|
||||
},
|
||||
],
|
||||
colorMode: 'value',
|
||||
thresholds: [1, 2],
|
||||
colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'],
|
||||
},
|
||||
{
|
||||
pattern: 'RangeMappingColored',
|
||||
type: 'string',
|
||||
mappingType: 2,
|
||||
rangeMaps: [
|
||||
{
|
||||
from: '1',
|
||||
to: '3',
|
||||
text: 'on',
|
||||
},
|
||||
{
|
||||
from: '3',
|
||||
to: '6',
|
||||
text: 'off',
|
||||
},
|
||||
],
|
||||
colorMode: 'value',
|
||||
thresholds: [2, 5],
|
||||
colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'],
|
||||
},
|
||||
];
|
||||
|
||||
export const simpleTable = {
|
||||
type: 'table',
|
||||
columns: [{ text: 'First' }, { text: 'Second' }, { text: 'Third' }],
|
||||
rows: [[701, 205, 305], [702, 206, 301], [703, 207, 304]],
|
||||
};
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
.thresholds-row-input-inner-value > input {
|
||||
height: $gf-form-input-height;
|
||||
height: $input-height;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
width: 150px;
|
||||
border-top: 1px solid $input-label-border-color;
|
||||
@@ -86,7 +86,6 @@
|
||||
|
||||
.thresholds-row-input-inner-color-colorpicker {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
@@ -96,7 +95,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: $gf-form-input-height;
|
||||
height: $input-height;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
width: 42px;
|
||||
background-color: $input-label-bg;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { SingleStatValueInfo, VizOrientation } from '../../types';
|
||||
|
||||
interface RenderProps {
|
||||
vizWidth: number;
|
||||
vizHeight: number;
|
||||
valueInfo: SingleStatValueInfo;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: (renderProps: RenderProps) => JSX.Element | JSX.Element[];
|
||||
height: number;
|
||||
width: number;
|
||||
values: SingleStatValueInfo[];
|
||||
orientation: VizOrientation;
|
||||
}
|
||||
|
||||
const SPACE_BETWEEN = 10;
|
||||
|
||||
export class VizRepeater extends PureComponent<Props> {
|
||||
getOrientation(): VizOrientation {
|
||||
const { orientation, width, height } = this.props;
|
||||
|
||||
if (orientation === VizOrientation.Auto) {
|
||||
if (width > height) {
|
||||
return VizOrientation.Vertical;
|
||||
} else {
|
||||
return VizOrientation.Horizontal;
|
||||
}
|
||||
}
|
||||
|
||||
return orientation;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, height, values, width } = this.props;
|
||||
const orientation = this.getOrientation();
|
||||
|
||||
const itemStyles: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
};
|
||||
|
||||
const repeaterStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
};
|
||||
|
||||
let vizHeight = height;
|
||||
let vizWidth = width;
|
||||
|
||||
if (orientation === VizOrientation.Horizontal) {
|
||||
repeaterStyle.flexDirection = 'column';
|
||||
itemStyles.margin = `${SPACE_BETWEEN / 2}px 0`;
|
||||
vizWidth = width;
|
||||
vizHeight = height / values.length - SPACE_BETWEEN;
|
||||
} else {
|
||||
repeaterStyle.flexDirection = 'row';
|
||||
itemStyles.margin = `0 ${SPACE_BETWEEN / 2}px`;
|
||||
vizHeight = height;
|
||||
vizWidth = width / values.length - SPACE_BETWEEN;
|
||||
}
|
||||
|
||||
itemStyles.width = `${vizWidth}px`;
|
||||
itemStyles.height = `${vizHeight}px`;
|
||||
|
||||
return (
|
||||
<div style={repeaterStyle}>
|
||||
{values.map((valueInfo, index) => {
|
||||
return (
|
||||
<div key={index} style={itemStyles}>
|
||||
{children({ vizHeight, vizWidth, valueInfo })}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import 'CustomScrollbar/CustomScrollbar';
|
||||
@import 'DeleteButton/DeleteButton';
|
||||
@import 'ThresholdsEditor/ThresholdsEditor';
|
||||
@import 'Table/Table';
|
||||
@import 'Table/TableInputCSV';
|
||||
@import 'Tooltip/Tooltip';
|
||||
@import 'Select/Select';
|
||||
@@ -10,3 +11,4 @@
|
||||
@import 'ValueMappingsEditor/ValueMappingsEditor';
|
||||
@import 'EmptySearchResult/EmptySearchResult';
|
||||
@import 'FormField/FormField';
|
||||
@import 'BarGauge/BarGauge';
|
||||
|
||||
@@ -19,11 +19,15 @@ export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
||||
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
|
||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Switch } from './Switch/Switch';
|
||||
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
|
||||
export { UnitPicker } from './UnitPicker/UnitPicker';
|
||||
|
||||
// Visualizations
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { BarGauge } from './BarGauge/BarGauge';
|
||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||
|
||||
@@ -52,7 +52,6 @@ $spacers: (
|
||||
),
|
||||
),
|
||||
) !default;
|
||||
$border-width: ${theme.border.width.sm} !default;
|
||||
|
||||
// Grid breakpoints
|
||||
//
|
||||
@@ -83,16 +82,13 @@ $container-max-widths: (
|
||||
// Set the number of columns and specify the width of the gutters.
|
||||
|
||||
$grid-columns: 12 !default;
|
||||
$grid-gutter-width: 30px !default;
|
||||
|
||||
$enable-flex: true;
|
||||
$grid-gutter-width: ${theme.spacing.gutter} !default;
|
||||
|
||||
// Typography
|
||||
// -------------------------
|
||||
|
||||
$font-family-sans-serif: ${theme.typography.fontFamily.sansSerif};
|
||||
$font-family-monospace: ${theme.typography.fontFamily.monospace};
|
||||
$font-family-base: $font-family-sans-serif !default;
|
||||
|
||||
$font-size-root: ${theme.typography.size.root} !default;
|
||||
$font-size-base: ${theme.typography.size.base} !default;
|
||||
@@ -103,7 +99,9 @@ $font-size-sm: ${theme.typography.size.sm} !default;
|
||||
$font-size-xs: ${theme.typography.size.xs} !default;
|
||||
|
||||
$line-height-base: ${theme.typography.lineHeight.lg} !default;
|
||||
$font-weight-semi-bold: ${theme.typography.weight.semibold};
|
||||
|
||||
$font-weight-regular: ${theme.typography.weight.regular} !default;
|
||||
$font-weight-semi-bold: ${theme.typography.weight.semibold} !default;
|
||||
|
||||
$font-size-h1: ${theme.typography.heading.h1} !default;
|
||||
$font-size-h2: ${theme.typography.heading.h2} !default;
|
||||
@@ -113,22 +111,17 @@ $font-size-h5: ${theme.typography.heading.h5} !default;
|
||||
$font-size-h6: ${theme.typography.heading.h6} !default;
|
||||
|
||||
$headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
$headings-font-weight: ${theme.typography.weight.normal} !default;
|
||||
$headings-line-height: ${theme.typography.lineHeight.sm} !default;
|
||||
|
||||
$hr-border-width: $border-width !default;
|
||||
$dt-font-weight: bold !default;
|
||||
|
||||
// Components
|
||||
//
|
||||
// Define common padding and border radius sizes and more.
|
||||
|
||||
$line-height-lg: (4 / 3) !default;
|
||||
$line-height-sm: 1.5 !default;
|
||||
$border-width: ${theme.border.width.sm} !default;
|
||||
|
||||
$border-radius: 3px !default;
|
||||
$border-radius-lg: 5px !default;
|
||||
$border-radius-sm: 2px !default;
|
||||
$border-radius: ${theme.border.radius.md} !default;
|
||||
$border-radius-lg: ${theme.border.radius.lg}!default;
|
||||
$border-radius-sm: ${theme.border.radius.sm} !default;
|
||||
|
||||
// Page
|
||||
|
||||
@@ -151,22 +144,17 @@ $input-padding-x: 10px !default;
|
||||
$input-padding-y: 8px !default;
|
||||
$input-line-height: 18px !default;
|
||||
|
||||
$input-btn-border-width: 1px;
|
||||
$input-border-radius: 0 $border-radius $border-radius 0 !default;
|
||||
$input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 0 !default;
|
||||
|
||||
$label-border-radius: $border-radius 0 0 $border-radius !default;
|
||||
$label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default;
|
||||
|
||||
$input-padding-y-sm: 4px !default;
|
||||
|
||||
$input-padding-x-lg: 20px !default;
|
||||
$input-padding-y-lg: 10px !default;
|
||||
|
||||
$input-height: 35px !default;
|
||||
|
||||
$gf-form-input-height: 35px;
|
||||
|
||||
$cursor-disabled: not-allowed !default;
|
||||
|
||||
// Form validation icons
|
||||
@@ -203,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
|
||||
$btn-padding-x-xl: 21px !default;
|
||||
$btn-padding-y-xl: 11px !default;
|
||||
|
||||
$btn-border-radius: 2px;
|
||||
|
||||
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const theme: GrafanaThemeCommons = {
|
||||
},
|
||||
weight: {
|
||||
light: 300,
|
||||
normal: 400,
|
||||
regular: 400,
|
||||
semibold: 500,
|
||||
},
|
||||
lineHeight: {
|
||||
@@ -54,9 +54,9 @@ const theme: GrafanaThemeCommons = {
|
||||
},
|
||||
border: {
|
||||
radius: {
|
||||
xs: '2px',
|
||||
sm: '3px',
|
||||
md: '5px',
|
||||
sm: '2px',
|
||||
md: '3px',
|
||||
lg: '5px',
|
||||
},
|
||||
width: {
|
||||
sm: '1px',
|
||||
|
||||
@@ -48,10 +48,7 @@ export enum NullValueMode {
|
||||
}
|
||||
|
||||
/** View model projection of many time series */
|
||||
export interface TimeSeriesVMs {
|
||||
[index: number]: TimeSeriesVM;
|
||||
length: number;
|
||||
}
|
||||
export type TimeSeriesVMs = TimeSeriesVM[];
|
||||
|
||||
export interface Column {
|
||||
text: string;
|
||||
@@ -69,3 +66,12 @@ export interface TableData {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { PluginMeta } from './plugin';
|
||||
import { TableData, TimeSeries } from './data';
|
||||
|
||||
export interface DataQueryResponse {
|
||||
data: TimeSeries[] | [TableData] | any;
|
||||
data: DataQueryResponseData;
|
||||
}
|
||||
|
||||
export type DataQueryResponseData = TimeSeries[] | [TableData] | any;
|
||||
|
||||
export interface DataQuery {
|
||||
/**
|
||||
* A - Z
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './panel';
|
||||
export * from './plugin';
|
||||
export * from './datasource';
|
||||
export * from './theme';
|
||||
export * from './threshold';
|
||||
|
||||
@@ -39,11 +39,14 @@ export interface PanelEditorProps<T = any> {
|
||||
*/
|
||||
export type PanelOptionsValidator<T = any> = (panelModel: any) => T;
|
||||
|
||||
export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
|
||||
|
||||
export class ReactPanelPlugin<TOptions = any> {
|
||||
panel: ComponentClass<PanelProps<TOptions>>;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
optionsValidator?: PanelOptionsValidator<TOptions>;
|
||||
defaults?: TOptions;
|
||||
preserveOptions?: PreservePanelOptionsHandler<TOptions>;
|
||||
|
||||
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
|
||||
this.panel = panel;
|
||||
@@ -60,6 +63,10 @@ export class ReactPanelPlugin<TOptions = any> {
|
||||
setDefaults(defaults: TOptions) {
|
||||
this.defaults = defaults;
|
||||
}
|
||||
|
||||
setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
|
||||
this.preserveOptions = handler;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PanelSize {
|
||||
@@ -76,17 +83,6 @@ export interface PanelMenuItem {
|
||||
subMenu?: PanelMenuItem[];
|
||||
}
|
||||
|
||||
export interface Threshold {
|
||||
index: number;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export enum BasicGaugeColor {
|
||||
Green = '#299c46',
|
||||
Red = '#d44a3a',
|
||||
}
|
||||
|
||||
export enum MappingType {
|
||||
ValueToText = 1,
|
||||
RangeToText = 2,
|
||||
@@ -109,3 +105,9 @@ export interface RangeMap extends BaseMap {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export enum VizOrientation {
|
||||
Auto = 'auto',
|
||||
Vertical = 'vertical',
|
||||
Horizontal = 'horizontal',
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface GrafanaThemeCommons {
|
||||
};
|
||||
weight: {
|
||||
light: number;
|
||||
normal: number;
|
||||
regular: number;
|
||||
semibold: number;
|
||||
};
|
||||
lineHeight: {
|
||||
@@ -59,9 +59,9 @@ export interface GrafanaThemeCommons {
|
||||
};
|
||||
border: {
|
||||
radius: {
|
||||
xs: string;
|
||||
sm: string;
|
||||
md: string;
|
||||
lg: string;
|
||||
};
|
||||
width: {
|
||||
sm: string;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface Threshold {
|
||||
index: number;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
export * from './processTimeSeries';
|
||||
export * from './singlestat';
|
||||
export * from './valueFormats/valueFormats';
|
||||
export * from './colors';
|
||||
export * from './namedColorsPalette';
|
||||
export * from './thresholds';
|
||||
export * from './string';
|
||||
export * from './deprecationWarning';
|
||||
export { getMappedValue } from './valueMappings';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { TableData, Column } from '../types/index';
|
||||
|
||||
// Libraries
|
||||
import isNumber from 'lodash/isNumber';
|
||||
import Papa, { ParseError, ParseMeta } from 'papaparse';
|
||||
|
||||
// Types
|
||||
import { TableData, Column } from '../types';
|
||||
|
||||
// Subset of all parse options
|
||||
export interface TableParseOptions {
|
||||
headerIsFirstLine?: boolean; // Not a papa-parse option
|
||||
@@ -131,3 +134,24 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
|
||||
columnMap: {},
|
||||
});
|
||||
}
|
||||
|
||||
export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
|
||||
if (isNumber(sortIndex)) {
|
||||
const copy = {
|
||||
...data,
|
||||
rows: [...data.rows].sort((a, b) => {
|
||||
a = a[sortIndex];
|
||||
b = b[sortIndex];
|
||||
// Sort null or undefined separately from comparable values
|
||||
return +(a == null) - +(b == null) || +(a > b) || -(a < b);
|
||||
}),
|
||||
};
|
||||
|
||||
if (reverse) {
|
||||
copy.rows.reverse();
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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 React from 'react';
|
||||
import { AutoSizer } from 'react-virtualized';
|
||||
|
||||
/** This will add full size with & height properties */
|
||||
export const withFullSizeStory = (component: React.ComponentType<any>, props: any) => (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<>
|
||||
{React.createElement(component, {
|
||||
...props,
|
||||
width,
|
||||
height,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Threshold } from '../types';
|
||||
|
||||
export function getThresholdForValue(
|
||||
thresholds: Threshold[],
|
||||
value: number | null | string | undefined
|
||||
): Threshold | null {
|
||||
if (thresholds.length === 1) {
|
||||
return thresholds[0];
|
||||
}
|
||||
|
||||
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||
if (atThreshold) {
|
||||
return atThreshold;
|
||||
}
|
||||
|
||||
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||
if (belowThreshold.length > 0) {
|
||||
const nearestThreshold = belowThreshold.sort((t1: Threshold, t2: Threshold) => t2.value - t1.value)[0];
|
||||
return nearestThreshold;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user