[TSVB] Support custom field format (#101245) (#111928)

* [TSVB] Support custom field format

Add format_label response processor for series vis data and bucket key formatting to process_bucket for table vis data

* Add ignore_field_formatting for series to support value formatting for all panel types except markdown

* Fix type issue for visData and rename getCustomFieldFormatter to createCustomFieldFormatter

* Update vis.test to cover custom field formats logic and add a migration script to set ignore_field_formatting to true for the series

* Move createCustomFieldFormatter to a separate file, make formatting respect only active metrics field name, refactor vis files and fix label formatting only for grouped by terms series

* Remove services, add getFieldFormatsService  to use it in format_label and get_table_data, replace getCustomFieldFormatter with createCustomFieldFormatter

* Update plugin.ts

* Update start for plugin.ts

* Add formatting for annotations and markdown values

* Refactor some code

* Update some naming and conditions

* Fix formatting of data type labels

* In process_bucket fix case for no getFieldFormatByName

* Add field formatting functional tests for all panel types

* Update tests to make them run correctly for firefox

* Update _tsvb_markdown test setup

* Move series ignoreFieldFormatting check to a separate file, change convertSeriesToVars signature, update migration script and refactor a bit functional tests

* Fix type check for timeseries_visualization.tsx

* Update migrations.js test expected version to 7.15

* Fix tests in _tsvb_chart.ts

* Fix merge conflict remove process_bucket.js

* Update process_bucket.test.ts

* Fix markdown labels formatting

* Add ignore_field_formatting for annotations, enhanced migration script to set that flag to true, refactor data_format_picker

* Fix migration script and add disabling for ignore component when string index pattern is used

* Add supporting URL and color formatters in tsvb table

* Fix eslint

* Remove ignore formatting component, add field formatting option to TSVB data format picker and make it default, remove migration script, update tests and refactor some files

* Fix failing tests, refactor create_field_formatter and add test to it, update some other files

* Fix series formatting for top hit when it has not numeric result

* Handle no fieldFormatMap case for table/vis.js

* Remove "Default" option form DataFormatPicker when index pattern is string, fix markdown variables issue and refactor some code

* Chore(TSVB): Replace aggregations lookup with map

* Fix types, update test expected data and remove unused translations

* Fix i18 check and useEffect in agg.tsx

* Handle aggregations field formatting case

* Fix agg_utils, vis and check_if_numeric_metric tests

* Correct typo and refactor condition in std_metric

* Fix type check

* Get rid of IFieldType

* Add URL and color formatting for topN and metric tabs, fix setting initial custom formatter and switching formatter in agg.tsx

* Update tsvb.asciidoc

* Remove link icon from Date format field help text, update click logic for top N in case of custom field format and fix CI

* Remove unused import

* Revert top N bar extra logic for click

* Refactor some code in agg.tsx

* Add URL and color formatting to Gauge

* Fix bug with terms formatting, refactor some code, update create_field_formatter

* Add comments to _gauge.scss

* Remove unnecessary await

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

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Uladzislau Lasitsa <Uladzislau_Lasitsa@epam.com>
This commit is contained in:
Diana Derevyankina 2021-09-13 14:18:34 +03:00 committed by GitHub
parent be8bc70601
commit b000066d64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1589 additions and 479 deletions

View file

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

View file

@ -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 = [

View file

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ export interface PanelSeries {
export interface PanelData {
id: string;
label: string;
labelFormatted?: string;
data: PanelDataArray[];
seriesId: string;
splitByLabel: string;

View file

@ -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<HTMLElement> {
disableDelete: boolean;
fields: Record<string, SanitizedFieldType[]>;
name: string;
model: Metric;
panel: Panel;
series: Series;
siblings: Metric[];
uiRestrictions: TimeseriesUIRestrictions;
dragHandleProps: DragHandleProps;
onChange: (part: Partial<Series>) => 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 (
<div className={props.className} style={style}>
@ -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}

View file

@ -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<string, SanitizedFieldType[]>;
uiRestrictions: TimeseriesUIRestrictions;
onChange(): void;
onChange(part: Partial<Series>): void;
}
export class Aggs extends PureComponent<AggsProps> {
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 (
<EuiDroppable droppableId={`${DROPPABLE_ID}:${model.id}`} type="MICRO" spacing="s">
{list.map((row, idx) => (
@ -51,6 +48,7 @@ export class Aggs extends PureComponent<AggsProps> {
key={row.id}
disableDelete={list.length < 2}
fields={fields}
name={name}
model={row}
onAdd={() => handleAdd(this.props, newMetricAggFn)}
onChange={onChange}

View file

@ -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 (
<EuiFlexGroup responsive={false} gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFormRow id={htmlId('date')} label={this.props.label}>
<EuiComboBox
isClearable={false}
options={options}
selectedOptions={selectedOption ? [selectedOption] : []}
onChange={this.handleChange}
singleSelection={{ asPlainText: true }}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
id={htmlId('from')}
label={
<FormattedMessage
id="visTypeTimeseries.dataFormatPicker.fromLabel"
defaultMessage="From"
/>
}
>
<EuiComboBox
isClearable={false}
options={durationInputOptions}
selectedOptions={selectedFrom ? [selectedFrom] : []}
onChange={this.handleDurationChange('from')}
singleSelection={{ asPlainText: true }}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
id={htmlId('to')}
label={
<FormattedMessage
id="visTypeTimeseries.dataFormatPicker.toLabel"
defaultMessage="To"
/>
}
>
<EuiComboBox
isClearable={false}
options={durationOutputOptions}
selectedOptions={selectedTo ? [selectedTo] : []}
onChange={this.handleDurationChange('to')}
singleSelection={{ asPlainText: true }}
/>
</EuiFormRow>
</EuiFlexItem>
{selectedTo && selectedTo.value !== 'humanize' && (
<EuiFlexItem grow={false}>
<EuiFormRow
id={htmlId('decimal')}
label={
<FormattedMessage
id="visTypeTimeseries.dataFormatPicker.decimalPlacesLabel"
defaultMessage="Decimal places"
/>
}
>
<EuiFieldText
defaultValue={decimals}
inputRef={(el) => (this.decimals = el)}
placeholder={DEFAULT_OUTPUT_PRECISION}
onChange={this.handleDurationChange('decimals')}
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
if (defaultValue === 'custom') {
custom = (
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<FormattedMessage
id="visTypeTimeseries.dataFormatPicker.formatStringLabel"
defaultMessage="Format string"
/>
}
helpText={
<span>
<FormattedMessage
id="visTypeTimeseries.dataFormatPicker.formatStringHelpText"
defaultMessage="See {numeralJsLink}"
values={{
numeralJsLink: (
<EuiLink href="http://numeraljs.com/#format" target="_BLANK">
Numeral.js
</EuiLink>
),
}}
/>
</span>
}
>
<EuiFieldText
defaultValue={value}
inputRef={(el) => (this.custom = el)}
onChange={this.handleCustomChange}
/>
</EuiFormRow>
</EuiFlexItem>
);
}
return (
<EuiFlexGroup responsive={false} gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFormRow label={this.props.label}>
<EuiComboBox
isClearable={false}
options={options}
selectedOptions={selectedOption ? [selectedOption] : []}
onChange={this.handleChange}
singleSelection={{ asPlainText: true }}
data-test-subj="tsvbDataFormatPicker"
/>
</EuiFormRow>
</EuiFlexItem>
{custom}
</EuiFlexGroup>
);
}
}
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);

View file

@ -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: (
<>
<span>{defaultOptionLabel}</span>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">
{i18n.translate('visTypeTimeseries.dataFormatPicker.defaultLabelDescription', {
defaultMessage: 'Applies common formatting',
})}
</p>
</EuiText>
</>
),
'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<HTMLInputElement>) => {
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<EuiComboBoxOptionOption<string>>) =>
handleDurationParamsChange(optionName, value!);
},
[handleDurationParamsChange]
);
const handleDecimalsChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) =>
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 = (
<>
<EuiFlexItem grow={false}>
<EuiFormRow
id={htmlId('from')}
label={i18n.translate('visTypeTimeseries.dataFormatPicker.fromLabel', {
defaultMessage: 'From',
})}
>
<EuiComboBox
isClearable={false}
options={durationInputOptions}
selectedOptions={selectedFrom ? [selectedFrom] : []}
onChange={handleDurationChange('from')}
singleSelection={{ asPlainText: true }}
data-test-subj="dataFormatPickerDurationFrom"
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
id={htmlId('to')}
label={i18n.translate('visTypeTimeseries.dataFormatPicker.toLabel', {
defaultMessage: 'To',
})}
>
<EuiComboBox
isClearable={false}
options={durationOutputOptions}
selectedOptions={selectedTo ? [selectedTo] : []}
onChange={handleDurationChange('to')}
singleSelection={{ asPlainText: true }}
data-test-subj="dataFormatPickerDurationTo"
/>
</EuiFormRow>
</EuiFlexItem>
{selectedTo?.value !== 'humanize' && (
<EuiFlexItem grow={false}>
<EuiFormRow
id={htmlId('decimal')}
label={i18n.translate('visTypeTimeseries.dataFormatPicker.decimalPlacesLabel', {
defaultMessage: 'Decimal places',
})}
>
<EuiFieldText
defaultValue={decimals}
placeholder={DEFAULT_OUTPUT_PRECISION}
onChange={handleDecimalsChange}
data-test-subj="dataFormatPickerDurationDecimal"
/>
</EuiFormRow>
</EuiFlexItem>
)}
</>
);
}
let custom;
if (selectedFormatter === DATA_FORMATTERS.CUSTOM && shouldIncludeNumberOptions) {
custom = (
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<FormattedMessage
id="visTypeTimeseries.dataFormatPicker.formatPatternLabel"
defaultMessage="Numeral.js format pattern (Default: {defaultPattern})"
values={{ defaultPattern: <EuiCode>{DEFAULT_CUSTOM_FORMAT_PATTERN}</EuiCode> }}
/>
}
helpText={
<span>
<EuiLink target="_blank" href="http://numeraljs.com/#format">
<FormattedMessage
id="visTypeTimeseries.dataFormatPicker.formatPatternHelpText"
defaultMessage="Documentation"
/>
</EuiLink>
</span>
}
>
<EuiFieldText
placeholder={DEFAULT_CUSTOM_FORMAT_PATTERN}
value={customFormatPattern}
onChange={handleCustomFormatStringChange}
/>
</EuiFormRow>
</EuiFlexItem>
);
}
return (
<>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('visTypeTimeseries.defaultDataFormatterLabel', {
defaultMessage: 'Data formatter',
})}
fullWidth
>
<EuiSuperSelect
options={options}
valueOfSelected={selectedFormatter}
onChange={handleChange}
data-test-subj="tsvbDataFormatPicker"
fullWidth
hasDividers
/>
</EuiFormRow>
</EuiFlexItem>
{selectedFormatter === DATA_FORMATTERS.DURATION && duration}
{selectedFormatter === DATA_FORMATTERS.CUSTOM && custom}
</>
);
};

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { 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);
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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: '<insert 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(
'<span ng-non-bindable><span style="color:#D36086;background-color:#ffffff">1234567890</span></span>'
);
});
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('<span ng-non-bindable>1,234,567,890</span>');
});
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(
'<span ng-non-bindable><a href="https://1234567890" target="_blank" rel="noopener noreferrer">1234567890</a></span>'
);
});
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);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<div className="tvbAggRow">
<DataFormatPicker onChange={handleSelectChange('formatter')} value={model.formatter} />
<EuiHorizontalRule margin="s" />
<SeriesConfigQueryBarWithIgnoreGlobalFilter
model={model}
onChange={props.onChange}
panel={props.panel}
indexPatternForQuery={seriesIndexPattern}
/>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<DataFormatPicker
formatterValue={model.formatter}
changeModelFormatter={changeModelFormatter}
shouldIncludeDefaultOption={isKibanaIndexPattern}
shouldIncludeNumberOptions={isNumericMetric}
/>
<EuiFlexItem grow={3}>
<EuiFormRow
id={htmlId('template')}
label={
@ -74,10 +75,25 @@ export const SeriesConfig = (props) => {
<EuiFieldText
onChange={handleTextChange('value_template')}
value={model.value_template}
disabled={model.formatter === DATA_FORMATTERS.DEFAULT}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="s" />
<SeriesConfigQueryBarWithIgnoreGlobalFilter
model={model}
onChange={props.onChange}
panel={props.panel}
indexPatternForQuery={seriesIndexPattern}
/>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFormRow
id={htmlId('offsetSeries')}

View file

@ -9,8 +9,6 @@
import './timeseries_visualization.scss';
import React, { useCallback, useEffect } from 'react';
import { get } from 'lodash';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { XYChartSeriesIdentifier, GeometryValue } from '@elastic/charts';
import { IUiSettingsClient } from 'src/core/public';
@ -19,11 +17,9 @@ import { PersistedState } from 'src/plugins/visualizations/public';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { TimeseriesVisTypes } from './vis_types';
import type { TimeseriesVisData, PanelData } from '../../../common/types';
import { isVisSeriesData } from '../../../common/vis_data_utils';
import { fetchIndexPattern } from '../../../common/index_patterns_utils';
import type { PanelData, TimeseriesVisData } from '../../../common/types';
import { isVisTableData } from '../../../common/vis_data_utils';
import { TimeseriesVisParams } from '../../types';
import { getDataStart } from '../../services';
import { convertSeriesToDataTable } from './lib/convert_series_to_datatable';
import { getClickFilterData } from './lib/get_click_filter_data';
import { X_ACCESSOR_INDEX } from '../visualizations/constants';
@ -31,6 +27,7 @@ import { LastValueModeIndicator } from './last_value_mode_indicator';
import { getInterval } from './lib/get_interval';
import { AUTO_INTERVAL } from '../../../common/constants';
import { TIME_RANGE_DATA_MODES, PANEL_TYPES } from '../../../common/enums';
import type { IndexPattern } from '../../../../data/common';
interface TimeseriesVisualizationProps {
className?: string;
@ -41,6 +38,7 @@ interface TimeseriesVisualizationProps {
uiState: PersistedState;
syncColors: boolean;
palettesService: PaletteRegistry;
indexPattern?: IndexPattern | null;
}
function TimeseriesVisualization({
@ -52,12 +50,10 @@ function TimeseriesVisualization({
getConfig,
syncColors,
palettesService,
indexPattern,
}: TimeseriesVisualizationProps) {
const onBrush = useCallback(
async (gte: string, lte: string, series: PanelData[]) => {
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 (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{shouldDisplayLastValueIndicator && (
<EuiFlexItem className="tvbLastValueIndicator" grow={false}>
<LastValueModeIndicator
seriesData={get(
visData,
`${isVisSeriesData(visData) ? model.id : 'series[0]'}.series[0].data`,
undefined
)}
seriesData={firstSeries?.data}
ignoreDaylightTime={model.ignore_daylight_time}
panelInterval={getInterval(visData, model)}
modelInterval={model.interval ?? AUTO_INTERVAL}
@ -180,6 +171,7 @@ function TimeseriesVisualization({
onUiState={handleUiState}
syncColors={syncColors}
palettesService={palettesService}
fieldFormatMap={indexPattern?.fieldFormatMap}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<div className="tvbAggRow">
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<DataFormatPicker onChange={handleSelectChange('formatter')} value={model.formatter} />
</EuiFlexItem>
<EuiFlexItem>
<DataFormatPicker
formatterValue={model.formatter}
changeModelFormatter={this.changeModelFormatter}
shouldIncludeDefaultOption={isKibanaIndexPattern}
shouldIncludeNumberOptions={isNumericMetric}
/>
<EuiFlexItem grow={3}>
<EuiFormRow
id={htmlId('template')}
label={
@ -139,6 +155,7 @@ export class TableSeriesConfig extends Component {
<EuiFieldText
onChange={handleTextChange('value_template')}
value={model.value_template}
disabled={model.formatter === DATA_FORMATTERS.DEFAULT}
fullWidth
/>
</EuiFormRow>

View file

@ -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 = <span dangerouslySetInnerHTML={{ __html: formatter(rowDisplay) }} />; // eslint-disable-line react/no-danger
}
if (model.drilldown_url) {
const url = replaceVars(model.drilldown_url, {}, { key: row.key });
rowDisplay = <a href={sanitizeUrl(url)}>{rowDisplay}</a>;
}
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}
>
<span>{value}</span>
{/* eslint-disable-next-line react/no-danger */}
<span dangerouslySetInnerHTML={{ __html: value }} />
{trend}
</td>
);

View file

@ -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 (
<div className="tvbAggRow">
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<DataFormatPicker onChange={handleSelectChange('formatter')} value={model.formatter} />
</EuiFlexItem>
<EuiFlexItem>
<DataFormatPicker
formatterValue={model.formatter}
changeModelFormatter={changeModelFormatter}
shouldIncludeDefaultOption={isKibanaIndexPattern}
shouldIncludeNumberOptions={isNumericMetric}
/>
<EuiFlexItem grow={3}>
<EuiFormRow
id={htmlId('template')}
label={
@ -370,6 +383,7 @@ export const TimeseriesConfig = injectI18n(function (props) {
<EuiFieldText
onChange={handleTextChange('value_template')}
value={model.value_template}
disabled={model.formatter === DATA_FORMATTERS.DEFAULT}
fullWidth
data-test-subj="tsvb_series_value"
/>

View file

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

View file

@ -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(
<TimeseriesVisualization
getConfig={(key) => 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);
});
});
});

View file

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

View file

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

View file

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

View file

@ -117,7 +117,8 @@ export class Gauge extends Component {
ref="label"
data-test-subj="gaugeValue"
>
{formatter(value)}
{/* eslint-disable-next-line react/no-danger */}
<span dangerouslySetInnerHTML={{ __html: formatter(value) }} />
</div>
{additionalLabel}
</div>
@ -135,7 +136,8 @@ export class Gauge extends Component {
ref="label"
data-test-subj="gaugeValue"
>
{formatter(value)}
{/* eslint-disable-next-line react/no-danger */}
<span dangerouslySetInnerHTML={{ __html: formatter(value) }} />
</div>
<div className="tvbVisGauge__label" ref="title" data-test-subj="gaugeLabel">
{title}

View file

@ -101,7 +101,8 @@ export class Metric extends Component {
<div className="tvbVisMetric__secondary">
{secondaryLabel}
<div style={styles.secondary_value} className="tvbVisMetric__value--secondary">
{secondaryValue}
{/* eslint-disable-next-line react/no-danger */}
<span dangerouslySetInnerHTML={{ __html: secondaryValue }} />
</div>
</div>
);
@ -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 */}
<span dangerouslySetInnerHTML={{ __html: primaryValue }} />
</div>
</div>
{secondarySnippet}

View file

@ -139,7 +139,8 @@ export class TopN extends Component {
</div>
</td>
<td className="tvbVisTopN__value" data-test-subj="tsvbTopNValue">
{formatter(lastValue)}
{/* eslint-disable-next-line react/no-danger */}
<span dangerouslySetInnerHTML={{ __html: formatter(lastValue) }} />
</td>
</tr>
);

View file

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

View file

@ -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(
<I18nProvider>
@ -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'])}
>
<TimeseriesVisualization
// it is mandatory to bind uiSettings because of "this" usage inside "get" method
getConfig={uiSettings.get.bind(uiSettings)}
handlers={handlers}
model={config.visParams}
visData={config.visData as TimeseriesVisData}
syncColors={config.syncColors}
indexPattern={indexPattern}
model={model}
visData={visData as TimeseriesVisData}
syncColors={syncColors}
uiState={handlers.uiState! as PersistedState}
palettesService={palettesService}
/>

View file

@ -30,6 +30,7 @@ export async function getVisData(
): Promise<TimeseriesVisData> {
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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<IndexPatternsService>;
getFieldFormatsService: (uiSettings: IUiSettingsClient) => Promise<FieldFormatsRegistry>;
getEsShardTimeout: () => Promise<number>;
}
@ -111,6 +114,11 @@ export class VisTypeTimeseriesPlugin implements Plugin<VisTypeTimeseriesSetup> {
requestContext.core.elasticsearch.client.asCurrentUser
);
},
getFieldFormatsService: async (uiSettings) => {
const [, { data }] = await core.getStartServices();
return data.fieldFormats.fieldFormatServiceFactory(uiSettings);
},
};
searchStrategyRegistry.addStrategy(new DefaultSearchStrategy());

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "パーセント",

View file

@ -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": "百分比",