diff --git a/src/plugins/vis_type_timeseries/common/get_last_value.test.js b/src/plugins/vis_type_timeseries/common/get_last_value.test.js deleted file mode 100644 index 794bbe17a1e7..000000000000 --- a/src/plugins/vis_type_timeseries/common/get_last_value.test.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getLastValue } from './get_last_value'; - -describe('getLastValue(data)', () => { - test('should returns data if data is not array', () => { - expect(getLastValue('foo')).toBe('foo'); - }); - - test('should returns 0 as a value when not an array', () => { - expect(getLastValue(0)).toBe(0); - }); - - test('should returns the last value', () => { - expect(getLastValue([[1, 2]])).toBe(2); - }); - - test('should return 0 as a valid value', () => { - expect(getLastValue([[0, 0]])).toBe(0); - }); - - test('should returns the default value ', () => { - expect(getLastValue()).toBe('-'); - }); - - test('should returns 0 if second to last is not defined (default)', () => { - expect( - getLastValue([ - [1, null], - [2, null], - ]) - ).toBe('-'); - }); -}); diff --git a/src/plugins/vis_type_timeseries/common/last_value_utils.test.ts b/src/plugins/vis_type_timeseries/common/last_value_utils.test.ts new file mode 100644 index 000000000000..34e1265b9a6a --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/last_value_utils.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getLastValue, isEmptyValue, EMPTY_VALUE } from './last_value_utils'; +import { clone } from 'lodash'; + +describe('getLastValue(data)', () => { + test('should return data, if data is not an array', () => { + const data = 'foo'; + expect(getLastValue(data)).toBe(data); + }); + + test('should return 0 as a value, when data is not an array', () => { + expect(getLastValue(0)).toBe(0); + }); + + test('should return the last value', () => { + const lastVal = 2; + expect(getLastValue([[1, lastVal]])).toBe(lastVal); + }); + + test('should return 0 as a valid value', () => { + expect(getLastValue([[0, 0]])).toBe(0); + }); + + test("should return empty value (null), if second array is empty or it's last element is null/undefined (default)", () => { + expect( + getLastValue([ + [1, null], + [2, null], + ]) + ).toBe(EMPTY_VALUE); + + expect( + getLastValue([ + [1, null], + [2, undefined], + ]) + ).toBe(EMPTY_VALUE); + }); +}); + +describe('isEmptyValue(value)', () => { + test('should return true if is equal to the empty value', () => { + // if empty value will change, no need to rewrite test for passing it. + const emptyValue = + typeof EMPTY_VALUE === 'object' && EMPTY_VALUE != null ? clone(EMPTY_VALUE) : EMPTY_VALUE; + expect(isEmptyValue(emptyValue)).toBe(true); + }); + + test('should return the last value', () => { + const notEmptyValue = [...Array(10).keys()]; + expect(isEmptyValue(notEmptyValue)).toBe(false); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/get_last_value.js b/src/plugins/vis_type_timeseries/common/last_value_utils.ts similarity index 50% rename from src/plugins/vis_type_timeseries/common/get_last_value.js rename to src/plugins/vis_type_timeseries/common/last_value_utils.ts index 80adf7098f24..a51a04962a89 100644 --- a/src/plugins/vis_type_timeseries/common/get_last_value.js +++ b/src/plugins/vis_type_timeseries/common/last_value_utils.ts @@ -6,16 +6,19 @@ * Side Public License, v 1. */ -import { isArray, last } from 'lodash'; +import { isArray, last, isEqual } from 'lodash'; -export const DEFAULT_VALUE = '-'; +export const EMPTY_VALUE = null; +export const DISPLAY_EMPTY_VALUE = '-'; -const extractValue = (data) => (data && data[1]) ?? null; +const extractValue = (data: unknown[] | void) => (data && data[1]) ?? EMPTY_VALUE; -export const getLastValue = (data) => { +export const getLastValue = (data: unknown) => { if (!isArray(data)) { - return data ?? DEFAULT_VALUE; + return data; } - return extractValue(last(data)) ?? DEFAULT_VALUE; + return extractValue(last(data)); }; + +export const isEmptyValue = (value: unknown) => isEqual(value, EMPTY_VALUE); diff --git a/src/plugins/vis_type_timeseries/common/operators_utils.test.ts b/src/plugins/vis_type_timeseries/common/operators_utils.test.ts new file mode 100644 index 000000000000..ad66f058a491 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/operators_utils.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getOperator, shouldOperate, Rule, Operator } from './operators_utils'; + +describe('getOperator(operator)', () => { + test('should return operator function', () => { + const operatorName = Operator.Gte; + const operator = getOperator(operatorName); + expect(typeof operator).toBe('function'); + }); +}); + +describe('shouldOperate(rule, value)', () => { + test('should operate, if value is not null and rule value is not null', () => { + const rule: Rule = { + value: 1, + operator: Operator.Gte, + }; + const value = 2; + + expect(shouldOperate(rule, value)).toBeTruthy(); + }); + + test('should operate, if value is null and operator allows null value', () => { + const rule: Rule = { + operator: Operator.Empty, + value: null, + }; + const value = null; + + expect(shouldOperate(rule, value)).toBeTruthy(); + }); + + test("should not operate, if value is null and operator doesn't allow null values", () => { + const rule: Rule = { + operator: Operator.Gte, + value: 2, + }; + const value = null; + + expect(shouldOperate(rule, value)).toBeFalsy(); + }); + + test("should not operate, if rule value is null and operator doesn't allow null values", () => { + const rule: Rule = { + operator: Operator.Gte, + value: null, + }; + const value = 3; + + expect(shouldOperate(rule, value)).toBeFalsy(); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/operators_utils.ts b/src/plugins/vis_type_timeseries/common/operators_utils.ts new file mode 100644 index 000000000000..603e63159b22 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/operators_utils.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { gt, gte, lt, lte, isNull } from 'lodash'; + +export enum Operator { + Gte = 'gte', + Lte = 'lte', + Gt = 'gt', + Lt = 'lt', + Empty = 'empty', +} + +export interface Rule { + operator: Operator; + value: unknown; +} + +type OperatorsAllowNullType = { + [name in Operator]?: boolean; +}; + +const OPERATORS = { + [Operator.Gte]: gte, + [Operator.Lte]: lte, + [Operator.Gt]: gt, + [Operator.Lt]: lt, + [Operator.Empty]: isNull, +}; + +const OPERATORS_ALLOW_NULL: OperatorsAllowNullType = { + [Operator.Empty]: true, +}; + +export const getOperator = (operator: Operator) => { + return OPERATORS[operator]; +}; + +// This check is necessary for preventing from comparing null values with numeric rules. +export const shouldOperate = (rule: Rule, value: unknown) => + (isNull(rule.value) && OPERATORS_ALLOW_NULL[rule.operator]) || + (!isNull(rule.value) && !isNull(value)); diff --git a/src/plugins/vis_type_timeseries/public/application/components/color_rules.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/color_rules.test.tsx index 9ea8898636ce..3b1356d57174 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/color_rules.test.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/color_rules.test.tsx @@ -12,22 +12,51 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { mountWithIntl } from '@kbn/test/jest'; import { collectionActions } from './lib/collection_actions'; -import { ColorRules, ColorRulesProps } from './color_rules'; +import { + ColorRules, + ColorRulesProps, + colorRulesOperatorsList, + ColorRulesOperator, +} from './color_rules'; +import { Operator } from '../../../common/operators_utils'; describe('src/legacy/core_plugins/metrics/public/components/color_rules.test.js', () => { - const defaultProps = ({ + const emptyRule: ColorRulesOperator = colorRulesOperatorsList.filter( + (operator) => operator.method === Operator.Empty + )[0]; + const notEmptyRule: ColorRulesOperator = colorRulesOperatorsList.filter( + (operator) => operator.method !== Operator.Empty + )[0]; + + const getColorRulesProps = (gaugeColorRules: unknown = []) => ({ name: 'gauge_color_rules', - model: { - gauge_color_rules: [ - { - gauge: null, - value: 0, - id: 'unique value', - }, - ], - }, + model: { gauge_color_rules: gaugeColorRules }, onChange: jest.fn(), - } as unknown) as ColorRulesProps; + }); + + const defaultProps = (getColorRulesProps([ + { + gauge: null, + value: 0, + id: 'unique value', + }, + ]) as unknown) as ColorRulesProps; + + const emptyColorRuleProps = (getColorRulesProps([ + { + operator: emptyRule?.method, + value: emptyRule?.value, + id: 'unique value', + }, + ]) as unknown) as ColorRulesProps; + + const notEmptyColorRuleProps = (getColorRulesProps([ + { + operator: notEmptyRule?.method, + value: notEmptyRule?.value, + id: 'unique value', + }, + ]) as unknown) as ColorRulesProps; describe('ColorRules', () => { it('should render empty
node', () => { @@ -47,6 +76,7 @@ describe('src/legacy/core_plugins/metrics/public/components/color_rules.test.js' expect(isNode).toBeTruthy(); }); + it('should handle change of operator and value correctly', () => { collectionActions.handleChange = jest.fn(); const wrapper = mountWithIntl(); @@ -57,8 +87,23 @@ describe('src/legacy/core_plugins/metrics/public/components/color_rules.test.js' expect((collectionActions.handleChange as jest.Mock).mock.calls[0][1].operator).toEqual('gt'); const numberInput = findTestSubject(wrapper, 'colorRuleValue'); + numberInput.simulate('change', { target: { value: '123' } }); expect((collectionActions.handleChange as jest.Mock).mock.calls[1][1].value).toEqual(123); }); + + it('should handle render of value field if empty value oparetor is selected by default', () => { + collectionActions.handleChange = jest.fn(); + const wrapper = mountWithIntl(); + const numberInput = findTestSubject(wrapper, 'colorRuleValue'); + expect(numberInput.exists()).toBeFalsy(); + }); + + it('should handle render of value field if not empty operator is selected by default', () => { + collectionActions.handleChange = jest.fn(); + const wrapper = mountWithIntl(); + const numberInput = findTestSubject(wrapper, 'colorRuleValue'); + expect(numberInput.exists()).toBeTruthy(); + }); }); }); diff --git a/src/plugins/vis_type_timeseries/public/application/components/color_rules.tsx b/src/plugins/vis_type_timeseries/public/application/components/color_rules.tsx index 7aea5f934ee9..0cc64528ae3f 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/color_rules.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/color_rules.tsx @@ -23,6 +23,7 @@ import { AddDeleteButtons } from './add_delete_buttons'; import { collectionActions } from './lib/collection_actions'; import { ColorPicker, ColorPickerProps } from './color_picker'; import { TimeseriesVisParams } from '../../types'; +import { Operator } from '../../../common/operators_utils'; export interface ColorRulesProps { name: keyof TimeseriesVisParams; @@ -40,10 +41,17 @@ interface ColorRule { id: string; background_color?: string; color?: string; - operator?: string; + operator?: Operator; text?: string; } +export interface ColorRulesOperator { + label: string; + method: Operator; + value?: unknown; + hideValueSelector?: boolean; +} + const defaultSecondaryName = i18n.translate( 'visTypeTimeseries.colorRules.defaultSecondaryNameLabel', { @@ -54,33 +62,45 @@ const defaultPrimaryName = i18n.translate('visTypeTimeseries.colorRules.defaultP defaultMessage: 'background', }); -const operatorOptions = [ +export const colorRulesOperatorsList: ColorRulesOperator[] = [ { label: i18n.translate('visTypeTimeseries.colorRules.greaterThanLabel', { defaultMessage: '> greater than', }), - value: 'gt', + method: Operator.Gt, }, { label: i18n.translate('visTypeTimeseries.colorRules.greaterThanOrEqualLabel', { defaultMessage: '>= greater than or equal', }), - value: 'gte', + method: Operator.Gte, }, { label: i18n.translate('visTypeTimeseries.colorRules.lessThanLabel', { defaultMessage: '< less than', }), - value: 'lt', + method: Operator.Lt, }, { label: i18n.translate('visTypeTimeseries.colorRules.lessThanOrEqualLabel', { defaultMessage: '<= less than or equal', }), - value: 'lte', + method: Operator.Lte, + }, + { + label: i18n.translate('visTypeTimeseries.colorRules.emptyLabel', { + defaultMessage: 'empty', + }), + method: Operator.Empty, + hideValueSelector: true, }, ]; +const operatorOptions = colorRulesOperatorsList.map((operator) => ({ + label: operator.label, + value: operator.method, +})); + export class ColorRules extends Component { constructor(props: ColorRulesProps) { super(props); @@ -100,9 +120,14 @@ export class ColorRules extends Component { handleOperatorChange = (item: ColorRule) => { return (options: Array>) => { + const selectedOperator = colorRulesOperatorsList.find( + (operator) => options[0]?.value === operator.method + ); + const value = selectedOperator?.value ?? null; collectionActions.handleChange(this.props, { ...item, - operator: options[0].value, + operator: options[0]?.value, + value, }); }; }; @@ -119,7 +144,11 @@ export class ColorRules extends Component { const selectedOperatorOption = operatorOptions.find( (option) => model.operator === option.value ); + const selectedOperator = colorRulesOperatorsList.find( + (operator) => model.operator === operator.method + ); + const hideValueSelectorField = selectedOperator?.hideValueSelector ?? false; const labelStyle = { marginBottom: 0 }; let secondary; @@ -203,19 +232,19 @@ export class ColorRules extends Component { fullWidth /> - - - - - + {!hideValueSelectorField && ( + + + + )} { - let value; - - if (val === DEFAULT_VALUE) { - return val; + if (isEmptyValue(val)) { + return DISPLAY_EMPTY_VALUE; } + let value; + if (!isNumber(val)) { value = val; } else { diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/vis.js index a464771b01af..6140726975cb 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/vis.js @@ -10,9 +10,10 @@ import PropTypes from 'prop-types'; import React from 'react'; import { visWithSplits } from '../../vis_with_splits'; import { createTickFormatter } from '../../lib/tick_formatter'; -import _, { get, isUndefined, assign, includes } from 'lodash'; +import { get, isUndefined, assign, includes } from 'lodash'; import { Gauge } from '../../../visualizations/views/gauge'; -import { getLastValue } from '../../../../../common/get_last_value'; +import { getLastValue } from '../../../../../common/last_value_utils'; +import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; function getColors(props) { const { model, visData } = props; @@ -21,9 +22,9 @@ function getColors(props) { let gauge; if (model.gauge_color_rules) { model.gauge_color_rules.forEach((rule) => { - if (rule.operator && rule.value != null) { - const value = (series[0] && getLastValue(series[0].data)) || 0; - if (_[rule.operator](value, rule.value)) { + if (rule.operator) { + const value = getLastValue(series[0]?.data); + if (shouldOperate(rule, value) && getOperator(rule.operator)(value, rule.value)) { gauge = rule.gauge; text = rule.text; } diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/vis.js index 3029bba04b45..b35ee977d3e4 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/vis.js @@ -10,10 +10,11 @@ import PropTypes from 'prop-types'; import React from 'react'; import { visWithSplits } from '../../vis_with_splits'; import { createTickFormatter } from '../../lib/tick_formatter'; -import _, { get, isUndefined, assign, includes, pick } from 'lodash'; +import { get, isUndefined, assign, includes, pick } from 'lodash'; import { Metric } from '../../../visualizations/views/metric'; -import { getLastValue } from '../../../../../common/get_last_value'; +import { getLastValue } from '../../../../../common/last_value_utils'; import { isBackgroundInverted } from '../../../lib/set_is_reversed'; +import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; function getColors(props) { const { model, visData } = props; @@ -22,9 +23,9 @@ function getColors(props) { let background; if (model.background_color_rules) { model.background_color_rules.forEach((rule) => { - if (rule.operator && rule.value != null) { - const value = (series[0] && getLastValue(series[0].data)) || 0; - if (_[rule.operator](value, rule.value)) { + if (rule.operator) { + const value = getLastValue(series[0]?.data); + if (shouldOperate(rule, value) && getOperator(rule.operator)(value, rule.value)) { background = rule.background_color; color = rule.color; } diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/vis.js index 41e6236cbc39..0b3a24615c0e 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/vis.js @@ -9,13 +9,13 @@ import { getCoreStart } from '../../../../services'; import { createTickFormatter } from '../../lib/tick_formatter'; import { TopN } from '../../../visualizations/views/top_n'; -import { getLastValue } from '../../../../../common/get_last_value'; +import { getLastValue } from '../../../../../common/last_value_utils'; import { isBackgroundInverted } from '../../../lib/set_is_reversed'; import { replaceVars } from '../../lib/replace_vars'; import PropTypes from 'prop-types'; import React from 'react'; -import { sortBy, first, get, gt, gte, lt, lte } from 'lodash'; -const OPERATORS = { gt, gte, lt, lte }; +import { sortBy, first, get } from 'lodash'; +import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; function sortByDirection(data, direction, fn) { if (direction === 'desc') { @@ -53,8 +53,8 @@ function TopNVisualization(props) { let color = item.color || seriesConfig.color; if (model.bar_color_rules) { model.bar_color_rules.forEach((rule) => { - if (rule.operator && rule.value != null && rule.bar_color) { - if (OPERATORS[rule.operator](value, rule.value)) { + if (shouldOperate(rule, value) && rule.operator && rule.bar_color) { + if (getOperator(rule.operator)(value, rule.value)) { color = rule.bar_color; } } diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js index 31ea3412972e..000701c3a076 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js @@ -11,7 +11,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import classNames from 'classnames'; import { isBackgroundInverted, isBackgroundDark } from '../../lib/set_is_reversed'; -import { getLastValue } from '../../../../common/get_last_value'; +import { getLastValue } from '../../../../common/last_value_utils'; import { getValueBy } from '../lib/get_value_by'; import { GaugeVis } from './gauge_vis'; import reactcss from 'reactcss'; @@ -61,7 +61,7 @@ export class Gauge extends Component { render() { const { metric, type } = this.props; const { scale, translateX, translateY } = this.state; - const value = metric && getLastValue(metric.data); + const value = getLastValue(metric?.data); const max = (metric && getValueBy('max', metric.data)) || 1; const formatter = (metric && (metric.tickFormatter || metric.formatter)) || @@ -76,16 +76,13 @@ export class Gauge extends Component { left: this.state.left || 0, transform: `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})`, }, - }, - valueColor: { - value: { + valueColor: { color: this.props.valueColor, }, }, }, this.props ); - const gaugeProps = { value, reversed: isBackgroundDark(this.props.backgroundColor), @@ -114,7 +111,7 @@ export class Gauge extends Component {
{title}
-
+
{formatter(value)}
{additionalLabel} @@ -127,7 +124,7 @@ export class Gauge extends Component { ref={(el) => (this.inner = el)} style={styles.inner} > -
+
{formatter(value)}
diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js index c8789f98969f..30b7844a90fd 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js @@ -12,6 +12,7 @@ import _ from 'lodash'; import reactcss from 'reactcss'; import { calculateCoordinates } from '../lib/calculate_coordinates'; import { COLORS } from '../constants/chart'; +import { isEmptyValue } from '../../../../common/last_value_utils'; export class GaugeVis extends Component { constructor(props) { @@ -55,10 +56,14 @@ export class GaugeVis extends Component { render() { const { type, value, max, color } = this.props; + + // if value is empty array, no metrics to display. + const formattedValue = isEmptyValue(value) ? 1 : value; + const { scale, translateX, translateY } = this.state; const size = 2 * Math.PI * 50; const sliceSize = type === 'half' ? 0.6 : 1; - const percent = value < max ? value / max : 1; + const percent = formattedValue < max ? formattedValue / max : 1; const styles = reactcss( { default: { @@ -161,6 +166,6 @@ GaugeVis.propTypes = { max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), metric: PropTypes.object, reversed: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]), type: PropTypes.oneOf(['half', 'circle']), }; diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/metric.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/metric.js index 17cadb94457b..bc4230d0a15e 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/metric.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/metric.js @@ -11,7 +11,7 @@ import React, { Component } from 'react'; import _ from 'lodash'; import reactcss from 'reactcss'; -import { getLastValue } from '../../../../common/get_last_value'; +import { getLastValue } from '../../../../common/last_value_utils'; import { calculateCoordinates } from '../lib/calculate_coordinates'; export class Metric extends Component { @@ -58,7 +58,8 @@ export class Metric extends Component { const { metric, secondary } = this.props; const { scale, translateX, translateY } = this.state; const primaryFormatter = (metric && (metric.tickFormatter || metric.formatter)) || ((n) => n); - const primaryValue = primaryFormatter(getLastValue(metric && metric.data)); + const primaryValue = primaryFormatter(getLastValue(metric?.data)); + const styles = reactcss( { default: { @@ -120,7 +121,6 @@ export class Metric extends Component { if (this.props.reversed) { className += ' tvbVisMetric--reversed'; } - return (
(this.resize = el)} className="tvbVisMetric__resize"> diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js index 2559ed543e54..0c43ab157fbb 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { getLastValue } from '../../../../common/get_last_value'; +import { getLastValue, isEmptyValue } from '../../../../common/last_value_utils'; import { labelDateFormatter } from '../../components/lib/label_date_formatter'; import { emptyLabel } from '../../../../common/empty_label'; import reactcss from 'reactcss'; @@ -97,15 +97,16 @@ export class TopN extends Component { const renderMode = TopN.getRenderMode(min, max); const key = `${item.id || item.label}`; const lastValue = getLastValue(item.data); + // if result is empty, all bar need to be colored. + const lastValueFormatted = isEmptyValue(lastValue) ? 1 : lastValue; const formatter = item.tickFormatter || this.props.tickFormatter; - const isPositiveValue = lastValue >= 0; + const isPositiveValue = lastValueFormatted >= 0; const intervalLength = TopN.calcDomain(renderMode, min, max); // if both are 0, the division returns NaN causing unexpected behavior. // For this it defaults to 0 - const width = 100 * (Math.abs(lastValue) / intervalLength) || 0; + const width = 100 * (Math.abs(lastValueFormatted) / intervalLength) || 0; const label = item.labelFormatted ? labelDateFormatter(item.labelFormatted) : item.label; - const styles = reactcss( { default: { @@ -150,7 +151,7 @@ export class TopN extends Component { const intervalSettings = this.props.series.reduce( (acc, series, index) => { - const value = getLastValue(series.data); + const value = getLastValue(series.data) ?? 1; return { min: !index || value < acc.min ? value : acc.min, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js index 9e3a2ac71ed0..88b06d7f7ffa 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js @@ -8,7 +8,7 @@ import { buildProcessorFunction } from '../build_processor_function'; import { processors } from '../response_processors/table'; -import { getLastValue } from '../../../../common/get_last_value'; +import { getLastValue } from '../../../../common/last_value_utils'; import { first, get } from 'lodash'; import { overwrite } from '../helpers'; import { getActiveSeries } from '../helpers/get_active_series'; diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts index 6f214745e129..212c033a65c2 100644 --- a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts @@ -13,6 +13,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonAddEmptyValueColorRule, } from '../migrations/visualization_common_migrations'; const byValueAddSupportOfDualIndexSelectionModeInTSVB = (state: SerializableState) => { @@ -36,6 +37,13 @@ const byValueRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel = (state: Serial }; }; +const byValueAddEmptyValueColorRule = (state: SerializableState) => { + return { + ...state, + savedVis: commonAddEmptyValueColorRule(state.savedVis), + }; +}; + export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { id: 'visualization', @@ -47,6 +55,7 @@ export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { byValueHideTSVBLastValueIndicator, byValueRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel )(state), + '7.14.0': (state) => flow(byValueAddEmptyValueColorRule)(state), }, }; }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index 3f09f19d9ac6..13b8d8c4a0f9 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +import { get, last } from 'lodash'; +import uuid from 'uuid'; + export const commonAddSupportOfDualIndexSelectionModeInTSVB = (visState: any) => { if (visState && visState.type === 'metrics') { const { params } = visState; @@ -42,3 +45,49 @@ export const commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel = (visStat return visState; }; + +export const commonAddEmptyValueColorRule = (visState: any) => { + if (visState && visState.type === 'metrics') { + const params: any = get(visState, 'params') || {}; + + const getRuleWithComparingToZero = (rules: any[] = []) => { + const compareWithEqualMethods = ['gte', 'lte']; + return last( + rules.filter((rule) => compareWithEqualMethods.includes(rule.operator) && rule.value === 0) + ); + }; + + const convertRuleToEmpty = (rule: any = {}) => ({ + ...rule, + id: uuid.v4(), + operator: 'empty', + value: null, + }); + + const addEmptyRuleToListIfNecessary = (rules: any[]) => { + const rule = getRuleWithComparingToZero(rules); + + if (rule) { + return [...rules, convertRuleToEmpty(rule)]; + } + + return rules; + }; + + const colorRules = { + bar_color_rules: addEmptyRuleToListIfNecessary(params.bar_color_rules), + background_color_rules: addEmptyRuleToListIfNecessary(params.background_color_rules), + gauge_color_rules: addEmptyRuleToListIfNecessary(params.gauge_color_rules), + }; + + return { + ...visState, + params: { + ...params, + ...colorRules, + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index dbe5482c442b..36e1635ad473 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2017,4 +2017,101 @@ describe('migration visualization', () => { expect(params.use_kibana_indexes).toBeFalsy(); }); }); + + describe('7.14.0 tsvb - add empty value rule to savedObjects with less and greater then zero rules', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.14.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const rule1 = { value: 0, operator: 'lte', color: 'rgb(145, 112, 184)' }; + const rule2 = { value: 0, operator: 'gte', color: 'rgb(96, 146, 192)' }; + const rule3 = { value: 0, operator: 'gt', color: 'rgb(84, 179, 153)' }; + const rule4 = { value: 0, operator: 'lt', color: 'rgb(84, 179, 153)' }; + + const createTestDocWithType = (params: any) => ({ + attributes: { + title: 'My Vis', + description: 'This is my super cool vis.', + visState: `{ + "type":"metrics", + "params": ${JSON.stringify(params)} + }`, + }, + }); + + const checkEmptyRuleIsAddedToArray = ( + rulesArrayProperty: string, + prevParams: any, + migratedParams: any, + rule: any + ) => { + expect(migratedParams).toHaveProperty(rulesArrayProperty); + expect(Array.isArray(migratedParams[rulesArrayProperty])).toBeTruthy(); + expect(migratedParams[rulesArrayProperty].length).toBe( + prevParams[rulesArrayProperty].length + 1 + ); + + const lastElementIndex = migratedParams[rulesArrayProperty].length - 1; + expect(migratedParams[rulesArrayProperty][lastElementIndex]).toHaveProperty('operator'); + expect(migratedParams[rulesArrayProperty][lastElementIndex].operator).toEqual('empty'); + expect(migratedParams[rulesArrayProperty][lastElementIndex].color).toEqual(rule.color); + }; + + const checkRuleIsNotAddedToArray = ( + rulesArrayProperty: string, + prevParams: any, + migratedParams: any, + rule: any + ) => { + expect(migratedParams).toHaveProperty(rulesArrayProperty); + expect(Array.isArray(migratedParams[rulesArrayProperty])).toBeTruthy(); + expect(migratedParams[rulesArrayProperty].length).toBe(prevParams[rulesArrayProperty].length); + // expects, that array contains one element... + expect(migratedParams[rulesArrayProperty][0].operator).toBe(rule.operator); + }; + + it('should add empty rule if operator = lte and value = 0', () => { + const params = { + bar_color_rules: [rule1], + background_color_rules: [rule1], + gauge_color_rules: [rule1], + }; + const migratedTestDoc = migrate(createTestDocWithType(params)); + const { params: migratedParams } = JSON.parse(migratedTestDoc.attributes.visState); + + checkEmptyRuleIsAddedToArray('bar_color_rules', params, migratedParams, rule1); + checkEmptyRuleIsAddedToArray('background_color_rules', params, migratedParams, rule1); + checkEmptyRuleIsAddedToArray('gauge_color_rules', params, migratedParams, rule1); + }); + + it('should add empty rule if operator = gte and value = 0', () => { + const params = { + bar_color_rules: [rule2], + background_color_rules: [rule2], + gauge_color_rules: [rule2], + }; + const migratedTestDoc = migrate(createTestDocWithType(params)); + const { params: migratedParams } = JSON.parse(migratedTestDoc.attributes.visState); + + checkEmptyRuleIsAddedToArray('bar_color_rules', params, migratedParams, rule2); + checkEmptyRuleIsAddedToArray('background_color_rules', params, migratedParams, rule2); + checkEmptyRuleIsAddedToArray('gauge_color_rules', params, migratedParams, rule2); + }); + + it('should not add empty rule if operator = gt or lt and value = any', () => { + const params = { + bar_color_rules: [rule3], + background_color_rules: [rule3], + gauge_color_rules: [rule4], + }; + const migratedTestDoc = migrate(createTestDocWithType(params)); + const { params: migratedParams } = JSON.parse(migratedTestDoc.attributes.visState); + + checkRuleIsNotAddedToArray('bar_color_rules', params, migratedParams, rule3); + checkRuleIsNotAddedToArray('background_color_rules', params, migratedParams, rule3); + checkRuleIsNotAddedToArray('gauge_color_rules', params, migratedParams, rule4); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index b9885588b6f7..c5050b4a6940 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -15,6 +15,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonAddEmptyValueColorRule, } from './visualization_common_migrations'; const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { @@ -966,6 +967,29 @@ const removeDefaultIndexPatternAndTimeFieldFromTSVBModel: SavedObjectMigrationFn }; }; +const addEmptyValueColorRule: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + const newVisState = commonAddEmptyValueColorRule(visState); + + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + return doc; +}; + export const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1012,4 +1036,5 @@ export const visualizationSavedObjectTypeMigrations = { hideTSVBLastValueIndicator, removeDefaultIndexPatternAndTimeFieldFromTSVBModel ), + '7.14.0': flow(addEmptyValueColorRule), };