Feat: Singlestat panel react progress & refactorings (#16039)

* big value component

* big value component

* editor for font and sparkline

* less logging

* remove sparkline from storybook

* add display value link wrapper

* follow tooltip

* follow tooltip

* merge master

* Just minor refactoring

* use series after last merge

* Refactoring: moving shared singlestat stuff to grafana-ui

* Refactor: Moved final getSingleStatDisplayValues func
This commit is contained in:
Ryan McKinley
2019-03-28 06:57:49 -07:00
committed by Torkel Ödegaard
parent 1d955a8762
commit c8b2102500
27 changed files with 751 additions and 167 deletions

View File

@@ -0,0 +1,37 @@
import { storiesOf } from '@storybook/react';
import { number, text } from '@storybook/addon-knobs';
import { BigValue } from './BigValue';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
const getKnobs = () => {
return {
value: text('value', 'Hello'),
valueFontSize: number('valueFontSize', 120),
prefix: text('prefix', ''),
};
};
const BigValueStories = storiesOf('UI/BigValue', module);
BigValueStories.addDecorator(withCenteredStory);
BigValueStories.add('Singlestat viz', () => {
const { value, prefix, valueFontSize } = getKnobs();
return renderComponentWithTheme(BigValue, {
width: 300,
height: 250,
value: {
text: value,
numeric: NaN,
fontSize: valueFontSize + '%',
},
prefix: prefix
? {
text: prefix,
numeric: NaN,
}
: null,
});
});

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { shallow } from 'enzyme';
import { BigValue, Props } from './BigValue';
import { getTheme } from '../../themes/index';
jest.mock('jquery', () => ({
plot: jest.fn(),
}));
const setup = (propOverrides?: object) => {
const props: Props = {
height: 300,
width: 300,
value: {
text: '25',
numeric: 25,
},
theme: getTheme(),
};
Object.assign(props, propOverrides);
const wrapper = shallow(<BigValue {...props} />);
const instance = wrapper.instance() as BigValue;
return {
instance,
wrapper,
};
};
describe('Render BarGauge with basic options', () => {
it('should render', () => {
const { wrapper } = setup();
expect(wrapper).toBeDefined();
// expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,134 @@
// Library
import React, { PureComponent, ReactNode, CSSProperties } from 'react';
import $ from 'jquery';
// Utils
import { getColorFromHexRgbOrName } from '../../utils';
// Types
import { Themeable, DisplayValue } from '../../types';
export interface BigValueSparkline {
data: any[][]; // [[number,number]]
minX: number;
maxX: number;
full: boolean; // full height
fillColor: string;
lineColor: string;
}
export interface Props extends Themeable {
height: number;
width: number;
value: DisplayValue;
prefix?: DisplayValue;
suffix?: DisplayValue;
sparkline?: BigValueSparkline;
backgroundColor?: string;
}
/*
* This visualization is still in POC state, needed more tests & better structure
*/
export class BigValue extends PureComponent<Props> {
canvasElement: any;
componentDidMount() {
this.draw();
}
componentDidUpdate() {
this.draw();
}
draw() {
const { sparkline, theme } = this.props;
if (sparkline && this.canvasElement) {
const { data, minX, maxX, fillColor, lineColor } = sparkline;
const options = {
legend: { show: false },
series: {
lines: {
show: true,
fill: 1,
zero: false,
lineWidth: 1,
fillColor: getColorFromHexRgbOrName(fillColor, theme.type),
},
},
yaxes: { show: false },
xaxis: {
show: false,
min: minX,
max: maxX,
},
grid: { hoverable: false, show: false },
};
const plotSeries = {
data,
color: getColorFromHexRgbOrName(lineColor, theme.type),
};
try {
$.plot(this.canvasElement, [plotSeries], options);
} catch (err) {
console.log('sparkline rendering error', err, options);
}
}
}
renderText = (value?: DisplayValue, padding?: string): ReactNode => {
if (!value || !value.text) {
return null;
}
const css: CSSProperties = {};
if (padding) {
css.padding = padding;
}
if (value.color) {
css.color = value.color;
}
if (value.fontSize) {
css.fontSize = value.fontSize;
}
return <span style={css}>{value.text}</span>;
};
render() {
const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props;
const plotCss: CSSProperties = {};
plotCss.position = 'absolute';
if (sparkline) {
if (sparkline.full) {
plotCss.bottom = '5px';
plotCss.left = '-5px';
plotCss.width = width - 10 + 'px';
const dynamicHeightMargin = height <= 100 ? 5 : Math.round(height / 100) * 15 + 5;
plotCss.height = height - dynamicHeightMargin + 'px';
} else {
plotCss.bottom = '0px';
plotCss.left = '-5px';
plotCss.width = width - 10 + 'px';
plotCss.height = Math.floor(height * 0.25) + 'px';
}
}
return (
<div className="big-value" style={{ width, height, backgroundColor }}>
<span className="big-value__value">
{this.renderText(prefix, '0px 2px 0px 0px')}
{this.renderText(value)}
{this.renderText(suffix)}
</span>
{sparkline && <div style={plotCss} ref={element => (this.canvasElement = element)} />}
</div>
);
}
}

View File

@@ -0,0 +1,15 @@
.big-value {
position: relative;
display: table;
}
.big-value__value {
line-height: 1;
display: table-cell;
vertical-align: middle;
text-align: center;
position: relative;
z-index: 1;
font-size: 3em;
font-weight: $font-weight-semi-bold;
}

View File

@@ -0,0 +1,90 @@
// Libraries
import React, { PureComponent, ChangeEvent } from 'react';
// Components
import {
FormField,
FormLabel,
PanelOptionsGroup,
StatsPicker,
UnitPicker,
StatID,
SelectOptionItem,
} from '@grafana/ui';
// Types
import { SingleStatValueOptions } from './shared';
const labelWidth = 6;
export interface Props {
options: SingleStatValueOptions;
onChange: (valueOptions: SingleStatValueOptions) => void;
}
export class SingleStatValueEditor extends PureComponent<Props> {
onUnitChange = (unit: SelectOptionItem) => this.props.onChange({ ...this.props.options, unit: unit.value });
onStatsChange = (stats: string[]) => {
const stat = stats[0] || StatID.mean;
this.props.onChange({ ...this.props.options, stat });
};
onDecimalChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!isNaN(parseInt(event.target.value, 10))) {
this.props.onChange({
...this.props.options,
decimals: parseInt(event.target.value, 10),
});
} else {
this.props.onChange({
...this.props.options,
decimals: null,
});
}
};
onPrefixChange = (event: ChangeEvent<HTMLInputElement>) =>
this.props.onChange({ ...this.props.options, prefix: event.target.value });
onSuffixChange = (event: ChangeEvent<HTMLInputElement>) =>
this.props.onChange({ ...this.props.options, suffix: event.target.value });
render() {
const { stat, unit, decimals, prefix, suffix } = this.props.options;
let decimalsString = '';
if (decimals !== null && decimals !== undefined && Number.isFinite(decimals as number)) {
decimalsString = decimals.toString();
}
return (
<PanelOptionsGroup title="Value">
<div className="gf-form">
<FormLabel width={labelWidth}>Show</FormLabel>
<StatsPicker
width={12}
placeholder="Choose Stat"
defaultStat={StatID.mean}
allowMultiple={false}
stats={[stat]}
onChange={this.onStatsChange}
/>
</div>
<div className="gf-form">
<FormLabel width={labelWidth}>Unit</FormLabel>
<UnitPicker defaultValue={unit} onChange={this.onUnitChange} />
</div>
<FormField
label="Decimals"
labelWidth={labelWidth}
placeholder="auto"
onChange={this.onDecimalChange}
value={decimalsString}
type="number"
/>
<FormField label="Prefix" labelWidth={labelWidth} onChange={this.onPrefixChange} value={prefix || ''} />
<FormField label="Suffix" labelWidth={labelWidth} onChange={this.onSuffixChange} value={suffix || ''} />
</PanelOptionsGroup>
);
}
}

View File

@@ -0,0 +1,122 @@
import cloneDeep from 'lodash/cloneDeep';
import {
ValueMapping,
Threshold,
VizOrientation,
PanelModel,
DisplayValue,
FieldType,
NullValueMode,
GrafanaTheme,
SeriesData,
InterpolateFunction,
} from '../../types';
import { getStatsCalculators, calculateStats } from '../../utils/statsCalculator';
import { getDisplayProcessor } from '../../utils/displayValue';
export { SingleStatValueEditor } from './SingleStatValueEditor';
export interface SingleStatBaseOptions {
valueMappings: ValueMapping[];
thresholds: Threshold[];
valueOptions: SingleStatValueOptions;
orientation: VizOrientation;
}
export interface SingleStatValueOptions {
unit: string;
suffix: string;
stat: string;
prefix: string;
decimals?: number | null;
}
export interface GetSingleStatDisplayValueOptions {
data: SeriesData[];
theme: GrafanaTheme;
valueMappings: ValueMapping[];
thresholds: Threshold[];
valueOptions: SingleStatValueOptions;
replaceVariables: InterpolateFunction;
}
export const getSingleStatDisplayValues = (options: GetSingleStatDisplayValueOptions): DisplayValue[] => {
const { data, replaceVariables, valueOptions } = options;
const { unit, decimals, stat } = valueOptions;
const display = getDisplayProcessor({
unit,
decimals,
mappings: options.valueMappings,
thresholds: options.thresholds,
prefix: replaceVariables(valueOptions.prefix),
suffix: replaceVariables(valueOptions.suffix),
theme: options.theme,
});
const values: DisplayValue[] = [];
for (const series of data) {
if (stat === 'name') {
values.push(display(series.name));
}
for (let i = 0; i < series.fields.length; i++) {
const column = series.fields[i];
// Show all fields that are not 'time'
if (column.type === FieldType.number) {
const stats = calculateStats({
series,
fieldIndex: i,
stats: [stat], // The stats to calculate
nullValueMode: NullValueMode.Null,
});
const displayValue = display(stats[stat]);
values.push(displayValue);
}
}
}
if (values.length === 0) {
values.push({
numeric: 0,
text: 'No data',
});
}
return values;
};
const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings'];
export const sharedSingleStatOptionsCheck = (
options: Partial<SingleStatBaseOptions> | any,
prevPluginId: string,
prevOptions: any
) => {
for (const k of optionsToKeep) {
if (prevOptions.hasOwnProperty(k)) {
options[k] = cloneDeep(prevOptions[k]);
}
}
return options;
};
export const sharedSingleStatMigrationCheck = (panel: PanelModel<SingleStatBaseOptions>) => {
const options = panel.options;
if (!options) {
// This happens on the first load or when migrating from angular
return {};
}
if (options.valueOptions) {
// 6.1 renamed some stats, This makes sure they are up to date
// avg -> mean, current -> last, total -> sum
const { valueOptions } = options;
if (valueOptions && valueOptions.stat) {
valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0];
}
}
return options;
};

View File

@@ -1,4 +1,5 @@
@import 'CustomScrollbar/CustomScrollbar';
@import 'BigValue/BigValue';
@import 'DeleteButton/DeleteButton';
@import 'ThresholdsEditor/ThresholdsEditor';
@import 'Table/Table';

View File

@@ -33,9 +33,11 @@ export { StatsPicker } from './StatsPicker/StatsPicker';
export { Input, InputStatus } from './Input/Input';
// Visualizations
export { BigValue } from './BigValue/BigValue';
export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { BarGauge } from './BarGauge/BarGauge';
export { VizRepeater } from './VizRepeater/VizRepeater';
export * from './SingleStatShared/shared';
export { CallToActionCard } from './CallToActionCard/CallToActionCard';

View File

@@ -3,6 +3,7 @@ export interface DisplayValue {
numeric: number; // Use isNaN to check if it is a real number
color?: string; // color based on configs or Threshold
title?: string;
fontSize?: string;
}
export interface DecimalInfo {