Commit 8994f3c2 by Torkel Ödegaard Committed by GitHub

Merge pull request #14461 from grafana/gauge-value-mappings

Gauge value mappings
parents 4dbfcde7 80a2c3b1
...@@ -11,7 +11,7 @@ interface Props { ...@@ -11,7 +11,7 @@ interface Props {
onSelected: (item: any) => {} | void; onSelected: (item: any) => {} | void;
options: any[]; options: any[];
placeholder?: string; placeholder?: string;
width: number; width?: number;
value: any; value: any;
} }
...@@ -28,8 +28,8 @@ const SimplePicker: SFC<Props> = ({ ...@@ -28,8 +28,8 @@ const SimplePicker: SFC<Props> = ({
}) => { }) => {
return ( return (
<Select <Select
classNamePrefix={`gf-form-select-box`} classNamePrefix="gf-form-select-box"
className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`} className={`${width ? 'width-' + width : ''} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
components={{ components={{
Option: DescriptionOption, Option: DescriptionOption,
}} }}
......
...@@ -63,7 +63,7 @@ export default class UnitPicker extends PureComponent<Props> { ...@@ -63,7 +63,7 @@ export default class UnitPicker extends PureComponent<Props> {
return ( return (
<Select <Select
classNamePrefix="gf-form-select-box" classNamePrefix="gf-form-select-box"
className={`width-${width} gf-form-input--form-dropdown`} className={`width-${width} gf-form-input gf-form-input--form-dropdown`}
defaultValue={value} defaultValue={value}
isSearchable={true} isSearchable={true}
menuShouldScrollIntoView={false} menuShouldScrollIntoView={false}
......
import React, { PureComponent } from 'react';
import { Label } from 'app/core/components/Label/Label';
import SimplePicker from 'app/core/components/Picker/SimplePicker';
import { MappingType, RangeMap, ValueMap } from 'app/types';
interface Props {
mapping: ValueMap | RangeMap;
updateMapping: (mapping) => void;
removeMapping: () => void;
}
interface State {
from: string;
id: number;
operator: string;
text: string;
to: string;
type: MappingType;
value: string;
}
const mappingOptions = [
{ value: MappingType.ValueToText, label: 'Value' },
{ value: MappingType.RangeToText, label: 'Range' },
];
export default class MappingRow extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
...props.mapping,
};
}
onMappingValueChange = event => {
this.setState({ value: event.target.value });
};
onMappingFromChange = event => {
this.setState({ from: event.target.value });
};
onMappingToChange = event => {
this.setState({ to: event.target.value });
};
onMappingTextChange = event => {
this.setState({ text: event.target.value });
};
onMappingTypeChange = mappingType => {
this.setState({ type: mappingType });
};
updateMapping = () => {
this.props.updateMapping({ ...this.state });
};
renderRow() {
const { from, text, to, type, value } = this.state;
if (type === MappingType.RangeToText) {
return (
<div className="gf-form">
<div className="gf-form-inline mapping-row-input">
<Label width={4}>From</Label>
<div>
<input
className="gf-form-input"
value={from}
onBlur={this.updateMapping}
onChange={this.onMappingFromChange}
/>
</div>
</div>
<div className="gf-form-inline mapping-row-input">
<Label width={4}>To</Label>
<div>
<input
className="gf-form-input"
value={to}
onBlur={this.updateMapping}
onChange={this.onMappingToChange}
/>
</div>
</div>
<div className="gf-form-inline mapping-row-input">
<Label width={4}>Text</Label>
<div>
<input
className="gf-form-input"
value={text}
onBlur={this.updateMapping}
onChange={this.onMappingTextChange}
/>
</div>
</div>
</div>
);
}
return (
<div className="gf-form">
<div className="gf-form-inline mapping-row-input">
<Label width={4}>Value</Label>
<div>
<input
className="gf-form-input"
onBlur={this.updateMapping}
onChange={this.onMappingValueChange}
value={value}
/>
</div>
</div>
<div className="gf-form-inline mapping-row-input">
<Label width={4}>Text</Label>
<div>
<input
className="gf-form-input"
onBlur={this.updateMapping}
value={text}
onChange={this.onMappingTextChange}
/>
</div>
</div>
</div>
);
}
render() {
const { type } = this.state;
return (
<div className="mapping-row">
<div className="gf-form-inline mapping-row-type">
<Label width={5}>Type</Label>
<SimplePicker
placeholder="Choose type"
options={mappingOptions}
value={mappingOptions.find(o => o.value === type)}
getOptionLabel={i => i.label}
getOptionValue={i => i.value}
onSelected={type => this.onMappingTypeChange(type.value)}
width={7}
/>
</div>
<div>{this.renderRow()}</div>
<div onClick={this.props.removeMapping} className="threshold-row-remove">
<i className="fa fa-times" />
</div>
</div>
);
}
}
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import Thresholds from './Thresholds'; import Thresholds from './Thresholds';
import { OptionsProps } from './module'; import { defaultProps, OptionsProps } from './module';
import { PanelOptionsProps } from '../../../types'; import { PanelOptionsProps } from '../../../types';
const setup = (propOverrides?: object) => { const setup = (propOverrides?: object) => {
const props: PanelOptionsProps<OptionsProps> = { const props: PanelOptionsProps<OptionsProps> = {
onChange: jest.fn(), onChange: jest.fn(),
options: {} as OptionsProps, options: {
...defaultProps.options,
thresholds: [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
{ index: 1, label: 'Max', value: 100, canRemove: false },
],
},
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
...@@ -15,12 +21,6 @@ const setup = (propOverrides?: object) => { ...@@ -15,12 +21,6 @@ const setup = (propOverrides?: object) => {
return shallow(<Thresholds {...props} />).instance() as Thresholds; return shallow(<Thresholds {...props} />).instance() as Thresholds;
}; };
const thresholds = [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
{ index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
{ index: 2, label: 'Max', value: 100, canRemove: false },
];
describe('Add threshold', () => { describe('Add threshold', () => {
it('should add threshold between min and max', () => { it('should add threshold between min and max', () => {
const instance = setup(); const instance = setup();
...@@ -36,7 +36,14 @@ describe('Add threshold', () => { ...@@ -36,7 +36,14 @@ describe('Add threshold', () => {
it('should add threshold between min and added threshold', () => { it('should add threshold between min and added threshold', () => {
const instance = setup({ const instance = setup({
options: { thresholds: thresholds }, options: {
...defaultProps.options,
thresholds: [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
{ index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
{ index: 2, label: 'Max', value: 100, canRemove: false },
],
},
}); });
instance.onAddThreshold(1); instance.onAddThreshold(1);
......
...@@ -2,27 +2,18 @@ import React, { PureComponent } from 'react'; ...@@ -2,27 +2,18 @@ import React, { PureComponent } from 'react';
import classNames from 'classnames/bind'; import classNames from 'classnames/bind';
import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker'; import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker';
import { OptionModuleProps } from './module'; import { OptionModuleProps } from './module';
import { Threshold } from 'app/types'; import { BasicGaugeColor, Threshold } from 'app/types';
interface State { interface State {
thresholds: Threshold[]; thresholds: Threshold[];
} }
enum BasicGaugeColor {
Green = 'rgba(50, 172, 45, 0.97)',
Orange = 'rgba(237, 129, 40, 0.89)',
Red = 'rgb(212, 74, 58)',
}
export default class Thresholds extends PureComponent<OptionModuleProps, State> { export default class Thresholds extends PureComponent<OptionModuleProps, State> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
thresholds: this.props.options.thresholds || [ thresholds: props.options.thresholds,
{ index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
{ index: 1, label: 'Max', value: 100, canRemove: false },
],
}; };
} }
......
import React from 'react';
import { shallow } from 'enzyme';
import ValueMappings from './ValueMappings';
import { defaultProps, OptionModuleProps } from './module';
import { MappingType } from 'app/types';
const setup = (propOverrides?: object) => {
const props: OptionModuleProps = {
onChange: jest.fn(),
options: {
...defaultProps.options,
mappings: [
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
],
},
};
Object.assign(props, propOverrides);
const wrapper = shallow(<ValueMappings {...props} />);
const instance = wrapper.instance() as ValueMappings;
return {
instance,
wrapper,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
});
describe('On remove mapping', () => {
it('Should remove mapping with id 0', () => {
const { instance } = setup();
instance.onRemoveMapping(1);
expect(instance.state.mappings).toEqual([
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
]);
});
it('should remove mapping with id 1', () => {
const { instance } = setup();
instance.onRemoveMapping(2);
expect(instance.state.mappings).toEqual([
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
]);
});
});
describe('Next id to add', () => {
it('should be 4', () => {
const { instance } = setup();
instance.addMapping();
expect(instance.state.nextIdToAdd).toEqual(4);
});
it('should default to 1', () => {
const { instance } = setup({ options: { ...defaultProps.options } });
expect(instance.state.nextIdToAdd).toEqual(1);
});
});
import React, { PureComponent } from 'react';
import MappingRow from './MappingRow';
import { OptionModuleProps } from './module';
import { MappingType, RangeMap, ValueMap } from 'app/types';
interface State {
mappings: Array<ValueMap | RangeMap>;
nextIdToAdd: number;
}
export default class ValueMappings extends PureComponent<OptionModuleProps, State> {
constructor(props) {
super(props);
const mappings = props.options.mappings;
this.state = {
mappings: mappings || [],
nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1,
};
}
getMaxIdFromMappings(mappings) {
return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
}
addMapping = () =>
this.setState(prevState => ({
mappings: [
...prevState.mappings,
{
id: prevState.nextIdToAdd,
operator: '',
value: '',
text: '',
type: MappingType.ValueToText,
from: '',
to: '',
},
],
nextIdToAdd: prevState.nextIdToAdd + 1,
}));
onRemoveMapping = id => {
this.setState(
prevState => ({
mappings: prevState.mappings.filter(m => {
return m.id !== id;
}),
}),
() => {
this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
}
);
};
updateGauge = mapping => {
this.setState(
prevState => ({
mappings: prevState.mappings.map(m => {
if (m.id === mapping.id) {
return { ...mapping };
}
return m;
}),
}),
() => {
this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
}
);
};
render() {
const { mappings } = this.state;
return (
<div className="section gf-form-group">
<h5 className="page-heading">Value mappings</h5>
<div>
{mappings.length > 0 &&
mappings.map((mapping, index) => (
<MappingRow
key={`${mapping.text}-${index}`}
mapping={mapping}
updateMapping={this.updateGauge}
removeMapping={() => this.onRemoveMapping(mapping.id)}
/>
))}
</div>
<div className="add-mapping-row" onClick={this.addMapping}>
<div className="add-mapping-row-icon">
<i className="fa fa-plus" />
</div>
<div className="add-mapping-row-label">Add mapping</div>
</div>
</div>
);
}
}
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="section gf-form-group"
>
<h5
className="page-heading"
>
Value mappings
</h5>
<div>
<MappingRow
key="Ok-0"
mapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
}
}
removeMapping={[Function]}
updateMapping={[Function]}
/>
<MappingRow
key="Meh-1"
mapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
}
}
removeMapping={[Function]}
updateMapping={[Function]}
/>
</div>
<div
className="add-mapping-row"
onClick={[Function]}
>
<div
className="add-mapping-row-icon"
>
<i
className="fa fa-plus"
/>
</div>
<div
className="add-mapping-row-label"
>
Add mapping
</div>
</div>
</div>
`;
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import Gauge from 'app/viz/Gauge'; import Gauge from 'app/viz/Gauge';
import { NullValueMode, PanelOptionsProps, PanelProps, Threshold } from 'app/types';
import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
import ValueOptions from './ValueOptions'; import ValueOptions from './ValueOptions';
import GaugeOptions from './GaugeOptions'; import GaugeOptions from './GaugeOptions';
import Thresholds from './Thresholds'; import Thresholds from './Thresholds';
import ValueMappings from './ValueMappings';
import {
BasicGaugeColor,
NullValueMode,
PanelOptionsProps,
PanelProps,
RangeMap,
Threshold,
ValueMap,
} from 'app/types';
export interface OptionsProps { export interface OptionsProps {
decimals: number; decimals: number;
...@@ -15,6 +24,7 @@ export interface OptionsProps { ...@@ -15,6 +24,7 @@ export interface OptionsProps {
suffix: string; suffix: string;
unit: string; unit: string;
thresholds: Threshold[]; thresholds: Threshold[];
mappings: Array<RangeMap | ValueMap>;
} }
export interface OptionModuleProps { export interface OptionModuleProps {
...@@ -30,6 +40,14 @@ export const defaultProps = { ...@@ -30,6 +40,14 @@ export const defaultProps = {
showThresholdMarkers: true, showThresholdMarkers: true,
showThresholdLabels: false, showThresholdLabels: false,
suffix: '', suffix: '',
decimals: 0,
stat: '',
unit: '',
mappings: [],
thresholds: [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
{ index: 1, label: 'Max', value: 100, canRemove: false },
],
}, },
}; };
...@@ -52,11 +70,17 @@ class Options extends PureComponent<PanelOptionsProps<OptionsProps>> { ...@@ -52,11 +70,17 @@ class Options extends PureComponent<PanelOptionsProps<OptionsProps>> {
static defaultProps = defaultProps; static defaultProps = defaultProps;
render() { render() {
const { onChange, options } = this.props;
return ( return (
<div> <div>
<ValueOptions onChange={this.props.onChange} options={this.props.options} /> <div className="form-section">
<GaugeOptions onChange={this.props.onChange} options={this.props.options} /> <ValueOptions onChange={onChange} options={options} />
<Thresholds onChange={this.props.onChange} options={this.props.options} /> <GaugeOptions onChange={onChange} options={options} />
<Thresholds onChange={onChange} options={options} />
</div>
<div className="form-section">
<ValueMappings onChange={onChange} options={options} />
</div>
</div> </div>
); );
} }
......
...@@ -21,7 +21,7 @@ import { ...@@ -21,7 +21,7 @@ import {
DataQueryOptions, DataQueryOptions,
IntervalValues, IntervalValues,
} from './series'; } from './series';
import { PanelProps, PanelOptionsProps, Threshold } from './panel'; import { BasicGaugeColor, MappingType, PanelProps, PanelOptionsProps, RangeMap, Threshold, ValueMap } from './panel';
import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins'; import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins';
import { Organization, OrganizationState } from './organization'; import { Organization, OrganizationState } from './organization';
import { import {
...@@ -93,7 +93,11 @@ export { ...@@ -93,7 +93,11 @@ export {
Threshold, Threshold,
ValidationEvents, ValidationEvents,
ValidationRule, ValidationRule,
ValueMap,
RangeMap,
IntervalValues, IntervalValues,
MappingType,
BasicGaugeColor,
}; };
export interface StoreState { export interface StoreState {
......
...@@ -36,3 +36,30 @@ export interface Threshold { ...@@ -36,3 +36,30 @@ export interface Threshold {
color?: string; color?: string;
canRemove: boolean; canRemove: boolean;
} }
export enum MappingType {
ValueToText = 1,
RangeToText = 2,
}
export enum BasicGaugeColor {
Green = 'rgba(50, 172, 45, 0.97)',
Orange = 'rgba(237, 129, 40, 0.89)',
Red = 'rgb(212, 74, 58)',
}
interface BaseMap {
id: number;
operator: string;
text: string;
type: MappingType;
}
export interface ValueMap extends BaseMap {
value: string;
}
export interface RangeMap extends BaseMap {
from: string;
to: string;
}
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import $ from 'jquery'; import $ from 'jquery';
import { Threshold, TimeSeriesVMs } from 'app/types'; import { MappingType, RangeMap, Threshold, TimeSeriesVMs, ValueMap } from 'app/types';
import config from '../core/config'; import config from '../core/config';
import kbn from '../core/utils/kbn'; import kbn from '../core/utils/kbn';
interface Props { interface Props {
decimals: number; decimals: number;
height: number;
mappings: Array<RangeMap | ValueMap>;
maxValue: number;
minValue: number;
prefix: string;
timeSeries: TimeSeriesVMs; timeSeries: TimeSeriesVMs;
showThresholdMarkers: boolean;
thresholds: Threshold[]; thresholds: Threshold[];
showThresholdMarkers: boolean;
showThresholdLabels: boolean; showThresholdLabels: boolean;
unit: string;
width: number;
height: number;
stat: string; stat: string;
prefix: string;
suffix: string; suffix: string;
unit: string;
width: number;
} }
export class Gauge extends PureComponent<Props> { export class Gauge extends PureComponent<Props> {
canvasElement: any; canvasElement: any;
static defaultProps = { static defaultProps = {
minValue: 0,
maxValue: 100, maxValue: 100,
mappings: [],
minValue: 0,
prefix: '', prefix: '',
showThresholdMarkers: true, showThresholdMarkers: true,
showThresholdLabels: false, showThresholdLabels: false,
suffix: '', suffix: '',
unit: 'none',
thresholds: [ thresholds: [
{ label: 'Min', value: 0, color: 'rgba(50, 172, 45, 0.97)' }, { label: 'Min', value: 0, color: 'rgba(50, 172, 45, 0.97)' },
{ label: 'Max', value: 100, color: 'rgba(245, 54, 54, 0.9)' }, { label: 'Max', value: 100, color: 'rgba(245, 54, 54, 0.9)' },
], ],
unit: 'none',
}; };
componentDidMount() { componentDidMount() {
...@@ -43,16 +47,49 @@ export class Gauge extends PureComponent<Props> { ...@@ -43,16 +47,49 @@ export class Gauge extends PureComponent<Props> {
this.draw(); this.draw();
} }
formatWithMappings(mappings, value) {
const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
const valueMap = valueMaps.map(mapping => {
if (mapping.value && value === mapping.value) {
return mapping.text;
}
})[0];
const rangeMap = rangeMaps.map(mapping => {
if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
return mapping.text;
}
})[0];
return {
rangeMap,
valueMap,
};
}
formatValue(value) { formatValue(value) {
const { decimals, prefix, suffix, unit } = this.props; const { decimals, mappings, prefix, suffix, unit } = this.props;
const formatFunc = kbn.valueFormats[unit]; const formatFunc = kbn.valueFormats[unit];
const formattedValue = formatFunc(value, decimals);
if (mappings.length > 0) {
const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
if (valueMap) {
return valueMap;
} else if (rangeMap) {
return rangeMap;
}
}
if (isNaN(value)) { if (isNaN(value)) {
return '-'; return '-';
} }
return `${prefix} ${formatFunc(value, decimals)} ${suffix}`; return `${prefix} ${formattedValue} ${suffix}`;
} }
draw() { draw() {
......
...@@ -106,6 +106,7 @@ ...@@ -106,6 +106,7 @@
@import 'components/unit-picker'; @import 'components/unit-picker';
@import 'components/thresholds'; @import 'components/thresholds';
@import 'components/toggle_button_group'; @import 'components/toggle_button_group';
@import 'components/value-mappings';
// PAGES // PAGES
@import 'pages/login'; @import 'pages/login';
......
...@@ -90,7 +90,7 @@ $select-input-bg-disabled: $input-bg-disabled; ...@@ -90,7 +90,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__value-container { .gf-form-select-box__value-container {
display: table-cell; display: table-cell;
padding: 8px 10px; padding: 6px 10px;
> div { > div {
display: inline-block; display: inline-block;
} }
......
...@@ -77,7 +77,8 @@ ...@@ -77,7 +77,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; height: 37px;
width: 37px;
cursor: pointer; cursor: pointer;
} }
......
.mapping-row {
display: flex;
margin-bottom: 10px;
}
.mapping-row-type {
margin-right: 5px;
}
.mapping-row-input {
margin-right: 5px;
}
.add-mapping-row {
display: flex;
overflow: hidden;
height: 37px;
cursor: pointer;
border-radius: $border-radius;
width: 200px;
}
.add-mapping-row-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
background-color: $green;
}
.add-mapping-row-label {
align-items: center;
display: flex;
padding: 5px 8px;
background-color: $input-label-bg;
width: calc(100% - 36px);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment