diff --git a/packages/grafana-ui/src/components/Button/ButtonContent.tsx b/packages/grafana-ui/src/components/Button/ButtonContent.tsx index 8e818fd6814..d2e3d4e9f27 100644 --- a/packages/grafana-ui/src/components/Button/ButtonContent.tsx +++ b/packages/grafana-ui/src/components/Button/ButtonContent.tsx @@ -14,6 +14,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ `, icon: css` + position: relative; + top: 1px; & + * { margin-left: ${theme.spacing.sm}; } diff --git a/packages/grafana-ui/src/components/Button/FullWidthButtonContainer.tsx b/packages/grafana-ui/src/components/Button/FullWidthButtonContainer.tsx new file mode 100644 index 00000000000..ab7087ed30a --- /dev/null +++ b/packages/grafana-ui/src/components/Button/FullWidthButtonContainer.tsx @@ -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 = ({ className, children }) => { + const styles = getStyles(); + + return
{children}
; +}; + +const getStyles = stylesFactory(() => { + return css` + display: flex; + + button { + flex-grow: 1; + justify-content: center; + } + + > * { + flex-grow: 1; + } + + label { + flex-grow: 1; + text-align: center; + } + `; +}); diff --git a/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap b/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap index b45cfe5ac1d..b42e545ccf2 100644 --- a/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap +++ b/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Button renders correct html 1`] = `""`; +exports[`Button renders correct html 1`] = `""`; -exports[`LinkButton renders correct html 1`] = `"Click me"`; +exports[`LinkButton renders correct html 1`] = `"Click me"`; diff --git a/packages/grafana-ui/src/components/FieldConfigs/links.tsx b/packages/grafana-ui/src/components/FieldConfigs/links.tsx index 334b9d3fc76..d11d04eb445 100644 --- a/packages/grafana-ui/src/components/FieldConfigs/links.tsx +++ b/packages/grafana-ui/src/components/FieldConfigs/links.tsx @@ -12,6 +12,7 @@ import { Modal } from '../Modal/Modal'; import { DataLinkEditor } from '../DataLinks/DataLinkEditor'; import cloneDeep from 'lodash/cloneDeep'; import { VariableSuggestion } from '@grafana/data'; +import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer'; export interface DataLinksFieldConfigSettings {} @@ -60,9 +61,11 @@ export const DataLinksValueEditor: React.FC - Create data link - + + + Add data link + + ); }; diff --git a/packages/grafana-ui/src/components/FieldConfigs/standardFieldConfigEditors.tsx b/packages/grafana-ui/src/components/FieldConfigs/standardFieldConfigEditors.tsx index 6be072e8348..0e34560c5bd 100644 --- a/packages/grafana-ui/src/components/FieldConfigs/standardFieldConfigEditors.tsx +++ b/packages/grafana-ui/src/components/FieldConfigs/standardFieldConfigEditors.tsx @@ -141,12 +141,12 @@ export namespace StandardFieldConfigEditors { export const getStandardFieldConfigs = () => { return [ - StandardFieldConfigEditors.decimals, - StandardFieldConfigEditors.max, + StandardFieldConfigEditors.unit, StandardFieldConfigEditors.min, - StandardFieldConfigEditors.noValue, + StandardFieldConfigEditors.max, + StandardFieldConfigEditors.decimals, StandardFieldConfigEditors.thresholds, StandardFieldConfigEditors.title, - StandardFieldConfigEditors.unit, + StandardFieldConfigEditors.noValue, ]; }; diff --git a/packages/grafana-ui/src/components/FieldConfigs/thresholds.tsx b/packages/grafana-ui/src/components/FieldConfigs/thresholds.tsx index a62d10b186a..5b13085dc1d 100644 --- a/packages/grafana-ui/src/components/FieldConfigs/thresholds.tsx +++ b/packages/grafana-ui/src/components/FieldConfigs/thresholds.tsx @@ -7,7 +7,7 @@ import { ThresholdsConfig, ThresholdsMode, } from '@grafana/data'; -import { ThresholdsEditor } from '../ThresholdsEditor/ThresholdsEditor'; +import { ThresholdsEditor } from '../ThresholdsEditorNew/ThresholdsEditor'; export interface ThresholdsFieldConfigSettings { // Anything? @@ -42,7 +42,7 @@ export class ThresholdsValueEditor extends React.PureComponent< }; } - return ; + return ; } } diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.tsx b/packages/grafana-ui/src/components/Forms/Input/Input.tsx index 6741ecdc273..a3749ef4c88 100644 --- a/packages/grafana-ui/src/components/Forms/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Forms/Input/Input.tsx @@ -64,6 +64,19 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe .input { 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}; height: 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` diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index 048b2e1bbd2..fc9f92c0aa1 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -31,6 +31,7 @@ const getRadioButtonGroupStyles = () => { `, }; }; + interface RadioButtonGroupProps { value?: T; disabled?: boolean; diff --git a/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.story.tsx b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.story.tsx new file mode 100644 index 00000000000..00bcc501593 --- /dev/null +++ b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.story.tsx @@ -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 ; +}; + +export const WithThreshold = () => { + return ; +}; diff --git a/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx new file mode 100644 index 00000000000..b70b084234f --- /dev/null +++ b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx @@ -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) => { + const props: Props = { + onChange: jest.fn(), + thresholds: { mode: ThresholdsMode.Absolute, steps: [] }, + }; + + Object.assign(props, propOverrides); + + const wrapper = mount(); + 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; + + 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; + + 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' }, + ]); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx new file mode 100644 index 00000000000..c2c36588263 --- /dev/null +++ b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx @@ -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> = [ + { 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 { + 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, 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 ( + + this.onChangeThresholdColor(threshold, color)} + enableNamedColors={true} + /> + + ) + } + /> + ); + } + + return ( + ) => this.onChangeThresholdValue(event, threshold)} + value={threshold.value} + onBlur={this.onBlur} + prefix={ +
+ {threshold.color && ( +
+ this.onChangeThresholdColor(threshold, color)} + enableNamedColors={true} + /> +
+ )} + {isPercent &&
%
} +
+ } + suffix={ this.onRemoveThreshold(threshold)} />} + /> + ); + } + + render() { + const { thresholds } = this.props; + const { steps } = this.state; + + return ( + + {theme => { + const styles = getStyles(theme); + return ( +
+ + + +
+ {steps + .slice(0) + .reverse() + .map(threshold => { + return ( +
+ {this.renderInput(threshold, styles)} +
+ ); + })} +
+ + + + + + +
+ ); + }} +
+ ); + } +} + +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}; + } + `, + }; + } +); diff --git a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx index 93c97891dd2..2b6b1ed6943 100644 --- a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx +++ b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx @@ -3,6 +3,7 @@ import { IconType } from '../Icon/types'; import { SelectableValue } from '@grafana/data'; import { Button, ButtonVariant } from '../Forms/Button'; import { Select } from '../Forms/Select/Select'; +import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer'; interface ValuePickerProps { /** Label to display on the picker button */ @@ -21,9 +22,11 @@ export function ValuePicker({ label, icon, options, onChange, variant }: Valu return ( <> {!isPicking && ( - + + + )} {isPicking && ( diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 29ac39b4831..7d11161d121 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -90,6 +90,7 @@ export { LogRows } from './Logs/LogRows'; export { getLogRowStyles } from './Logs/getLogRowStyles'; export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup'; // Panel editors +export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer'; export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor'; export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper'; export * from './SingleStatShared/index'; diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsGroup.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsGroup.tsx index 7d98cf692db..106e98a35ac 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsGroup.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsGroup.tsx @@ -1,5 +1,5 @@ import React, { useState, FC } from 'react'; -import { css } from 'emotion'; +import { css, cx } from 'emotion'; import { GrafanaTheme } from '@grafana/data'; import { useTheme, Icon, stylesFactory } from '@grafana/ui'; @@ -16,7 +16,7 @@ export const OptionsGroup: FC = ({ title, children }) => {
toggleExpand(!isExpanded)}> {title} -
+
@@ -31,6 +31,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { border-bottom: 1px solid ${theme.colors.pageHeaderBorder}; `, toggle: css` + color: ${theme.colors.textWeak}; font-size: ${theme.typography.size.lg}; `, header: css` @@ -40,9 +41,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { align-items: center; padding: ${theme.spacing.sm} ${theme.spacing.md}; font-weight: ${theme.typography.weight.semibold}; + + &:hover { + .editor-options-group-toggle { + color: ${theme.colors.text}; + } + } `, body: css` - padding: ${theme.spacing.md}; + padding: 0 ${theme.spacing.md} ${theme.spacing.md} ${theme.spacing.md}; `, }; });