From c85a0f8b687360a9c40f49102c63d0684715f717 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Mon, 24 Jun 2019 17:14:09 -0700 Subject: [PATCH] fix(ui): improve threshold settings validation Closes #14175 Closes #14190 --- ui/src/shared/components/ColorDropdown.tsx | 1 + ui/src/shared/components/ThresholdSetting.tsx | 102 +++++++ .../shared/components/ThresholdsSettings.scss | 50 ++++ .../components/ThresholdsSettings.test.tsx | 174 ++++++++++++ .../shared/components/ThresholdsSettings.tsx | 189 ++++++++++++ .../draggable_column/DraggableColumn.tsx | 4 +- ui/src/shared/constants/colorOperations.ts | 4 +- ui/src/shared/constants/thresholds.ts | 5 +- ui/src/shared/utils/thresholds.ts | 142 ++++++++++ ui/src/shared/utils/useOneWayReducer.ts | 52 ++++ ui/src/style/chronograf.scss | 3 +- .../components/view_options/DecimalPlaces.tsx | 3 +- .../components/view_options/GaugeOptions.tsx | 89 +----- .../view_options/SingleStatOptions.tsx | 39 +-- .../components/view_options/TableOptions.tsx | 33 +-- .../components/view_options/ThresholdItem.tsx | 161 ----------- .../view_options/ThresholdList.scss | 48 ---- .../components/view_options/ThresholdList.tsx | 160 ----------- ui/src/timeMachine/reducers/index.ts | 8 +- ui/src/types/colors.ts | 49 +++- ui/src/types/logs.ts | 268 ------------------ 21 files changed, 791 insertions(+), 793 deletions(-) create mode 100644 ui/src/shared/components/ThresholdSetting.tsx create mode 100644 ui/src/shared/components/ThresholdsSettings.scss create mode 100644 ui/src/shared/components/ThresholdsSettings.test.tsx create mode 100644 ui/src/shared/components/ThresholdsSettings.tsx create mode 100644 ui/src/shared/utils/thresholds.ts create mode 100644 ui/src/shared/utils/useOneWayReducer.ts delete mode 100644 ui/src/timeMachine/components/view_options/ThresholdItem.tsx delete mode 100644 ui/src/timeMachine/components/view_options/ThresholdList.scss delete mode 100644 ui/src/timeMachine/components/view_options/ThresholdList.tsx delete mode 100644 ui/src/types/logs.ts diff --git a/ui/src/shared/components/ColorDropdown.tsx b/ui/src/shared/components/ColorDropdown.tsx index 33958986d7..cea3d21749 100644 --- a/ui/src/shared/components/ColorDropdown.tsx +++ b/ui/src/shared/components/ColorDropdown.tsx @@ -38,6 +38,7 @@ const ColorDropdown: SFC = props => { return ( void + onChangeColor: (name: string, hex: string) => void + onRemove: () => void + onBlur: () => void +} + +const ThresholdSetting: FunctionComponent = ({ + 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 ( +
+
+
{label}
+ {!isBaseThreshold && ( + onChangeValue(e.target.value)} + onBlur={onBlur} + onKeyDown={e => { + if (e.key === 'Enter') { + onBlur() + } + }} + /> + )} + d.name === name)} + onChoose={({name, hex}) => onChangeColor(name, hex)} + stretchToFit={true} + /> + {isRemoveable && ( +
+ {error &&
{error}
} +
+ ) +} + +export default ThresholdSetting diff --git a/ui/src/shared/components/ThresholdsSettings.scss b/ui/src/shared/components/ThresholdsSettings.scss new file mode 100644 index 0000000000..08d856e5c4 --- /dev/null +++ b/ui/src/shared/components/ThresholdsSettings.scss @@ -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; +} diff --git a/ui/src/shared/components/ThresholdsSettings.test.tsx b/ui/src/shared/components/ThresholdsSettings.test.tsx new file mode 100644 index 0000000000..e0cb93fd64 --- /dev/null +++ b/ui/src/shared/components/ThresholdsSettings.test.tsx @@ -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( + + ) + + // 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( + + ) + + // 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( + + ) + + // 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 &&
} + + + ) + } + + const {container, getByTestId} = render() + + // 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}, + ]) + }) +}) diff --git a/ui/src/shared/components/ThresholdsSettings.tsx b/ui/src/shared/components/ThresholdsSettings.tsx new file mode 100644 index 0000000000..5fc7bed650 --- /dev/null +++ b/ui/src/shared/components/ThresholdsSettings.tsx @@ -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 = ({ + 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 ( +
+
+ ) +} + +export default ThresholdsSettings diff --git a/ui/src/shared/components/draggable_column/DraggableColumn.tsx b/ui/src/shared/components/draggable_column/DraggableColumn.tsx index d69c029ccb..58e3cdeb38 100644 --- a/ui/src/shared/components/draggable_column/DraggableColumn.tsx +++ b/ui/src/shared/components/draggable_column/DraggableColumn.tsx @@ -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 diff --git a/ui/src/shared/constants/colorOperations.ts b/ui/src/shared/constants/colorOperations.ts index 8ccbd10bb5..299da9b6ce 100644 --- a/ui/src/shared/constants/colorOperations.ts +++ b/ui/src/shared/constants/colorOperations.ts @@ -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, } diff --git a/ui/src/shared/constants/thresholds.ts b/ui/src/shared/constants/thresholds.ts index 969e6b5f71..568f4c1e01 100644 --- a/ui/src/shared/constants/thresholds.ts +++ b/ui/src/shared/constants/thresholds.ts @@ -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, }, diff --git a/ui/src/shared/utils/thresholds.ts b/ui/src/shared/utils/thresholds.ts new file mode 100644 index 0000000000..07c81a4e0f --- /dev/null +++ b/ui/src/shared/utils/thresholds.ts @@ -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 +} diff --git a/ui/src/shared/utils/useOneWayReducer.ts b/ui/src/shared/utils/useOneWayReducer.ts new file mode 100644 index 0000000000..8eb1b7ac14 --- /dev/null +++ b/ui/src/shared/utils/useOneWayReducer.ts @@ -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 = >( + reducer: R, + defaultState: ReducerState +): [ReducerState, Dispatch>] => { + // 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, action: ReducerAction) => { + 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] +} diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 826c61f8df..78409de752 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -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'; diff --git a/ui/src/timeMachine/components/view_options/DecimalPlaces.tsx b/ui/src/timeMachine/components/view_options/DecimalPlaces.tsx index 17499a87cf..cfff52a58c 100644 --- a/ui/src/timeMachine/components/view_options/DecimalPlaces.tsx +++ b/ui/src/timeMachine/components/view_options/DecimalPlaces.tsx @@ -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 { public render() { return ( - + {

Colorized Thresholds

- + + + ) } @@ -87,80 +88,6 @@ class GaugeOptions extends PureComponent { /> ) } - - 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 = { diff --git a/ui/src/timeMachine/components/view_options/SingleStatOptions.tsx b/ui/src/timeMachine/components/view_options/SingleStatOptions.tsx index 14435a8b1c..f4ec46d5ba 100644 --- a/ui/src/timeMachine/components/view_options/SingleStatOptions.tsx +++ b/ui/src/timeMachine/components/view_options/SingleStatOptions.tsx @@ -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 => { 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 ( <> @@ -95,11 +78,7 @@ const SingleStatOptions: SFC = props => {

Colorized Thresholds

- true} - /> + diff --git a/ui/src/timeMachine/components/view_options/TableOptions.tsx b/ui/src/timeMachine/components/view_options/TableOptions.tsx index d2da0b5d0d..127a1c5e56 100644 --- a/ui/src/timeMachine/components/view_options/TableOptions.tsx +++ b/ui/src/timeMachine/components/view_options/TableOptions.tsx @@ -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 { onSetColors, fieldOptions, tableOptions, + colors, decimalPlaces, onSetTimeFormat, onSetDecimalPlaces, @@ -126,11 +125,12 @@ export class TableOptions extends Component {

Colorized Thresholds

- true} - /> + + + ) } @@ -159,23 +159,6 @@ export class TableOptions extends Component { 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) => { diff --git a/ui/src/timeMachine/components/view_options/ThresholdItem.tsx b/ui/src/timeMachine/components/view_options/ThresholdItem.tsx deleted file mode 100644 index 8ca0c95987..0000000000 --- a/ui/src/timeMachine/components/view_options/ThresholdItem.tsx +++ /dev/null @@ -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 { - 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 ( -
-
{this.props.label}
- {!isBase && ( - - )} - - {isDeletable && !isBase && ( - - )} -
- ) - } - - 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) => { - 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) => { - if (e.key === 'Enter') { - e.currentTarget.blur() - } - } - - private handleDelete = () => { - const {threshold, onDeleteThreshold} = this.props - onDeleteThreshold(threshold) - } -} - -export default Threshold diff --git a/ui/src/timeMachine/components/view_options/ThresholdList.scss b/ui/src/timeMachine/components/view_options/ThresholdList.scss deleted file mode 100644 index ede8cc9e1d..0000000000 --- a/ui/src/timeMachine/components/view_options/ThresholdList.scss +++ /dev/null @@ -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; -} diff --git a/ui/src/timeMachine/components/view_options/ThresholdList.tsx b/ui/src/timeMachine/components/view_options/ThresholdList.tsx deleted file mode 100644 index 1e9ea31234..0000000000 --- a/ui/src/timeMachine/components/view_options/ThresholdList.tsx +++ /dev/null @@ -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 { - public render() { - return ( - - -
-
-
-
- ) - } - - 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( - [...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 diff --git a/ui/src/timeMachine/reducers/index.ts b/ui/src/timeMachine/reducers/index.ts index 147dfa7df0..2863853ca6 100644 --- a/ui/src/timeMachine/reducers/index.ts +++ b/ui/src/timeMachine/reducers/index.ts @@ -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 diff --git a/ui/src/types/colors.ts b/ui/src/types/colors.ts index dd4e836e87..5998bfcaee 100644 --- a/ui/src/types/colors.ts +++ b/ui/src/types/colors.ts @@ -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', diff --git a/ui/src/types/logs.ts b/ui/src/types/logs.ts deleted file mode 100644 index 17b066cc55..0000000000 --- a/ui/src/types/logs.ts +++ /dev/null @@ -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 -}