[TSVB] Multi-field group by (#126015)

* fieldSelect

* activate multifield support for table

* update table>pivot request_processor

* fix some tests

* apply some changes

* fix JEST

* push initial logic for series request_processor

* fix some broken cases for Table tab

* update convert_series_to_datatable / convert_series_to_vars

* add some logic

* fix table/terms

* do some logic

* fix some issues

* push some logic

* navigation to Lens

* fix CI

* add excludedFieldFormatsIds param into excludedFieldFormatsIds

* fix ci

* fix translations

* fix some comments

* fix series_agg label

* update labels in lens

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alexey Antonov 2022-03-03 15:04:04 +03:00 committed by GitHub
parent c302779004
commit efcdbb66dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1007 additions and 376 deletions

View file

@ -276,20 +276,4 @@ For other types of month over month calculations, use <<timelion, *Timelion*>> o
Calculating the duration between the start and end of an event is unsupported in *TSVB* because *TSVB* requires correlation between different time periods.
*TSVB* requires that the duration is pre-calculated.
====
[discrete]
[group-on-multiple-fields]
.*How do I group on multiple fields?*
[%collapsible]
====
To group with multiple fields, create runtime fields in the {data-source} you are visualizing.
. Create a runtime field. Refer to <<managing-data-views, Manage data views>> for more information.
+
[role="screenshot"]
image::images/tsvb_group_by_multiple_fields.png[Group by multiple fields]
. Create a *TSVB* visualization and group by this field.
====

View file

@ -9,6 +9,7 @@
import { calculateLabel } from './calculate_label';
import type { Metric } from './types';
import { SanitizedFieldType } from './types';
import { KBN_FIELD_TYPES } from '../../../data/common';
describe('calculateLabel(metric, metrics)', () => {
test('returns the metric.alias if set', () => {
@ -90,7 +91,7 @@ describe('calculateLabel(metric, metrics)', () => {
{ id: 1, type: 'max', field: 'network.out.bytes', alias: 'Outbound Traffic' },
metric,
] as unknown as Metric[];
const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: 'field' }];
const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: KBN_FIELD_TYPES.DATE }];
expect(() => calculateLabel(metric, metrics, fields)).toThrowError('Field "3" not found');
});
@ -101,7 +102,7 @@ describe('calculateLabel(metric, metrics)', () => {
{ id: 1, type: 'max', field: 'network.out.bytes', alias: 'Outbound Traffic' },
metric,
] as unknown as Metric[];
const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: 'field' }];
const fields: SanitizedFieldType[] = [{ name: '2', label: '2', type: KBN_FIELD_TYPES.DATE }];
expect(calculateLabel(metric, metrics, fields, false)).toBe('Max of 3');
});

View file

@ -6,8 +6,16 @@
* Side Public License, v 1.
*/
import { toSanitizedFieldType } from './fields_utils';
import type { FieldSpec } from '../../../data/common';
import {
getFieldsForTerms,
toSanitizedFieldType,
getMultiFieldLabel,
createCachedFieldValueFormatter,
} from './fields_utils';
import { FieldSpec, KBN_FIELD_TYPES } from '../../../data/common';
import { DataView } from '../../../data_views/common';
import { stubLogstashDataView } from '../../../data/common/stubs';
import { FieldFormatsRegistry, StringFormat } from '../../../field_formats/common';
describe('fields_utils', () => {
describe('toSanitizedFieldType', () => {
@ -59,4 +67,92 @@ describe('fields_utils', () => {
expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`);
});
});
describe('getFieldsForTerms', () => {
test('should return fields as array', () => {
expect(getFieldsForTerms('field')).toEqual(['field']);
expect(getFieldsForTerms(['field', 'field1'])).toEqual(['field', 'field1']);
});
test('should exclude empty values', () => {
expect(getFieldsForTerms([null, ''])).toEqual([]);
});
test('should return empty array in case of undefined field', () => {
expect(getFieldsForTerms(undefined)).toEqual([]);
});
});
describe('getMultiFieldLabel', () => {
test('should return label for single field', () => {
expect(
getMultiFieldLabel(
['field'],
[{ name: 'field', label: 'Label', type: KBN_FIELD_TYPES.DATE }]
)
).toBe('Label');
});
test('should return label for multi fields', () => {
expect(
getMultiFieldLabel(
['field', 'field1'],
[
{ name: 'field', label: 'Label', type: KBN_FIELD_TYPES.DATE },
{ name: 'field2', label: 'Label1', type: KBN_FIELD_TYPES.DATE },
]
)
).toBe('Label + 1 other');
});
test('should return label for multi fields (2 others)', () => {
expect(
getMultiFieldLabel(
['field', 'field1', 'field2'],
[
{ name: 'field', label: 'Label', type: KBN_FIELD_TYPES.DATE },
{ name: 'field1', label: 'Label1', type: KBN_FIELD_TYPES.DATE },
{ name: 'field3', label: 'Label2', type: KBN_FIELD_TYPES.DATE },
]
)
).toBe('Label + 2 others');
});
});
describe('createCachedFieldValueFormatter', () => {
let dataView: DataView;
beforeEach(() => {
dataView = stubLogstashDataView;
});
test('should use data view formatters', () => {
const getFormatterForFieldSpy = jest.spyOn(dataView, 'getFormatterForField');
const cache = createCachedFieldValueFormatter(dataView);
cache('bytes', '10001');
cache('bytes', '20002');
expect(getFormatterForFieldSpy).toHaveBeenCalledTimes(1);
});
test('should use default formatters in case of Data view not defined', () => {
const fieldFormatServiceMock = {
getDefaultInstance: jest.fn().mockReturnValue(new StringFormat()),
} as unknown as FieldFormatsRegistry;
const cache = createCachedFieldValueFormatter(
null,
[{ name: 'field', label: 'Label', type: KBN_FIELD_TYPES.STRING }],
fieldFormatServiceMock
);
cache('field', '10001');
cache('field', '20002');
expect(fieldFormatServiceMock.getDefaultInstance).toHaveBeenCalledTimes(1);
expect(fieldFormatServiceMock.getDefaultInstance).toHaveBeenCalledWith('string');
});
});
});

View file

@ -5,11 +5,12 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { FieldSpec } from '../../../data/common';
import { isNestedField } from '../../../data/common';
import { FetchedIndexPattern, SanitizedFieldType } from './types';
import { isNestedField, FieldSpec, DataView } from '../../../data/common';
import { FieldNotFoundError } from './errors';
import type { FetchedIndexPattern, SanitizedFieldType } from './types';
import { FieldFormat, FieldFormatsRegistry, FIELD_FORMAT_IDS } from '../../../field_formats/common';
export const extractFieldLabel = (
fields: SanitizedFieldType[],
@ -49,3 +50,63 @@ export const toSanitizedFieldType = (fields: FieldSpec[]) =>
type: field.type,
} as SanitizedFieldType)
);
export const getFieldsForTerms = (fields: string | Array<string | null> | undefined): string[] => {
return fields ? ([fields].flat().filter(Boolean) as string[]) : [];
};
export const getMultiFieldLabel = (fieldForTerms: string[], fields?: SanitizedFieldType[]) => {
const firstFieldLabel = fields ? extractFieldLabel(fields, fieldForTerms[0]) : fieldForTerms[0];
if (fieldForTerms.length > 1) {
return i18n.translate('visTypeTimeseries.fieldUtils.multiFieldLabel', {
defaultMessage: '{firstFieldLabel} + {count} {count, plural, one {other} other {others}}',
values: {
firstFieldLabel,
count: fieldForTerms.length - 1,
},
});
}
return firstFieldLabel ?? '';
};
export const createCachedFieldValueFormatter = (
dataView?: DataView | null,
fields?: SanitizedFieldType[],
fieldFormatService?: FieldFormatsRegistry,
excludedFieldFormatsIds: FIELD_FORMAT_IDS[] = []
) => {
const cache = new Map<string, FieldFormat>();
return (fieldName: string, value: string, contentType: 'text' | 'html' = 'text') => {
const cachedFormatter = cache.get(fieldName);
if (cachedFormatter) {
return cachedFormatter.convert(value, contentType);
}
if (dataView && !excludedFieldFormatsIds.includes(dataView.fieldFormatMap?.[fieldName]?.id)) {
const field = dataView.fields.getByName(fieldName);
if (field) {
const formatter = dataView.getFormatterForField(field);
if (formatter) {
cache.set(fieldName, formatter);
return formatter.convert(value, contentType);
}
}
} else if (fieldFormatService && fields) {
const f = fields.find((item) => item.name === fieldName);
if (f) {
const formatter = fieldFormatService.getDefaultInstance(f.type);
if (formatter) {
cache.set(fieldName, formatter);
return formatter.convert(value, contentType);
}
}
}
};
};
export const MULTI_FIELD_VALUES_SEPARATOR = ' ';

View file

@ -7,7 +7,7 @@
*/
import { Filter } from '@kbn/es-query';
import { IndexPattern, Query } from '../../../../data/common';
import { IndexPattern, KBN_FIELD_TYPES, Query } from '../../../../data/common';
import { Panel } from './panel_model';
export type { Metric, Series, Panel, MetricType } from './panel_model';
@ -28,7 +28,7 @@ export interface FetchedIndexPattern {
export interface SanitizedFieldType {
name: string;
type: string;
type: KBN_FIELD_TYPES;
label?: string;
}

View file

@ -6,10 +6,15 @@
* Side Public License, v 1.
*/
import { METRIC_TYPES, Query } from '../../../../data/common';
import { Query, METRIC_TYPES, KBN_FIELD_TYPES } from '../../../../data/common';
import { PANEL_TYPES, TOOLTIP_MODES, TSVB_METRIC_TYPES } from '../enums';
import { IndexPatternValue, Annotation } from './index';
import { ColorRules, BackgroundColorRules, BarColorRules, GaugeColorRules } from './color_rules';
import type { IndexPatternValue, Annotation } from './index';
import type {
ColorRules,
BackgroundColorRules,
BarColorRules,
GaugeColorRules,
} from './color_rules';
interface MetricVariable {
field?: string;
@ -109,7 +114,7 @@ export interface Series {
steps: number;
terms_direction?: string;
terms_exclude?: string;
terms_field?: string;
terms_field?: string | Array<string | null>;
terms_include?: string;
terms_order_by?: string;
terms_size?: string;
@ -155,10 +160,10 @@ export interface Panel {
markdown_scrollbars: number;
markdown_vertical_align?: string;
max_bars: number;
pivot_id?: string;
pivot_id?: string | Array<string | null>;
pivot_label?: string;
pivot_rows?: string;
pivot_type?: string;
pivot_type?: KBN_FIELD_TYPES | Array<KBN_FIELD_TYPES | null>;
series: Series[];
show_grid: number;
show_legend: number;

View file

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

View file

@ -1,149 +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 { i18n } from '@kbn/i18n';
import React, { ReactNode } from 'react';
import {
EuiComboBox,
EuiComboBoxProps,
EuiComboBoxOptionOption,
EuiFormRow,
htmlIdGenerator,
} from '@elastic/eui';
import { getIndexPatternKey } from '../../../../common/index_patterns_utils';
import type { SanitizedFieldType, IndexPatternValue } from '../../../../common/types';
import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions';
import { isFieldEnabled } from '../../../../common/check_ui_restrictions';
interface FieldSelectProps {
label: string | ReactNode;
type: string;
fields: Record<string, SanitizedFieldType[]>;
indexPattern: IndexPatternValue;
value?: string | null;
onChange: (options: Array<EuiComboBoxOptionOption<string>>) => void;
disabled?: boolean;
restrict?: string[];
placeholder?: string;
uiRestrictions?: TimeseriesUIRestrictions;
'data-test-subj'?: string;
}
const defaultPlaceholder = i18n.translate('visTypeTimeseries.fieldSelect.selectFieldPlaceholder', {
defaultMessage: 'Select field...',
});
const isFieldTypeEnabled = (fieldRestrictions: string[], fieldType: string) =>
fieldRestrictions.length ? fieldRestrictions.includes(fieldType) : true;
const sortByLabel = (a: EuiComboBoxOptionOption<string>, b: EuiComboBoxOptionOption<string>) => {
const getNormalizedString = (option: EuiComboBoxOptionOption<string>) =>
(option.label || '').toLowerCase();
return getNormalizedString(a).localeCompare(getNormalizedString(b));
};
export function FieldSelect({
label,
type,
fields,
indexPattern = '',
value = '',
onChange,
disabled = false,
restrict = [],
placeholder = defaultPlaceholder,
uiRestrictions,
'data-test-subj': dataTestSubj = 'metricsIndexPatternFieldsSelect',
}: FieldSelectProps) {
const htmlId = htmlIdGenerator();
let selectedOptions: Array<EuiComboBoxOptionOption<string>> = [];
let newPlaceholder = placeholder;
const fieldsSelector = getIndexPatternKey(indexPattern);
const groupedOptions: EuiComboBoxProps<string>['options'] = Object.values(
(fields[fieldsSelector] || []).reduce<Record<string, EuiComboBoxOptionOption<string>>>(
(acc, field) => {
if (placeholder === field?.name) {
newPlaceholder = field.label ?? field.name;
}
if (
isFieldTypeEnabled(restrict, field.type) &&
isFieldEnabled(field.name, type, uiRestrictions)
) {
const item: EuiComboBoxOptionOption<string> = {
value: field.name,
label: field.label ?? field.name,
};
const fieldTypeOptions = acc[field.type]?.options;
if (fieldTypeOptions) {
fieldTypeOptions.push(item);
} else {
acc[field.type] = {
options: [item],
label: field.type,
};
}
if (value === item.value) {
selectedOptions.push(item);
}
}
return acc;
},
{}
)
);
// sort groups
groupedOptions.sort(sortByLabel);
// sort items
groupedOptions.forEach((group) => {
if (Array.isArray(group.options)) {
group.options.sort(sortByLabel);
}
});
const isInvalid = Boolean(value && fields[fieldsSelector] && !selectedOptions.length);
if (value && !selectedOptions.length) {
selectedOptions = [{ label: value, id: 'INVALID_FIELD' }];
}
return (
<EuiFormRow
id={htmlId('timeField')}
label={label}
isInvalid={isInvalid}
error={i18n.translate('visTypeTimeseries.fieldSelect.fieldIsNotValid', {
defaultMessage:
'The "{fieldParameter}" field is not valid for use with the current index. Please select a new field.',
values: {
fieldParameter: value,
},
})}
>
<EuiComboBox
data-test-subj={dataTestSubj}
placeholder={newPlaceholder}
isDisabled={disabled}
options={groupedOptions}
selectedOptions={selectedOptions}
onChange={onChange}
singleSelection={{ asPlainText: true }}
isInvalid={isInvalid}
/>
</EuiFormRow>
);
}

View file

@ -0,0 +1,185 @@
/*
* 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, { useCallback, useMemo, ReactNode } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiComboBoxOptionOption,
EuiComboBoxProps,
EuiFormRow,
htmlIdGenerator,
DragDropContextProps,
} from '@elastic/eui';
import { FieldSelectItem } from './field_select_item';
import { IndexPatternValue, SanitizedFieldType } from '../../../../../common/types';
import { TimeseriesUIRestrictions } from '../../../../../common/ui_restrictions';
import { getIndexPatternKey } from '../../../../../common/index_patterns_utils';
import { MultiFieldSelect } from './multi_field_select';
import {
addNewItem,
deleteItem,
swapItems,
getGroupedOptions,
findInGroupedOptions,
INVALID_FIELD_ID,
MAX_MULTI_FIELDS_ITEMS,
updateItem,
} from './field_select_utils';
interface FieldSelectProps {
label: string | ReactNode;
type: string;
uiRestrictions?: TimeseriesUIRestrictions;
restrict?: string[];
value?: string | Array<string | null> | null;
fields: Record<string, SanitizedFieldType[]>;
indexPattern: IndexPatternValue;
onChange: (selectedValues: Array<string | null>) => void;
disabled?: boolean;
placeholder?: string;
allowMultiSelect?: boolean;
'data-test-subj'?: string;
fullWidth?: boolean;
}
const getPreselectedFields = (
placeholder?: string,
options?: Array<EuiComboBoxOptionOption<string>>
) => placeholder && findInGroupedOptions(options, placeholder)?.label;
export function FieldSelect({
label,
fullWidth,
type,
value,
fields,
indexPattern,
uiRestrictions,
restrict,
onChange,
disabled,
placeholder,
allowMultiSelect = false,
'data-test-subj': dataTestSubj,
}: FieldSelectProps) {
const htmlId = htmlIdGenerator();
const fieldsSelector = getIndexPatternKey(indexPattern);
const selectedIds = useMemo(() => [value ?? null].flat(), [value]);
const groupedOptions = useMemo(
() => getGroupedOptions(type, selectedIds, fields[fieldsSelector], uiRestrictions, restrict),
[fields, fieldsSelector, restrict, selectedIds, type, uiRestrictions]
);
const selectedOptionsMap = useMemo(() => {
const map = new Map<string, EuiComboBoxProps<string>['selectedOptions']>();
if (selectedIds) {
const addIntoSet = (item: string) => {
const option = findInGroupedOptions(groupedOptions, item);
if (option) {
map.set(item, [option]);
} else {
map.set(item, [{ label: item, id: INVALID_FIELD_ID }]);
}
};
selectedIds.forEach((v) => v && addIntoSet(v));
}
return map;
}, [groupedOptions, selectedIds]);
const invalidSelectedOptions = useMemo(
() =>
[...selectedOptionsMap.values()]
.flat()
.filter((item) => item?.label && item?.id === INVALID_FIELD_ID)
.map((item) => item!.label),
[selectedOptionsMap]
);
const onFieldSelectItemChange = useCallback(
(index: number = 0, [selectedItem]) => {
onChange(updateItem(selectedIds, selectedItem?.value, index));
},
[selectedIds, onChange]
);
const onNewItemAdd = useCallback(
(index?: number) => onChange(addNewItem(selectedIds, index)),
[selectedIds, onChange]
);
const onDeleteItem = useCallback(
(index?: number) => onChange(deleteItem(selectedIds, index)),
[onChange, selectedIds]
);
const onDragEnd: DragDropContextProps['onDragEnd'] = useCallback(
({ source, destination }) => {
if (destination && source.index !== destination?.index) {
onChange(swapItems(selectedIds, source.index, destination.index));
}
},
[onChange, selectedIds]
);
const FieldSelectItemFactory = useMemo(
() => (props: { value?: string | null; index?: number }) =>
(
<FieldSelectItem
options={groupedOptions}
selectedOptions={(props.value ? selectedOptionsMap.get(props.value) : undefined) ?? []}
disabled={disabled}
onNewItemAdd={onNewItemAdd.bind(undefined, props.index)}
onDeleteItem={onDeleteItem.bind(undefined, props.index)}
onChange={onFieldSelectItemChange.bind(undefined, props.index)}
placeholder={getPreselectedFields(placeholder, groupedOptions)}
disableAdd={!allowMultiSelect || selectedIds?.length >= MAX_MULTI_FIELDS_ITEMS}
disableDelete={!allowMultiSelect || selectedIds?.length <= 1}
/>
),
[
groupedOptions,
selectedOptionsMap,
disabled,
onNewItemAdd,
onDeleteItem,
onFieldSelectItemChange,
placeholder,
allowMultiSelect,
selectedIds?.length,
]
);
return (
<EuiFormRow
id={htmlId('fieldSelect')}
label={label}
error={i18n.translate('visTypeTimeseries.fieldSelect.fieldIsNotValid', {
defaultMessage:
'The "{fieldParameter}" selection is not valid for use with the current index.',
values: {
fieldParameter: invalidSelectedOptions.join(', '),
},
})}
fullWidth={fullWidth}
isInvalid={Boolean(invalidSelectedOptions.length)}
data-test-subj={dataTestSubj ?? 'metricsIndexPatternFieldsSelect'}
>
{selectedIds?.length > 1 ? (
<MultiFieldSelect
values={selectedIds}
onDragEnd={onDragEnd}
WrappedComponent={FieldSelectItemFactory}
/>
) : (
<FieldSelectItemFactory value={selectedIds?.[0]} />
)}
</EuiFormRow>
);
}

View file

@ -0,0 +1,76 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiComboBoxProps,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { AddDeleteButtons } from '../../add_delete_buttons';
import { INVALID_FIELD_ID } from './field_select_utils';
export interface FieldSelectItemProps {
onChange: (options: Array<EuiComboBoxOptionOption<string>>) => void;
options: EuiComboBoxProps<string>['options'];
selectedOptions: EuiComboBoxProps<string>['selectedOptions'];
placeholder?: string;
disabled?: boolean;
disableAdd?: boolean;
disableDelete?: boolean;
onNewItemAdd?: () => void;
onDeleteItem?: () => void;
}
const defaultPlaceholder = i18n.translate('visTypeTimeseries.fieldSelect.selectFieldPlaceholder', {
defaultMessage: 'Select field...',
});
export function FieldSelectItem({
options,
selectedOptions,
placeholder = defaultPlaceholder,
disabled,
disableAdd,
disableDelete,
onChange,
onDeleteItem,
onNewItemAdd,
}: FieldSelectItemProps) {
const isInvalid = Boolean(selectedOptions?.find((item) => item.id === INVALID_FIELD_ID));
return (
<EuiFlexGroup alignItems="center" gutterSize={'xs'}>
<EuiFlexItem grow={true}>
<EuiComboBox
placeholder={placeholder}
isDisabled={disabled}
options={options}
selectedOptions={selectedOptions}
onChange={onChange}
singleSelection={{ asPlainText: true }}
isInvalid={isInvalid}
fullWidth={true}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddDeleteButtons
onAdd={onNewItemAdd}
onDelete={onDeleteItem}
disableDelete={disableDelete}
disableAdd={disableAdd}
responsive={false}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,109 @@
/*
* 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 type { EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui';
import { isFieldEnabled } from '../../../../../common/check_ui_restrictions';
import type { SanitizedFieldType } from '../../../../..//common/types';
import type { TimeseriesUIRestrictions } from '../../../../../common/ui_restrictions';
export const INVALID_FIELD_ID = 'INVALID_FIELD';
export const MAX_MULTI_FIELDS_ITEMS = 4;
export const getGroupedOptions = (
type: string,
selectedIds: Array<string | null>,
fields: SanitizedFieldType[] = [],
uiRestrictions: TimeseriesUIRestrictions | undefined,
restrict: string[] = []
): EuiComboBoxProps<string>['options'] => {
const isFieldTypeEnabled = (fieldType: string) =>
restrict.length ? restrict.includes(fieldType) : true;
const sortByLabel = (a: EuiComboBoxOptionOption<string>, b: EuiComboBoxOptionOption<string>) => {
const getNormalizedString = (option: EuiComboBoxOptionOption<string>) =>
(option.label || '').toLowerCase();
return getNormalizedString(a).localeCompare(getNormalizedString(b));
};
const groupedOptions: EuiComboBoxProps<string>['options'] = Object.values(
fields.reduce<Record<string, EuiComboBoxOptionOption<string>>>((acc, field) => {
if (isFieldTypeEnabled(field.type) && isFieldEnabled(field.name, type, uiRestrictions)) {
const item: EuiComboBoxOptionOption<string> = {
value: field.name,
label: field.label ?? field.name,
disabled: selectedIds.includes(field.name),
};
const fieldTypeOptions = acc[field.type]?.options;
if (fieldTypeOptions) {
fieldTypeOptions.push(item);
} else {
acc[field.type] = {
options: [item],
label: field.type,
};
}
}
return acc;
}, {})
);
// sort groups
groupedOptions.sort(sortByLabel);
// sort items
groupedOptions.forEach((group) => {
if (Array.isArray(group.options)) {
group.options.sort(sortByLabel);
}
});
return groupedOptions;
};
export const findInGroupedOptions = (
groupedOptions: EuiComboBoxProps<string>['options'],
fieldName: string
) =>
(groupedOptions || [])
.map((i) => i.options)
.flat()
.find((i) => i?.value === fieldName);
export const updateItem = (
existingItems: Array<string | null>,
value: string | null = null,
index: number = 0
) => {
const arr = [...existingItems];
arr[index] = value;
return arr;
};
export const addNewItem = (existingItems: Array<string | null>, insertAfter: number = 0) => {
const arr = [...existingItems];
arr.splice(insertAfter + 1, 0, null);
return arr;
};
export const deleteItem = (existingItems: Array<string | null>, index: number = 0) =>
existingItems.filter((item, i) => i !== index);
export const swapItems = (
existingItems: Array<string | null>,
source: number = 0,
destination: number = 0
) => {
const arr = [...existingItems];
arr.splice(destination, 0, arr.splice(source, 1)[0]);
return arr;
};

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { FieldSelect } from './field_select';

View file

@ -0,0 +1,65 @@
/*
* 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 { i18n } from '@kbn/i18n';
import {
EuiDragDropContext,
EuiDroppable,
DragDropContextProps,
EuiDraggable,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiIcon,
} from '@elastic/eui';
import React, { FunctionComponent } from 'react';
const DROPPABLE_ID = 'onDragEnd';
const dragAriaLabel = i18n.translate('visTypeTimeseries.fieldSelect.dragAriaLabel', {
defaultMessage: 'Drag field',
});
export function MultiFieldSelect(props: {
values: Array<string | null>;
onDragEnd: DragDropContextProps['onDragEnd'];
WrappedComponent: FunctionComponent<{ value?: string | null; index?: number }>;
}) {
return (
<EuiDragDropContext onDragEnd={props.onDragEnd}>
<EuiDroppable droppableId={DROPPABLE_ID} spacing="none">
{props.values.map((value, index) => (
<EuiDraggable
spacing="m"
key={index}
index={index}
draggableId={`${index}`}
customDragHandle={true}
>
{(provided) => (
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiPanel
color="transparent"
paddingSize="s"
{...provided.dragHandleProps}
aria-label={dragAriaLabel}
>
<EuiIcon type="grab" />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<props.WrappedComponent value={value} index={index} />
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiDraggable>
))}
</EuiDroppable>
</EuiDragDropContext>
);
}

View file

@ -168,7 +168,11 @@ export const FilterRatioAgg = (props) => {
restrict={getSupportedFieldsByMetricType(model.metric_agg)}
indexPattern={indexPattern}
value={model.field}
onChange={handleSelectChange('field')}
onChange={(value) =>
handleChange({
field: value?.[0],
})
}
/>
</EuiFlexItem>
) : null}

View file

@ -72,6 +72,7 @@ describe('TSVB Filter Ratio', () => {
label: 'number',
options: [
{
disabled: false,
label: 'system.cpu.user.pct',
value: 'system.cpu.user.pct',
},
@ -95,6 +96,7 @@ describe('TSVB Filter Ratio', () => {
"label": "date",
"options": Array [
Object {
"disabled": false,
"label": "@timestamp",
"value": "@timestamp",
},
@ -104,6 +106,7 @@ describe('TSVB Filter Ratio', () => {
"label": "number",
"options": Array [
Object {
"disabled": false,
"label": "system.cpu.user.pct",
"value": "system.cpu.user.pct",
},

View file

@ -90,7 +90,11 @@ export function PercentileAgg(props) {
restrict={RESTRICT_FIELDS}
indexPattern={indexPattern}
value={model.field}
onChange={handleSelectChange('field')}
onChange={(value) =>
handleChange({
field: value?.[0],
})
}
/>
</EuiFlexItem>
<EuiFlexItem>

View file

@ -45,7 +45,7 @@ interface PercentileRankAggProps {
series: Series;
dragHandleProps: DragHandleProps;
onAdd(): void;
onChange(): void;
onChange(partialModel: Record<string, unknown>): void;
onDelete(): void;
}
@ -111,7 +111,11 @@ export const PercentileRankAgg = (props: PercentileRankAggProps) => {
restrict={RESTRICT_FIELDS}
indexPattern={indexPattern}
value={model.field ?? ''}
onChange={handleSelectChange('field')}
onChange={(value) =>
props.onChange({
field: value?.[0],
})
}
/>
</EuiFlexItem>

View file

@ -111,7 +111,11 @@ export const PositiveRateAgg = (props) => {
restrict={[KBN_FIELD_TYPES.NUMBER]}
indexPattern={indexPattern}
value={model.field}
onChange={handleSelectChange('field')}
onChange={(value) =>
handleChange({
field: value?.[0],
})
}
uiRestrictions={props.uiRestrictions}
fullWidth
/>

View file

@ -68,7 +68,11 @@ export function StandardAgg(props) {
restrict={restrictFields}
indexPattern={indexPattern}
value={model.field}
onChange={handleSelectChange('field')}
onChange={(value) =>
handleChange({
field: value?.[0],
})
}
uiRestrictions={uiRestrictions}
fullWidth
/>

View file

@ -119,7 +119,11 @@ const StandardDeviationAggUi = (props) => {
restrict={RESTRICT_FIELDS}
indexPattern={indexPattern}
value={model.field}
onChange={handleSelectChange('field')}
onChange={(value) =>
handleChange({
field: value?.[0],
})
}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -180,7 +180,11 @@ const TopHitAggUi = (props) => {
restrict={aggWithOptionsRestrictFields}
indexPattern={indexPattern}
value={model.field}
onChange={handleSelectChange('field')}
onChange={(value) =>
handleChange({
field: value?.[0],
})
}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -242,7 +246,11 @@ const TopHitAggUi = (props) => {
}
restrict={ORDER_DATE_RESTRICT_FIELDS}
value={model.order_by}
onChange={handleSelectChange('order_by')}
onChange={(value) =>
handleChange({
order_by: value?.[0],
})
}
indexPattern={indexPattern}
fields={fields}
data-test-subj="topHitOrderByFieldSelect"

View file

@ -148,8 +148,12 @@ export const AnnotationRow = ({
/>
}
restrict={RESTRICT_FIELDS}
value={model.time_field}
onChange={handleChange(TIME_FIELD_KEY)}
value={model[TIME_FIELD_KEY]}
onChange={(value) =>
onChange({
[TIME_FIELD_KEY]: value?.[0] ?? undefined,
})
}
indexPattern={model.index_pattern}
fields={fields}
/>

View file

@ -259,7 +259,11 @@ export const IndexPattern = ({
restrict={RESTRICT_FIELDS}
value={model[timeFieldName]}
disabled={disabled}
onChange={handleSelectChange(timeFieldName)}
onChange={(value) =>
onChange({
[timeFieldName]: value?.[0],
})
}
indexPattern={model[indexPatternName]}
fields={fields}
placeholder={

View file

@ -11,6 +11,7 @@ import { TSVB_METRIC_TYPES } from '../../../../common/enums';
import { checkIfNumericMetric } from './check_if_numeric_metric';
import type { Metric } from '../../../../common/types';
import type { VisFields } from '../../lib/fetch_fields';
describe('checkIfNumericMetric(metric, fields, indexPattern)', () => {
const indexPattern = { id: 'some_id' };
@ -20,7 +21,7 @@ describe('checkIfNumericMetric(metric, fields, indexPattern)', () => {
{ name: 'string field', type: 'string' },
{ name: 'date field', type: 'date' },
],
};
} as VisFields;
it('should return true for Count metric', () => {
const metric = { type: METRIC_TYPES.COUNT } as Metric;

View file

@ -201,11 +201,11 @@ describe('convert series to datatables', () => {
expect(tables.series1.rows.length).toEqual(8);
const expected1 = series[0].data.map((d) => {
d.push(parseInt(series[0].label, 10));
d.push(parseInt([series[0].label].flat()[0], 10));
return d;
});
const expected2 = series[1].data.map((d) => {
d.push(parseInt(series[1].label, 10));
d.push(parseInt([series[1].label].flat()[0], 10));
return d;
});
expect(tables.series1.rows).toEqual([...expected1, ...expected2]);

View file

@ -10,6 +10,7 @@ import { DatatableRow, DatatableColumn, DatatableColumnType } from 'src/plugins/
import { Query } from 'src/plugins/data/common';
import { TimeseriesVisParams } from '../../../types';
import type { PanelData, Metric } from '../../../../common/types';
import { getMultiFieldLabel, getFieldsForTerms } from '../../../../common/fields_utils';
import { BUCKET_TYPES, TSVB_METRIC_TYPES } from '../../../../common/enums';
import { fetchIndexPattern } from '../../../../common/index_patterns_utils';
import { getDataStart } from '../../../services';
@ -131,7 +132,7 @@ export const convertSeriesToDataTable = async (
id++;
columns.push({
id,
name: layer.terms_field || '',
name: getMultiFieldLabel(getFieldsForTerms(layer.terms_field)),
isMetric: false,
type: BUCKET_TYPES.TERMS,
});
@ -154,7 +155,7 @@ export const convertSeriesToDataTable = async (
const row: DatatableRow = [rowData[0], rowData[1]];
// If the layer is split by terms aggregation, the data array should also contain the split value.
if (isGroupedByTerms || filtersColumn) {
row.push(seriesPerLayer[j].label);
row.push([seriesPerLayer[j].label].flat()[0]);
}
return row;
});

View file

@ -16,6 +16,7 @@ import { getMetricsField } from './get_metrics_field';
import { createFieldFormatter } from './create_field_formatter';
import { labelDateFormatter } from './label_date_formatter';
import moment from 'moment';
import { getFieldsForTerms } from '../../../../common/fields_utils';
export const convertSeriesToVars = (series, model, getConfig = null, fieldFormatMap) => {
const variables = {};
@ -50,10 +51,16 @@ export const convertSeriesToVars = (series, model, getConfig = null, fieldFormat
}),
},
};
const rowLabel =
seriesModel.split_mode === BUCKET_TYPES.TERMS
? createFieldFormatter(seriesModel.terms_field, fieldFormatMap)(row.label)
: row.label;
let rowLabel = row.label;
if (seriesModel.split_mode === BUCKET_TYPES.TERMS) {
const fieldsForTerms = getFieldsForTerms(seriesModel.terms_field);
if (fieldsForTerms.length === 1) {
rowLabel = createFieldFormatter(fieldsForTerms[0], fieldFormatMap)(row.label);
}
}
set(variables, varName, data);
set(variables, `${label}.label`, rowLabel);

View file

@ -23,7 +23,6 @@ import {
EuiHorizontalRule,
EuiCode,
EuiText,
EuiComboBoxOptionOption,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
@ -65,16 +64,27 @@ export class TablePanelConfig extends Component<
this.setState({ selectedTab });
}
handlePivotChange = (selectedOption: Array<EuiComboBoxOptionOption<string>>) => {
handlePivotChange = (selectedOptions: Array<string | null>) => {
const { fields, model } = this.props;
const pivotId = get(selectedOption, '[0].value', null);
const field = fields[getIndexPatternKey(model.index_pattern)].find((f) => f.name === pivotId);
const pivotType = get(field, 'type', model.pivot_type);
this.props.onChange({
pivot_id: pivotId,
pivot_type: pivotType,
});
const getPivotType = (fieldName?: string | null): KBN_FIELD_TYPES | null => {
const field = fields[getIndexPatternKey(model.index_pattern)].find(
(f) => f.name === fieldName
);
return get(field, 'type', null);
};
this.props.onChange(
selectedOptions.length === 1
? {
pivot_id: selectedOptions[0] || undefined,
pivot_type: getPivotType(selectedOptions[0]) || undefined,
}
: {
pivot_id: selectedOptions,
pivot_type: selectedOptions.map((item) => getPivotType(item)),
}
);
};
handleTextChange =
@ -129,6 +139,8 @@ export class TablePanelConfig extends Component<
onChange={this.handlePivotChange}
uiRestrictions={this.context.uiRestrictions}
type={BUCKET_TYPES.TERMS}
allowMultiSelect={true}
fullWidth={true}
/>
</EuiFlexItem>
<EuiFlexItem>

View file

@ -20,13 +20,14 @@ exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js
}
labelType="label"
>
<InjectIntl(GroupBySelectUi)
<GroupBySelect
onChange={[Function]}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<FieldSelect
allowMultiSelect={true}
data-test-subj="groupByField"
fields={
Object {

View file

@ -1,75 +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 from 'react';
import { EuiComboBox } from '@elastic/eui';
import { injectI18n } from '@kbn/i18n-react';
import { isGroupByFieldsEnabled } from '../../../../common/check_ui_restrictions';
function GroupBySelectUi(props) {
const { intl, uiRestrictions } = props;
const modeOptions = [
{
label: intl.formatMessage({
id: 'visTypeTimeseries.splits.groupBySelect.modeOptions.everythingLabel',
defaultMessage: 'Everything',
}),
value: 'everything',
},
{
label: intl.formatMessage({
id: 'visTypeTimeseries.splits.groupBySelect.modeOptions.filterLabel',
defaultMessage: 'Filter',
}),
value: 'filter',
},
{
label: intl.formatMessage({
id: 'visTypeTimeseries.splits.groupBySelect.modeOptions.filtersLabel',
defaultMessage: 'Filters',
}),
value: 'filters',
},
{
label: intl.formatMessage({
id: 'visTypeTimeseries.splits.groupBySelect.modeOptions.termsLabel',
defaultMessage: 'Terms',
}),
value: 'terms',
},
].map((field) => ({
...field,
disabled: !isGroupByFieldsEnabled(field.value, uiRestrictions),
}));
const selectedValue = props.value || 'everything';
const selectedOption = modeOptions.find((option) => {
return selectedValue === option.value;
});
return (
<EuiComboBox
id={props.id}
isClearable={false}
options={modeOptions}
selectedOptions={[selectedOption]}
onChange={props.onChange}
singleSelection={{ asPlainText: true }}
data-test-subj="groupBySelect"
/>
);
}
GroupBySelectUi.propTypes = {
onChange: PropTypes.func,
value: PropTypes.string,
uiRestrictions: PropTypes.object,
};
export const GroupBySelect = injectI18n(GroupBySelectUi);

View file

@ -0,0 +1,75 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui';
import { isGroupByFieldsEnabled } from '../../../../common/check_ui_restrictions';
import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions';
interface GroupBySelectProps {
id: string;
onChange: EuiComboBoxProps<string>['onChange'];
value?: string;
uiRestrictions: TimeseriesUIRestrictions;
}
const getAvailableOptions = () => [
{
label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.everythingLabel', {
defaultMessage: 'Everything',
}),
value: 'everything',
},
{
label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.filterLabel', {
defaultMessage: 'Filter',
}),
value: 'filter',
},
{
label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.filtersLabel', {
defaultMessage: 'Filters',
}),
value: 'filters',
},
{
label: i18n.translate('visTypeTimeseries.splits.groupBySelect.modeOptions.termsLabel', {
defaultMessage: 'Terms',
}),
value: 'terms',
},
];
export const GroupBySelect = ({
id,
onChange,
value = 'everything',
uiRestrictions,
}: GroupBySelectProps) => {
const modeOptions = getAvailableOptions().map((field) => ({
...field,
disabled: !isGroupByFieldsEnabled(field.value, uiRestrictions),
}));
const selectedOption: EuiComboBoxOptionOption<string> | undefined = modeOptions.find(
(option) => value === option.value
);
return (
<EuiComboBox
id={id}
isClearable={false}
options={modeOptions}
selectedOptions={selectedOption ? [selectedOption] : undefined}
onChange={onChange}
singleSelection={{ asPlainText: true }}
data-test-subj="groupBySelect"
/>
);
};

View file

@ -7,7 +7,7 @@
*/
import PropTypes from 'prop-types';
import React from 'react';
import React, { useCallback } from 'react';
import { get, find } from 'lodash';
import { GroupBySelect } from './group_by_select';
import { createTextHandler } from '../lib/create_text_handler';
@ -91,6 +91,15 @@ export const SplitByTermsUI = ({
const selectedField = find(fields[fieldsSelector], ({ name }) => name === model.terms_field);
const selectedFieldType = get(selectedField, 'type');
const onTermsFieldChange = useCallback(
(selectedOptions) => {
onChange({
terms_field: selectedOptions.length === 1 ? selectedOptions[0] : selectedOptions,
});
},
[onChange]
);
if (
seriesQuantity &&
model.stacked === STACKED_OPTIONS.PERCENT &&
@ -142,11 +151,12 @@ export const SplitByTermsUI = ({
]}
data-test-subj="groupByField"
indexPattern={indexPattern}
onChange={handleSelectChange('terms_field')}
onChange={onTermsFieldChange}
value={model.terms_field}
fields={fields}
uiRestrictions={uiRestrictions}
type={'terms'}
allowMultiSelect={true}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -190,6 +190,7 @@ function TimeseriesVisualization({
onUiState={handleUiState}
syncColors={syncColors}
palettesService={palettesService}
indexPattern={indexPattern}
fieldFormatMap={indexPattern?.fieldFormatMap}
/>
</Suspense>

View file

@ -15,6 +15,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';
import { FetchedIndexPattern } from '../../../../common/types';
/**
* Lazy load each visualization type, since the only one is presented on the screen at the same time.
@ -62,5 +63,7 @@ export interface TimeseriesVisProps {
getConfig: IUiSettingsClient['get'];
syncColors: boolean;
palettesService: PaletteRegistry;
indexPattern?: FetchedIndexPattern['indexPattern'];
/** @deprecated please use indexPattern.fieldFormatMap instead **/
fieldFormatMap?: FieldFormatMap;
}

View file

@ -221,7 +221,11 @@ export class TableSeriesConfig extends Component {
fields={this.props.fields}
indexPattern={this.props.panel.index_pattern}
value={model.aggregate_by}
onChange={handleSelectChange('aggregate_by')}
onChange={(value) =>
this.props.onChange({
aggregate_by: value?.[0],
})
}
fullWidth
restrict={[
KBN_FIELD_TYPES.NUMBER,

View file

@ -18,11 +18,17 @@ import { isSortable } from './is_sortable';
import { EuiToolTip, EuiIcon } from '@elastic/eui';
import { replaceVars } from '../../lib/replace_vars';
import { ExternalUrlErrorModal } from '../../lib/external_url_error_modal';
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';
import { FIELD_FORMAT_IDS } from '../../../../../../../../plugins/field_formats/common';
import {
createCachedFieldValueFormatter,
getFieldsForTerms,
getMultiFieldLabel,
MULTI_FIELD_VALUES_SEPARATOR,
} from '../../../../../common/fields_utils';
function getColor(rules, colorKey, value) {
let color;
@ -49,12 +55,7 @@ function sanitizeUrl(url) {
class TableVis extends Component {
constructor(props) {
super(props);
const fieldFormatsService = getFieldFormats();
const DateFormat = fieldFormatsService.getType(FIELD_FORMAT_IDS.DATE);
this.dateFormatter = new DateFormat({}, this.props.getConfig);
this.fieldFormatsService = getFieldFormats();
this.state = {
accessDeniedDrilldownUrl: null,
};
@ -74,17 +75,21 @@ class TableVis extends Component {
}
};
renderRow = (row) => {
renderRow = (row, pivotIds, fieldValuesFormatter) => {
const { model, fieldFormatMap, getConfig } = this.props;
let rowDisplay = getValueOrEmpty(
model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key
);
let rowDisplay = 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 (pivotIds.length) {
rowDisplay = pivotIds
.map((item, index) => {
const value = [row.key ?? null].flat()[index];
const formatted = fieldValuesFormatter(item, value, 'html');
// eslint-disable-next-line react/no-danger
return <span dangerouslySetInnerHTML={{ __html: formatted ?? value }} />;
})
.reduce((prev, curr) => [prev, MULTI_FIELD_VALUES_SEPARATOR, curr]);
}
if (model.drilldown_url) {
@ -150,7 +155,7 @@ class TableVis extends Component {
);
};
renderHeader() {
renderHeader(pivotIds) {
const { model, uiState, onUiState, visData } = this.props;
const stateKey = `${model.type}.sort`;
const sort = uiState.get(stateKey, {
@ -210,7 +215,7 @@ class TableVis extends Component {
</th>
);
});
const label = visData.pivot_label || model.pivot_label || model.pivot_id;
const label = visData.pivot_label || model.pivot_label || getMultiFieldLabel(pivotIds);
let sortIcon;
if (sort.column === '_default_') {
sortIcon = sort.order === 'asc' ? 'sortUp' : 'sortDown';
@ -240,13 +245,26 @@ class TableVis extends Component {
closeExternalUrlErrorModal = () => this.setState({ accessDeniedDrilldownUrl: null });
render() {
const { visData } = this.props;
const { visData, model, indexPattern } = this.props;
const { accessDeniedDrilldownUrl } = this.state;
const header = this.renderHeader();
const fields = (model.pivot_type ? [model.pivot_type ?? null].flat() : []).map(
(type, index) => ({
name: [model.pivot_id ?? null].flat()[index],
type,
})
);
const fieldValuesFormatter = createCachedFieldValueFormatter(
indexPattern,
fields,
this.fieldFormatsService,
model.drilldown_url ? [FIELD_FORMAT_IDS.URL] : []
);
const pivotIds = getFieldsForTerms(model.pivot_id);
const header = this.renderHeader(pivotIds);
let rows = null;
if (isArray(visData.series) && visData.series.length) {
rows = visData.series.map(this.renderRow);
rows = visData.series.map((item) => this.renderRow(item, pivotIds, fieldValuesFormatter));
}
return (
@ -285,6 +303,7 @@ TableVis.propTypes = {
uiState: PropTypes.object,
pageNumber: PropTypes.number,
getConfig: PropTypes.func,
indexPattern: PropTypes.object,
};
// default export required for React.Lazy

View file

@ -16,6 +16,7 @@ import { getDataSourceInfo } from './get_datasource_info';
import { getFieldType } from './get_field_type';
import { getSeries } from './get_series';
import { getYExtents } from './get_extents';
import { getFieldsForTerms } from '../../common/fields_utils';
const SUPPORTED_FORMATTERS = ['bytes', 'percent', 'number'];
@ -99,13 +100,21 @@ export const triggerTSVBtoLensConfiguration = async (
}
const palette = layer.palette as PaletteOutput;
const splitFields = getFieldsForTerms(layer.terms_field);
// in case of terms in a date field, we want to apply the date_histogram
let splitWithDateHistogram = false;
if (layer.terms_field && layer.split_mode === 'terms') {
const fieldType = await getFieldType(indexPatternId, layer.terms_field);
if (fieldType === 'date') {
splitWithDateHistogram = true;
if (layer.terms_field && layer.split_mode === 'terms' && splitFields) {
for (const f of splitFields) {
const fieldType = await getFieldType(indexPatternId, f);
if (fieldType === 'date') {
if (splitFields.length === 1) {
splitWithDateHistogram = true;
} else {
return null;
}
}
}
}
@ -114,7 +123,7 @@ export const triggerTSVBtoLensConfiguration = async (
timeFieldName: timeField,
chartType,
axisPosition: layer.separate_axis ? layer.axis_position : model.axis_position,
...(layer.terms_field && { splitField: layer.terms_field }),
...(layer.terms_field && { splitFields }),
splitWithDateHistogram,
...(layer.split_mode !== 'everything' && { splitMode: layer.split_mode }),
...(splitFilters.length > 0 && { splitFilters }),

View file

@ -14,7 +14,7 @@ import { handleErrorResponse } from './handle_error_response';
import { processBucket } from './table/process_bucket';
import { createFieldsFetcher } from '../search_strategies/lib/fields_fetcher';
import { extractFieldLabel } from '../../../common/fields_utils';
import { getFieldsForTerms, getMultiFieldLabel } from '../../../common/fields_utils';
import { isAggSupported } from './helpers/check_aggs';
import { isConfigurationFeatureEnabled } from '../../../common/check_ui_restrictions';
import { FilterCannotBeAppliedError, PivotNotSelectedForTableError } from '../../../common/errors';
@ -62,12 +62,15 @@ export async function getTableData(
});
const calculatePivotLabel = async () => {
if (panel.pivot_id && panelIndex.indexPattern?.id) {
const fields = await extractFields({ id: panelIndex.indexPattern.id });
const pivotIds = getFieldsForTerms(panel.pivot_id);
return extractFieldLabel(fields, panel.pivot_id);
if (pivotIds.length) {
const fields = panelIndex.indexPattern?.id
? await extractFields({ id: panelIndex.indexPattern.id })
: [];
return getMultiFieldLabel(pivotIds, fields);
}
return panel.pivot_id;
};
const meta: DataResponseMeta = {
@ -85,7 +88,7 @@ export async function getTableData(
}
});
if (!panel.pivot_id) {
if (!getFieldsForTerms(panel.pivot_id).length) {
throw new PivotNotSelectedForTableError();
}

View file

@ -54,7 +54,7 @@ export async function getSplits<TRawResponse = unknown, TMeta extends BaseMeta =
const metric = getLastMetric(series);
const buckets = get(resp, `aggregations.${series.id}.buckets`);
const fieldsForSeries = meta?.index ? await extractFields({ id: meta.index }) : [];
const fieldsForSeries = meta?.dataViewId ? await extractFields({ id: meta.dataViewId }) : [];
const splitByLabel = calculateLabel(metric, series.metrics, fieldsForSeries);
if (buckets) {

View file

@ -75,7 +75,8 @@ export function dateHistogram(
panelId: panel.id,
seriesId: series.id,
intervalString: bucketInterval,
index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined,
dataViewId: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined,
indexPatternString: seriesIndex.indexPatternString,
});
return next(doc);

View file

@ -250,7 +250,8 @@ describe('dateHistogram(req, panel, series)', () => {
expect(doc.aggs.test.meta).toMatchInlineSnapshot(`
Object {
"index": undefined,
"dataViewId": undefined,
"indexPatternString": undefined,
"intervalString": "900000ms",
"panelId": "panelId",
"seriesId": "test",

View file

@ -7,12 +7,13 @@
*/
import { overwrite } from '../../helpers';
import { getFieldsForTerms } from '../../../../../common/fields_utils';
export function splitByEverything(req, panel, series) {
return (next) => (doc) => {
if (
series.split_mode === 'everything' ||
(series.split_mode === 'terms' && !series.terms_field)
(series.split_mode === 'terms' && !getFieldsForTerms(series.terms_field).length)
) {
overwrite(doc, `aggs.${series.id}.filter.match_all`, {});
}

View file

@ -10,25 +10,41 @@ import { overwrite } from '../../helpers';
import { basicAggs } from '../../../../../common/basic_aggs';
import { getBucketsPath } from '../../helpers/get_buckets_path';
import { bucketTransform } from '../../helpers/bucket_transform';
import { validateField } from '../../../../../common/fields_utils';
import { getFieldsForTerms, validateField } from '../../../../../common/fields_utils';
export function splitByTerms(req, panel, series, esQueryConfig, seriesIndex) {
return (next) => (doc) => {
if (series.split_mode === 'terms' && series.terms_field) {
const termsField = series.terms_field;
const termsIds = getFieldsForTerms(series.terms_field);
if (series.split_mode === 'terms' && termsIds.length) {
const termsType = termsIds.length > 1 ? 'multi_terms' : 'terms';
const orderByTerms = series.terms_order_by;
validateField(termsField, seriesIndex);
termsIds.forEach((termsField) => {
validateField(termsField, seriesIndex);
});
const direction = series.terms_direction || 'desc';
const metric = series.metrics.find((item) => item.id === orderByTerms);
overwrite(doc, `aggs.${series.id}.terms.field`, termsField);
overwrite(doc, `aggs.${series.id}.terms.size`, series.terms_size);
if (termsType === 'multi_terms') {
overwrite(
doc,
`aggs.${series.id}.${termsType}.terms`,
termsIds.map((item) => ({
field: item,
}))
);
} else {
overwrite(doc, `aggs.${series.id}.${termsType}.field`, termsIds[0]);
}
overwrite(doc, `aggs.${series.id}.${termsType}.size`, series.terms_size);
if (series.terms_include) {
overwrite(doc, `aggs.${series.id}.terms.include`, series.terms_include);
overwrite(doc, `aggs.${series.id}.${termsType}.include`, series.terms_include);
}
if (series.terms_exclude) {
overwrite(doc, `aggs.${series.id}.terms.exclude`, series.terms_exclude);
overwrite(doc, `aggs.${series.id}.${termsType}.exclude`, series.terms_exclude);
}
if (metric && metric.type !== 'count' && ~basicAggs.indexOf(metric.type)) {
const sortAggKey = `${orderByTerms}-SORT`;
@ -37,12 +53,12 @@ export function splitByTerms(req, panel, series, esQueryConfig, seriesIndex) {
orderByTerms,
sortAggKey
);
overwrite(doc, `aggs.${series.id}.terms.order`, { [bucketPath]: direction });
overwrite(doc, `aggs.${series.id}.${termsType}.order`, { [bucketPath]: direction });
overwrite(doc, `aggs.${series.id}.aggs`, { [sortAggKey]: fn(metric) });
} else if (['_key', '_count'].includes(orderByTerms)) {
overwrite(doc, `aggs.${series.id}.terms.order`, { [orderByTerms]: direction });
overwrite(doc, `aggs.${series.id}.${termsType}.order`, { [orderByTerms]: direction });
} else {
overwrite(doc, `aggs.${series.id}.terms.order`, { _count: direction });
overwrite(doc, `aggs.${series.id}.${termsType}.order`, { _count: direction });
}
}
return next(doc);

View file

@ -24,7 +24,8 @@ export const dateHistogram: TableRequestProcessorsFunction =
const meta: TableSearchRequestMeta = {
timeField,
index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined,
dataViewId: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined,
indexPatternString: seriesIndex.indexPatternString,
panelId: panel.id,
};

View file

@ -8,7 +8,7 @@
import { get, last } from 'lodash';
import { overwrite, getBucketsPath, bucketTransform } from '../../helpers';
import { getFieldsForTerms } from '../../../../../common/fields_utils';
import { basicAggs } from '../../../../../common/basic_aggs';
import type { TableRequestProcessorsFunction } from './types';
@ -18,15 +18,29 @@ export const pivot: TableRequestProcessorsFunction =
(next) =>
(doc) => {
const { sort } = req.body.state;
const pivotIds = getFieldsForTerms(panel.pivot_id);
const termsType = pivotIds.length > 1 ? 'multi_terms' : 'terms';
if (pivotIds.length) {
if (termsType === 'multi_terms') {
overwrite(
doc,
`aggs.pivot.${termsType}.terms`,
pivotIds.map((item: string) => ({
field: item,
}))
);
} else {
overwrite(doc, `aggs.pivot.${termsType}.field`, pivotIds[0]);
}
overwrite(doc, `aggs.pivot.${termsType}.size`, panel.pivot_rows);
if (panel.pivot_id) {
overwrite(doc, 'aggs.pivot.terms.field', panel.pivot_id);
overwrite(doc, 'aggs.pivot.terms.size', panel.pivot_rows);
if (sort) {
const series = panel.series.find((item) => item.id === sort.column);
const metric = series && last(series.metrics);
if (metric && metric.type === 'count') {
overwrite(doc, 'aggs.pivot.terms.order', { _count: sort.order });
overwrite(doc, `aggs.pivot.${termsType}.order`, { _count: sort.order });
} else if (metric && series && basicAggs.includes(metric.type)) {
const sortAggKey = `${metric.id}-SORT`;
const fn = bucketTransform[metric.type];
@ -34,10 +48,10 @@ export const pivot: TableRequestProcessorsFunction =
metric.id,
sortAggKey
);
overwrite(doc, `aggs.pivot.terms.order`, { [bucketPath]: sort.order });
overwrite(doc, `aggs.pivot.${termsType}.order`, { [bucketPath]: sort.order });
overwrite(doc, `aggs.pivot.aggs`, { [sortAggKey]: fn(metric) });
} else {
overwrite(doc, 'aggs.pivot.terms.order', {
overwrite(doc, `aggs.pivot.${termsType}.order`, {
_key: get(sort, 'order', 'asc'),
});
}

View file

@ -7,5 +7,6 @@
*/
export interface BaseMeta {
index?: string;
dataViewId?: string;
indexPatternString?: string;
}

View file

@ -6,46 +6,67 @@
* Side Public License, v 1.
*/
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { BUCKET_TYPES, PANEL_TYPES } from '../../../../../common/enums';
import { BUCKET_TYPES, PANEL_TYPES, TSVB_METRIC_TYPES } from '../../../../../common/enums';
import {
createCachedFieldValueFormatter,
getFieldsForTerms,
MULTI_FIELD_VALUES_SEPARATOR,
} from '../../../../../common/fields_utils';
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';
import type { BaseMeta } from '../../request_processors/types';
import { SanitizedFieldType } from '../../../../../common/types';
export function formatLabel(
resp: unknown,
panel: Panel,
series: Series,
meta: any,
meta: BaseMeta,
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 termsIds = getFieldsForTerms(termsField);
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 &&
// no need to format labels for series_agg
!series.metrics.some((m) => m.type === TSVB_METRIC_TYPES.SERIES_AGG) &&
termsIds.length &&
splitMode === BUCKET_TYPES.TERMS &&
// no need to format labels for markdown as they also used there as variables keys
panel.type !== PANEL_TYPES.MARKDOWN;
if (shouldFormatLabels) {
const { indexPattern } = await cachedIndexPatternFetcher({ id: meta.index });
const getFieldFormatByName = (fieldName: string) =>
fieldFormatService.deserialize(indexPattern?.fieldFormatMap?.[fieldName]);
const fetchedIndex = meta.dataViewId
? await cachedIndexPatternFetcher({ id: meta.dataViewId })
: undefined;
let fields: SanitizedFieldType[] = [];
if (!fetchedIndex?.indexPattern && meta.indexPatternString) {
fields = await extractFields(meta.indexPatternString);
}
const formatField = createCachedFieldValueFormatter(
fetchedIndex?.indexPattern,
fields,
fieldFormatService
);
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;
const formatted = termsIds
.map((i, index) => formatField(i, [item.label].flat()[index]))
.join(MULTI_FIELD_VALUES_SEPARATOR);
if (formatted) {
item.label = formatted;
item.labelFormatted = formatted;
}
});
}

View file

@ -33,7 +33,7 @@ export function seriesAgg(resp, panel, series, meta, extractFields) {
return (fn && fn(acc)) || acc;
}, targetSeries);
const fieldsForSeries = meta.index ? await extractFields({ id: meta.index }) : [];
const fieldsForSeries = meta.dataViewId ? await extractFields({ id: meta.dataViewId }) : [];
results.push({
id: `${series.id}`,

View file

@ -36,7 +36,7 @@ export const seriesAgg: TableResponseProcessorsFunction =
const fn = SeriesAgg[series.aggregate_function];
const data = fn(targetSeries);
const fieldsForSeries = meta.index ? await extractFields({ id: meta.index }) : [];
const fieldsForSeries = meta.dataViewId ? await extractFields({ id: meta.dataViewId }) : [];
results.push({
id: `${series.id}`,

View file

@ -98,7 +98,7 @@ export interface VisualizeEditorLayersContext {
chartType?: string;
axisPosition?: string;
termsParams?: Record<string, unknown>;
splitField?: string;
splitFields?: string[];
splitMode?: string;
splitFilters?: SplitByFilters[];
palette?: PaletteOutput;

View file

@ -802,9 +802,7 @@ export class VisualBuilderPageObject extends FtrService {
}
public async checkSelectedMetricsGroupByValue(value: string) {
const groupBy = await this.find.byCssSelector(
'.tvbAggRow--split [data-test-subj="comboBoxInput"]'
);
const groupBy = await this.testSubjects.find('groupBySelect');
return await this.comboBox.isOptionSelected(groupBy, value);
}

View file

@ -83,7 +83,9 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter {
series: panel.series.map((series) => {
return {
id: series.id,
label: series.label,
// In case of grouping by multiple fields, "series.label" is array.
// If infra will perform this type of grouping, the following code needs to be updated
label: [series.label].flat()[0],
data: series.data.map((point) => ({
timestamp: point[0] as number,
value: point[1] as number | null,

View file

@ -1604,7 +1604,7 @@ describe('IndexPattern Data Source suggestions', () => {
const updatedContext = [
{
...context[0],
splitField: 'source',
splitFields: ['source'],
splitMode: 'terms',
termsParams: {
size: 10,

View file

@ -181,10 +181,16 @@ function createNewTimeseriesLayerWithMetricAggregationFromVizEditor(
): IndexPatternLayer | undefined {
const { timeFieldName, splitMode, splitFilters, metrics, timeInterval } = layer;
const dateField = indexPattern.getFieldByName(timeFieldName!);
const splitField = layer.splitField ? indexPattern.getFieldByName(layer.splitField) : null;
const splitFields = layer.splitFields
? (layer.splitFields
.map((item) => indexPattern.getFieldByName(item))
.filter(Boolean) as IndexPatternField[])
: null;
// generate the layer for split by terms
if (splitMode === 'terms' && splitField) {
return getSplitByTermsLayer(indexPattern, splitField, dateField, layer);
if (splitMode === 'terms' && splitFields?.length) {
return getSplitByTermsLayer(indexPattern, splitFields, dateField, layer);
// generate the layer for split by filters
} else if (splitMode?.includes('filter') && splitFilters && splitFilters.length) {
return getSplitByFiltersLayer(indexPattern, dateField, layer);

View file

@ -1672,12 +1672,13 @@ export function computeLayerFromContext(
export function getSplitByTermsLayer(
indexPattern: IndexPattern,
splitField: IndexPatternField,
splitFields: IndexPatternField[],
dateField: IndexPatternField | undefined,
layer: VisualizeEditorLayersContext
): IndexPatternLayer {
const { termsParams, metrics, timeInterval, splitWithDateHistogram } = layer;
const copyMetricsArray = [...metrics];
const computedLayer = computeLayerFromContext(
metrics.length === 1,
copyMetricsArray,
@ -1686,7 +1687,9 @@ export function getSplitByTermsLayer(
layer.label
);
const [baseField, ...secondaryFields] = splitFields;
const columnId = generateId();
let termsLayer = insertNewColumn({
op: splitWithDateHistogram ? 'date_histogram' : 'terms',
layer: insertNewColumn({
@ -1701,10 +1704,22 @@ export function getSplitByTermsLayer(
},
}),
columnId,
field: splitField,
field: baseField,
indexPattern,
visualizationGroups: [],
});
if (secondaryFields.length) {
termsLayer = updateColumnParam({
layer: termsLayer,
columnId,
paramName: 'secondaryFields',
value: secondaryFields.map((i) => i.name),
});
termsLayer = updateDefaultLabels(termsLayer, indexPattern);
}
const termsColumnParams = termsParams as TermsIndexPatternColumn['params'];
if (termsColumnParams) {
for (const [param, value] of Object.entries(termsColumnParams)) {

View file

@ -5795,7 +5795,6 @@
"visTypeTimeseries.externalUrlErrorModal.closeButtonLabel": "閉じる",
"visTypeTimeseries.externalUrlErrorModal.headerTitle": "この外部URLへのアクセスはまだ有効ではありません",
"visTypeTimeseries.fetchFields.loadIndexPatternFieldsErrorMessage": "index_pattern フィールドを読み込めません",
"visTypeTimeseries.fieldSelect.fieldIsNotValid": "\"{fieldParameter}\"フィールドは無効であり、現在のインデックスで使用できません。新しいフィールドを選択してください。",
"visTypeTimeseries.fieldSelect.selectFieldPlaceholder": "フィールドを選択してください...",
"visTypeTimeseries.filterCannotBeAppliedError": "この構成ではフィルターを適用できません",
"visTypeTimeseries.filterRatio.aggregationLabel": "アグリゲーション",

View file

@ -5805,7 +5805,6 @@
"visTypeTimeseries.externalUrlErrorModal.closeButtonLabel": "关闭",
"visTypeTimeseries.externalUrlErrorModal.headerTitle": "尚未启用对此外部 URL 的访问权限",
"visTypeTimeseries.fetchFields.loadIndexPatternFieldsErrorMessage": "无法加载 index_pattern 字段",
"visTypeTimeseries.fieldSelect.fieldIsNotValid": "“{fieldParameter}”字段无效,无法用于当前索引。请选择新字段。",
"visTypeTimeseries.fieldSelect.selectFieldPlaceholder": "选择字段......",
"visTypeTimeseries.filterCannotBeAppliedError": "在此配置下,不能应用该“筛选”",
"visTypeTimeseries.filterRatio.aggregationLabel": "聚合",