mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
NewPanelEditor: Thresholds v2 (#22232)
* initial wip * Progress on new threshold design * Testing radio button group for mode option * Starting to come together * Fixed percent mode * Full width radio groups * Minor tweaks, cursor to trash icon and hover state * fixed unit tests * Fixed storybook * re-ordering fields * Updated snapshot * Make it consistent, add vs create
This commit is contained in:
parent
a743a1cc12
commit
f1ecaa5d45
@ -14,6 +14,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
`,
|
`,
|
||||||
|
|
||||||
icon: css`
|
icon: css`
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
& + * {
|
& + * {
|
||||||
margin-left: ${theme.spacing.sm};
|
margin-left: ${theme.spacing.sm};
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import { stylesFactory } from '../../themes';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FullWidthButtonContainer: FC<Props> = ({ className, children }) => {
|
||||||
|
const styles = getStyles();
|
||||||
|
|
||||||
|
return <div className={cx(styles, className)}>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = stylesFactory(() => {
|
||||||
|
return css`
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
});
|
@ -1,5 +1,5 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`Button renders correct html 1`] = `"<button class=\\"css-1kiu3dy-button\\" type=\\"button\\"><span class=\\"css-1beih13\\"><span class=\\"css-xhe2zh\\"><i class=\\"fa fa-plus\\"></i></span><span>Click me</span></span></button>"`;
|
exports[`Button renders correct html 1`] = `"<button class=\\"css-1kiu3dy-button\\" type=\\"button\\"><span class=\\"css-1beih13\\"><span class=\\"css-1rgbe4\\"><i class=\\"fa fa-plus\\"></i></span><span>Click me</span></span></button>"`;
|
||||||
|
|
||||||
exports[`LinkButton renders correct html 1`] = `"<a class=\\"css-1kiu3dy-button\\"><span class=\\"css-1beih13\\"><span class=\\"css-xhe2zh\\"><i class=\\"fa fa-plus\\"></i></span><span>Click me</span></span></a>"`;
|
exports[`LinkButton renders correct html 1`] = `"<a class=\\"css-1kiu3dy-button\\"><span class=\\"css-1beih13\\"><span class=\\"css-1rgbe4\\"><i class=\\"fa fa-plus\\"></i></span><span>Click me</span></span></a>"`;
|
||||||
|
@ -12,6 +12,7 @@ import { Modal } from '../Modal/Modal';
|
|||||||
import { DataLinkEditor } from '../DataLinks/DataLinkEditor';
|
import { DataLinkEditor } from '../DataLinks/DataLinkEditor';
|
||||||
import cloneDeep from 'lodash/cloneDeep';
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
import { VariableSuggestion } from '@grafana/data';
|
import { VariableSuggestion } from '@grafana/data';
|
||||||
|
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
|
||||||
|
|
||||||
export interface DataLinksFieldConfigSettings {}
|
export interface DataLinksFieldConfigSettings {}
|
||||||
|
|
||||||
@ -60,9 +61,11 @@ export const DataLinksValueEditor: React.FC<FieldConfigEditorProps<DataLink[], D
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Forms.Button size="sm" icon="fa fa-plus" onClick={onDataLinkAdd}>
|
<FullWidthButtonContainer>
|
||||||
Create data link
|
<Forms.Button size="sm" icon="fa fa-plus" onClick={onDataLinkAdd}>
|
||||||
</Forms.Button>
|
Add data link
|
||||||
|
</Forms.Button>
|
||||||
|
</FullWidthButtonContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -141,12 +141,12 @@ export namespace StandardFieldConfigEditors {
|
|||||||
|
|
||||||
export const getStandardFieldConfigs = () => {
|
export const getStandardFieldConfigs = () => {
|
||||||
return [
|
return [
|
||||||
StandardFieldConfigEditors.decimals,
|
StandardFieldConfigEditors.unit,
|
||||||
StandardFieldConfigEditors.max,
|
|
||||||
StandardFieldConfigEditors.min,
|
StandardFieldConfigEditors.min,
|
||||||
StandardFieldConfigEditors.noValue,
|
StandardFieldConfigEditors.max,
|
||||||
|
StandardFieldConfigEditors.decimals,
|
||||||
StandardFieldConfigEditors.thresholds,
|
StandardFieldConfigEditors.thresholds,
|
||||||
StandardFieldConfigEditors.title,
|
StandardFieldConfigEditors.title,
|
||||||
StandardFieldConfigEditors.unit,
|
StandardFieldConfigEditors.noValue,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
ThresholdsConfig,
|
ThresholdsConfig,
|
||||||
ThresholdsMode,
|
ThresholdsMode,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { ThresholdsEditor } from '../ThresholdsEditor/ThresholdsEditor';
|
import { ThresholdsEditor } from '../ThresholdsEditorNew/ThresholdsEditor';
|
||||||
|
|
||||||
export interface ThresholdsFieldConfigSettings {
|
export interface ThresholdsFieldConfigSettings {
|
||||||
// Anything?
|
// Anything?
|
||||||
@ -42,7 +42,7 @@ export class ThresholdsValueEditor extends React.PureComponent<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ThresholdsEditor showAlphaUI={true} thresholds={value} onChange={onChange} />;
|
return <ThresholdsEditor thresholds={value} onChange={onChange} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,6 +64,19 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
|
|||||||
.input {
|
.input {
|
||||||
border-color: ${invalid ? colors.redBase : colors.formInputBorder};
|
border-color: ${invalid ? colors.redBase : colors.formInputBorder};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only show number buttons on hover
|
||||||
|
input[type='number'] {
|
||||||
|
-moz-appearance: number-input;
|
||||||
|
-webkit-appearance: number-input;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number']::-webkit-inner-spin-button,
|
||||||
|
input[type='number']::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: inner-spin-button !important;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
@ -129,21 +142,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
|
|||||||
border-radius: ${borderRadius};
|
border-radius: ${borderRadius};
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
/*
|
|
||||||
Restoring increase/decrease spinner on number inputs. Overwriting rules implemented in
|
|
||||||
https://github.com/grafana/grafana/commit/488fe62f158a9e0a0bced2b678ada5d43cf3998e.
|
|
||||||
*/
|
|
||||||
|
|
||||||
&[type='number']::-webkit-outer-spin-button,
|
|
||||||
&[type='number']::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: inner-spin-button !important;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[type='number'] {
|
|
||||||
-moz-appearance: number-input;
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
inputDisabled: css`
|
inputDisabled: css`
|
||||||
|
@ -31,6 +31,7 @@ const getRadioButtonGroupStyles = () => {
|
|||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RadioButtonGroupProps<T> {
|
interface RadioButtonGroupProps<T> {
|
||||||
value?: T;
|
value?: T;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
|
import { ThresholdsEditor } from './ThresholdsEditor';
|
||||||
|
import { ThresholdsMode, ThresholdsConfig } from '@grafana/data';
|
||||||
|
|
||||||
|
const thresholds: ThresholdsConfig = {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [
|
||||||
|
{ value: -Infinity, color: 'green' },
|
||||||
|
{ value: 50, color: 'red' },
|
||||||
|
{ value: 60, color: 'blue' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Panel/ThresholdsEditorNew',
|
||||||
|
component: ThresholdsEditor,
|
||||||
|
decorators: [withCenteredStory],
|
||||||
|
parameters: {
|
||||||
|
docs: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return <ThresholdsEditor thresholds={{} as ThresholdsConfig} onChange={action('Thresholds changed')} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithThreshold = () => {
|
||||||
|
return <ThresholdsEditor thresholds={thresholds} onChange={action('Thresholds changed')} />;
|
||||||
|
};
|
@ -0,0 +1,194 @@
|
|||||||
|
import React, { ChangeEvent } from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { GrafanaThemeType, ThresholdsMode } from '@grafana/data';
|
||||||
|
import { ThresholdsEditor, Props, thresholdsWithoutKey } from './ThresholdsEditor';
|
||||||
|
import { colors } from '../../utils';
|
||||||
|
import { mockThemeContext } from '../../themes/ThemeContext';
|
||||||
|
import { getTheme } from '../../themes/getTheme';
|
||||||
|
|
||||||
|
const setup = (propOverrides?: Partial<Props>) => {
|
||||||
|
const props: Props = {
|
||||||
|
onChange: jest.fn(),
|
||||||
|
thresholds: { mode: ThresholdsMode.Absolute, steps: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
|
||||||
|
const wrapper = mount(<ThresholdsEditor {...props} />);
|
||||||
|
const instance = wrapper.instance() as ThresholdsEditor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
instance,
|
||||||
|
wrapper,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCurrentThresholds(editor: ThresholdsEditor) {
|
||||||
|
return thresholdsWithoutKey(editor.props.thresholds, editor.state.steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThresholdsEditor', () => {
|
||||||
|
let restoreThemeContext: any;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
restoreThemeContext = mockThemeContext(getTheme('dark'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreThemeContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with base threshold', () => {
|
||||||
|
const { wrapper } = setup();
|
||||||
|
expect(wrapper.find('input').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('should add a base threshold if missing', () => {
|
||||||
|
const { instance } = setup();
|
||||||
|
expect(getCurrentThresholds(instance).steps).toEqual([{ value: -Infinity, color: 'green' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Add threshold', () => {
|
||||||
|
it('should add threshold', () => {
|
||||||
|
const { instance } = setup();
|
||||||
|
|
||||||
|
instance.onAddThreshold();
|
||||||
|
|
||||||
|
expect(getCurrentThresholds(instance).steps).toEqual([
|
||||||
|
{ value: -Infinity, color: 'green' }, // 0
|
||||||
|
{ value: 0, color: colors[1] }, // 1
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add another threshold above last', () => {
|
||||||
|
const { instance } = setup({
|
||||||
|
thresholds: {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [
|
||||||
|
{ value: -Infinity, color: colors[0] }, // 0
|
||||||
|
{ value: 50, color: colors[2] }, // 1
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.onAddThreshold();
|
||||||
|
|
||||||
|
expect(getCurrentThresholds(instance).steps).toEqual([
|
||||||
|
{ value: -Infinity, color: colors[0] }, // 0
|
||||||
|
{ value: 50, color: colors[2] }, // 1
|
||||||
|
{ value: 60, color: colors[3] }, // 2
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Remove threshold', () => {
|
||||||
|
it('should not remove threshold at index 0', () => {
|
||||||
|
const thresholds = {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [
|
||||||
|
{ value: -Infinity, color: '#7EB26D' },
|
||||||
|
{ value: 50, color: '#EAB839' },
|
||||||
|
{ value: 75, color: '#6ED0E0' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { instance } = setup({ thresholds });
|
||||||
|
|
||||||
|
instance.onRemoveThreshold(instance.state.steps[0]);
|
||||||
|
|
||||||
|
expect(getCurrentThresholds(instance)).toEqual(thresholds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove threshold', () => {
|
||||||
|
const thresholds = {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [
|
||||||
|
{ value: -Infinity, color: '#7EB26D' },
|
||||||
|
{ value: 50, color: '#EAB839' },
|
||||||
|
{ value: 75, color: '#6ED0E0' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { instance } = setup({ thresholds });
|
||||||
|
|
||||||
|
instance.onRemoveThreshold(instance.state.steps[1]);
|
||||||
|
|
||||||
|
expect(getCurrentThresholds(instance).steps).toEqual([
|
||||||
|
{ value: -Infinity, color: '#7EB26D' },
|
||||||
|
{ value: 75, color: '#6ED0E0' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('change threshold value', () => {
|
||||||
|
it('should not change threshold at index 0', () => {
|
||||||
|
const thresholds = {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [
|
||||||
|
{ value: -Infinity, color: '#7EB26D' },
|
||||||
|
{ value: 50, color: '#EAB839' },
|
||||||
|
{ value: 75, color: '#6ED0E0' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { instance } = setup({ thresholds });
|
||||||
|
|
||||||
|
const mockEvent = ({ target: { value: '12' } } as any) as ChangeEvent<HTMLInputElement>;
|
||||||
|
|
||||||
|
instance.onChangeThresholdValue(mockEvent, instance.state.steps[0]);
|
||||||
|
|
||||||
|
expect(getCurrentThresholds(instance)).toEqual(thresholds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update value', () => {
|
||||||
|
const { instance } = setup();
|
||||||
|
const thresholds = {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [
|
||||||
|
{ value: -Infinity, color: '#7EB26D', key: 1 },
|
||||||
|
{ value: 50, color: '#EAB839', key: 2 },
|
||||||
|
{ value: 75, color: '#6ED0E0', key: 3 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
instance.state = {
|
||||||
|
steps: thresholds.steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = ({ target: { value: '78' } } as any) as ChangeEvent<HTMLInputElement>;
|
||||||
|
|
||||||
|
instance.onChangeThresholdValue(mockEvent, thresholds.steps[1]);
|
||||||
|
|
||||||
|
expect(getCurrentThresholds(instance).steps).toEqual([
|
||||||
|
{ value: -Infinity, color: '#7EB26D' },
|
||||||
|
{ value: 75, color: '#6ED0E0' },
|
||||||
|
{ value: 78, color: '#EAB839' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('on blur threshold value', () => {
|
||||||
|
it('should resort rows and update indexes', () => {
|
||||||
|
const { instance } = setup();
|
||||||
|
const thresholds = {
|
||||||
|
mode: ThresholdsMode.Absolute,
|
||||||
|
steps: [
|
||||||
|
{ value: -Infinity, color: '#7EB26D', key: 1 },
|
||||||
|
{ value: 78, color: '#EAB839', key: 2 },
|
||||||
|
{ value: 75, color: '#6ED0E0', key: 3 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
instance.setState({
|
||||||
|
steps: thresholds.steps,
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.onBlur();
|
||||||
|
|
||||||
|
expect(getCurrentThresholds(instance).steps).toEqual([
|
||||||
|
{ value: -Infinity, color: '#7EB26D' },
|
||||||
|
{ value: 75, color: '#6ED0E0' },
|
||||||
|
{ value: 78, color: '#EAB839' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,317 @@
|
|||||||
|
import React, { PureComponent, ChangeEvent } from 'react';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import {
|
||||||
|
Threshold,
|
||||||
|
sortThresholds,
|
||||||
|
ThresholdsConfig,
|
||||||
|
ThresholdsMode,
|
||||||
|
SelectableValue,
|
||||||
|
GrafanaTheme,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { colors } from '../../utils';
|
||||||
|
import { ThemeContext } from '../../themes/ThemeContext';
|
||||||
|
import { Input } from '../Forms/Input/Input';
|
||||||
|
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
||||||
|
import { stylesFactory } from '../../themes';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import { RadioButtonGroup } from '../Forms/RadioButtonGroup/RadioButtonGroup';
|
||||||
|
import { Field } from '../Forms/Field';
|
||||||
|
import { Button } from '../Forms/Button';
|
||||||
|
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
|
||||||
|
|
||||||
|
const modes: Array<SelectableValue<ThresholdsMode>> = [
|
||||||
|
{ value: ThresholdsMode.Absolute, label: 'Absolute', description: 'Pick thresholds based on the absolute values' },
|
||||||
|
{
|
||||||
|
value: ThresholdsMode.Percentage,
|
||||||
|
label: 'Percentage',
|
||||||
|
description: 'Pick threshold based on the percent between min/max',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
thresholds: ThresholdsConfig;
|
||||||
|
onChange: (thresholds: ThresholdsConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
steps: ThresholdWithKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const steps = toThresholdsWithKey(props.thresholds!.steps);
|
||||||
|
steps[0].value = -Infinity;
|
||||||
|
|
||||||
|
this.state = { steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
onAddThreshold = () => {
|
||||||
|
const { steps } = this.state;
|
||||||
|
|
||||||
|
let nextValue = 0;
|
||||||
|
|
||||||
|
if (steps.length > 1) {
|
||||||
|
nextValue = steps[steps.length - 1].value + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = colors.filter(c => !steps.some(t => t.color === c))[1];
|
||||||
|
|
||||||
|
const add = {
|
||||||
|
value: nextValue,
|
||||||
|
color: color,
|
||||||
|
key: counter++,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newThresholds = [...steps, add];
|
||||||
|
sortThresholds(newThresholds);
|
||||||
|
|
||||||
|
this.setState({ steps: newThresholds }, this.onChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
onRemoveThreshold = (threshold: ThresholdWithKey) => {
|
||||||
|
const { steps } = this.state;
|
||||||
|
|
||||||
|
if (!steps.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't remove index 0
|
||||||
|
if (threshold.key === steps[0].key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ steps: steps.filter(t => t.key !== threshold.key) }, this.onChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
onChangeThresholdValue = (event: ChangeEvent<HTMLInputElement>, threshold: ThresholdWithKey) => {
|
||||||
|
const cleanValue = event.target.value.replace(/,/g, '.');
|
||||||
|
const parsedValue = parseFloat(cleanValue);
|
||||||
|
const value = isNaN(parsedValue) ? '' : parsedValue;
|
||||||
|
|
||||||
|
const steps = this.state.steps.map(t => {
|
||||||
|
if (t.key === threshold.key) {
|
||||||
|
t = { ...t, value: value as number };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (steps.length) {
|
||||||
|
steps[0].value = -Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortThresholds(steps);
|
||||||
|
this.setState({ steps });
|
||||||
|
};
|
||||||
|
|
||||||
|
onChangeThresholdColor = (threshold: ThresholdWithKey, color: string) => {
|
||||||
|
const { steps } = this.state;
|
||||||
|
|
||||||
|
const newThresholds = steps.map(t => {
|
||||||
|
if (t.key === threshold.key) {
|
||||||
|
t = { ...t, color: color };
|
||||||
|
}
|
||||||
|
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({ steps: newThresholds }, this.onChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
onBlur = () => {
|
||||||
|
const steps = [...this.state.steps];
|
||||||
|
sortThresholds(steps);
|
||||||
|
this.setState({ steps }, this.onChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange = () => {
|
||||||
|
this.props.onChange(thresholdsWithoutKey(this.props.thresholds, this.state.steps));
|
||||||
|
};
|
||||||
|
|
||||||
|
onModeChanged = (value?: ThresholdsMode) => {
|
||||||
|
this.props.onChange({
|
||||||
|
...this.props.thresholds,
|
||||||
|
mode: value!,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
renderInput(threshold: ThresholdWithKey, styles: ThresholdStyles) {
|
||||||
|
const isPercent = this.props.thresholds.mode === ThresholdsMode.Percentage;
|
||||||
|
|
||||||
|
if (!isFinite(threshold.value)) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={'Base'}
|
||||||
|
disabled
|
||||||
|
prefix={
|
||||||
|
threshold.color && (
|
||||||
|
<div className={styles.colorPicker}>
|
||||||
|
<ColorPicker
|
||||||
|
color={threshold.color}
|
||||||
|
onChange={color => this.onChangeThresholdColor(threshold, color)}
|
||||||
|
enableNamedColors={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
key={isPercent.toString()}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onChangeThresholdValue(event, threshold)}
|
||||||
|
value={threshold.value}
|
||||||
|
onBlur={this.onBlur}
|
||||||
|
prefix={
|
||||||
|
<div className={styles.inputPrefix}>
|
||||||
|
{threshold.color && (
|
||||||
|
<div className={styles.colorPicker}>
|
||||||
|
<ColorPicker
|
||||||
|
color={threshold.color}
|
||||||
|
onChange={color => this.onChangeThresholdColor(threshold, color)}
|
||||||
|
enableNamedColors={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPercent && <div className={styles.percentIcon}>%</div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
suffix={<Icon className={styles.trashIcon} name="trash" onClick={() => this.onRemoveThreshold(threshold)} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { thresholds } = this.props;
|
||||||
|
const { steps } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Consumer>
|
||||||
|
{theme => {
|
||||||
|
const styles = getStyles(theme);
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<FullWidthButtonContainer className={styles.addButton}>
|
||||||
|
<Button size="sm" icon="fa fa-plus" onClick={() => this.onAddThreshold()}>
|
||||||
|
Add threshold
|
||||||
|
</Button>
|
||||||
|
</FullWidthButtonContainer>
|
||||||
|
<div className={styles.thresholds}>
|
||||||
|
{steps
|
||||||
|
.slice(0)
|
||||||
|
.reverse()
|
||||||
|
.map(threshold => {
|
||||||
|
return (
|
||||||
|
<div className={styles.item} key={`${threshold.key}`}>
|
||||||
|
{this.renderInput(threshold, styles)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Threshold mode">
|
||||||
|
<FullWidthButtonContainer>
|
||||||
|
<RadioButtonGroup size="sm" options={modes} onChange={this.onModeChanged} value={thresholds.mode} />
|
||||||
|
</FullWidthButtonContainer>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ThemeContext.Consumer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThresholdWithKey extends Threshold {
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = 100;
|
||||||
|
|
||||||
|
function toThresholdsWithKey(steps?: Threshold[]): ThresholdWithKey[] {
|
||||||
|
if (!steps || steps.length === 0) {
|
||||||
|
steps = [{ value: -Infinity, color: 'green' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps.map(t => {
|
||||||
|
return {
|
||||||
|
color: t.color,
|
||||||
|
value: t.value === null ? -Infinity : t.value,
|
||||||
|
key: counter++,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thresholdsWithoutKey(thresholds: ThresholdsConfig, steps: ThresholdWithKey[]): ThresholdsConfig {
|
||||||
|
const mode = thresholds.mode ?? ThresholdsMode.Absolute;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
steps: steps.map(t => {
|
||||||
|
const { key, ...rest } = t;
|
||||||
|
return rest; // everything except key
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThresholdStyles {
|
||||||
|
wrapper: string;
|
||||||
|
thresholds: string;
|
||||||
|
item: string;
|
||||||
|
colorPicker: string;
|
||||||
|
addButton: string;
|
||||||
|
percentIcon: string;
|
||||||
|
inputPrefix: string;
|
||||||
|
trashIcon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = stylesFactory(
|
||||||
|
(theme: GrafanaTheme): ThresholdStyles => {
|
||||||
|
return {
|
||||||
|
wrapper: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: -${theme.spacing.formSpacingBase * 2}px;
|
||||||
|
`,
|
||||||
|
thresholds: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: ${theme.spacing.formSpacingBase * 2}px;
|
||||||
|
`,
|
||||||
|
item: css`
|
||||||
|
margin-bottom: ${theme.spacing.sm};
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
colorPicker: css`
|
||||||
|
padding: 0 ${theme.spacing.sm};
|
||||||
|
`,
|
||||||
|
addButton: css`
|
||||||
|
margin-bottom: ${theme.spacing.sm};
|
||||||
|
`,
|
||||||
|
percentIcon: css`
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
color: ${theme.colors.textWeak};
|
||||||
|
`,
|
||||||
|
inputPrefix: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`,
|
||||||
|
trashIcon: css`
|
||||||
|
color: ${theme.colors.textWeak};
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${theme.colors.text};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
@ -3,6 +3,7 @@ import { IconType } from '../Icon/types';
|
|||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { Button, ButtonVariant } from '../Forms/Button';
|
import { Button, ButtonVariant } from '../Forms/Button';
|
||||||
import { Select } from '../Forms/Select/Select';
|
import { Select } from '../Forms/Select/Select';
|
||||||
|
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
|
||||||
|
|
||||||
interface ValuePickerProps<T> {
|
interface ValuePickerProps<T> {
|
||||||
/** Label to display on the picker button */
|
/** Label to display on the picker button */
|
||||||
@ -21,9 +22,11 @@ export function ValuePicker<T>({ label, icon, options, onChange, variant }: Valu
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isPicking && (
|
{!isPicking && (
|
||||||
<Button onClick={() => setIsPicking(true)} variant={variant} icon={`fa fa-${icon}`}>
|
<FullWidthButtonContainer>
|
||||||
{label}
|
<Button size="sm" icon={`fa fa-${icon || 'plus'}`} onClick={() => setIsPicking(true)} variant={variant}>
|
||||||
</Button>
|
{label}
|
||||||
|
</Button>
|
||||||
|
</FullWidthButtonContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPicking && (
|
{isPicking && (
|
||||||
|
@ -90,6 +90,7 @@ export { LogRows } from './Logs/LogRows';
|
|||||||
export { getLogRowStyles } from './Logs/getLogRowStyles';
|
export { getLogRowStyles } from './Logs/getLogRowStyles';
|
||||||
export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup';
|
export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup';
|
||||||
// Panel editors
|
// Panel editors
|
||||||
|
export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer';
|
||||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||||
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
||||||
export * from './SingleStatShared/index';
|
export * from './SingleStatShared/index';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, FC } from 'react';
|
import React, { useState, FC } from 'react';
|
||||||
import { css } from 'emotion';
|
import { css, cx } from 'emotion';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { useTheme, Icon, stylesFactory } from '@grafana/ui';
|
import { useTheme, Icon, stylesFactory } from '@grafana/ui';
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export const OptionsGroup: FC<Props> = ({ title, children }) => {
|
|||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
<div className={styles.header} onClick={() => toggleExpand(!isExpanded)}>
|
<div className={styles.header} onClick={() => toggleExpand(!isExpanded)}>
|
||||||
{title}
|
{title}
|
||||||
<div className={styles.toggle}>
|
<div className={cx(styles.toggle, 'editor-options-group-toggle')}>
|
||||||
<Icon name={isExpanded ? 'chevron-down' : 'chevron-left'} />
|
<Icon name={isExpanded ? 'chevron-down' : 'chevron-left'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -31,6 +31,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
|
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
|
||||||
`,
|
`,
|
||||||
toggle: css`
|
toggle: css`
|
||||||
|
color: ${theme.colors.textWeak};
|
||||||
font-size: ${theme.typography.size.lg};
|
font-size: ${theme.typography.size.lg};
|
||||||
`,
|
`,
|
||||||
header: css`
|
header: css`
|
||||||
@ -40,9 +41,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: ${theme.spacing.sm} ${theme.spacing.md};
|
padding: ${theme.spacing.sm} ${theme.spacing.md};
|
||||||
font-weight: ${theme.typography.weight.semibold};
|
font-weight: ${theme.typography.weight.semibold};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.editor-options-group-toggle {
|
||||||
|
color: ${theme.colors.text};
|
||||||
|
}
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
body: css`
|
body: css`
|
||||||
padding: ${theme.spacing.md};
|
padding: 0 ${theme.spacing.md} ${theme.spacing.md} ${theme.spacing.md};
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user