diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index 935eb109fed8..25ce5beecb96 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -56,6 +56,8 @@ The index pattern mode unlocks many new features, such as: * Interactive filters for time series visualizations +* Custom field formats + * Better performance [float] diff --git a/src/plugins/vis_type_timeseries/common/agg_utils.test.ts b/src/plugins/vis_type_timeseries/common/agg_utils.test.ts index 3e450c789b65..63d81e2c43d4 100644 --- a/src/plugins/vis_type_timeseries/common/agg_utils.test.ts +++ b/src/plugins/vis_type_timeseries/common/agg_utils.test.ts @@ -60,6 +60,7 @@ describe('agg utils', () => { isFieldRequired: true, isFilterRatioSupported: false, isHistogramSupported: false, + isFieldFormattingDisabled: false, hasExtendedStats: true, }; const expected = [ @@ -95,6 +96,7 @@ describe('agg utils', () => { isFieldRequired: false, isFilterRatioSupported: false, isHistogramSupported: false, + isFieldFormattingDisabled: false, hasExtendedStats: false, }; const expected = [ diff --git a/src/plugins/vis_type_timeseries/common/agg_utils.ts b/src/plugins/vis_type_timeseries/common/agg_utils.ts index 8b071cc680af..2f0488bdc4db 100644 --- a/src/plugins/vis_type_timeseries/common/agg_utils.ts +++ b/src/plugins/vis_type_timeseries/common/agg_utils.ts @@ -28,6 +28,7 @@ export interface Agg { isFieldRequired: boolean; isFilterRatioSupported: boolean; isHistogramSupported: boolean; + isFieldFormattingDisabled: boolean; hasExtendedStats: boolean; }; } @@ -37,6 +38,7 @@ const aggDefaultMeta = { isFieldRequired: true, isFilterRatioSupported: false, isHistogramSupported: false, + isFieldFormattingDisabled: false, hasExtendedStats: false, }; @@ -201,6 +203,7 @@ export const aggs: Agg[] = [ id: TSVB_METRIC_TYPES.CALCULATION, meta: { ...aggDefaultMeta, + isFieldFormattingDisabled: true, type: AGG_TYPE.PARENT_PIPELINE, label: i18n.translate('visTypeTimeseries.aggUtils.bucketScriptLabel', { defaultMessage: 'Bucket Script', @@ -342,6 +345,7 @@ export const aggs: Agg[] = [ id: TSVB_METRIC_TYPES.MATH, meta: { ...aggDefaultMeta, + isFieldFormattingDisabled: true, type: AGG_TYPE.SPECIAL, label: i18n.translate('visTypeTimeseries.aggUtils.mathLabel', { defaultMessage: 'Math' }), }, diff --git a/src/plugins/vis_type_timeseries/common/calculate_label.ts b/src/plugins/vis_type_timeseries/common/calculate_label.ts index 7ea035eef923..d054698536b5 100644 --- a/src/plugins/vis_type_timeseries/common/calculate_label.ts +++ b/src/plugins/vis_type_timeseries/common/calculate_label.ts @@ -82,7 +82,7 @@ export const calculateLabel = ( if (includes(paths, metric.type)) { const targetMetric = metrics.find((m) => startsWith(metric.field!, m.id)); - const targetLabel = calculateLabel(targetMetric!, metrics, fields); + const targetLabel = calculateLabel(targetMetric!, metrics, fields, isThrowErrorOnFieldNotFound); // For percentiles we need to parse the field id to extract the percentile // the user configured in the percentile aggregation and specified in the diff --git a/src/plugins/vis_type_timeseries/common/enums/index.ts b/src/plugins/vis_type_timeseries/common/enums/index.ts index 506abeea247c..8a4d9a21f09a 100644 --- a/src/plugins/vis_type_timeseries/common/enums/index.ts +++ b/src/plugins/vis_type_timeseries/common/enums/index.ts @@ -20,3 +20,12 @@ export enum TOOLTIP_MODES { SHOW_ALL = 'show_all', SHOW_FOCUSED = 'show_focused', } + +export enum DATA_FORMATTERS { + BYTES = 'bytes', + CUSTOM = 'custom', + DEFAULT = 'default', + DURATION = 'duration', + NUMBER = 'number', + PERCENT = 'percent', +} diff --git a/src/plugins/vis_type_timeseries/common/types/vis_data.ts b/src/plugins/vis_type_timeseries/common/types/vis_data.ts index fb3e0db82f18..1a7be0b46700 100644 --- a/src/plugins/vis_type_timeseries/common/types/vis_data.ts +++ b/src/plugins/vis_type_timeseries/common/types/vis_data.ts @@ -39,6 +39,7 @@ export interface PanelSeries { export interface PanelData { id: string; label: string; + labelFormatted?: string; data: PanelDataArray[]; seriesId: string; splitByLabel: string; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.tsx index 17af812ae5ce..3c68cb02dd07 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.tsx @@ -6,33 +6,39 @@ * Side Public License, v 1. */ -import React, { HTMLAttributes } from 'react'; +import React, { useMemo, useEffect, HTMLAttributes } from 'react'; // @ts-ignore import { aggToComponent } from '../lib/agg_to_component'; // @ts-ignore import { isMetricEnabled } from '../../lib/check_ui_restrictions'; +// @ts-expect-error not typed yet +import { seriesChangeHandler } from '../lib/series_change_handler'; +import { checkIfNumericMetric } from '../lib/check_if_numeric_metric'; +import { getFormatterType } from '../lib/get_formatter_type'; import { UnsupportedAgg } from './unsupported_agg'; import { TemporaryUnsupportedAgg } from './temporary_unsupported_agg'; +import { DATA_FORMATTERS } from '../../../../common/enums'; import type { Metric, Panel, Series, SanitizedFieldType } from '../../../../common/types'; -import { DragHandleProps } from '../../../types'; -import { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; +import type { DragHandleProps } from '../../../types'; +import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; interface AggProps extends HTMLAttributes { disableDelete: boolean; fields: Record; + name: string; model: Metric; panel: Panel; series: Series; siblings: Metric[]; uiRestrictions: TimeseriesUIRestrictions; dragHandleProps: DragHandleProps; + onChange: (part: Partial) => void; onAdd: () => void; - onChange: () => void; onDelete: () => void; } export function Agg(props: AggProps) { - const { model, uiRestrictions } = props; + const { model, uiRestrictions, series, name, onChange, fields, siblings } = props; let Component = aggToComponent[model.type]; @@ -50,6 +56,34 @@ export function Agg(props: AggProps) { const indexPattern = props.series.override_index_pattern ? props.series.series_index_pattern : props.panel.index_pattern; + const isKibanaIndexPattern = props.panel.use_kibana_indexes || indexPattern === ''; + + const onAggChange = useMemo( + () => seriesChangeHandler({ name, model: series, onChange }, siblings), + [name, onChange, siblings, series] + ); + + useEffect(() => { + // formatter is based on the last agg, i.e. active or resulting one as pipeline + if (siblings[siblings.length - 1]?.id === model.id) { + const formatterType = getFormatterType(series.formatter); + const isNumericMetric = checkIfNumericMetric(model, fields, indexPattern); + const isNumberFormatter = ![DATA_FORMATTERS.DEFAULT, DATA_FORMATTERS.CUSTOM].includes( + formatterType + ); + + if (isNumberFormatter && !isNumericMetric) { + onChange({ formatter: DATA_FORMATTERS.DEFAULT }); + } + // in case of string index pattern mode, change default formatter depending on metric type + // "number" formatter for numeric metric and "" as custom formatter for any other type + if (formatterType === DATA_FORMATTERS.DEFAULT && !isKibanaIndexPattern) { + onChange({ + formatter: isNumericMetric ? DATA_FORMATTERS.NUMBER : '', + }); + } + } + }, [indexPattern, model, onChange, fields, series.formatter, isKibanaIndexPattern, siblings]); return (
@@ -58,7 +92,7 @@ export function Agg(props: AggProps) { disableDelete={props.disableDelete} model={props.model} onAdd={props.onAdd} - onChange={props.onChange} + onChange={onAggChange} onDelete={props.onDelete} panel={props.panel} series={props.series} diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.tsx index 0edd8b9c3feb..516e3551fb01 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.tsx @@ -12,7 +12,6 @@ import { EuiDraggable, EuiDroppable } from '@elastic/eui'; import { Agg } from './agg'; // @ts-ignore -import { seriesChangeHandler } from '../lib/series_change_handler'; import { handleAdd, handleDelete } from '../lib/collection_actions'; import { newMetricAggFn } from '../lib/new_metric_agg_fn'; import type { Panel, Series, SanitizedFieldType } from '../../../../common/types'; @@ -26,16 +25,14 @@ export interface AggsProps { model: Series; fields: Record; uiRestrictions: TimeseriesUIRestrictions; - onChange(): void; + onChange(part: Partial): void; } export class Aggs extends PureComponent { render() { - const { panel, model, fields, uiRestrictions } = this.props; + const { panel, model, fields, name, uiRestrictions, onChange } = this.props; const list = model.metrics; - const onChange = seriesChangeHandler(this.props, list); - return ( {list.map((row, idx) => ( @@ -51,6 +48,7 @@ export class Aggs extends PureComponent { key={row.id} disableDelete={list.length < 2} fields={fields} + name={name} model={row} onAdd={() => handleAdd(this.props, newMetricAggFn)} onChange={onChange} diff --git a/src/plugins/vis_type_timeseries/public/application/components/data_format_picker.js b/src/plugins/vis_type_timeseries/public/application/components/data_format_picker.js deleted file mode 100644 index 12428b7fbe6a..000000000000 --- a/src/plugins/vis_type_timeseries/public/application/components/data_format_picker.js +++ /dev/null @@ -1,298 +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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import _ from 'lodash'; -import { - htmlIdGenerator, - EuiComboBox, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiFieldText, - EuiLink, -} from '@elastic/eui'; -import { durationOutputOptions, durationInputOptions, isDuration } from './lib/durations'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; - -const DEFAULT_OUTPUT_PRECISION = '2'; - -class DataFormatPickerUI extends Component { - constructor(props) { - super(props); - - let from; - let to; - let decimals; - - if (isDuration(props.value)) { - [from, to, decimals] = props.value.split(','); - } - - this.state = { - from: from || 'ms', - to: to || 'ms', - decimals: decimals || '', - }; - } - - handleCustomChange = () => { - this.props.onChange([{ value: (this.custom && this.custom.value) || '' }]); - }; - - handleChange = (selectedOptions) => { - if (selectedOptions.length < 1) { - return; - } - - if (selectedOptions[0].value === 'custom') { - this.handleCustomChange(); - } else if (selectedOptions[0].value === 'duration') { - const { from, to, decimals } = this.state; - this.props.onChange([ - { - value: `${from},${to},${decimals}`, - }, - ]); - } else { - this.props.onChange(selectedOptions); - } - }; - - handleDurationChange(name) { - return (selectedOptions) => { - if (selectedOptions.length < 1) { - return; - } - - let newValue; - if (name === 'decimals') { - newValue = this.decimals.value; - } else { - newValue = selectedOptions[0].value; - } - - this.setState( - { - [name]: newValue, - }, - () => { - const { from, to, decimals } = this.state; - this.props.onChange([ - { - value: `${from},${to},${decimals}`, - }, - ]); - } - ); - }; - } - - render() { - const htmlId = htmlIdGenerator(); - const value = this.props.value || ''; - let defaultValue = value; - if (!_.includes(['bytes', 'number', 'percent'], value)) { - defaultValue = 'custom'; - } - if (isDuration(value)) { - defaultValue = 'duration'; - } - const { intl } = this.props; - const options = [ - { - label: intl.formatMessage({ - id: 'visTypeTimeseries.dataFormatPicker.bytesLabel', - defaultMessage: 'Bytes', - }), - value: 'bytes', - }, - { - label: intl.formatMessage({ - id: 'visTypeTimeseries.dataFormatPicker.numberLabel', - defaultMessage: 'Number', - }), - value: 'number', - }, - { - label: intl.formatMessage({ - id: 'visTypeTimeseries.dataFormatPicker.percentLabel', - defaultMessage: 'Percent', - }), - value: 'percent', - }, - { - label: intl.formatMessage({ - id: 'visTypeTimeseries.dataFormatPicker.durationLabel', - defaultMessage: 'Duration', - }), - value: 'duration', - }, - { - label: intl.formatMessage({ - id: 'visTypeTimeseries.dataFormatPicker.customLabel', - defaultMessage: 'Custom', - }), - value: 'custom', - }, - ]; - const selectedOption = options.find((option) => { - return defaultValue === option.value; - }); - - let custom; - if (defaultValue === 'duration') { - const [from, to, decimals] = value.split(','); - const selectedFrom = durationInputOptions.find((option) => from === option.value); - const selectedTo = durationOutputOptions.find((option) => to === option.value); - - return ( - - - - - - - - - } - > - - - - - - } - > - - - - - {selectedTo && selectedTo.value !== 'humanize' && ( - - - } - > - (this.decimals = el)} - placeholder={DEFAULT_OUTPUT_PRECISION} - onChange={this.handleDurationChange('decimals')} - /> - - - )} - - ); - } - if (defaultValue === 'custom') { - custom = ( - - - } - helpText={ - - - Numeral.js - - ), - }} - /> - - } - > - (this.custom = el)} - onChange={this.handleCustomChange} - /> - - - ); - } - return ( - - - - - - - {custom} - - ); - } -} - -DataFormatPickerUI.defaultProps = { - label: i18n.translate('visTypeTimeseries.defaultDataFormatterLabel', { - defaultMessage: 'Data Formatter', - }), -}; - -DataFormatPickerUI.propTypes = { - value: PropTypes.string, - label: PropTypes.string, - onChange: PropTypes.func, -}; - -export const DataFormatPicker = injectI18n(DataFormatPickerUI); diff --git a/src/plugins/vis_type_timeseries/public/application/components/data_format_picker.tsx b/src/plugins/vis_type_timeseries/public/application/components/data_format_picker.tsx new file mode 100644 index 000000000000..fa76f8534f85 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/data_format_picker.tsx @@ -0,0 +1,310 @@ +/* + * 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 React, { useEffect, useMemo, useCallback, useState, ChangeEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { + htmlIdGenerator, + EuiComboBox, + EuiFieldText, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiSuperSelect, + EuiText, + EuiCode, +} from '@elastic/eui'; +import { DATA_FORMATTERS } from '../../../common/enums'; +import { getFormatterType } from './lib/get_formatter_type'; +import { durationInputOptions, durationOutputOptions, getDurationParams } from './lib/durations'; + +const DEFAULT_OUTPUT_PRECISION = '2'; +const DEFAULT_CUSTOM_FORMAT_PATTERN = '0,0.[000]'; + +const defaultOptionLabel = i18n.translate('visTypeTimeseries.dataFormatPicker.defaultLabel', { + defaultMessage: 'Default', +}); + +const getDataFormatPickerOptions = ( + shouldIncludeDefaultOption: boolean, + shouldIncludeNumberOptions: boolean +) => { + const additionalOptions = []; + + if (shouldIncludeDefaultOption) { + additionalOptions.push({ + value: DATA_FORMATTERS.DEFAULT, + inputDisplay: defaultOptionLabel, + dropdownDisplay: ( + <> + {defaultOptionLabel} + +

+ {i18n.translate('visTypeTimeseries.dataFormatPicker.defaultLabelDescription', { + defaultMessage: 'Applies common formatting', + })} +

+
+ + ), + 'data-test-subj': `tsvbDataFormatPicker-${DATA_FORMATTERS.DEFAULT}`, + }); + } + + if (shouldIncludeNumberOptions) { + additionalOptions.push( + { + value: DATA_FORMATTERS.NUMBER, + inputDisplay: i18n.translate('visTypeTimeseries.dataFormatPicker.numberLabel', { + defaultMessage: 'Number', + }), + 'data-test-subj': `tsvbDataFormatPicker-${DATA_FORMATTERS.NUMBER}`, + }, + { + value: DATA_FORMATTERS.BYTES, + inputDisplay: i18n.translate('visTypeTimeseries.dataFormatPicker.bytesLabel', { + defaultMessage: 'Bytes', + }), + 'data-test-subj': `tsvbDataFormatPicker-${DATA_FORMATTERS.BYTES}`, + }, + { + value: DATA_FORMATTERS.PERCENT, + inputDisplay: i18n.translate('visTypeTimeseries.dataFormatPicker.percentLabel', { + defaultMessage: 'Percent', + }), + 'data-test-subj': `tsvbDataFormatPicker-${DATA_FORMATTERS.PERCENT}`, + }, + { + value: DATA_FORMATTERS.DURATION, + inputDisplay: i18n.translate('visTypeTimeseries.dataFormatPicker.durationLabel', { + defaultMessage: 'Duration', + }), + 'data-test-subj': `tsvbDataFormatPicker-${DATA_FORMATTERS.DURATION}`, + } + ); + } + + return [ + ...additionalOptions, + { + value: DATA_FORMATTERS.CUSTOM, + inputDisplay: i18n.translate('visTypeTimeseries.dataFormatPicker.customLabel', { + defaultMessage: 'Custom', + }), + 'data-test-subj': `tsvbDataFormatPicker-${DATA_FORMATTERS.CUSTOM}`, + }, + ]; +}; + +interface DataFormatPickerProps { + formatterValue: string; + changeModelFormatter: (formatter: string) => void; + shouldIncludeDefaultOption: boolean; + shouldIncludeNumberOptions: boolean; +} + +const htmlId = htmlIdGenerator(); + +export const DataFormatPicker = ({ + formatterValue, + changeModelFormatter, + shouldIncludeDefaultOption, + shouldIncludeNumberOptions, +}: DataFormatPickerProps) => { + const options = useMemo( + () => getDataFormatPickerOptions(shouldIncludeDefaultOption, shouldIncludeNumberOptions), + [shouldIncludeDefaultOption, shouldIncludeNumberOptions] + ); + const [selectedFormatter, setSelectedFormatter] = useState(getFormatterType(formatterValue)); + const [customFormatPattern, setCustomFormatPattern] = useState( + selectedFormatter === DATA_FORMATTERS.CUSTOM ? formatterValue : '' + ); + const [durationParams, setDurationParams] = useState( + getDurationParams(selectedFormatter === DATA_FORMATTERS.DURATION ? formatterValue : 'ms,ms,') + ); + + useEffect(() => { + // formatter value is set to the first option in case options do not include selected formatter + if (!options.find(({ value }) => value === selectedFormatter)) { + const [{ value: firstOptionValue }] = options; + setSelectedFormatter(firstOptionValue); + changeModelFormatter(firstOptionValue); + } + }, [options, selectedFormatter, changeModelFormatter]); + + const handleChange = useCallback( + (selectedOption: DATA_FORMATTERS) => { + setSelectedFormatter(selectedOption); + if (selectedOption === DATA_FORMATTERS.DURATION) { + const { from, to, decimals } = durationParams; + changeModelFormatter(`${from},${to},${decimals}`); + } else if (selectedOption === DATA_FORMATTERS.CUSTOM) { + changeModelFormatter(customFormatPattern); + } else { + changeModelFormatter(selectedOption); + } + }, + [changeModelFormatter, customFormatPattern, durationParams] + ); + + const handleCustomFormatStringChange = useCallback( + (event: ChangeEvent) => { + const stringPattern = event.target.value; + changeModelFormatter(stringPattern); + setCustomFormatPattern(stringPattern); + }, + [changeModelFormatter] + ); + + const handleDurationParamsChange = useCallback( + (paramName: string, paramValue: string) => { + const newDurationParams = { ...durationParams, [paramName]: paramValue }; + setDurationParams(newDurationParams); + const { from, to, decimals } = newDurationParams; + changeModelFormatter(`${from},${to},${decimals}`); + }, + [changeModelFormatter, durationParams] + ); + + const handleDurationChange = useCallback( + (optionName: 'from' | 'to') => { + return ([{ value }]: Array>) => + handleDurationParamsChange(optionName, value!); + }, + [handleDurationParamsChange] + ); + + const handleDecimalsChange = useCallback( + (event: ChangeEvent) => + handleDurationParamsChange('decimals', event.target.value), + [handleDurationParamsChange] + ); + + let duration; + if (selectedFormatter === DATA_FORMATTERS.DURATION) { + const { from, to, decimals = DEFAULT_OUTPUT_PRECISION } = durationParams; + const selectedFrom = durationInputOptions.find(({ value }) => value === from); + const selectedTo = durationOutputOptions.find(({ value }) => value === to); + + duration = ( + <> + + + + + + + + + + + + {selectedTo?.value !== 'humanize' && ( + + + + + + )} + + ); + } + + let custom; + if (selectedFormatter === DATA_FORMATTERS.CUSTOM && shouldIncludeNumberOptions) { + custom = ( + + {DEFAULT_CUSTOM_FORMAT_PATTERN} }} + /> + } + helpText={ + + + + + + } + > + + + + ); + } + + return ( + <> + + + + + + {selectedFormatter === DATA_FORMATTERS.DURATION && duration} + {selectedFormatter === DATA_FORMATTERS.CUSTOM && custom} + + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_numeric_metric.test.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_numeric_metric.test.ts new file mode 100644 index 000000000000..17827275f86d --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_numeric_metric.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 { METRIC_TYPES } from '../../../../../data/common'; +import { TSVB_METRIC_TYPES } from '../../../../common/enums'; +import { checkIfNumericMetric } from './check_if_numeric_metric'; + +import type { Metric } from '../../../../common/types'; + +describe('checkIfNumericMetric(metric, fields, indexPattern)', () => { + const indexPattern = { id: 'some_id' }; + const fields = { + some_id: [ + { name: 'number field', type: 'number' }, + { name: 'string field', type: 'string' }, + { name: 'date field', type: 'date' }, + ], + }; + + it('should return true for Count metric', () => { + const metric = { type: METRIC_TYPES.COUNT } as Metric; + + const actual = checkIfNumericMetric(metric, fields, indexPattern); + expect(actual).toBe(true); + }); + + it('should return true for Average metric', () => { + const metric = { field: 'number field', type: METRIC_TYPES.AVG } as Metric; + + const actual = checkIfNumericMetric(metric, fields, indexPattern); + expect(actual).toBe(true); + }); + + it('should return true for Top Hit metric with numeric field', () => { + const metric = { field: 'number field', type: TSVB_METRIC_TYPES.TOP_HIT } as Metric; + + const actual = checkIfNumericMetric(metric, fields, indexPattern); + expect(actual).toBe(true); + }); + + it('should return false for Top Hit metric with string field', () => { + const metric = { field: 'string field', type: TSVB_METRIC_TYPES.TOP_HIT } as Metric; + + const actual = checkIfNumericMetric(metric, fields, indexPattern); + expect(actual).toBe(false); + }); + + it('should return false for Top Hit metric with date field', () => { + const metric = { field: 'date field', type: TSVB_METRIC_TYPES.TOP_HIT } as Metric; + + const actual = checkIfNumericMetric(metric, fields, indexPattern); + expect(actual).toBe(false); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_numeric_metric.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_numeric_metric.ts new file mode 100644 index 000000000000..a70abaeac9f8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_numeric_metric.ts @@ -0,0 +1,30 @@ +/* + * 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 { getIndexPatternKey } from '../../../../common/index_patterns_utils'; +import { TSVB_METRIC_TYPES } from '../../../../common/enums'; +import { KBN_FIELD_TYPES } from '../../../../../data/public'; + +import type { Metric, IndexPatternValue } from '../../../../common/types'; +import type { VisFields } from '../../lib/fetch_fields'; + +// this function checks if metric has numeric value result +export const checkIfNumericMetric = ( + metric: Metric, + fields: VisFields, + indexPattern: IndexPatternValue +) => { + // currently only Top Hit could have not numeric value result + if (metric?.type === TSVB_METRIC_TYPES.TOP_HIT) { + const selectedField = fields[getIndexPatternKey(indexPattern)]?.find( + ({ name }) => name === metric?.field + ); + return selectedField?.type === KBN_FIELD_TYPES.NUMBER; + } + return true; +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_series_have_same_formatters.test.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_series_have_same_formatters.test.ts new file mode 100644 index 000000000000..71aed8c7315e --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_series_have_same_formatters.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { checkIfSeriesHaveSameFormatters } from './check_if_series_have_same_formatters'; +import { DATA_FORMATTERS } from '../../../../common/enums'; +import type { Series } from '../../../../common/types'; + +describe('checkIfSeriesHaveSameFormatters(seriesModel, fieldFormatMap)', () => { + const fieldFormatMap = { + someField: { id: 'string', params: { transform: 'upper' } }, + anotherField: { id: 'number', params: { pattern: '$0,0.[00]' } }, + }; + + it('should return true for the same series formatters', () => { + const seriesModel = [ + { formatter: DATA_FORMATTERS.BYTES, metrics: [{ field: 'someField' }] }, + { formatter: DATA_FORMATTERS.BYTES, metrics: [{ field: 'anotherField' }] }, + ] as Series[]; + const result = checkIfSeriesHaveSameFormatters(seriesModel, fieldFormatMap); + + expect(result).toBe(true); + }); + + it('should return false for the different value_template series formatters', () => { + const seriesModel = [ + { + formatter: DATA_FORMATTERS.PERCENT, + value_template: '{{value}} first', + }, + { + formatter: DATA_FORMATTERS.PERCENT, + value_template: '{{value}} second', + }, + ] as Series[]; + const result = checkIfSeriesHaveSameFormatters(seriesModel, fieldFormatMap); + + expect(result).toBe(false); + }); + + it('should return true for the same field formatters', () => { + const seriesModel = [ + { formatter: DATA_FORMATTERS.DEFAULT, metrics: [{ field: 'someField' }] }, + { formatter: DATA_FORMATTERS.DEFAULT, metrics: [{ field: 'someField' }] }, + ] as Series[]; + const result = checkIfSeriesHaveSameFormatters(seriesModel, fieldFormatMap); + + expect(result).toBe(true); + }); + + it('should return false for the different field formatters', () => { + const seriesModel = [ + { formatter: DATA_FORMATTERS.DEFAULT, metrics: [{ field: 'someField' }] }, + { + formatter: DATA_FORMATTERS.DEFAULT, + + metrics: [{ field: 'anotherField' }], + }, + ] as Series[]; + const result = checkIfSeriesHaveSameFormatters(seriesModel, fieldFormatMap); + + expect(result).toBe(false); + }); + + it('should return false for when there is no custom formatter for a field', () => { + const seriesModel = [ + { + formatter: DATA_FORMATTERS.DEFAULT, + + metrics: [{ field: 'someField' }, { field: 'field' }], + }, + { formatter: DATA_FORMATTERS.DEFAULT, metrics: [{ field: 'someField' }] }, + ] as Series[]; + const result = checkIfSeriesHaveSameFormatters(seriesModel, fieldFormatMap); + + expect(result).toBe(false); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_series_have_same_formatters.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_series_have_same_formatters.ts new file mode 100644 index 000000000000..afa1216406ab --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/check_if_series_have_same_formatters.ts @@ -0,0 +1,33 @@ +/* + * 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 { last, isEqual } from 'lodash'; +import { DATA_FORMATTERS } from '../../../../common/enums'; +import type { Series } from '../../../../common/types'; +import type { FieldFormatMap } from '../../../../../data/common'; + +export const checkIfSeriesHaveSameFormatters = ( + seriesModel: Series[], + fieldFormatMap?: FieldFormatMap +) => { + const allSeriesHaveDefaultFormatting = seriesModel.every( + (seriesGroup) => seriesGroup.formatter === DATA_FORMATTERS.DEFAULT + ); + + return allSeriesHaveDefaultFormatting && fieldFormatMap + ? seriesModel + .map(({ metrics }) => fieldFormatMap[last(metrics)?.field ?? '']) + .every((fieldFormat, index, [firstSeriesFieldFormat]) => + isEqual(fieldFormat, firstSeriesFieldFormat) + ) + : seriesModel.every( + (series) => + series.formatter === seriesModel[0].formatter && + series.value_template === seriesModel[0].value_template + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.js b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.js index 816bce5dac75..867ba673cf1d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/convert_series_to_vars.js @@ -7,32 +7,35 @@ */ import { set } from '@elastic/safer-lodash-set'; -import _ from 'lodash'; +import { startsWith, snakeCase } from 'lodash'; +import { BUCKET_TYPES, DATA_FORMATTERS } from '../../../../common/enums'; import { getLastValue } from '../../../../common/last_value_utils'; import { getValueOrEmpty, emptyLabel } from '../../../../common/empty_label'; import { createTickFormatter } from './tick_formatter'; +import { getMetricsField } from './get_metrics_field'; +import { createFieldFormatter } from './create_field_formatter'; import { labelDateFormatter } from './label_date_formatter'; import moment from 'moment'; -export const convertSeriesToVars = (series, model, dateFormat = 'lll', getConfig = null) => { +export const convertSeriesToVars = (series, model, getConfig = null, fieldFormatMap) => { const variables = {}; + const dateFormat = getConfig?.('dateFormat') ?? 'lll'; model.series.forEach((seriesModel) => { series - .filter((row) => _.startsWith(row.id, seriesModel.id)) + .filter((row) => startsWith(row.id, seriesModel.id)) .forEach((row) => { let label = getValueOrEmpty(row.label); if (label !== emptyLabel) { - label = _.snakeCase(label); + label = snakeCase(label); } - const varName = [label, _.snakeCase(seriesModel.var_name)].filter((v) => v).join('.'); + const varName = [label, snakeCase(seriesModel.var_name)].filter((v) => v).join('.'); - const formatter = createTickFormatter( - seriesModel.formatter, - seriesModel.value_template, - getConfig - ); + const formatter = + seriesModel.formatter === DATA_FORMATTERS.DEFAULT + ? createFieldFormatter(getMetricsField(seriesModel.metrics), fieldFormatMap) + : createTickFormatter(seriesModel.formatter, seriesModel.value_template, getConfig); const lastValue = getLastValue(row.data); const data = { @@ -47,8 +50,12 @@ export const convertSeriesToVars = (series, model, dateFormat = 'lll', getConfig }), }, }; + const rowLabel = + seriesModel.split_mode === BUCKET_TYPES.TERMS + ? createFieldFormatter(seriesModel.terms_field, fieldFormatMap)(row.label) + : row.label; set(variables, varName, data); - set(variables, `${label}.label`, row.label); + set(variables, `${label}.label`, rowLabel); /** * Handle the case when a field has "key_as_string" value. diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/create_field_formatter.test.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/create_field_formatter.test.ts new file mode 100644 index 000000000000..0173ca4db15a --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/create_field_formatter.test.ts @@ -0,0 +1,125 @@ +/* + * 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 { createFieldFormatter } from './create_field_formatter'; +import { getFieldFormatsRegistry } from '../../../../../data/public/test_utils'; +import { setFieldFormats } from '../../../services'; +import { FORMATS_UI_SETTINGS } from 'src/plugins/field_formats/common'; +import type { CoreSetup } from 'kibana/public'; + +const mockUiSettings = ({ + get: jest.fn((item: keyof typeof mockUiSettings) => mockUiSettings[item]), + [FORMATS_UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', + [FORMATS_UI_SETTINGS.FORMAT_NUMBER_DEFAULT_PATTERN]: '0,0.[000]', +} as unknown) as CoreSetup['uiSettings']; + +describe('createFieldFormatter(fieldName, fieldFormatMap?, contextType?, hasColorRules)', () => { + setFieldFormats( + getFieldFormatsRegistry(({ + uiSettings: mockUiSettings, + } as unknown) as CoreSetup) + ); + const value = 1234567890; + const stringValue = 'some string'; + const fieldFormatMap = { + bytesField: { + id: 'bytes', + }, + stringField: { + id: 'string', + params: { + transform: 'base64', + }, + }, + colorField: { + id: 'color', + params: { + fieldType: 'number', + colors: [ + { + range: '-Infinity:Infinity', + regex: '', + text: '#D36086', + background: '#ffffff', + }, + ], + }, + }, + urlField: { + id: 'url', + params: { + urlTemplate: 'https://{{value}}', + labelTemplate: '{{value}}', + }, + }, + }; + + it('should return byte formatted value for bytesField', () => { + const formatter = createFieldFormatter('bytesField', fieldFormatMap); + + expect(formatter(value)).toBe('1.15GB'); + }); + + it('should return base64 formatted value for stringField', () => { + const formatter = createFieldFormatter('stringField', fieldFormatMap); + + expect(formatter(value)).toBe('×møç®ü÷'); + }); + + it('should return color formatted value for colorField', () => { + const formatter = createFieldFormatter('colorField', fieldFormatMap, 'html'); + + expect(formatter(value)).toBe( + '1234567890' + ); + }); + + it('should return number formatted value wrapped in span for colorField when color rules are applied', () => { + const formatter = createFieldFormatter('colorField', fieldFormatMap, 'html', true); + + expect(formatter(value)).toBe('1,234,567,890'); + }); + + it('should return not formatted string value for colorField when color rules are applied', () => { + const formatter = createFieldFormatter('colorField', fieldFormatMap, 'html', true); + + expect(formatter(stringValue)).toBe(stringValue); + }); + + it('should return url formatted value for urlField', () => { + const formatter = createFieldFormatter('urlField', fieldFormatMap, 'html'); + + expect(formatter(value)).toBe( + '1234567890' + ); + }); + + it('should return "-" for null value when field has format', () => { + const formatter = createFieldFormatter('bytesField', fieldFormatMap); + + expect(formatter(null)).toBe('-'); + }); + + it('should return "-" for null value when field that has no format', () => { + const formatter = createFieldFormatter('urlField', fieldFormatMap); + + expect(formatter(null)).toBe('-'); + }); + + it('should return number formatted value for number when field has no format', () => { + const formatter = createFieldFormatter('noSuchField', fieldFormatMap); + + expect(formatter(value)).toBe('1,234,567,890'); + }); + + it('should not format string value when field has no format', () => { + const formatter = createFieldFormatter('noSuchField', fieldFormatMap); + + expect(formatter(stringValue)).toBe(stringValue); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/create_field_formatter.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/create_field_formatter.ts new file mode 100644 index 000000000000..5cba549220f2 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/create_field_formatter.ts @@ -0,0 +1,43 @@ +/* + * 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 { isNumber } from 'lodash'; +import { getFieldFormats } from '../../../services'; +import { isEmptyValue, DISPLAY_EMPTY_VALUE } from '../../../../common/last_value_utils'; +import { FIELD_FORMAT_IDS } from '../../../../../field_formats/common'; +import type { FieldFormatMap } from '../../../../../data/common'; +import type { FieldFormatsContentType } from '../../../../../field_formats/common'; + +const DEFAULT_FIELD_FORMAT = { id: 'number' }; + +export const createFieldFormatter = ( + fieldName: string = '', + fieldFormatMap?: FieldFormatMap, + contextType?: FieldFormatsContentType, + hasColorRules: boolean = false +) => { + const serializedFieldFormat = fieldFormatMap?.[fieldName]; + // field formatting should be skipped either there's no such field format in fieldFormatMap + // or it's color formatting and color rules are already applied + const shouldSkipFormatting = + !serializedFieldFormat || + (hasColorRules && serializedFieldFormat?.id === FIELD_FORMAT_IDS.COLOR); + + const fieldFormat = getFieldFormats().deserialize( + shouldSkipFormatting ? DEFAULT_FIELD_FORMAT : serializedFieldFormat + ); + + return (value: unknown) => { + if (isEmptyValue(value)) { + return DISPLAY_EMPTY_VALUE; + } + return isNumber(value) || !shouldSkipFormatting + ? fieldFormat.convert(value, contextType) + : value; + }; +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/durations.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/durations.test.ts similarity index 100% rename from src/plugins/vis_type_timeseries/public/application/components/lib/durations.test.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/durations.test.ts diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/durations.js b/src/plugins/vis_type_timeseries/public/application/components/lib/durations.ts similarity index 86% rename from src/plugins/vis_type_timeseries/public/application/components/lib/durations.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/durations.ts index ac1eb76e7063..df84c5d6781d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/durations.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/durations.ts @@ -104,6 +104,7 @@ export const inputFormats = { M: 'months', Y: 'years', }; +type InputFormat = keyof typeof inputFormats; export const outputFormats = { humanize: 'humanize', @@ -116,10 +117,24 @@ export const outputFormats = { M: 'asMonths', Y: 'asYears', }; +type OutputFormat = keyof typeof outputFormats; -export const isDuration = (format) => { +export const getDurationParams = (format: string) => { + const [from, to, decimals] = format.split(','); + + return { + from, + to, + decimals, + }; +}; + +export const isDuration = (format: string) => { const splittedFormat = format.split(','); const [input, output] = splittedFormat; - return Boolean(inputFormats[input] && outputFormats[output]) && splittedFormat.length === 3; + return ( + Boolean(inputFormats[input as InputFormat] && outputFormats[output as OutputFormat]) && + splittedFormat.length === 3 + ); }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_formatter_type.test.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/get_formatter_type.test.ts new file mode 100644 index 000000000000..59b778a08490 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_formatter_type.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { DATA_FORMATTERS } from '../../../../common/enums'; +import { getFormatterType } from './get_formatter_type'; + +describe('getFormatterType(formatter)', () => { + it('should return bytes formatter for "bytes"', () => { + const actual = getFormatterType(DATA_FORMATTERS.BYTES); + + expect(actual).toBe(DATA_FORMATTERS.BYTES); + }); + + it('should return duration formatter for duration format string', () => { + const actual = getFormatterType('ns,ms,2'); + + expect(actual).toBe(DATA_FORMATTERS.DURATION); + }); + + it('should return custom formatter for Numeral.js pattern', () => { + const actual = getFormatterType('$ 0.00'); + + expect(actual).toBe(DATA_FORMATTERS.CUSTOM); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_formatter_type.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/get_formatter_type.ts new file mode 100644 index 000000000000..eb6b2c40f31a --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_formatter_type.ts @@ -0,0 +1,25 @@ +/* + * 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 { DATA_FORMATTERS } from '../../../../common/enums'; +import { isDuration } from './durations'; + +export const getFormatterType = (formatter: string) => { + if ( + [ + DATA_FORMATTERS.NUMBER, + DATA_FORMATTERS.BYTES, + DATA_FORMATTERS.PERCENT, + DATA_FORMATTERS.DEFAULT, + ].includes(formatter as DATA_FORMATTERS) + ) { + return formatter as DATA_FORMATTERS; + } + + return formatter && isDuration(formatter) ? DATA_FORMATTERS.DURATION : DATA_FORMATTERS.CUSTOM; +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_metrics_field.test.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/get_metrics_field.test.ts new file mode 100644 index 000000000000..88d671af2f1a --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_metrics_field.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { getMetricsField } from './get_metrics_field'; +import type { Metric } from '../../../../common/types'; + +describe('getMetricsField(metrics)', () => { + it('should return last metric field', () => { + const metrics = [ + { id: 'some-id', type: 'avg', field: 'some field' }, + { id: 'another-id', type: 'sum_bucket', field: 'some-id' }, + { id: 'one-more-id', type: 'top_hit', field: 'one more field' }, + ] as Metric[]; + + const field = getMetricsField(metrics); + expect(field).toBe('one more field'); + }); + + it('should return undefined when last metric has no field', () => { + const metrics = [ + { id: 'some-id', type: 'avg', field: 'some field' }, + { id: 'another-id', type: 'count' }, + ] as Metric[]; + + const field = getMetricsField(metrics); + expect(field).toBeUndefined(); + }); + + it('should return field of basic aggregation', () => { + const metrics = [ + { id: 'some-id', type: 'avg', field: 'some field' }, + { id: 'another-id', type: 'sum_bucket', field: 'some-id' }, + ] as Metric[]; + + const field = getMetricsField(metrics); + expect(field).toBe('some field'); + }); + + it('should return undefined when basic aggregation has no field', () => { + const metrics = [ + { id: 'some-id', type: 'filter_ratio' }, + { id: 'another-id', type: 'max_bucket', field: 'some-id' }, + ] as Metric[]; + + const field = getMetricsField(metrics); + expect(field).toBeUndefined(); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_metrics_field.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/get_metrics_field.ts new file mode 100644 index 000000000000..c61f147c388f --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_metrics_field.ts @@ -0,0 +1,27 @@ +/* + * 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 { last } from 'lodash'; +import { Metric } from '../../../../common/types'; +import { getAggByPredicate, isBasicAgg } from '../../../../common/agg_utils'; + +export const getMetricsField = (metrics: Metric[]) => { + const selectedMetric = last(metrics); + + if (selectedMetric) { + const { isFieldRequired, isFieldFormattingDisabled } = getAggByPredicate( + selectedMetric.type + )?.meta; + + if (isFieldRequired && !isFieldFormattingDisabled) { + return isBasicAgg(selectedMetric) + ? selectedMetric.field + : metrics.find(({ id }) => selectedMetric.field === id)?.field; + } + } +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/new_series_fn.js b/src/plugins/vis_type_timeseries/public/application/components/lib/new_series_fn.js index 9064cd1afc3f..ad1230247305 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/new_series_fn.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/new_series_fn.js @@ -24,7 +24,7 @@ export const newSeriesFn = (obj = {}) => { metrics: [newMetricAggFn()], separate_axis: 0, axis_position: 'right', - formatter: 'number', + formatter: 'default', chart_type: 'line', line_width: 1, point_size: 1, diff --git a/src/plugins/vis_type_timeseries/public/application/components/markdown_editor.js b/src/plugins/vis_type_timeseries/public/application/components/markdown_editor.js index 27622e29c206..046b1c579983 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/markdown_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/markdown_editor.js @@ -20,8 +20,15 @@ import { CodeEditor, MarkdownLang } from '../../../../kibana_react/public'; import { EuiText, EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { getDataStart } from '../../services'; +import { fetchIndexPattern } from '../../../common/index_patterns_utils'; export class MarkdownEditor extends Component { + constructor(props) { + super(props); + this.state = { fieldFormatMap: undefined }; + } + handleChange = (value) => { this.props.onChange({ markdown: value }); }; @@ -38,17 +45,22 @@ export class MarkdownEditor extends Component { } }; + async componentDidMount() { + const { indexPatterns } = getDataStart(); + const { indexPattern } = await fetchIndexPattern(this.props.model.index_pattern, indexPatterns); + this.setState({ fieldFormatMap: indexPattern?.fieldFormatMap }); + } + render() { const { visData, model, getConfig } = this.props; if (!visData) { return null; } - const dateFormat = getConfig('dateFormat'); const series = _.get(visData, `${model.id}.series`, []); - const variables = convertSeriesToVars(series, model, dateFormat, this.props.getConfig); + const variables = convertSeriesToVars(series, model, getConfig, this.state.fieldFormatMap); const rows = []; - const rawFormatter = createTickFormatter('0.[0000]', null, this.props.getConfig); + const rawFormatter = createTickFormatter('0.[0000]', null, getConfig); const createPrimitiveRow = (key) => { const snippet = `{{ ${key} }}`; diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config.js b/src/plugins/vis_type_timeseries/public/application/components/series_config.js index 86781c9922e4..b4907d4eaa5c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config.js @@ -6,12 +6,13 @@ * Side Public License, v 1. */ import { i18n } from '@kbn/i18n'; +import { last } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import { DataFormatPicker } from './data_format_picker'; -import { createSelectHandler } from './lib/create_select_handler'; import { createTextHandler } from './lib/create_text_handler'; +import { checkIfNumericMetric } from './lib/check_if_numeric_metric'; import { YesNo } from './yes_no'; import { IndexPattern } from './index_pattern'; import { @@ -24,34 +25,34 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { SeriesConfigQueryBarWithIgnoreGlobalFilter } from './series_config_query_bar_with_ignore_global_filter'; +import { DATA_FORMATTERS } from '../../../common/enums'; export const SeriesConfig = (props) => { - const defaults = { offset_time: '', value_template: '' }; + const defaults = { offset_time: '', value_template: '{{value}}' }; const model = { ...defaults, ...props.model }; - const handleSelectChange = createSelectHandler(props.onChange); const handleTextChange = createTextHandler(props.onChange); const htmlId = htmlIdGenerator(); const seriesIndexPattern = props.model.override_index_pattern ? props.model.series_index_pattern : props.indexPatternForQuery; + const changeModelFormatter = useCallback((formatter) => props.onChange({ formatter }), [props]); + const isNumericMetric = useMemo( + () => checkIfNumericMetric(last(model.metrics), props.fields, seriesIndexPattern), + [model.metrics, props.fields, seriesIndexPattern] + ); + const isKibanaIndexPattern = props.panel.use_kibana_indexes || seriesIndexPattern === ''; + return (
- - - - - - - - - + + { + + + + + + + + + { - const indexPatternValue = model.index_pattern || ''; - const { indexPatterns } = getDataStart(); - const { indexPattern } = await fetchIndexPattern(indexPatternValue, indexPatterns); let event; // trigger applyFilter if no index pattern found, url drilldowns are supported only // for the index pattern mode @@ -98,15 +94,11 @@ function TimeseriesVisualization({ handlers.event(event); }, - [handlers, model] + [handlers, indexPattern, model] ); const handleFilterClick = useCallback( async (series: PanelData[], points: Array<[GeometryValue, XYChartSeriesIdentifier]>) => { - const indexPatternValue = model.index_pattern || ''; - const { indexPatterns } = getDataStart(); - const { indexPattern } = await fetchIndexPattern(indexPatternValue, indexPatterns); - // it should work only if index pattern is found if (!indexPattern) return; @@ -129,7 +121,7 @@ function TimeseriesVisualization({ handlers.event(event); }, - [handlers, model] + [handlers, indexPattern, model] ); const handleUiState = useCallback( @@ -152,17 +144,16 @@ function TimeseriesVisualization({ const shouldDisplayLastValueIndicator = isLastValueMode && !model.hide_last_value_indicator && model.type !== PANEL_TYPES.TIMESERIES; + const [firstSeries] = + (isVisTableData(visData) ? visData.series : visData[model.id]?.series) ?? []; + if (VisComponent) { return ( {shouldDisplayLastValueIndicator && ( 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 6140726975cb..dbe86961db24 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 @@ -9,10 +9,13 @@ import PropTypes from 'prop-types'; import React from 'react'; import { visWithSplits } from '../../vis_with_splits'; +import { getMetricsField } from '../../lib/get_metrics_field'; import { createTickFormatter } from '../../lib/tick_formatter'; +import { createFieldFormatter } from '../../lib/create_field_formatter'; import { get, isUndefined, assign, includes } from 'lodash'; import { Gauge } from '../../../visualizations/views/gauge'; import { getLastValue } from '../../../../../common/last_value_utils'; +import { DATA_FORMATTERS } from '../../../../../common/enums'; import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; function getColors(props) { @@ -35,7 +38,7 @@ function getColors(props) { } function GaugeVisualization(props) { - const { backgroundColor, model, visData } = props; + const { backgroundColor, model, visData, fieldFormatMap, getConfig } = props; const colors = getColors(props); const series = get(visData, `${model.id}.series`, []) @@ -44,11 +47,16 @@ function GaugeVisualization(props) { const seriesDef = model.series.find((s) => includes(row.id, s.id)); const newProps = {}; if (seriesDef) { - newProps.formatter = createTickFormatter( - seriesDef.formatter, - seriesDef.value_template, - props.getConfig - ); + const hasTextColorRules = model.gauge_color_rules.some(({ text }) => text); + newProps.formatter = + seriesDef.formatter === DATA_FORMATTERS.DEFAULT + ? createFieldFormatter( + getMetricsField(seriesDef.metrics), + fieldFormatMap, + 'html', + hasTextColorRules + ) + : createTickFormatter(seriesDef.formatter, seriesDef.value_template, getConfig); } if (i === 0 && colors.gauge) newProps.color = colors.gauge; return assign({}, row, newProps); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts b/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts index 544e2bf49690..b2e40940b800 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/index.ts @@ -14,6 +14,7 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { TimeseriesVisParams } from '../../../types'; import type { TimeseriesVisData, PanelData } from '../../../../common/types'; +import type { FieldFormatMap } from '../../../../../data/common'; /** * Lazy load each visualization type, since the only one is presented on the screen at the same time. @@ -61,4 +62,5 @@ export interface TimeseriesVisProps { getConfig: IUiSettingsClient['get']; syncColors: boolean; palettesService: PaletteRegistry; + fieldFormatMap?: FieldFormatMap; } diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/vis.js index ef6b30be30a3..fc7019bd3829 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/vis.js @@ -21,9 +21,9 @@ import { isBackgroundInverted } from '../../../lib/set_is_reversed'; const getMarkdownId = (id) => `markdown-${id}`; function MarkdownVisualization(props) { - const { backgroundColor, model, visData, getConfig } = props; + const { backgroundColor, model, visData, getConfig, fieldFormatMap } = props; const series = get(visData, `${model.id}.series`, []); - const variables = convertSeriesToVars(series, model, getConfig('dateFormat'), props.getConfig); + const variables = convertSeriesToVars(series, model, getConfig, fieldFormatMap); const markdownElementId = getMarkdownId(uuid.v1()); const panelBackgroundColor = model.background_color || backgroundColor; 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 b35ee977d3e4..90e2a57d925a 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 @@ -9,9 +9,12 @@ import PropTypes from 'prop-types'; import React from 'react'; import { visWithSplits } from '../../vis_with_splits'; +import { getMetricsField } from '../../lib/get_metrics_field'; import { createTickFormatter } from '../../lib/tick_formatter'; +import { createFieldFormatter } from '../../lib/create_field_formatter'; import { get, isUndefined, assign, includes, pick } from 'lodash'; import { Metric } from '../../../visualizations/views/metric'; +import { DATA_FORMATTERS } from '../../../../../common/enums'; import { getLastValue } from '../../../../../common/last_value_utils'; import { isBackgroundInverted } from '../../../lib/set_is_reversed'; import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; @@ -36,7 +39,7 @@ function getColors(props) { } function MetricVisualization(props) { - const { backgroundColor, model, visData } = props; + const { backgroundColor, model, visData, fieldFormatMap, getConfig } = props; const colors = getColors(props); const series = get(visData, `${model.id}.series`, []) .filter((row) => row) @@ -44,11 +47,15 @@ function MetricVisualization(props) { const seriesDef = model.series.find((s) => includes(row.id, s.id)); const newProps = {}; if (seriesDef) { - newProps.formatter = createTickFormatter( - seriesDef.formatter, - seriesDef.value_template, - props.getConfig - ); + newProps.formatter = + seriesDef.formatter === DATA_FORMATTERS.DEFAULT + ? createFieldFormatter( + getMetricsField(seriesDef.metrics), + fieldFormatMap, + 'html', + colors.color + ) + : createTickFormatter(seriesDef.formatter, seriesDef.value_template, getConfig); } if (i === 0 && colors.color) newProps.color = colors.color; return assign({}, pick(row, ['label', 'data']), newProps); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js index 094c33f131fd..e7d13e1497f5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js @@ -10,6 +10,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import uuid from 'uuid'; import { i18n } from '@kbn/i18n'; +import { last } from 'lodash'; import { DataFormatPicker } from '../../data_format_picker'; import { createSelectHandler } from '../../lib/create_select_handler'; @@ -31,7 +32,9 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { getDefaultQueryLanguage } from '../../lib/get_default_query_language'; +import { checkIfNumericMetric } from '../../lib/check_if_numeric_metric'; import { QueryBarWrapper } from '../../query_bar_wrapper'; +import { DATA_FORMATTERS } from '../../../../../common/enums'; export class TableSeriesConfig extends Component { UNSAFE_componentWillMount() { @@ -43,8 +46,10 @@ export class TableSeriesConfig extends Component { } } + changeModelFormatter = (formatter) => this.props.onChange({ formatter }); + render() { - const defaults = { offset_time: '', value_template: '' }; + const defaults = { offset_time: '', value_template: '{{value}}' }; const model = { ...defaults, ...this.props.model }; const handleSelectChange = createSelectHandler(this.props.onChange); const handleTextChange = createTextHandler(this.props.onChange); @@ -110,13 +115,24 @@ export class TableSeriesConfig extends Component { return model.aggregate_function === option.value; }); + const isNumericMetric = checkIfNumericMetric( + last(model.metrics), + this.props.fields, + this.props.indexPatternForQuery + ); + const isKibanaIndexPattern = + this.props.panel.use_kibana_indexes || this.props.indexPatternForQuery === ''; + return (
- - - - + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js index ba235a20b97c..21d7de9f1d88 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js @@ -11,13 +11,16 @@ import React, { Component } from 'react'; import { parse as parseUrl } from 'url'; import PropTypes from 'prop-types'; import { RedirectAppLinks } from '../../../../../../kibana_react/public'; +import { getMetricsField } from '../../lib/get_metrics_field'; import { createTickFormatter } from '../../lib/tick_formatter'; +import { createFieldFormatter } from '../../lib/create_field_formatter'; import { isSortable } from './is_sortable'; import { EuiToolTip, EuiIcon } from '@elastic/eui'; import { replaceVars } from '../../lib/replace_vars'; import { FIELD_FORMAT_IDS } from '../../../../../../../plugins/field_formats/common'; import { FormattedMessage } from '@kbn/i18n/react'; import { getFieldFormats, getCoreStart } from '../../../../services'; +import { DATA_FORMATTERS } from '../../../../../common/enums'; import { getValueOrEmpty } from '../../../../../common/empty_label'; function getColor(rules, colorKey, value) { @@ -57,26 +60,40 @@ class TableVis extends Component { } renderRow = (row) => { - const { model } = this.props; + const { model, fieldFormatMap, getConfig } = this.props; let rowDisplay = getValueOrEmpty( model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key ); + // we should skip url field formatting for key if tsvb have drilldown_url + if (fieldFormatMap?.[model.pivot_id]?.id !== FIELD_FORMAT_IDS.URL || !model.drilldown_url) { + const formatter = createFieldFormatter(model?.pivot_id, fieldFormatMap, 'html'); + rowDisplay = ; // eslint-disable-line react/no-danger + } + if (model.drilldown_url) { const url = replaceVars(model.drilldown_url, {}, { key: row.key }); rowDisplay = {rowDisplay}; } + const columns = row.series .filter((item) => item) .map((item) => { const column = this.visibleSeries.find((c) => c.id === item.id); if (!column) return null; - const formatter = createTickFormatter( - column.formatter, - column.value_template, - this.props.getConfig + const hasColorRules = column.color_rules?.some( + ({ value, operator, text }) => value || operator || text ); + const formatter = + column.formatter === DATA_FORMATTERS.DEFAULT + ? createFieldFormatter( + getMetricsField(column.metrics), + fieldFormatMap, + 'html', + hasColorRules + ) + : createTickFormatter(column.formatter, column.value_template, getConfig); const value = formatter(item.last); let trend; if (column.trend_arrows) { @@ -95,7 +112,8 @@ class TableVis extends Component { className="eui-textRight" style={style} > - {value} + {/* eslint-disable-next-line react/no-danger */} + {trend} ); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 01ba8b6e2811..4257c35a6d4c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -8,7 +8,9 @@ import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; -import React, { useState, useEffect } from 'react'; +import { last } from 'lodash'; +import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import { DATA_FORMATTERS } from '../../../../../common/enums'; import { DataFormatPicker } from '../../data_format_picker'; import { createSelectHandler } from '../../lib/create_select_handler'; import { YesNo } from '../../yes_no'; @@ -29,6 +31,7 @@ import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; import { SeriesConfigQueryBarWithIgnoreGlobalFilter } from '../../series_config_query_bar_with_ignore_global_filter'; import { PalettePicker } from '../../palette_picker'; import { getCharts } from '../../../../services'; +import { checkIfNumericMetric } from '../../lib/check_if_numeric_metric'; import { isPercentDisabled } from '../../lib/stacked'; import { STACKED_OPTIONS } from '../../../visualizations/constants/chart'; @@ -328,6 +331,13 @@ export const TimeseriesConfig = injectI18n(function (props) { ? props.model.series_index_pattern : props.indexPatternForQuery; + const changeModelFormatter = useCallback((formatter) => props.onChange({ formatter }), [props]); + const isNumericMetric = useMemo( + () => checkIfNumericMetric(last(model.metrics), props.fields, seriesIndexPattern), + [model.metrics, props.fields, seriesIndexPattern] + ); + const isKibanaIndexPattern = props.panel.use_kibana_indexes || seriesIndexPattern === ''; + const initialPalette = model.palette ?? { type: 'palette', name: 'default', @@ -344,10 +354,13 @@ export const TimeseriesConfig = injectI18n(function (props) { return (
- - - - + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js index d9440804701b..fed295fef9d3 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js @@ -13,7 +13,10 @@ import { startsWith, get, cloneDeep, map } from 'lodash'; import { htmlIdGenerator } from '@elastic/eui'; import { ScaleType } from '@elastic/charts'; +import { getMetricsField } from '../../lib/get_metrics_field'; import { createTickFormatter } from '../../lib/tick_formatter'; +import { createFieldFormatter } from '../../lib/create_field_formatter'; +import { checkIfSeriesHaveSameFormatters } from '../../lib/check_if_series_have_same_formatters'; import { TimeSeries } from '../../../visualizations/views/timeseries'; import { MarkdownSimple } from '../../../../../../../plugins/kibana_react/public'; import { replaceVars } from '../../lib/replace_vars'; @@ -21,6 +24,7 @@ import { getInterval } from '../../lib/get_interval'; import { createIntervalBasedFormatter } from '../../lib/create_interval_based_formatter'; import { STACKED_OPTIONS } from '../../../visualizations/constants'; import { getCoreStart } from '../../../../services'; +import { DATA_FORMATTERS } from '../../../../../common/enums'; class TimeseriesVisualization extends Component { static propTypes = { @@ -51,6 +55,16 @@ class TimeseriesVisualization extends Component { }; applyDocTo = (template) => (doc) => { + const { fieldFormatMap } = this.props; + + // formatting each doc value with custom field formatter if fieldFormatMap contains that doc field name + Object.keys(doc).forEach((fieldName) => { + if (fieldFormatMap?.[fieldName]) { + const valueFieldFormatter = createFieldFormatter(fieldName, fieldFormatMap); + doc[fieldName] = valueFieldFormatter(doc[fieldName]); + } + }); + const vars = replaceVars(template, null, doc, { noEscape: true, }); @@ -139,7 +153,16 @@ class TimeseriesVisualization extends Component { }; render() { - const { model, visData, onBrush, onFilterClick, syncColors, palettesService } = this.props; + const { + model, + visData, + onBrush, + onFilterClick, + syncColors, + palettesService, + fieldFormatMap, + getConfig, + } = this.props; const series = get(visData, `${model.id}.series`, []); const interval = getInterval(visData, model); const yAxisIdGenerator = htmlIdGenerator('yaxis'); @@ -152,10 +175,6 @@ class TimeseriesVisualization extends Component { const yAxis = []; let mainDomainAdded = false; - const allSeriesHaveSameFormatters = seriesModel.every( - (seriesGroup) => seriesGroup.formatter === seriesModel[0].formatter - ); - this.showToastNotification = null; seriesModel.forEach((seriesGroup) => { @@ -166,10 +185,12 @@ class TimeseriesVisualization extends Component { ? TimeseriesVisualization.getYAxisDomain(seriesGroup) : undefined; const isCustomDomain = groupId !== mainAxisGroupId; - const seriesGroupTickFormatter = TimeseriesVisualization.getTickFormatter( - seriesGroup, - this.props.getConfig - ); + + const seriesGroupTickFormatter = + seriesGroup.formatter === DATA_FORMATTERS.DEFAULT + ? createFieldFormatter(getMetricsField(seriesGroup.metrics), fieldFormatMap) + : TimeseriesVisualization.getTickFormatter(seriesGroup, getConfig); + const palette = { ...seriesGroup.palette, name: @@ -214,8 +235,12 @@ class TimeseriesVisualization extends Component { : seriesGroupTickFormatter, }); } else if (!mainDomainAdded) { + const tickFormatter = checkIfSeriesHaveSameFormatters(seriesModel, fieldFormatMap) + ? seriesGroupTickFormatter + : (val) => val; + TimeseriesVisualization.addYAxis(yAxis, { - tickFormatter: allSeriesHaveSameFormatters ? seriesGroupTickFormatter : (val) => val, + tickFormatter, id: yAxisIdGenerator('main'), groupId: mainAxisGroupId, position: model.axis_position, diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.test.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.test.js index fd155623d5da..d6e7484e903b 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.test.js @@ -11,9 +11,15 @@ import { shallow } from 'enzyme'; import { TimeSeries } from '../../../visualizations/views/timeseries'; import TimeseriesVisualization from './vis'; import { setFieldFormats } from '../../../../services'; +import { createFieldFormatter } from '../../lib/create_field_formatter'; import { FORMATS_UI_SETTINGS } from '../../../../../../field_formats/common'; +import { METRIC_TYPES } from '../../../../../../data/common'; import { getFieldFormatsRegistry } from '../../../../../../data/public/test_utils'; +jest.mock('../../../../../../data/public/services', () => ({ + getUiSettings: () => ({ get: jest.fn() }), +})); + describe('TimeseriesVisualization', () => { describe('TimeSeries Y-Axis formatted value', () => { const config = { @@ -29,19 +35,34 @@ describe('TimeseriesVisualization', () => { }) ); - const setupTimeSeriesPropsWithFormatters = (...formatters) => { - const series = formatters.map((formatter) => ({ - id, + const setupTimeSeriesProps = (formatters, valueTemplates) => { + const series = formatters.map((formatter, index) => ({ + id: id + index, formatter, + value_template: valueTemplates?.[index], data: [], + metrics: [ + { + type: METRIC_TYPES.AVG, + field: `field${index}`, + }, + ], })); + const fieldFormatMap = { + field0: { id: 'duration', params: { inputFormat: 'years' } }, + field1: { id: 'duration', params: { inputFormat: 'years' } }, + field2: { id: 'duration', params: { inputFormat: 'months' } }, + field3: { id: 'number', params: { pattern: '$0,0.[00]' } }, + }; + const timeSeriesVisualization = shallow( config[key]} model={{ id, series, + use_kibana_indexes: true, }} visData={{ [id]: { @@ -49,56 +70,69 @@ describe('TimeseriesVisualization', () => { series, }, }} + fieldFormatMap={fieldFormatMap} + createCustomFieldFormatter={createFieldFormatter} /> ); return timeSeriesVisualization.find(TimeSeries).props(); }; - test('should be byte for single byte series', () => { - const timeSeriesProps = setupTimeSeriesPropsWithFormatters('byte'); + test('should return byte formatted value from yAxis formatter for single byte series', () => { + const timeSeriesProps = setupTimeSeriesProps(['byte']); const yAxisFormattedValue = timeSeriesProps.yAxis[0].tickFormatter(value); expect(yAxisFormattedValue).toBe('500B'); }); - test('should have custom format for single series', () => { - const timeSeriesProps = setupTimeSeriesPropsWithFormatters('0.00bitd'); + test('should return custom formatted value from yAxis formatter for single series with custom formatter', () => { + const timeSeriesProps = setupTimeSeriesProps(['0.00bitd']); const yAxisFormattedValue = timeSeriesProps.yAxis[0].tickFormatter(value); expect(yAxisFormattedValue).toBe('500.00bit'); }); - test('should be the same number for byte and percent series', () => { - const timeSeriesProps = setupTimeSeriesPropsWithFormatters('byte', 'percent'); + test('should return the same number from yAxis formatter for byte and percent series', () => { + const timeSeriesProps = setupTimeSeriesProps(['byte', 'percent']); const yAxisFormattedValue = timeSeriesProps.yAxis[0].tickFormatter(value); expect(yAxisFormattedValue).toBe(value); }); - test('should be the same stringified number for byte and percent series', () => { - const timeSeriesProps = setupTimeSeriesPropsWithFormatters('byte', 'percent'); + test('should return the same stringified number from yAxis formatter for byte and percent series', () => { + const timeSeriesProps = setupTimeSeriesProps(['byte', 'percent']); const yAxisFormattedValue = timeSeriesProps.yAxis[0].tickFormatter(value.toString()); expect(yAxisFormattedValue).toBe('500'); }); - test('should be byte for two byte formatted series', () => { - const timeSeriesProps = setupTimeSeriesPropsWithFormatters('byte', 'byte'); + test('should return byte formatted value from yAxis formatter and from two byte formatted series with the same value templates', () => { + const timeSeriesProps = setupTimeSeriesProps(['byte', 'byte']); + const { series, yAxis } = timeSeriesProps; - const yAxisFormattedValue = timeSeriesProps.yAxis[0].tickFormatter(value); - const firstSeriesFormattedValue = timeSeriesProps.series[0].tickFormat(value); - - expect(firstSeriesFormattedValue).toBe('500B'); - expect(yAxisFormattedValue).toBe(firstSeriesFormattedValue); + expect(series[0].tickFormat(value)).toBe('500B'); + expect(series[1].tickFormat(value)).toBe('500B'); + expect(yAxis[0].tickFormatter(value)).toBe('500B'); }); - test('should be percent for three percent formatted series', () => { - const timeSeriesProps = setupTimeSeriesPropsWithFormatters('percent', 'percent', 'percent'); + test('should return simple number from yAxis formatter and different values from the same byte formatters, but with different value templates', () => { + const timeSeriesProps = setupTimeSeriesProps( + ['byte', 'byte'], + ['{{value}}', '{{value}} value'] + ); + const { series, yAxis } = timeSeriesProps; + + expect(series[0].tickFormat(value)).toBe('500B'); + expect(series[1].tickFormat(value)).toBe('500B value'); + expect(yAxis[0].tickFormatter(value)).toBe(value); + }); + + test('should return percent formatted value from yAxis formatter and three percent formatted series with the same value templates', () => { + const timeSeriesProps = setupTimeSeriesProps(['percent', 'percent', 'percent']); const yAxisFormattedValue = timeSeriesProps.yAxis[0].tickFormatter(value); const firstSeriesFormattedValue = timeSeriesProps.series[0].tickFormat(value); @@ -106,5 +140,56 @@ describe('TimeseriesVisualization', () => { expect(firstSeriesFormattedValue).toBe('50000%'); expect(yAxisFormattedValue).toBe(firstSeriesFormattedValue); }); + + test('should return simple number from yAxis formatter and different values for the same value templates, but with different formatters', () => { + const timeSeriesProps = setupTimeSeriesProps( + ['number', 'byte'], + ['{{value}} template', '{{value}} template'] + ); + const { series, yAxis } = timeSeriesProps; + + expect(series[0].tickFormat(value)).toBe('500 template'); + expect(series[1].tickFormat(value)).toBe('500B template'); + expect(yAxis[0].tickFormatter(value)).toBe(value); + }); + + test('should return field formatted value for yAxis and single series with default formatter', () => { + const timeSeriesProps = setupTimeSeriesProps(['default']); + const { series, yAxis } = timeSeriesProps; + + expect(series[0].tickFormat(value)).toBe('500 years'); + expect(yAxis[0].tickFormatter(value)).toBe('500 years'); + }); + + test('should return custom field formatted value for yAxis and both series having same fieldFormats', () => { + const timeSeriesProps = setupTimeSeriesProps(['default', 'default']); + const { series, yAxis } = timeSeriesProps; + + expect(series[0].tickFormat(value)).toBe('500 years'); + expect(series[1].tickFormat(value)).toBe('500 years'); + expect(yAxis[0].tickFormatter(value)).toBe('500 years'); + }); + + test('should return simple number from yAxis formatter and default formatted values for series', () => { + const timeSeriesProps = setupTimeSeriesProps(['default', 'default', 'default', 'default']); + const { series, yAxis } = timeSeriesProps; + + expect(series[0].tickFormat(value)).toBe('500 years'); + expect(series[1].tickFormat(value)).toBe('500 years'); + expect(series[2].tickFormat(value)).toBe('42 years'); + expect(series[3].tickFormat(value)).toBe('$500'); + expect(yAxis[0].tickFormatter(value)).toBe(value); + }); + + test('should return simple number from yAxis formatter and correctly formatted series values', () => { + const timeSeriesProps = setupTimeSeriesProps(['default', 'byte', 'percent', 'default']); + const { series, yAxis } = timeSeriesProps; + + expect(series[0].tickFormat(value)).toBe('500 years'); + expect(series[1].tickFormat(value)).toBe('500B'); + expect(series[2].tickFormat(value)).toBe('50000%'); + expect(series[3].tickFormat(value)).toBe('$500'); + expect(yAxis[0].tickFormatter(value)).toBe(value); + }); }); }); 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 0b3a24615c0e..8176f6ece280 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 @@ -7,7 +7,9 @@ */ import { getCoreStart } from '../../../../services'; +import { getMetricsField } from '../../lib/get_metrics_field'; import { createTickFormatter } from '../../lib/tick_formatter'; +import { createFieldFormatter } from '../../lib/create_field_formatter'; import { TopN } from '../../../visualizations/views/top_n'; import { getLastValue } from '../../../../../common/last_value_utils'; import { isBackgroundInverted } from '../../../lib/set_is_reversed'; @@ -15,6 +17,7 @@ import { replaceVars } from '../../lib/replace_vars'; import PropTypes from 'prop-types'; import React from 'react'; import { sortBy, first, get } from 'lodash'; +import { DATA_FORMATTERS } from '../../../../../common/enums'; import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; function sortByDirection(data, direction, fn) { @@ -38,17 +41,17 @@ function sortSeries(visData, model) { } function TopNVisualization(props) { - const { backgroundColor, model, visData } = props; + const { backgroundColor, model, visData, fieldFormatMap, getConfig } = props; const series = sortSeries(visData, model).map((item) => { const id = first(item.id.split(/:/)); const seriesConfig = model.series.find((s) => s.id === id); if (seriesConfig) { - const tickFormatter = createTickFormatter( - seriesConfig.formatter, - seriesConfig.value_template, - props.getConfig - ); + const tickFormatter = + seriesConfig.formatter === DATA_FORMATTERS.DEFAULT + ? createFieldFormatter(getMetricsField(seriesConfig.metrics), fieldFormatMap, 'html') + : createTickFormatter(seriesConfig.formatter, seriesConfig.value_template, getConfig); + const value = getLastValue(item.data); let color = item.color || seriesConfig.color; if (model.bar_color_rules) { diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js index 945a7ac986d3..86c0af1c9798 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js @@ -15,7 +15,7 @@ import { getSplitByTermsColor } from '../lib/get_split_by_terms_color'; export function visWithSplits(WrappedComponent) { function SplitVisComponent(props) { - const { model, visData, syncColors, palettesService } = props; + const { model, visData, syncColors, palettesService, fieldFormatMap } = props; const getSeriesColor = useCallback( (seriesName, seriesId, baseColor) => { @@ -34,10 +34,11 @@ export function visWithSplits(WrappedComponent) { seriesPalette: palette, palettesRegistry: palettesService, syncColors, + fieldFormatMap, }; return getSplitByTermsColor(props) || null; }, - [model, palettesService, syncColors, visData] + [fieldFormatMap, model.id, model.series, palettesService, syncColors, visData] ); if (!model || !visData || !visData[model.id] || visData[model.id].series.length === 1) @@ -114,6 +115,7 @@ export function visWithSplits(WrappedComponent) { additionalLabel={getValueOrEmpty(additionalLabel)} backgroundColor={props.backgroundColor} getConfig={props.getConfig} + fieldFormatMap={props.fieldFormatMap} />
); diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/_gauge.scss b/src/plugins/vis_type_timeseries/public/application/visualizations/views/_gauge.scss index 7f3c049a131d..fdab7f02957e 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/_gauge.scss +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/_gauge.scss @@ -47,6 +47,8 @@ font-size: .9em; /* 1 */ line-height: 1em; /* 1 */ text-align: center; + // make gauge value the target for pointer-events + pointer-events: all; .tvbVisGauge--reversed & { color: $tvbValueColorReversed; @@ -71,4 +73,6 @@ display: flex; flex-direction: column; flex: 1 0 auto; + // disable gauge container pointer-events as it shouldn't be event target + pointer-events: none; } 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 723a054baeea..ca5021a88293 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 @@ -117,7 +117,8 @@ export class Gauge extends Component { ref="label" data-test-subj="gaugeValue" > - {formatter(value)} + {/* eslint-disable-next-line react/no-danger */} +
{additionalLabel}
@@ -135,7 +136,8 @@ export class Gauge extends Component { ref="label" data-test-subj="gaugeValue" > - {formatter(value)} + {/* eslint-disable-next-line react/no-danger */} +
{title} 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 bc4230d0a15e..0ceb2daa831b 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 @@ -101,7 +101,8 @@ export class Metric extends Component {
{secondaryLabel}
- {secondaryValue} + {/* eslint-disable-next-line react/no-danger */} +
); @@ -132,7 +133,8 @@ export class Metric extends Component { data-test-subj="tsvbMetricValue" className="tvbVisMetric__value--primary" > - {primaryValue} + {/* eslint-disable-next-line react/no-danger */} +
{secondarySnippet} 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 72b2c7ce34fd..aaec701a42ee 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 @@ -139,7 +139,8 @@ export class TopN extends Component { - {formatter(lastValue)} + {/* eslint-disable-next-line react/no-danger */} + ); diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 855d56169ef3..5d4a61c1edb8 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -77,7 +77,7 @@ export const metricsVisDefinition: VisTypeDefinition< ], separate_axis: 0, axis_position: 'right', - formatter: 'number', + formatter: 'default', chart_type: 'line', line_width: 1, point_size: 1, diff --git a/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx b/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx index 3f324fcfc2f2..9a19ddc285eb 100644 --- a/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx +++ b/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx @@ -13,11 +13,12 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { IUiSettingsClient } from 'kibana/public'; +import { fetchIndexPattern } from '../common/index_patterns_utils'; import { VisualizationContainer, PersistedState } from '../../visualizations/public'; import type { TimeseriesVisData } from '../common/types'; import { isVisTableData } from '../common/vis_data_utils'; -import { getCharts } from './services'; +import { getCharts, getDataStart } from './services'; import type { TimeseriesVisParams } from './types'; import type { ExpressionRenderDefinition } from '../../expressions/common'; @@ -49,9 +50,15 @@ export const getTimeseriesVisRenderer: (deps: { handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); + const { visParams: model, visData, syncColors } = config; const { palettes } = getCharts(); - const showNoResult = !checkIfDataExists(config.visData, config.visParams); - const palettesService = await palettes.getPalettes(); + const { indexPatterns } = getDataStart(); + + const showNoResult = !checkIfDataExists(visData, model); + const [palettesService, { indexPattern }] = await Promise.all([ + palettes.getPalettes(), + fetchIndexPattern(model.index_pattern, indexPatterns), + ]); render( @@ -59,15 +66,16 @@ export const getTimeseriesVisRenderer: (deps: { data-test-subj="timeseriesVis" handlers={handlers} showNoResult={showNoResult} - error={get(config.visData, [config.visParams.id, 'error'])} + error={get(visData, [model.id, 'error'])} > diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index 817812a88ca9..bc4fbf9159a0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -30,6 +30,7 @@ export async function getVisData( ): Promise { const uiSettings = requestContext.core.uiSettings.client; const esShardTimeout = await framework.getEsShardTimeout(); + const fieldFormatService = await framework.getFieldFormatsService(uiSettings); const indexPatternsService = await framework.getIndexPatternsService(requestContext); const esQueryConfig = await getEsQueryConfig(uiSettings); @@ -40,6 +41,7 @@ export async function getVisData( const services: VisTypeTimeseriesRequestServices = { esQueryConfig, esShardTimeout, + fieldFormatService, indexPatternsService, uiSettings, cachedIndexPatternFetcher, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts index 8d495d68eb62..12fe95ccc50c 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts @@ -27,13 +27,16 @@ export async function getSeriesData( panel: Panel, services: VisTypeTimeseriesRequestServices ) { - const panelIndex = await services.cachedIndexPatternFetcher(panel.index_pattern); + const { + cachedIndexPatternFetcher, + searchStrategyRegistry, + indexPatternsService, + fieldFormatService, + } = services; - const strategy = await services.searchStrategyRegistry.getViableStrategy( - requestContext, - req, - panelIndex - ); + const panelIndex = await cachedIndexPatternFetcher(panel.index_pattern); + + const strategy = await searchStrategyRegistry.getViableStrategy(requestContext, req, panelIndex); if (!strategy) { throw new Error( @@ -56,15 +59,22 @@ export async function getSeriesData( getSeriesRequestParams(req, panel, panelIndex, series, capabilities, services) ); - const searches = await Promise.all(bodiesPromises); - const data = await searchStrategy.search(requestContext, req, searches); - - const handleResponseBodyFn = handleResponseBody(panel, req, { - indexPatternsService: services.indexPatternsService, - cachedIndexPatternFetcher: services.cachedIndexPatternFetcher, + const fieldFetchServices = { + indexPatternsService, + cachedIndexPatternFetcher, searchStrategy, capabilities, - }); + }; + + const handleResponseBodyFn = handleResponseBody( + panel, + req, + fieldFetchServices, + fieldFormatService + ); + + const searches = await Promise.all(bodiesPromises); + const data = await searchStrategy.search(requestContext, req, searches); const series = await Promise.all( data.map( diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/format_label.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/format_label.ts new file mode 100644 index 000000000000..7908cbccb984 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/format_label.ts @@ -0,0 +1,55 @@ +/* + * 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 { KBN_FIELD_TYPES } from '@kbn/field-types'; +import { BUCKET_TYPES, PANEL_TYPES } from '../../../../../common/enums'; +import type { Panel, PanelData, Series } from '../../../../../common/types'; +import type { FieldFormatsRegistry } from '../../../../../../field_formats/common'; +import type { createFieldsFetcher } from '../../../search_strategies/lib/fields_fetcher'; +import type { CachedIndexPatternFetcher } from '../../../search_strategies/lib/cached_index_pattern_fetcher'; + +export function formatLabel( + resp: unknown, + panel: Panel, + series: Series, + meta: any, + extractFields: ReturnType, + fieldFormatService: FieldFormatsRegistry, + cachedIndexPatternFetcher: CachedIndexPatternFetcher +) { + return (next: (results: PanelData[]) => unknown) => async (results: PanelData[]) => { + const { terms_field: termsField, split_mode: splitMode } = series; + + const isKibanaIndexPattern = panel.use_kibana_indexes || panel.index_pattern === ''; + // no need to format labels for markdown as they also used there as variables keys + const shouldFormatLabels = + isKibanaIndexPattern && + termsField && + splitMode === BUCKET_TYPES.TERMS && + panel.type !== PANEL_TYPES.MARKDOWN; + + if (shouldFormatLabels) { + const { indexPattern } = await cachedIndexPatternFetcher({ id: meta.index }); + const getFieldFormatByName = (fieldName: string) => + fieldFormatService.deserialize(indexPattern?.fieldFormatMap?.[fieldName]); + + results + .filter(({ seriesId }) => series.id === seriesId) + .forEach((item) => { + const formattedLabel = getFieldFormatByName(termsField!).convert(item.label); + item.label = formattedLabel; + const termsFieldType = indexPattern?.fields.find(({ name }) => name === termsField)?.type; + if (termsFieldType === KBN_FIELD_TYPES.DATE) { + item.labelFormatted = formattedLabel; + } + }); + } + + return next(results); + }; +} diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/index.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/index.js index 71c3bdf5e5c2..68385bb5cbbe 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/index.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/index.js @@ -17,6 +17,7 @@ import { stdSibling } from './std_sibling'; import { timeShift } from './time_shift'; import { dropLastBucket } from './drop_last_bucket'; import { mathAgg } from './math'; +import { formatLabel } from './format_label'; export const processors = [ percentile, @@ -29,4 +30,5 @@ export const processors = [ seriesAgg, timeShift, dropLastBucket, + formatLabel, ]; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts index 6642fd8f5d79..78e9f971a61d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts @@ -17,11 +17,13 @@ import { FieldsFetcherServices, } from '../../search_strategies/lib/fields_fetcher'; import { VisTypeTimeseriesVisDataRequest } from '../../../types'; +import type { FieldFormatsRegistry } from '../../../../../field_formats/common'; export function handleResponseBody( panel: Panel, req: VisTypeTimeseriesVisDataRequest, - services: FieldsFetcherServices + services: FieldsFetcherServices, + fieldFormatService: FieldFormatsRegistry ) { return async (resp: any) => { if (resp.error) { @@ -55,7 +57,9 @@ export function handleResponseBody( panel, series, meta, - extractFields + extractFields, + fieldFormatService, + services.cachedIndexPatternFetcher ); return await processor([]); diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index 58cd58c812e4..d2ecb07c0273 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -13,6 +13,7 @@ import { Plugin, Logger, KibanaRequest, + IUiSettingsClient, } from 'src/core/server'; import { Observable } from 'rxjs'; import { Server } from '@hapi/hapi'; @@ -29,6 +30,7 @@ import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from './types'; +import type { FieldFormatsRegistry } from '../../field_formats/common'; import { SearchStrategyRegistry, @@ -70,6 +72,7 @@ export interface Framework { getIndexPatternsService: ( requestContext: VisTypeTimeseriesRequestHandlerContext ) => Promise; + getFieldFormatsService: (uiSettings: IUiSettingsClient) => Promise; getEsShardTimeout: () => Promise; } @@ -111,6 +114,11 @@ export class VisTypeTimeseriesPlugin implements Plugin { requestContext.core.elasticsearch.client.asCurrentUser ); }, + getFieldFormatsService: async (uiSettings) => { + const [, { data }] = await core.getStartServices(); + + return data.fieldFormats.fieldFormatServiceFactory(uiSettings); + }, }; searchStrategyRegistry.addStrategy(new DefaultSearchStrategy()); diff --git a/src/plugins/vis_type_timeseries/server/types.ts b/src/plugins/vis_type_timeseries/server/types.ts index 11131f33e4a1..40ced7293301 100644 --- a/src/plugins/vis_type_timeseries/server/types.ts +++ b/src/plugins/vis_type_timeseries/server/types.ts @@ -11,6 +11,7 @@ import { EsQueryConfig } from '@kbn/es-query'; import { SharedGlobalConfig } from 'kibana/server'; import type { IRouter, IUiSettingsClient, KibanaRequest } from 'src/core/server'; import type { DataRequestHandlerContext, IndexPatternsService } from '../../data/server'; +import type { FieldFormatsRegistry } from '../../field_formats/common'; import type { Series, VisPayload } from '../common/types'; import type { SearchStrategyRegistry } from './lib/search_strategies'; import type { CachedIndexPatternFetcher } from './lib/search_strategies/lib/cached_index_pattern_fetcher'; @@ -33,6 +34,7 @@ export interface VisTypeTimeseriesRequestServices { indexPatternsService: IndexPatternsService; searchStrategyRegistry: SearchStrategyRegistry; cachedIndexPatternFetcher: CachedIndexPatternFetcher; + fieldFormatService: FieldFormatsRegistry; buildSeriesMetaParams: ( index: FetchedIndexPattern, useKibanaIndexes: boolean, diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index d6862487196f..c7f228e9aa05 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -17,11 +17,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const security = getService('security'); - const { timePicker, visChart, visualBuilder, visualize } = getPageObjects([ + const { timePicker, visChart, visualBuilder, visualize, settings } = getPageObjects([ 'timePicker', 'visChart', 'visualBuilder', 'visualize', + 'settings', ]); describe('visual builder', function describeIndexTests() { @@ -174,6 +175,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should display correct data for max aggregation with entire time range mode', async () => { await visualBuilder.selectAggType('Max'); await visualBuilder.setFieldForAggregation('bytes'); + await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('number'); const gaugeLabel = await visualBuilder.getGaugeLabel(); const gaugeCount = await visualBuilder.getGaugeCount(); @@ -269,6 +272,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should display correct data for sum of squares aggregation with entire time range mode', async () => { await visualBuilder.selectAggType('Sum of squares'); await visualBuilder.setFieldForAggregation('bytes'); + await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('number'); await visualBuilder.clickPanelOptions('topN'); await visualBuilder.setMetricsDataTimerangeMode('Entire time range'); @@ -452,5 +457,118 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(legendItems3).to.eql(finalLegendItems); }); }); + + describe('applying field formats from Advanced Settings', () => { + const toggleSetFormatForMachineOsRaw = async () => { + log.debug( + 'Navigate to Advanced Settings Index Patterns and toggle Set Format for machine.os.raw' + ); + await settings.navigateTo(); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternLogstash(); + await settings.openControlsByName('machine.os.raw'); + await settings.toggleRow('formatRow'); + }; + + before(async () => { + log.debug('Toggle on Set Format for machine.os.raw and set it to the title case'); + await toggleSetFormatForMachineOsRaw(); + await settings.setFieldFormat('string'); + await settings.setScriptedFieldStringTransform('title'); + await settings.controlChangeSave(); + }); + + beforeEach(async () => { + await visualBuilder.resetPage(); + await visualBuilder.selectAggType('Average'); + await visualBuilder.setFieldForAggregation('bytes'); + await visualBuilder.setMetricsGroupByTerms('machine.os.raw'); + await visChart.waitForVisualizationRenderingStabilized(); + }); + + it('should display title field formatted labels with byte field formatted values by default', async () => { + const expectedLegendItems = [ + 'Win 8: 4.968KB', + 'Win Xp: 4.23KB', + 'Win 7: 6.181KB', + 'Ios: 5.84KB', + 'Osx: 5.928KB', + ]; + + const legendItems = await visualBuilder.getLegendItemsContent(); + expect(legendItems).to.eql(expectedLegendItems); + }); + + it('should display title field formatted labels with raw values', async () => { + const expectedLegendItems = [ + 'Win 8: 5,087.5', + 'Win Xp: 4,332', + 'Win 7: 6,328.938', + 'Ios: 5,980', + 'Osx: 6,070', + ]; + await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('number'); + const legendItems = await visualBuilder.getLegendItemsContent(); + + expect(legendItems).to.eql(expectedLegendItems); + }); + + it('should display title field formatted labels with TSVB formatted values', async () => { + const expectedLegendItems = [ + 'Win 8: 5,087.5 format', + 'Win Xp: 4,332 format', + 'Win 7: 6,328.938 format', + 'Ios: 5,980 format', + 'Osx: 6,070 format', + ]; + + await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('number'); + await visualBuilder.enterSeriesTemplate('{{value}} format'); + await visChart.waitForVisualizationRenderingStabilized(); + + const legendItems = await visualBuilder.getLegendItemsContent(); + expect(legendItems).to.eql(expectedLegendItems); + }); + + describe('formatting values for Metric, TopN and Gauge', () => { + it('should display field formatted value for Metric', async () => { + await visualBuilder.clickMetric(); + await visualBuilder.checkMetricTabIsPresent(); + + const metricValue = await visualBuilder.getMetricValue(); + expect(metricValue).to.eql('5.514KB'); + }); + + it('should display field formatted label and value for TopN', async () => { + await visualBuilder.clickTopN(); + await visualBuilder.checkTopNTabIsPresent(); + + const topNLabel = await visualBuilder.getTopNLabel(); + const topNCount = await visualBuilder.getTopNCount(); + + expect(topNLabel).to.eql('Win 7'); + expect(topNCount).to.eql('5.664KB'); + }); + + it('should display field formatted label and value for Gauge', async () => { + await visualBuilder.clickGauge(); + await visualBuilder.checkGaugeTabIsPresent(); + + const gaugeLabel = await visualBuilder.getGaugeLabel(); + const gaugeCount = await visualBuilder.getGaugeCount(); + + expect(gaugeLabel).to.eql('Average of bytes'); + expect(gaugeCount).to.eql('5.514KB'); + }); + }); + + after(async () => { + log.debug('Toggle off Set Format for machine.os.raw'); + await toggleSetFormatForMachineOsRaw(); + await settings.controlChangeSave(); + }); + }); }); } diff --git a/test/functional/apps/visualize/_tsvb_markdown.ts b/test/functional/apps/visualize/_tsvb_markdown.ts index b8b74d5cd7bf..98ed05d854f0 100644 --- a/test/functional/apps/visualize/_tsvb_markdown.ts +++ b/test/functional/apps/visualize/_tsvb_markdown.ts @@ -146,6 +146,31 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(aggregationLength).to.be.equal(2); }); }); + + describe('applying field formats from Advanced Settings for values', () => { + before(async () => { + await visualBuilder.resetPage(); + await visualBuilder.clickMarkdown(); + await visualBuilder.markdownSwitchSubTab('markdown'); + await visualBuilder.enterMarkdown('{{ average_of_bytes.last.formatted }}'); + await visualBuilder.markdownSwitchSubTab('data'); + await visualBuilder.selectAggType('Average'); + await visualBuilder.setFieldForAggregation('bytes'); + await visualBuilder.clickSeriesOption(); + }); + + it('should apply field formatting by default', async () => { + const text = await visualBuilder.getMarkdownText(); + expect(text).to.be('5.588KB'); + }); + + it('should apply TSVB formatting', async () => { + await visualBuilder.changeDataFormatter('percent'); + + const text = await visualBuilder.getMarkdownText(); + expect(text).to.be('572,241.265%'); + }); + }); }); }); } diff --git a/test/functional/apps/visualize/_tsvb_table.ts b/test/functional/apps/visualize/_tsvb_table.ts index 7c093b5a9640..ed668e4bca8e 100644 --- a/test/functional/apps/visualize/_tsvb_table.ts +++ b/test/functional/apps/visualize/_tsvb_table.ts @@ -11,10 +11,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const { visualBuilder, visualize, visChart } = getPageObjects([ + const { visualBuilder, visualize, visChart, settings } = getPageObjects([ 'visualBuilder', 'visualize', 'visChart', + 'settings', ]); const findService = getService('find'); const retry = getService('retry'); @@ -45,6 +46,19 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(tableData).to.be(EXPECTED); }); + it('should display drilldown urls', async () => { + const baseURL = 'http://elastic.co/foo/'; + + await visualBuilder.clickPanelOptions('table'); + await visualBuilder.setDrilldownUrl(`${baseURL}{{key}}`); + + await retry.try(async () => { + const links = await findService.allByCssSelector(`a[href="${baseURL}ios"]`); + + expect(links.length).to.be(1); + }); + }); + it('should display correct values on changing metrics aggregation', async () => { const EXPECTED = 'OS Cardinality\nwin 8 12\nwin xp 9\nwin 7 8\nios 5\nosx 3'; @@ -71,6 +85,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'OS Variance of bytes\nwin 8 2,707,941.822\nwin xp 2,595,612.24\nwin 7 16,055,541.306\nios 6,505,206.56\nosx 1,016,620.667'; await visualBuilder.selectAggType('Variance'); await visualBuilder.setFieldForAggregation('bytes'); + await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('number'); const tableData = await visualBuilder.getViewTable(); expect(tableData).to.be(EXPECTED); @@ -122,6 +138,63 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(tableData).to.be(EXPECTED); }); + describe('applying field formats from Advanced Settings', () => { + const toggleSetFormatForMachineOsRaw = async () => { + await settings.navigateTo(); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternLogstash(); + await settings.openControlsByName('machine.os.raw'); + await settings.toggleRow('formatRow'); + }; + + before(async () => { + await toggleSetFormatForMachineOsRaw(); + await settings.setFieldFormat('string'); + await settings.setScriptedFieldStringTransform('upper'); + await settings.controlChangeSave(); + }); + + beforeEach(async () => { + await visualBuilder.selectAggType('Average'); + await visualBuilder.setFieldForAggregation('bytes'); + }); + + it('should display field formatted row labels with field formatted data by default', async () => { + const expected = + 'OS Average of bytes\nWIN 8 6.786KB\nWIN XP 3.804KB\nWIN 7 6.596KB\nIOS 4.844KB\nOSX 3.06KB'; + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(expected); + }); + + it('should display field formatted row labels with raw data', async () => { + const expected = + 'OS Average of bytes\nWIN 8 6,948.846\nWIN XP 3,895.6\nWIN 7 6,753.833\nIOS 4,960.2\nOSX 3,133'; + + await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('number'); + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(expected); + }); + + it('should display field formatted row labels with TSVB formatted data', async () => { + const expected = + 'OS Average of bytes\nWIN 8 694,884.615%\nWIN XP 389,560%\nWIN 7 675,383.333%\nIOS 496,020%\nOSX 313,300%'; + + await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('percent'); + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(expected); + }); + + after(async () => { + await toggleSetFormatForMachineOsRaw(); + await settings.controlChangeSave(); + }); + }); + it('should display drilldown urls', async () => { const baseURL = 'http://elastic.co/foo/'; diff --git a/test/functional/apps/visualize/_tsvb_time_series.ts b/test/functional/apps/visualize/_tsvb_time_series.ts index 4733efade69e..21bee2d16442 100644 --- a/test/functional/apps/visualize/_tsvb_time_series.ts +++ b/test/functional/apps/visualize/_tsvb_time_series.ts @@ -89,6 +89,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const expectedLegendValue = '$ 156'; await visualBuilder.clickSeriesOption(); + await visualBuilder.changeDataFormatter('number'); await visualBuilder.enterSeriesTemplate('$ {{value}}'); await retry.try(async () => { const actualCount = await visualBuilder.getRhythmChartLegendValue(); @@ -100,7 +101,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const expectedLegendValue = '15,600%'; await visualBuilder.clickSeriesOption(); - await visualBuilder.changeDataFormatter('Percent'); + await visualBuilder.changeDataFormatter('percent'); const actualCount = await visualBuilder.getRhythmChartLegendValue(); expect(actualCount).to.be(expectedLegendValue); }); @@ -109,14 +110,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const expectedLegendValue = '156B'; await visualBuilder.clickSeriesOption(); - await visualBuilder.changeDataFormatter('Bytes'); + await visualBuilder.changeDataFormatter('bytes'); const actualCount = await visualBuilder.getRhythmChartLegendValue(); expect(actualCount).to.be(expectedLegendValue); }); it('should show the correct count in the legend with "Human readable" duration formatter', async () => { await visualBuilder.clickSeriesOption(); - await visualBuilder.changeDataFormatter('Duration'); + await visualBuilder.changeDataFormatter('duration'); await visualBuilder.setDurationFormatterSettings({ to: 'Human readable' }); const actualCountDefault = await visualBuilder.getRhythmChartLegendValue(); expect(actualCountDefault).to.be('a few seconds'); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index c96faab2dc32..81b2e2763eb1 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -270,13 +270,14 @@ export class VisualBuilderPageObject extends FtrService { /** * change the data formatter for template in an `options` label tab * - * @param formatter - typeof formatter which you can use for presenting data. By default kibana show `Number` formatter + * @param formatter - typeof formatter which you can use for presenting data. By default kibana show `Default` formatter */ public async changeDataFormatter( - formatter: 'Bytes' | 'Number' | 'Percent' | 'Duration' | 'Custom' + formatter: 'default' | 'bytes' | 'number' | 'percent' | 'duration' | 'custom' ) { - const formatterEl = await this.testSubjects.find('tsvbDataFormatPicker'); - await this.comboBox.setElement(formatterEl, formatter, { clickWithMouse: true }); + await this.testSubjects.click('tsvbDataFormatPicker'); + await this.testSubjects.click(`tsvbDataFormatPicker-${formatter}`); + await this.visChart.waitForVisualizationRenderingStabilized(); } public async setDrilldownUrl(value: string) { @@ -304,16 +305,16 @@ export class VisualBuilderPageObject extends FtrService { }) { if (from) { await this.retry.try(async () => { - const fromCombobox = await this.find.byCssSelector('[id$="from-row"] .euiComboBox'); - await this.comboBox.setElement(fromCombobox, from, { clickWithMouse: true }); + await this.comboBox.set('dataFormatPickerDurationFrom', from); }); } if (to) { - const toCombobox = await this.find.byCssSelector('[id$="to-row"] .euiComboBox'); - await this.comboBox.setElement(toCombobox, to, { clickWithMouse: true }); + await this.retry.try(async () => { + await this.comboBox.set('dataFormatPickerDurationTo', to); + }); } if (decimalPlaces) { - const decimalPlacesInput = await this.find.byCssSelector('[id$="decimal"]'); + const decimalPlacesInput = await this.testSubjects.find('dataFormatPickerDurationDecimal'); await decimalPlacesInput.type(decimalPlaces); } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 748f8acf48ac..04bcc951c5d5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5254,8 +5254,8 @@ "visTypeTimeseries.dataFormatPicker.customLabel": "カスタム", "visTypeTimeseries.dataFormatPicker.decimalPlacesLabel": "小数部分の桁数", "visTypeTimeseries.dataFormatPicker.durationLabel": "期間", - "visTypeTimeseries.dataFormatPicker.formatStringHelpText": "{numeralJsLink}を参照", - "visTypeTimeseries.dataFormatPicker.formatStringLabel": "フォーマット文字列", + "visTypeTimeseries.dataFormatPicker.formatPatternHelpText": "ドキュメント", + "visTypeTimeseries.dataFormatPicker.formatPatternLabel": "Numeral.js のフォーマットパターン (デフォルト: {defaultPattern})", "visTypeTimeseries.dataFormatPicker.fromLabel": "開始:", "visTypeTimeseries.dataFormatPicker.numberLabel": "数字", "visTypeTimeseries.dataFormatPicker.percentLabel": "パーセント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index eeb7a1df111c..18a38ae138f4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5300,8 +5300,8 @@ "visTypeTimeseries.dataFormatPicker.customLabel": "定制", "visTypeTimeseries.dataFormatPicker.decimalPlacesLabel": "小数位数", "visTypeTimeseries.dataFormatPicker.durationLabel": "持续时间", - "visTypeTimeseries.dataFormatPicker.formatStringHelpText": "请参阅 {numeralJsLink}", - "visTypeTimeseries.dataFormatPicker.formatStringLabel": "格式字符串", + "visTypeTimeseries.dataFormatPicker.formatPatternHelpText": "文档", + "visTypeTimeseries.dataFormatPicker.formatPatternLabel": "Numeral.js 格式模式(默认值:{defaultPattern})", "visTypeTimeseries.dataFormatPicker.fromLabel": "自", "visTypeTimeseries.dataFormatPicker.numberLabel": "数字", "visTypeTimeseries.dataFormatPicker.percentLabel": "百分比",