mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[TSVB] Support custom field format (#101245)
* [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>
This commit is contained in:
parent
d60fab1413
commit
33cfc4183a
58 changed files with 1589 additions and 479 deletions
|
@ -59,6 +59,8 @@ The index pattern mode unlocks many new features, such as:
|
|||
|
||||
* Interactive filters for time series visualizations
|
||||
|
||||
* Custom field formats
|
||||
|
||||
* Better performance
|
||||
|
||||
[float]
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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' }),
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ export interface PanelSeries {
|
|||
export interface PanelData {
|
||||
id: string;
|
||||
label: string;
|
||||
labelFormatted?: string;
|
||||
data: PanelDataArray[];
|
||||
seriesId: string;
|
||||
splitByLabel: string;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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} }}`;
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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([]);
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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%');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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/';
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5150,8 +5150,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": "パーセント",
|
||||
|
|
|
@ -5195,8 +5195,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": "百分比",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue