fix(ui): improve threshold settings validation

Closes #14175
Closes #14190
pull/14248/head
Christopher Henn 2019-06-24 17:14:09 -07:00
parent 3d9b9fe36f
commit c85a0f8b68
21 changed files with 791 additions and 793 deletions

View File

@ -38,6 +38,7 @@ const ColorDropdown: SFC<Props> = props => {
return (
<Dropdown
customClass="color-dropdown"
selectedID={selected.name}
onChange={onChoose}
status={status}

View File

@ -0,0 +1,102 @@
// Libraries
import React, {FunctionComponent} from 'react'
// Components
import {
Input,
Button,
ButtonShape,
IconFont,
ComponentStatus,
} from '@influxdata/clockface'
import ColorDropdown from 'src/shared/components/ColorDropdown'
// Constants
import {
THRESHOLD_COLORS,
BASE_THRESHOLD_ID,
COLOR_TYPE_MIN,
COLOR_TYPE_MAX,
} from 'src/shared/constants/thresholds'
interface Props {
id: string
type: string
name: string
value: string
error?: string
onChangeValue: (value: string) => void
onChangeColor: (name: string, hex: string) => void
onRemove: () => void
onBlur: () => void
}
const ThresholdSetting: FunctionComponent<Props> = ({
id,
type,
name,
value,
error,
onChangeValue,
onChangeColor,
onRemove,
onBlur,
}) => {
const isBaseThreshold = id === BASE_THRESHOLD_ID
let label: string = ''
if (isBaseThreshold) {
label = 'Base'
} else if (type === COLOR_TYPE_MIN) {
label = 'Minimum'
} else if (type === COLOR_TYPE_MAX) {
label = 'Maximum'
} else {
label = 'Value is <='
}
const isRemoveable =
!isBaseThreshold && type !== COLOR_TYPE_MIN && type !== COLOR_TYPE_MAX
const inputStatus = error ? ComponentStatus.Error : ComponentStatus.Default
return (
<div className="threshold-setting" data-test-id={id}>
<div className="threshold-setting--controls">
<div className="threshold-setting--label">{label}</div>
{!isBaseThreshold && (
<Input
className="threshold-setting--value"
value={value}
status={inputStatus}
onChange={e => onChangeValue(e.target.value)}
onBlur={onBlur}
onKeyDown={e => {
if (e.key === 'Enter') {
onBlur()
}
}}
/>
)}
<ColorDropdown
colors={THRESHOLD_COLORS}
selected={THRESHOLD_COLORS.find(d => d.name === name)}
onChoose={({name, hex}) => onChangeColor(name, hex)}
stretchToFit={true}
/>
{isRemoveable && (
<Button
className="threshold-setting--remove"
icon={IconFont.Remove}
shape={ButtonShape.Square}
onClick={onRemove}
/>
)}
</div>
{error && <div className="threshold-setting--error">{error}</div>}
</div>
)
}
export default ThresholdSetting

View File

@ -0,0 +1,50 @@
.threshold-setting {
margin: $ix-marg-b 0;
}
.threshold-setting--controls {
display: flex;
justify-content: stretch;
> * {
margin-right: $ix-marg-a;
&:last-child {
margin-right: 0;
}
}
}
.threshold-setting--label,
.threshold-setting--error {
font-weight: 600;
font-size: $form-xs-font;
}
.threshold-setting--label {
flex: 0 0 70px;
display: flex;
align-items: center;
height: $form-sm-height;
justify-content: flex-end;
padding: 0 $ix-marg-b;
border-radius: $radius;
@include no-user-select();
background-color: $g4-onyx;
color: $g13-mist;
}
.threshold-setting--error {
grid-column: 1 / 4;
align-self: center;
color: $c-curacao;
margin: $ix-marg-b 0;
}
.threshold-setting--value {
flex: 1 0 110px;
}
.threshold-setting--remove {
flex: 0 0 auto;
}

View File

@ -0,0 +1,174 @@
import * as React from 'react'
import {useState} from 'react'
import {render, fireEvent, wait} from 'react-testing-library'
import ThresholdsSettings from 'src/shared/components/ThresholdsSettings'
import {BASE_THRESHOLD_ID} from 'src/shared/constants/thresholds'
describe('ThresholdSettings', () => {
const getErrorMessage = (container, thresholdID) => {
const node = container.querySelector(
`.threshold-setting[data-test-id='${thresholdID}'] .threshold-setting--error`
)
return node ? node.textContent.trim() : null
}
const getInput = (container, thresholdID) =>
container.querySelector(
`.threshold-setting[data-test-id='${thresholdID}'] input`
)
test('making then correcting an error', () => {
const thresholds = [
{
id: BASE_THRESHOLD_ID,
type: 'threshold',
name: 'thunder',
hex: '',
value: null,
},
{id: '0', type: 'threshold', name: 'fire', hex: '', value: 30},
]
const {container} = render(
<ThresholdsSettings thresholds={thresholds} onSetThresholds={jest.fn()} />
)
// Enter an invalid value in the input
fireEvent.change(getInput(container, '0'), {
target: {value: 'baloney'},
})
// Blur the input
fireEvent.blur(getInput(container, '0'))
// Expect an error message to exist
expect(getErrorMessage(container, '0')).toEqual(
'Please enter a valid number'
)
// Enter a valid value in the input
fireEvent.change(getInput(container, '0'), {
target: {value: '9000'},
})
// Blur the input
fireEvent.blur(getInput(container, '0'))
// Expect there to be no error
expect(getErrorMessage(container, '0')).toBeNull()
})
test('entering value less than min threshold shows error', () => {
const thresholds = [
{id: '0', type: 'min', name: 'thunder', hex: '', value: 20},
{id: '1', type: 'threshold', name: 'fire', hex: '', value: 30},
{id: '2', type: 'max', name: 'ruby', hex: '', value: 60},
]
const {container} = render(
<ThresholdsSettings thresholds={thresholds} onSetThresholds={jest.fn()} />
)
// Enter a value in the input
fireEvent.change(getInput(container, '1'), {
target: {value: '10'},
})
// Blur the input
fireEvent.blur(getInput(container, '1'))
// Expect an error message to exist
expect(getErrorMessage(container, '1')).toEqual(
'Please enter a value greater than the minimum threshold'
)
})
test('entering value greater than max threshold shows error', () => {
const thresholds = [
{id: '0', type: 'min', name: 'thunder', hex: '', value: 20},
{id: '1', type: 'threshold', name: 'fire', hex: '', value: 30},
{id: '2', type: 'max', name: 'ruby', hex: '', value: 60},
]
const {container} = render(
<ThresholdsSettings thresholds={thresholds} onSetThresholds={jest.fn()} />
)
// Enter a value in the input
fireEvent.change(getInput(container, '1'), {
target: {value: '80'},
})
// Blur the input
fireEvent.blur(getInput(container, '1'))
// Expect an error message to be called
expect(getErrorMessage(container, '1')).toEqual(
'Please enter a value less than the maximum threshold'
)
})
test('broadcasts edited thresholds only when changes are valid', async () => {
const handleSetThresholdsSpy = jest.fn()
const TestWrapper = () => {
const [thresholds, setThresholds] = useState([
{id: '0', type: 'min', name: 'thunder', hex: '', value: 20},
{id: '1', type: 'threshold', name: 'fire', hex: '', value: 30},
{id: '2', type: 'max', name: 'ruby', hex: '', value: 60},
])
const [didRerender, setDidRerender] = useState(false)
const handleSetThresholds = newThresholds => {
setThresholds(newThresholds)
setDidRerender(true)
handleSetThresholdsSpy(newThresholds)
}
return (
<>
{didRerender && <div data-testid="did-rerender" />}
<ThresholdsSettings
thresholds={thresholds}
onSetThresholds={handleSetThresholds}
/>
</>
)
}
const {container, getByTestId} = render(<TestWrapper />)
// Enter an invalid value in the input
fireEvent.change(getInput(container, '1'), {
target: {value: 'baloney'},
})
// Blur the input
fireEvent.blur(getInput(container, '1'))
// Now enter a valid value
fireEvent.change(getInput(container, '1'), {
target: {value: '40'},
})
// Blur the input again
fireEvent.blur(getInput(container, '1'))
// Wait for the changes to propogate to the test component
await wait(() => {
getByTestId('did-rerender')
})
// Changed thresholds should only have emitted once
expect(handleSetThresholdsSpy).toHaveBeenCalledTimes(1)
// ...with the expected values
expect(handleSetThresholdsSpy.mock.calls[0][0]).toEqual([
{id: '0', type: 'min', name: 'thunder', hex: '', value: 20},
{id: '1', type: 'threshold', name: 'fire', hex: '', value: 40},
{id: '2', type: 'max', name: 'ruby', hex: '', value: 60},
])
})
})

View File

@ -0,0 +1,189 @@
// Libraries
import React, {useMemo, useEffect, FunctionComponent} from 'react'
import {cloneDeep} from 'lodash'
// Components
import ThresholdSetting from 'src/shared/components/ThresholdSetting'
import {Button, ButtonShape, IconFont} from '@influxdata/clockface'
// Utils
import {useOneWayReducer} from 'src/shared/utils/useOneWayReducer'
import {
sortThresholds,
validateThresholds,
addThreshold,
} from 'src/shared/utils/thresholds'
// Types
import {Color} from 'src/types'
interface Props {
thresholds: Color[]
onSetThresholds: (thresholds: Color[]) => void
}
interface State {
thresholds: Color[]
inputs: {[thresholdID: string]: string}
errors: {[thresholdID: string]: string}
isValid: boolean
isDirty: boolean
}
type Action =
| {type: 'COLOR_CHANGED'; id: string; name: string; hex: string}
| {type: 'VALUE_CHANGED'; id: string; value: string}
| {type: 'VALUE_BLURRED'; id: string}
| {type: 'THRESHOLD_REMOVED'; id: string}
| {type: 'THRESHOLD_ADDED'}
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'COLOR_CHANGED': {
const {id, name, hex} = action
const thresholds = state.thresholds.map(threshold =>
threshold.id === id ? {...threshold, name, hex} : threshold
)
return {...state, thresholds, isDirty: true}
}
case 'VALUE_CHANGED': {
const {id, value} = action
const inputs = {...state.inputs, [id]: value}
return {...state, inputs, isDirty: true, isValid: false}
}
case 'VALUE_BLURRED': {
const thresholds = state.thresholds.map(threshold =>
threshold.id === action.id
? {...threshold, value: parseFloat(state.inputs[action.id])}
: threshold
)
const errors = validateThresholds(thresholds)
const isValid = Object.values(errors).length === 0
return {...state, thresholds, errors, isValid}
}
case 'THRESHOLD_ADDED': {
const newThreshold = addThreshold(state.thresholds)
const thresholds = sortThresholds([...state.thresholds, newThreshold])
const inputs = {
...state.inputs,
[newThreshold.id]: String(newThreshold.value),
}
return {...state, thresholds, inputs, isDirty: true}
}
case 'THRESHOLD_REMOVED': {
const thresholds = state.thresholds.filter(
threshold => threshold.id !== action.id
)
return {...state, thresholds, isDirty: true}
}
default:
const unknownAction: never = action
const unknownActionType = (unknownAction as any).type
throw new Error(
`unhandled action of type "${unknownActionType}" in ThresholdsSettings`
)
}
}
const ThresholdsSettings: FunctionComponent<Props> = ({
thresholds,
onSetThresholds,
}) => {
const initialState: State = useMemo(
() => ({
thresholds: sortThresholds(
cloneDeep(thresholds.filter(({type}) => type !== 'scale'))
),
inputs: thresholds.reduce(
(acc, {id, value}) => ({...acc, [id]: String(value)}),
{}
),
errors: {},
isDirty: false,
isValid: true,
}),
[thresholds]
)
const [state, dispatch] = useOneWayReducer(reducer, initialState)
useEffect(() => {
if (state.isDirty && state.isValid) {
onSetThresholds(state.thresholds)
}
}, [state])
return (
<div className="thresholds-settings">
<Button
className="thresholds-settings--add"
shape={ButtonShape.StretchToFit}
icon={IconFont.Plus}
text="Add a Threshold"
onClick={() => dispatch({type: 'THRESHOLD_ADDED'})}
/>
{state.thresholds.map(threshold => {
const onChangeValue = value =>
dispatch({
type: 'VALUE_CHANGED',
id: threshold.id,
value,
})
const onChangeColor = (name, hex) =>
dispatch({
type: 'COLOR_CHANGED',
id: threshold.id,
name,
hex,
})
const onRemove = () =>
dispatch({
type: 'THRESHOLD_REMOVED',
id: threshold.id,
})
const onBlur = () =>
dispatch({
type: 'VALUE_BLURRED',
id: threshold.id,
})
return (
<ThresholdSetting
key={threshold.id}
id={threshold.id}
name={threshold.name}
type={threshold.type}
value={state.inputs[threshold.id]}
error={state.errors[threshold.id]}
onBlur={onBlur}
onRemove={onRemove}
onChangeValue={onChangeValue}
onChangeColor={onChangeColor}
/>
)
})}
</div>
)
}
export default ThresholdsSettings

View File

@ -21,7 +21,7 @@ import {ErrorHandling} from 'src/shared/decorators/errors'
// Types
import {ComponentStatus} from '@influxdata/clockface'
import {LogsTableColumn} from 'src/types/logs'
import {FieldOption} from 'src/types'
// Constants
const columnType = 'column'
@ -33,7 +33,7 @@ interface Props {
index: number
id: string
key: string
onUpdateColumn: (column: LogsTableColumn) => void
onUpdateColumn: (column: FieldOption) => void
isDragging?: boolean
connectDragSource?: ConnectDragSource
connectDropTarget?: ConnectDropTarget

View File

@ -3,7 +3,7 @@ import chroma from 'chroma-js'
import {
THRESHOLD_COLORS,
THRESHOLD_TYPE_BASE,
BASE_THRESHOLD_ID,
THRESHOLD_TYPE_TEXT,
} from 'src/shared/constants/thresholds'
@ -52,7 +52,7 @@ export const generateThresholdsListHexs = ({
}
// baseColor is expected in all cases
const baseColor = colors.find(color => color.id === THRESHOLD_TYPE_BASE) || {
const baseColor = colors.find(color => color.id === BASE_THRESHOLD_ID) || {
hex: defaultColoring.textColor,
}

View File

@ -11,7 +11,8 @@ export const COLOR_TYPE_THRESHOLD = 'threshold'
export const THRESHOLD_TYPE_TEXT = 'text'
export const THRESHOLD_TYPE_BG = 'background'
export const THRESHOLD_TYPE_BASE = 'base'
export const BASE_THRESHOLD_ID = 'base'
export const THRESHOLD_COLORS = [
{
@ -113,7 +114,7 @@ export const DEFAULT_THRESHOLDS_LIST_COLORS = [
{
type: THRESHOLD_TYPE_TEXT,
hex: THRESHOLD_COLORS[11].hex,
id: THRESHOLD_TYPE_BASE,
id: BASE_THRESHOLD_ID,
name: THRESHOLD_COLORS[11].name,
value: 0,
},

View File

@ -0,0 +1,142 @@
// Libraries
import uuid from 'uuid'
// Constants
import {
THRESHOLD_COLORS,
DEFAULT_VALUE_MIN,
DEFAULT_VALUE_MAX,
COLOR_TYPE_THRESHOLD,
COLOR_TYPE_MIN,
COLOR_TYPE_MAX,
BASE_THRESHOLD_ID,
} from 'src/shared/constants/thresholds'
// Types
import {Color} from 'src/types'
/*
Sort a list of thresholds for rendering.
- Base or minimum thresholds come first
- Max thresholds come last
- All other thresholds are sorted by value
*/
export const sortThresholds = (thresholds: Color[]): Color[] => {
const result = [...thresholds]
result.sort((a, b) =>
a.id === BASE_THRESHOLD_ID ? -Infinity : a.value - b.value
)
return result
}
/*
Given a list of thresholds, return an object of error messages for any
invalid threshold in the list.
A threshold is invalid if:
- Its value is NaN
- Its value is less than the min threshold in the list
- Its value is more than the max threshold in the list
*/
export const validateThresholds = (
thresholds: Color[]
): {[thresholdID: string]: string} => {
const minThreshold = thresholds.find(({type}) => type === COLOR_TYPE_MIN)
const maxThreshold = thresholds.find(({type}) => type === COLOR_TYPE_MAX)
const errors = {}
for (const {id, value, type} of thresholds) {
if (isNaN(value)) {
errors[id] = 'Please enter a valid number'
} else if (
minThreshold &&
type !== COLOR_TYPE_MIN &&
value < minThreshold.value
) {
errors[id] = 'Please enter a value greater than the minimum threshold'
} else if (
maxThreshold &&
type !== COLOR_TYPE_MAX &&
value > maxThreshold.value
) {
errors[id] = 'Please enter a value less than the maximum threshold'
}
}
return errors
}
/*
Given a list of thresholds, produce a new threshold that is suitable for
adding to the list.
*/
export const addThreshold = (thresholds: Color[]): Color => {
const values = thresholds.map(threshold => threshold.value)
let minValue = Math.min(...values)
let maxValue = Math.max(...values)
if (minValue === Infinity || isNaN(minValue) || minValue === maxValue) {
minValue = DEFAULT_VALUE_MIN
maxValue = DEFAULT_VALUE_MAX
}
const value = randomTick(minValue, maxValue)
const colorChoice =
THRESHOLD_COLORS[Math.floor(Math.random() * THRESHOLD_COLORS.length)]
const firstThresholdType = thresholds[0].type
const thresholdType =
firstThresholdType === COLOR_TYPE_MIN ||
firstThresholdType === COLOR_TYPE_MAX
? COLOR_TYPE_THRESHOLD
: firstThresholdType
const threshold = {
...colorChoice,
id: uuid.v4(),
type: thresholdType,
value,
}
return threshold
}
/*
Generate a nice random number between `min` and `max` inclusive.
*/
const randomTick = (min: number, max: number): number => {
const domainWidth = max - min
let roundTo
if (domainWidth > 1000) {
roundTo = 100
} else if (domainWidth > 100) {
roundTo = 10
} else if (domainWidth > 50) {
roundTo = 5
} else if (domainWidth > 10) {
roundTo = 1
} else {
roundTo = null
}
let value: number
if (roundTo) {
value = Math.round((Math.random() * (max - min)) / roundTo) * roundTo
} else {
value = Number((Math.random() * (max - min)).toFixed(2))
}
return value
}

View File

@ -0,0 +1,52 @@
import {
useRef,
useReducer,
useCallback,
Reducer,
ReducerState,
ReducerAction,
Dispatch,
} from 'react'
/*
Works like `useReducer`, except if the `defaultState` argument changes then
the reducer state will reset to `defaultState`.
It is assumed that the passed `reducer` function has a stable identity over
the lifetime of the component.
*/
export const useOneWayReducer = <R extends Reducer<any, any>>(
reducer: R,
defaultState: ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>] => {
// The value of `defaultState` the last time the hook was called
const prevDefaultState = useRef(defaultState)
// Whether or not the next run of the reducer should be against its internal
// state, or against the defaultState
const reduceDefaultState = useRef(false)
const wrappedReducer = useCallback(
(state: ReducerState<R>, action: ReducerAction<R>) => {
if (reduceDefaultState.current) {
reduceDefaultState.current = false
return reducer(prevDefaultState.current, action)
}
return reducer(state, action)
},
[]
)
const [reducerState, dispatch] = useReducer(wrappedReducer, defaultState)
if (defaultState !== prevDefaultState.current) {
reduceDefaultState.current = true
prevDefaultState.current = defaultState
return [defaultState, dispatch]
}
return [reducerState, dispatch]
}

View File

@ -95,7 +95,6 @@
@import 'src/timeMachine/components/variableToolbar/VariableToolbar.scss';
@import 'src/timeMachine/components/fluxFunctionsToolbar/FluxFunctionsToolbar.scss';
@import 'src/timeMachine/components/view_options/TimeFormat.scss';
@import 'src/timeMachine/components/view_options/ThresholdList.scss';
@import 'src/timeMachine/components/view_options/HistogramOptions.scss';
@import 'src/timeMachine/components/view_options/ViewOptions.scss';
@import 'src/timeMachine/components/view_options/ViewTypeDropdown.scss';
@ -109,6 +108,8 @@
@import 'src/shared/components/dapperScrollbars/DapperScrollbars.scss';
@import 'src/templates/components/createFromTemplateOverlay/CreateFromTemplateOverlay.scss';
@import 'src/onboarding/components/SigninForm.scss';
@import 'src/shared/components/ThresholdsSettings.scss';
// External
@import '../../node_modules/@influxdata/react-custom-scrollbars/dist/styles.css';

View File

@ -10,7 +10,6 @@ import {MIN_DECIMAL_PLACES, MAX_DECIMAL_PLACES} from 'src/dashboards/constants'
// Types
import {DecimalPlaces} from 'src/types'
import {Columns} from '@influxdata/clockface'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
@ -27,7 +26,7 @@ class DecimalPlacesOption extends PureComponent<Props> {
public render() {
return (
<Grid.Column widthXS={Columns.Six}>
<Grid.Column>
<Form.Element label="Decimal Places">
<AutoInput
name="decimal-places"

View File

@ -6,7 +6,7 @@ import {connect} from 'react-redux'
import {Grid} from '@influxdata/clockface'
import Affixes from 'src/timeMachine/components/view_options/Affixes'
import DecimalPlacesOption from 'src/timeMachine/components/view_options/DecimalPlaces'
import ThresholdList from 'src/timeMachine/components/view_options/ThresholdList'
import ThresholdsSettings from 'src/shared/components/ThresholdsSettings'
// Actions
import {
@ -19,7 +19,7 @@ import {
// Types
import {ViewType} from 'src/types'
import {DecimalPlaces} from 'src/types/dashboards'
import {Color, ThresholdConfig} from 'src/types/colors'
import {Color} from 'src/types/colors'
interface OwnProps {
type: ViewType
@ -63,11 +63,12 @@ class GaugeOptions extends PureComponent<Props> {
<Grid.Column>
<h4 className="view-options--header">Colorized Thresholds</h4>
</Grid.Column>
<ThresholdList
colorConfigs={this.colorConfigs}
onUpdateColors={onUpdateColors}
onValidateNewColor={this.handleValidateNewColor}
/>
<Grid.Column>
<ThresholdsSettings
thresholds={this.props.colors}
onSetThresholds={onUpdateColors}
/>
</Grid.Column>
</>
)
}
@ -87,80 +88,6 @@ class GaugeOptions extends PureComponent<Props> {
/>
)
}
private get colorConfigs(): ThresholdConfig[] {
const {maxColor, minColor} = this.extents
const {colors} = this.props
return colors.map(color => {
switch (color.id) {
case minColor.id:
return {
color,
isDeletable: false,
disableColor: false,
label: 'Minimum',
}
case maxColor.id:
return {
color,
isDeletable: false,
disableColor: colors.length > 2,
label: 'Maximum',
}
default:
return {color}
}
})
}
private get extents(): {minColor: Color; maxColor: Color} {
const first = this.props.colors[0]
const defaults = {minColor: first, maxColor: first}
return this.props.colors.reduce((extents, color) => {
if (!extents.minColor || extents.minColor.value > color.value) {
return {...extents, minColor: color}
} else if (!extents.minColor || extents.maxColor.value < color.value) {
return {...extents, maxColor: color}
}
return extents
}, defaults)
}
private handleValidateNewColor = (sortedColors: Color[], newColor: Color) => {
const newColorValue = newColor.value
let allowedToUpdate = false
const minValue = sortedColors[0].value
const maxValue = sortedColors[sortedColors.length - 1].value
if (newColorValue === minValue) {
const nextValue = sortedColors[1].value
allowedToUpdate = newColorValue < nextValue
} else if (newColorValue === maxValue) {
const previousValue = sortedColors[sortedColors.length - 2].value
allowedToUpdate = previousValue < newColorValue
} else {
const greaterThanMin = newColorValue > minValue
const lessThanMax = newColorValue < maxValue
const colorsWithoutMinOrMax = sortedColors.slice(
1,
sortedColors.length - 1
)
const isUnique = !colorsWithoutMinOrMax.some(
color => color.value === newColorValue && color.id !== newColor.id
)
allowedToUpdate = greaterThanMin && lessThanMax && isUnique
}
return allowedToUpdate
}
}
const mdtp: DispatchProps = {

View File

@ -6,8 +6,8 @@ import {connect} from 'react-redux'
import {Grid} from '@influxdata/clockface'
import Affixes from 'src/timeMachine/components/view_options/Affixes'
import DecimalPlacesOption from 'src/timeMachine/components/view_options/DecimalPlaces'
import ThresholdList from 'src/timeMachine/components/view_options/ThresholdList'
import ThresholdColoring from 'src/timeMachine/components/view_options/ThresholdColoring'
import ThresholdsSettings from 'src/shared/components/ThresholdsSettings'
// Actions
import {
@ -20,14 +20,14 @@ import {
// Utils
import {getActiveTimeMachine} from 'src/timeMachine/selectors'
// Constants
import {THRESHOLD_TYPE_BASE} from 'src/shared/constants/thresholds'
// Types
import {AppState, NewView} from 'src/types'
import {SingleStatView} from 'src/types/dashboards'
import {DecimalPlaces} from 'src/types/dashboards'
import {Color, ThresholdConfig} from 'src/types/colors'
import {
AppState,
NewView,
Color,
SingleStatView,
DecimalPlaces,
} from 'src/types'
interface StateProps {
colors: Color[]
@ -57,23 +57,6 @@ const SingleStatOptions: SFC<Props> = props => {
onSetColors,
} = props
const colorConfigs = colors
.filter(c => c.type !== 'scale')
.map(color => {
const isBase = color.id === THRESHOLD_TYPE_BASE
const config: ThresholdConfig = {
color,
isBase,
}
if (isBase) {
config.label = 'Base'
}
return config
})
return (
<>
<Grid.Column>
@ -95,11 +78,7 @@ const SingleStatOptions: SFC<Props> = props => {
<Grid.Column>
<h4 className="view-options--header">Colorized Thresholds</h4>
</Grid.Column>
<ThresholdList
colorConfigs={colorConfigs}
onUpdateColors={onSetColors}
onValidateNewColor={() => true}
/>
<ThresholdsSettings thresholds={colors} onSetThresholds={onSetColors} />
<Grid.Column>
<ThresholdColoring />
</Grid.Column>

View File

@ -4,15 +4,14 @@ import {connect} from 'react-redux'
// Components
import DecimalPlacesOption from 'src/timeMachine/components/view_options/DecimalPlaces'
import ThresholdList from 'src/timeMachine/components/view_options/ThresholdList'
import ColumnOptions from 'src/shared/components/columns_options/ColumnsOptions'
import FixFirstColumn from 'src/timeMachine/components/view_options/FixFirstColumn'
import TimeFormat from 'src/timeMachine/components/view_options/TimeFormat'
import SortBy from 'src/timeMachine/components/view_options/SortBy'
import {Grid} from '@influxdata/clockface'
import ThresholdsSettings from 'src/shared/components/ThresholdsSettings'
// Constants
import {THRESHOLD_TYPE_BASE} from 'src/shared/constants/thresholds'
// Actions
import {
@ -38,7 +37,6 @@ import {
FieldOption,
TableOptions as ViewTableOptions,
Color,
ThresholdConfig,
} from 'src/types'
import {move} from 'src/shared/utils/move'
@ -68,6 +66,7 @@ export class TableOptions extends Component<Props, {}> {
onSetColors,
fieldOptions,
tableOptions,
colors,
decimalPlaces,
onSetTimeFormat,
onSetDecimalPlaces,
@ -126,11 +125,12 @@ export class TableOptions extends Component<Props, {}> {
<Grid.Column>
<h4 className="view-options--header">Colorized Thresholds</h4>
</Grid.Column>
<ThresholdList
colorConfigs={this.colorConfigs}
onUpdateColors={onSetColors}
onValidateNewColor={() => true}
/>
<Grid.Column>
<ThresholdsSettings
thresholds={colors}
onSetThresholds={onSetColors}
/>
</Grid.Column>
</>
)
}
@ -159,23 +159,6 @@ export class TableOptions extends Component<Props, {}> {
const fixFirstColumn = !tableOptions.fixFirstColumn
onSetTableOptions({...tableOptions, fixFirstColumn})
}
private get colorConfigs(): ThresholdConfig[] {
return this.props.colors.map(color => {
const isBase = color.id === THRESHOLD_TYPE_BASE
const config: ThresholdConfig = {
color,
isBase,
}
if (isBase) {
config.label = 'Base'
}
return config
})
}
}
const mstp = (state: AppState) => {

View File

@ -1,161 +0,0 @@
// Libraries
import React, {PureComponent, ChangeEvent, KeyboardEvent} from 'react'
// Components
import {Input, SquareButton} from '@influxdata/clockface'
// Types
import {
IconFont,
InputType,
ButtonType,
ComponentSize,
ComponentStatus,
} from '@influxdata/clockface'
import ColorDropdown from 'src/shared/components/ColorDropdown'
import {THRESHOLD_COLORS} from 'src/shared/constants/thresholds'
import {Color, ColorLabel} from 'src/types/colors'
import {SeverityColor, SeverityColorOptions} from 'src/types/logs'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
threshold: Color
label: string
isBase: boolean
isDeletable: boolean
disableColor: boolean
onChooseColor: (threshold: Color) => void
onValidateColorValue: (threshold: Color, targetValue: number) => boolean
onUpdateColorValue: (threshold: Color, targetValue: number) => void
onDeleteThreshold: (threshold: Color) => void
}
interface State {
workingValue: number | string
valid: boolean
}
@ErrorHandling
class Threshold extends PureComponent<Props, State> {
public static defaultProps = {
label: 'Value is <=',
disableColor: false,
isDeletable: true,
isBase: false,
}
constructor(props) {
super(props)
this.state = {
workingValue: this.props.threshold.value,
valid: true,
}
}
public render() {
const {isDeletable, disableColor, isBase} = this.props
const {workingValue} = this.state
return (
<div className="threshold-item">
<div className="threshold-item--label">{this.props.label}</div>
{!isBase && (
<Input
value={workingValue.toString()}
className="threshold-item--input"
type={InputType.Number}
onChange={this.handleChangeWorkingValue}
onBlur={this.handleBlur}
onKeyUp={this.handleKeyUp}
status={this.inputStatus}
/>
)}
<ColorDropdown
colors={THRESHOLD_COLORS}
selected={this.selectedColor}
onChoose={this.handleChooseColor}
disabled={disableColor}
stretchToFit={isBase}
widthPixels={this.dropdownWidthPixels}
/>
{isDeletable && !isBase && (
<SquareButton
size={ComponentSize.Small}
onClick={this.handleDelete}
icon={IconFont.Remove}
type={ButtonType.Button}
/>
)}
</div>
)
}
private handleChooseColor = (color: ColorLabel): void => {
const {onChooseColor, threshold} = this.props
const {hex, name} = color
onChooseColor({...threshold, hex, name})
}
private get dropdownWidthPixels(): number {
const {isDeletable} = this.props
return isDeletable ? 124 : 124 + 34
}
private get selectedColor(): SeverityColor {
const {
threshold: {hex, name},
} = this.props
const colorName = name as SeverityColorOptions
return {hex, name: colorName}
}
private get inputStatus(): ComponentStatus {
const {valid} = this.state
if (!valid) {
return ComponentStatus.Error
}
return ComponentStatus.Valid
}
private handleChangeWorkingValue = (e: ChangeEvent<HTMLInputElement>) => {
const {threshold, onValidateColorValue} = this.props
const targetValue = e.target.value
const valid = onValidateColorValue(threshold, Number(targetValue))
this.setState({valid, workingValue: targetValue})
}
private handleBlur = () => {
const {valid, workingValue} = this.state
const {threshold, onUpdateColorValue} = this.props
if (valid) {
onUpdateColorValue(threshold, Number(workingValue))
} else {
this.setState({workingValue: threshold.value, valid: true})
}
}
private handleKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}
private handleDelete = () => {
const {threshold, onDeleteThreshold} = this.props
onDeleteThreshold(threshold)
}
}
export default Threshold

View File

@ -1,48 +0,0 @@
/*
Threshold Controls
------------------------------------------------------------------------------
Used primarily within the View Editor Overlay for Single Stat, Gauge,
and Table type cells
*/
.threshold-list {
display: flex;
flex-direction: column;
align-items: stretch;
}
.threshold-item {
display: flex;
flex-wrap: nowrap;
align-items: center;
height: $form-sm-height;
margin-top: $ix-marg-b;
> * {
margin-left: $ix-marg-a;
&:first-child {
margin-left: 0;
}
}
}
.threshold-item--label {
flex: 0 0 76px;
display: flex;
align-items: center;
justify-content: space-between;
height: $form-sm-height;
font-weight: 600;
font-size: $form-xs-font;
padding-left: $ix-marg-b;
padding-right: $ix-marg-a;
border-radius: $radius;
@include no-user-select();
background-color: $g4-onyx;
color: $g13-mist;
}
.threshold-item--input {
flex: 1 1 0;
}

View File

@ -1,160 +0,0 @@
// Libraries
import React, {PureComponent} from 'react'
import _ from 'lodash'
import uuid from 'uuid'
// Components
import ThresholdItem from 'src/timeMachine/components/view_options/ThresholdItem'
import {Form, Button, Grid} from '@influxdata/clockface'
// Constants
import {
COLOR_TYPE_THRESHOLD,
THRESHOLD_COLORS,
MAX_THRESHOLDS,
DEFAULT_VALUE_MAX,
} from 'src/shared/constants/thresholds'
// Types
import {
IconFont,
ButtonType,
ComponentSize,
ComponentStatus,
} from '@influxdata/clockface'
import {Color, ThresholdConfig} from 'src/types/colors'
interface Props {
colorConfigs: ThresholdConfig[]
onUpdateColors: (colors: Color[]) => void
onValidateNewColor: (colors: Color[], newColor: Color) => boolean
}
class ThresholdList extends PureComponent<Props> {
public render() {
return (
<Grid.Column>
<Form.Element label="Thresholds">
<div className="threshold-list">
<Button
size={ComponentSize.Small}
onClick={this.handleAddThreshold}
status={this.disableAddThreshold}
icon={IconFont.Plus}
type={ButtonType.Button}
text="Add a Threshold"
/>
{this.sortedColorConfigs.map<JSX.Element>(colorConfig => {
const {
color: threshold,
isDeletable,
isBase,
disableColor,
label,
} = colorConfig
return (
<ThresholdItem
label={label}
key={uuid.v4()}
threshold={threshold}
isBase={isBase}
isDeletable={isDeletable}
disableColor={disableColor}
onChooseColor={this.handleChooseColor}
onDeleteThreshold={this.handleDeleteThreshold}
onUpdateColorValue={this.handleUpdateColorValue}
onValidateColorValue={this.handleValidateColorValue}
/>
)
})}
</div>
</Form.Element>
</Grid.Column>
)
}
private handleAddThreshold = () => {
const sortedColors = this.sortedColorConfigs.map(config => config.color)
if (sortedColors.length <= MAX_THRESHOLDS) {
const randomColor = _.random(0, THRESHOLD_COLORS.length - 1)
const maxColor = sortedColors[sortedColors.length - 1]
let maxValue = DEFAULT_VALUE_MAX
if (sortedColors.length > 1) {
maxValue = maxColor.value
}
const minValue = sortedColors[0].value
const randomValue = _.round(_.random(minValue, maxValue, true), 2)
const color: Color = {
type: COLOR_TYPE_THRESHOLD,
id: uuid.v4(),
value: randomValue,
hex: THRESHOLD_COLORS[randomColor].hex,
name: THRESHOLD_COLORS[randomColor].name,
}
const updatedColors = _.sortBy<Color>(
[...sortedColors, color],
color => color.value
)
this.props.onUpdateColors(updatedColors)
}
}
private handleChooseColor = (threshold: Color) => {
const colors = this.props.colorConfigs.map(({color}) =>
color.id === threshold.id
? {...color, hex: threshold.hex, name: threshold.name}
: color
)
this.props.onUpdateColors(colors)
}
private handleUpdateColorValue = (threshold: Color, value: number) => {
const colors = this.props.colorConfigs.map(({color}) =>
color.id === threshold.id ? {...color, value} : color
)
this.props.onUpdateColors(colors)
}
private handleDeleteThreshold = (threshold: Color) => {
const updatedColors = this.sortedColorConfigs.reduce(
(colors, {color}) =>
color.id === threshold.id ? colors : [...colors, color],
[]
)
this.props.onUpdateColors(updatedColors)
}
private handleValidateColorValue = (newColor: Color) => {
const {sortedColorConfigs} = this
const sortedColors = sortedColorConfigs.map(config => config.color)
return this.props.onValidateNewColor(sortedColors, newColor)
}
private get disableAddThreshold(): ComponentStatus {
if (this.props.colorConfigs.length > MAX_THRESHOLDS) {
return ComponentStatus.Disabled
} else {
return ComponentStatus.Valid
}
}
private get sortedColorConfigs() {
return _.sortBy(this.props.colorConfigs, config => config.color.value)
}
}
export default ThresholdList

View File

@ -13,6 +13,10 @@ import {
DE_TIME_MACHINE_ID,
} from 'src/timeMachine/constants'
import {AUTOREFRESH_DEFAULT} from 'src/shared/constants'
import {
THRESHOLD_TYPE_TEXT,
THRESHOLD_TYPE_BG,
} from 'src/shared/constants/thresholds'
// Types
import {TimeRange, View, AutoRefresh} from 'src/types'
@ -451,7 +455,7 @@ export const timeMachineReducer = (
if (color.type !== 'scale') {
return {
...color,
type: 'background',
type: THRESHOLD_TYPE_BG,
}
}
@ -468,7 +472,7 @@ export const timeMachineReducer = (
if (color.type !== 'scale') {
return {
...color,
type: 'text',
type: THRESHOLD_TYPE_TEXT,
}
}
return color

View File

@ -1,8 +1,47 @@
export interface Color {
// The following values are used in the `type` field:
//
// - 'threshold'
// - 'max'
// - 'min'
// - 'text'
// - 'background'
// - 'scale'
//
// This field drastically changes how a `Color` is used in the UI.
type: string
hex: string
// The `id` field is one of the following:
//
// - '0'
// - '1'
// - 'base'
// - A client-generated UUID
//
// When the `id` is 'base', the `Color` is treated specially in certain UI
// features. The `id` is unique within the array of colors for a particular
// `View`.
id: string
// A hex code for this color
hex: string
// A name for the hex code for this color; used as a stable identifier
name: string
// When a `Color` is being used as a threshold for coloring parts of a
// visualization, then the `type` field is one of the following:
//
// - 'threshold'
// - 'max'
// - 'min'
// - 'text'
// - 'background'
//
// In this case, the `value` is used to determine when the `hex` for this
// color is applied to parts of a visualization.
//
// If the `type` field is 'scale', then this field is unused.
value: number
}
@ -11,14 +50,6 @@ export interface ColorLabel {
name: string
}
export interface ThresholdConfig {
color: Color
label?: string
isDeletable?: boolean
isBase?: boolean
disableColor?: boolean
}
export enum LabelColorType {
Preset = 'preset',
Custom = 'custom',

View File

@ -1,268 +0,0 @@
import {Index} from 'react-virtualized'
import {QueryConfig} from 'src/types'
import {Bucket, Source} from '@influxdata/influx'
import {FieldOption, TimeSeriesValue} from 'src/types/dashboards'
export interface LogSearchParams {
lower: string
upper: string
config: QueryConfig
filters: Filter[]
}
export interface LogQuery extends LogSearchParams {
source: Source
}
export enum SearchStatus {
None = 'None',
Loading = 'Loading',
NoResults = 'NoResults',
UpdatingTimeBounds = 'UpdatingTimeBounds',
UpdatingFilters = 'UpdatingFilters',
UpdatingSource = 'UpdatingSource',
UpdatingBucket = 'UpdatingBucket',
SourceError = 'SourceError',
Loaded = 'Loaded',
Clearing = 'Clearing',
Cleared = 'Cleared',
}
export interface Filter {
id: string
key: string
value: string
operator: string
}
export interface LogsConfigState {
currentSource: Source | null
currentBuckets: Bucket[]
currentBucket: Bucket | null
tableQueryConfig: QueryConfig | null
filters: Filter[]
queryCount: number
logConfig: LogConfig
searchStatus: SearchStatus
}
export interface LogsTableDataState {
currentTailID: number | undefined
currentOlderBatchID: string | undefined
tableTime: TableTime
currentTailUpperBound: number | undefined
nextTailLowerBound: number | undefined
nextOlderUpperBound: number | undefined
nextOlderLowerBound: number | undefined
olderChunkDurationMs: number
tailChunkDurationMs: number
tableQueryConfig: QueryConfig | null
tableInfiniteData: {
forward: TableData
backward: TableData
}
}
export type LogsState = LogsConfigState & LogsTableDataState
// Log Config
export interface LogConfig {
id?: string
link?: string
tableColumns: LogsTableColumn[]
severityFormat: SeverityFormat
severityLevelColors: SeverityLevelColor[]
isTruncated: boolean
}
// Severity Colors
export interface SeverityLevelColor {
level: SeverityLevelOptions
color: SeverityColorOptions
}
export interface SeverityColor {
hex: string
name: SeverityColorOptions
}
export type SeverityFormat = SeverityFormatOptions
export type LogsTableColumn = FieldOption
// Log Severity
export enum SeverityLevelOptions {
Emerg = 'emerg',
Alert = 'alert',
Crit = 'crit',
Err = 'err',
Warning = 'warning',
Notice = 'notice',
Info = 'info',
Debug = 'debug',
}
export enum SeverityFormatOptions {
Dot = 'dot',
DotText = 'dotText',
Text = 'text',
}
export enum SeverityColorOptions {
Ruby = 'ruby',
Fire = 'fire',
Curacao = 'curacao',
Tiger = 'tiger',
Pineapple = 'pineapple',
Thunder = 'thunder',
Sulfur = 'sulfur',
Viridian = 'viridian',
Rainforest = 'rainforest',
Honeydew = 'honeydew',
Ocean = 'ocean',
Pool = 'pool',
Laser = 'laser',
Planet = 'planet',
Star = 'star',
Comet = 'comet',
Graphite = 'graphite',
Wolf = 'wolf',
Mist = 'mist',
Pearl = 'pearl',
}
export const SeverityColorValues = {
[SeverityColorOptions.Ruby]: '#BF3D5E',
[SeverityColorOptions.Fire]: '#DC4E58',
[SeverityColorOptions.Curacao]: '#F95F53',
[SeverityColorOptions.Tiger]: '#F48D38',
[SeverityColorOptions.Pineapple]: '#FFB94A',
[SeverityColorOptions.Thunder]: '#FFD255',
[SeverityColorOptions.Sulfur]: '#FFE480',
[SeverityColorOptions.Viridian]: '#32B08C',
[SeverityColorOptions.Rainforest]: '#4ED8A0',
[SeverityColorOptions.Honeydew]: '#7CE490',
[SeverityColorOptions.Ocean]: '#4591ED',
[SeverityColorOptions.Pool]: '#22ADF6',
[SeverityColorOptions.Laser]: '#00C9FF',
[SeverityColorOptions.Planet]: '#513CC6',
[SeverityColorOptions.Star]: '#7A65F2',
[SeverityColorOptions.Comet]: '#9394FF',
[SeverityColorOptions.Graphite]: '#545667',
[SeverityColorOptions.Wolf]: '#8E91A1',
[SeverityColorOptions.Mist]: '#BEC2CC',
[SeverityColorOptions.Pearl]: '#E7E8EB',
}
// Log Column Settings
export enum ColumnSettingTypes {
Visibility = 'visibility',
Display = 'displayName',
Label = 'label',
Color = 'color',
}
export enum ColumnSettingLabelOptions {
Text = 'text',
Icon = 'icon',
}
export enum ColumnSettingVisibilityOptions {
Visible = 'visible',
Hidden = 'hidden',
}
// Time
export interface TimeWindow {
seconds: number
windowOption: string
}
export interface TimeRange {
upper?: string
lower: string
seconds?: number
windowOption: string
timeOption: string
}
// Log Search
export interface Term {
type: TermType
term: string
attribute: string
}
export interface TokenLiteralMatch {
literal: string
nextText: string
rule: TermRule
attribute: string
}
export interface TermRule {
type: TermType
pattern: RegExp
}
export enum TermType {
Exclude,
Include,
}
export enum TermPart {
Exclusion = '-',
SingleQuoted = "'([^']+)'",
DoubleQuoted = '"([^"]+)"',
Attribute = '(\\w+(?=\\:))',
Colon = '(?::)',
UnquotedWord = '([\\S]+)',
}
export enum Operator {
NotLike = '!~',
Like = '=~',
Equal = '==',
NotEqual = '!=',
}
export enum MatchType {
None = 'no-match',
Match = 'match',
}
export interface MatchSection {
id: string
type: MatchType
text: string
}
// Table Data
export interface TableData {
columns: string[]
values: TimeSeriesValue[][]
}
export type RowHeightHandler = (index: Index) => number
export enum ScrollMode {
None = 'None',
TailScrolling = 'TailScrolling',
TailTop = 'TailTop',
TimeSelected = 'TimeSelected',
TimeSelectedScrolling = 'TimeSelectedScroll',
}
// Logs State Getter
export interface State {
logs: LogsState
}
export type GetState = () => State
export interface TableTime {
custom?: string
relative?: number
}