[TSVB] Fix color rules are not applied for null series (#100404) (#101135)

* Fixed color rules behaviour on empty metrics data.

Refactored function `getLastValue`, which was providing unexpected result on empty arrays. It was returning string, instead of null/undefined value.
Created two useful functions, which are providing possibility to handle empty result and get default value, which is expected.

* Tests added.

Tests for getLastValue refactored.
Tests for getLastValueOrDefault and getLastValueOrZero written.

* Removed console.log

* Added default value for empty operator.

Added default value for empty operator, which will compare statistics to empty array.
Added conditional render of colorRuleValue field, if operator doesn't require some specified value to be chosen ( as default, in this case ).

* Added empty data handling.

Added empty value var and way of displaying in widgets.
Added way of handling empty results and prevented comparing null, empty array and numeric rules.

* Prettier fixes.

* Added the same logic of displaying data to gauge.

Added displaying of empty data to gauge module.
Fixed label color styles (before, it was ignoring, because of setting colorValue out of default scope while reactcss(...) call).

* Added empty data handling in Top N chart.

* Removed getLastValueOrZero.

Removed getLastValueOrZero and replaced by getLastValueOrEmpty.

* Added isEmptyValue function.

Added isEmptyValue function, which is able to check equality. It provides a possibility to encapsulate the logic of empty value and flexible changing of its' behavior.

* Fixed and refactor.

Fixed hidden value input, if no operator selected.
Removed useless DEFAULT_VALUE and getLastValueOrDefault.

* Color rules Tests.

Changed from js to ts last_value_utils. Updated tests for color_rules component.

* Replaces isEqual rule with eq.

* Migrations added.

* Fixed types, EMPTY_VALUE, empty method.

Removed type definition for methods in last_value_utils.ts.
Changed EMPTY_VALUE from array to null. Removed default value.
Added logic for handling empty value handling and comparison methods.

* Fixed comparing null and numeric rules.

* Changed migrations.

* Added test for migrations.

* Migration fix.

* Updated code, based on nits and fixed reasons of pipeline errors.

* Moved actions, connected to operators to the separate file. Reduced duplication of code.

* Type names changed.

* Test for operators_utils added.

* Fixed based on nits.

* Added vis_type_timeseries to tsconfig references.

* Changed version and added migrations.

* Small fix in migrations.

* Fixes based on review.

* Revert "Fixes based on review."

This reverts commit 35af7b2b6a.

* Fixes based on review.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yaroslav Kuznietsov 2021-06-02 13:44:52 +03:00 committed by GitHub
parent 4f0781737c
commit abaa442a0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 506 additions and 118 deletions

View file

@ -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('-');
});
});

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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));

View file

@ -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 <div/> 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(<ColorRules {...defaultProps} />);
@ -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(<ColorRules {...emptyColorRuleProps} />);
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(<ColorRules {...notEmptyColorRuleProps} />);
const numberInput = findTestSubject(wrapper, 'colorRuleValue');
expect(numberInput.exists()).toBeTruthy();
});
});
});

View file

@ -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<ColorRulesProps> {
constructor(props: ColorRulesProps) {
super(props);
@ -100,9 +120,14 @@ export class ColorRules extends Component<ColorRulesProps> {
handleOperatorChange = (item: ColorRule) => {
return (options: Array<EuiComboBoxOptionOption<string>>) => {
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<ColorRulesProps> {
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<ColorRulesProps> {
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFieldNumber
aria-label={i18n.translate('visTypeTimeseries.colorRules.valueAriaLabel', {
defaultMessage: 'Value',
})}
value={model.value ?? ''}
onChange={this.handleValueChange(model)}
data-test-subj="colorRuleValue"
fullWidth
/>
</EuiFlexItem>
{!hideValueSelectorField && (
<EuiFlexItem>
<EuiFieldNumber
aria-label={i18n.translate('visTypeTimeseries.colorRules.valueAriaLabel', {
defaultMessage: 'Value',
})}
value={model.value ?? ''}
onChange={this.handleValueChange(model)}
data-test-subj="colorRuleValue"
fullWidth
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<AddDeleteButtons
onAdd={handleAdd}

View file

@ -8,7 +8,7 @@
import { set } from '@elastic/safer-lodash-set';
import _ from 'lodash';
import { getLastValue } from '../../../../common/get_last_value';
import { getLastValue } from '../../../../common/last_value_utils';
import { emptyLabel } from '../../../../common/empty_label';
import { createTickFormatter } from './tick_formatter';
import { labelDateFormatter } from './label_date_formatter';

View file

@ -8,7 +8,7 @@
import handlebars from 'handlebars/dist/handlebars';
import { isNumber } from 'lodash';
import { DEFAULT_VALUE } from '../../../../common/get_last_value';
import { isEmptyValue, DISPLAY_EMPTY_VALUE } from '../../../../common/last_value_utils';
import { inputFormats, outputFormats, isDuration } from '../lib/durations';
import { getFieldFormats } from '../../../services';
@ -38,12 +38,12 @@ export const createTickFormatter = (format = '0,0.[00]', template, getConfig = n
}
}
return (val) => {
let value;
if (val === DEFAULT_VALUE) {
return val;
if (isEmptyValue(val)) {
return DISPLAY_EMPTY_VALUE;
}
let value;
if (!isNumber(val)) {
value = val;
} else {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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 {
<div className="tvbVisGauge__label" ref="title">
{title}
</div>
<div className="tvbVisGauge__value" style={styles.value} ref="label">
<div className="tvbVisGauge__value" style={styles.valueColor} ref="label">
{formatter(value)}
</div>
{additionalLabel}
@ -127,7 +124,7 @@ export class Gauge extends Component {
ref={(el) => (this.inner = el)}
style={styles.inner}
>
<div className="tvbVisGauge__value" style={styles.value} ref="label">
<div className="tvbVisGauge__value" style={styles.valueColor} ref="label">
{formatter(value)}
</div>
<div className="tvbVisGauge__label" ref="title">

View file

@ -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']),
};

View file

@ -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 (
<div className={className} style={styles.container}>
<div ref={(el) => (this.resize = el)} className="tvbVisMetric__resize">

View file

@ -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,

View file

@ -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';

View file

@ -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),
},
};
};

View file

@ -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;
};

View file

@ -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<SavedObjectMigrationFn>[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);
});
});
});

View file

@ -15,6 +15,7 @@ import {
commonAddSupportOfDualIndexSelectionModeInTSVB,
commonHideTSVBLastValueIndicator,
commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel,
commonAddEmptyValueColorRule,
} from './visualization_common_migrations';
const migrateIndexPattern: SavedObjectMigrationFn<any, any> = (doc) => {
@ -966,6 +967,29 @@ const removeDefaultIndexPatternAndTimeFieldFromTSVBModel: SavedObjectMigrationFn
};
};
const addEmptyValueColorRule: SavedObjectMigrationFn<any, any> = (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),
};