mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
c302779004
commit
efcdbb66dd
57 changed files with 1007 additions and 376 deletions
|
@ -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.
|
||||
====
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 = ' › ';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -46,7 +46,7 @@ export interface PanelSeries {
|
|||
|
||||
export interface PanelData {
|
||||
id: string;
|
||||
label: string;
|
||||
label: string | string[];
|
||||
labelFormatted?: string;
|
||||
data: PanelDataArray[];
|
||||
seriesId: string;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -190,6 +190,7 @@ function TimeseriesVisualization({
|
|||
onUiState={handleUiState}
|
||||
syncColors={syncColors}
|
||||
palettesService={palettesService}
|
||||
indexPattern={indexPattern}
|
||||
fieldFormatMap={indexPattern?.fieldFormatMap}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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`, {});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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'),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,5 +7,6 @@
|
|||
*/
|
||||
|
||||
export interface BaseMeta {
|
||||
index?: string;
|
||||
dataViewId?: string;
|
||||
indexPatternString?: string;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1604,7 +1604,7 @@ describe('IndexPattern Data Source suggestions', () => {
|
|||
const updatedContext = [
|
||||
{
|
||||
...context[0],
|
||||
splitField: 'source',
|
||||
splitFields: ['source'],
|
||||
splitMode: 'terms',
|
||||
termsParams: {
|
||||
size: 10,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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": "アグリゲーション",
|
||||
|
|
|
@ -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": "聚合",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue