[Lens] Time scale ui (#83904)
This commit is contained in:
parent
3af64cac34
commit
6828859cac
|
@ -39,14 +39,19 @@ export function getBucketIdentifier(row: DatatableRow, groupColumns?: string[])
|
|||
* @param outputColumnId Id of the output column
|
||||
* @param inputColumnId Id of the input column
|
||||
* @param outputColumnName Optional name of the output column
|
||||
* @param options Optional options, set `allowColumnOverwrite` to true to not raise an exception if the output column exists already
|
||||
*/
|
||||
export function buildResultColumns(
|
||||
input: Datatable,
|
||||
outputColumnId: string,
|
||||
inputColumnId: string,
|
||||
outputColumnName: string | undefined
|
||||
outputColumnName: string | undefined,
|
||||
options: { allowColumnOverwrite: boolean } = { allowColumnOverwrite: false }
|
||||
) {
|
||||
if (input.columns.some((column) => column.id === outputColumnId)) {
|
||||
if (
|
||||
!options.allowColumnOverwrite &&
|
||||
input.columns.some((column) => column.id === outputColumnId)
|
||||
) {
|
||||
throw new Error(
|
||||
i18n.translate('expressions.functions.seriesCalculations.columnConflictMessage', {
|
||||
defaultMessage:
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import './dimension_editor.scss';
|
||||
import _ from 'lodash';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiListGroup,
|
||||
|
@ -34,6 +34,7 @@ import { BucketNestingEditor } from './bucket_nesting_editor';
|
|||
import { IndexPattern, IndexPatternLayer } from '../types';
|
||||
import { trackUiEvent } from '../../lens_ui_telemetry';
|
||||
import { FormatSelector } from './format_selector';
|
||||
import { TimeScaling } from './time_scaling';
|
||||
|
||||
const operationPanels = getOperationDisplay();
|
||||
|
||||
|
@ -43,10 +44,30 @@ export interface DimensionEditorProps extends IndexPatternDimensionEditorProps {
|
|||
currentIndexPattern: IndexPattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a debounced input for the label of a dimension. It will update on root state changes
|
||||
* if no debounced changes are in flight because the user is currently typing into the input.
|
||||
*/
|
||||
const LabelInput = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => {
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const unflushedChanges = useRef(false);
|
||||
|
||||
const onChangeDebounced = useMemo(() => _.debounce(onChange, 256), [onChange]);
|
||||
const onChangeDebounced = useMemo(() => {
|
||||
const callback = _.debounce((val: string) => {
|
||||
onChange(val);
|
||||
unflushedChanges.current = false;
|
||||
}, 256);
|
||||
return (val: string) => {
|
||||
unflushedChanges.current = true;
|
||||
callback(val);
|
||||
};
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unflushedChanges.current && value !== inputValue) {
|
||||
setInputValue(value);
|
||||
}
|
||||
}, [value, inputValue]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = String(e.target.value);
|
||||
|
@ -329,6 +350,17 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
</EuiFormRow>
|
||||
) : null}
|
||||
|
||||
{!currentFieldIsInvalid && !incompatibleSelectedOperationType && selectedColumn && (
|
||||
<TimeScaling
|
||||
selectedColumn={selectedColumn}
|
||||
columnId={columnId}
|
||||
layer={state.layers[layerId]}
|
||||
updateLayer={(newLayer: IndexPatternLayer) =>
|
||||
setState(mergeLayer({ layerId, state, newLayer }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!currentFieldIsInvalid &&
|
||||
!incompatibleSelectedOperationType &&
|
||||
selectedColumn &&
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { ReactWrapper, ShallowWrapper } from 'enzyme';
|
||||
import React from 'react';
|
||||
import React, { ChangeEvent, MouseEvent } from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EuiComboBox, EuiListGroupItemProps, EuiListGroup, EuiRange } from '@elastic/eui';
|
||||
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
|
||||
|
@ -22,6 +22,10 @@ import { documentField } from '../document_field';
|
|||
import { OperationMetadata } from '../../types';
|
||||
import { DateHistogramIndexPatternColumn } from '../operations/definitions/date_histogram';
|
||||
import { getFieldByNameFactory } from '../pure_helpers';
|
||||
import { TimeScaling } from './time_scaling';
|
||||
import { EuiSelect } from '@elastic/eui';
|
||||
import { EuiButtonIcon } from '@elastic/eui';
|
||||
import { DimensionEditor } from './dimension_editor';
|
||||
|
||||
jest.mock('../loader');
|
||||
jest.mock('../operations');
|
||||
|
@ -111,7 +115,10 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
let defaultProps: IndexPatternDimensionEditorProps;
|
||||
|
||||
function getStateWithColumns(columns: Record<string, IndexPatternColumn>) {
|
||||
return { ...state, layers: { first: { ...state.layers.first, columns } } };
|
||||
return {
|
||||
...state,
|
||||
layers: { first: { ...state.layers.first, columns, columnOrder: Object.keys(columns) } },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -785,6 +792,226 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('time scaling', () => {
|
||||
function getProps(colOverrides: Partial<IndexPatternColumn>) {
|
||||
return {
|
||||
...defaultProps,
|
||||
state: getStateWithColumns({
|
||||
datecolumn: {
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '',
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'ts',
|
||||
params: {
|
||||
interval: '1d',
|
||||
},
|
||||
},
|
||||
col2: {
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
label: 'Count of records',
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
...colOverrides,
|
||||
} as IndexPatternColumn,
|
||||
}),
|
||||
columnId: 'col2',
|
||||
};
|
||||
}
|
||||
it('should not show custom options if time scaling is not available', () => {
|
||||
wrapper = mount(
|
||||
<IndexPatternDimensionEditorComponent
|
||||
{...getProps({
|
||||
operationType: 'avg',
|
||||
sourceField: 'bytes',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="indexPattern-time-scaling"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should show custom options if time scaling is available', () => {
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...getProps({})} />);
|
||||
expect(
|
||||
wrapper
|
||||
.find(TimeScaling)
|
||||
.find('[data-test-subj="indexPattern-time-scaling-popover"]')
|
||||
.exists()
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should show current time scaling if set', () => {
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...getProps({ timeScale: 'd' })} />);
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="indexPattern-time-scaling-unit"]')
|
||||
.find(EuiSelect)
|
||||
.prop('value')
|
||||
).toEqual('d');
|
||||
});
|
||||
|
||||
it('should allow to set time scaling initially', () => {
|
||||
const props = getProps({});
|
||||
wrapper = shallow(<IndexPatternDimensionEditorComponent {...props} />);
|
||||
wrapper
|
||||
.find(DimensionEditor)
|
||||
.dive()
|
||||
.find(TimeScaling)
|
||||
.dive()
|
||||
.find('[data-test-subj="indexPattern-time-scaling-enable"]')
|
||||
.prop('onClick')!({} as MouseEvent);
|
||||
expect(props.setState).toHaveBeenCalledWith({
|
||||
...props.state,
|
||||
layers: {
|
||||
first: {
|
||||
...props.state.layers.first,
|
||||
columns: {
|
||||
...props.state.layers.first.columns,
|
||||
col2: expect.objectContaining({
|
||||
timeScale: 's',
|
||||
label: 'Count of records per second',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should carry over time scaling to other operation if possible', () => {
|
||||
const props = getProps({
|
||||
timeScale: 'h',
|
||||
sourceField: 'bytes',
|
||||
operationType: 'sum',
|
||||
label: 'Sum of bytes per hour',
|
||||
});
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...props} />);
|
||||
wrapper
|
||||
.find('button[data-test-subj="lns-indexPatternDimension-count incompatible"]')
|
||||
.simulate('click');
|
||||
expect(props.setState).toHaveBeenCalledWith({
|
||||
...props.state,
|
||||
layers: {
|
||||
first: {
|
||||
...props.state.layers.first,
|
||||
columns: {
|
||||
...props.state.layers.first.columns,
|
||||
col2: expect.objectContaining({
|
||||
timeScale: 'h',
|
||||
label: 'Count of records per hour',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not carry over time scaling if the other operation does not support it', () => {
|
||||
const props = getProps({
|
||||
timeScale: 'h',
|
||||
sourceField: 'bytes',
|
||||
operationType: 'sum',
|
||||
label: 'Sum of bytes per hour',
|
||||
});
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...props} />);
|
||||
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
|
||||
expect(props.setState).toHaveBeenCalledWith({
|
||||
...props.state,
|
||||
layers: {
|
||||
first: {
|
||||
...props.state.layers.first,
|
||||
columns: {
|
||||
...props.state.layers.first.columns,
|
||||
col2: expect.objectContaining({
|
||||
timeScale: undefined,
|
||||
label: 'Average of bytes',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow to change time scaling', () => {
|
||||
const props = getProps({ timeScale: 's', label: 'Count of records per second' });
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...props} />);
|
||||
wrapper
|
||||
.find('[data-test-subj="indexPattern-time-scaling-unit"]')
|
||||
.find(EuiSelect)
|
||||
.prop('onChange')!(({
|
||||
target: { value: 'h' },
|
||||
} as unknown) as ChangeEvent<HTMLSelectElement>);
|
||||
expect(props.setState).toHaveBeenCalledWith({
|
||||
...props.state,
|
||||
layers: {
|
||||
first: {
|
||||
...props.state.layers.first,
|
||||
columns: {
|
||||
...props.state.layers.first.columns,
|
||||
col2: expect.objectContaining({
|
||||
timeScale: 'h',
|
||||
label: 'Count of records per hour',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not adjust label if it is custom', () => {
|
||||
const props = getProps({ timeScale: 's', customLabel: true, label: 'My label' });
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...props} />);
|
||||
wrapper
|
||||
.find('[data-test-subj="indexPattern-time-scaling-unit"]')
|
||||
.find(EuiSelect)
|
||||
.prop('onChange')!(({
|
||||
target: { value: 'h' },
|
||||
} as unknown) as ChangeEvent<HTMLSelectElement>);
|
||||
expect(props.setState).toHaveBeenCalledWith({
|
||||
...props.state,
|
||||
layers: {
|
||||
first: {
|
||||
...props.state.layers.first,
|
||||
columns: {
|
||||
...props.state.layers.first.columns,
|
||||
col2: expect.objectContaining({
|
||||
timeScale: 'h',
|
||||
label: 'My label',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow to remove time scaling', () => {
|
||||
const props = getProps({ timeScale: 's', label: 'Count of records per second' });
|
||||
wrapper = mount(<IndexPatternDimensionEditorComponent {...props} />);
|
||||
wrapper
|
||||
.find('[data-test-subj="indexPattern-time-scaling-remove"]')
|
||||
.find(EuiButtonIcon)
|
||||
.prop('onClick')!(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{} as any
|
||||
);
|
||||
expect(props.setState).toHaveBeenCalledWith({
|
||||
...props.state,
|
||||
layers: {
|
||||
first: {
|
||||
...props.state.layers.first,
|
||||
columns: {
|
||||
...props.state.layers.first.columns,
|
||||
col2: expect.objectContaining({
|
||||
timeScale: undefined,
|
||||
label: 'Count of records',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render invalid field if field reference is broken', () => {
|
||||
wrapper = mount(
|
||||
<IndexPatternDimensionEditorComponent
|
||||
|
@ -1024,7 +1251,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
|
||||
act(() => {
|
||||
wrapper.find('[data-test-subj="lns-indexPatternDimension-min"]').first().prop('onClick')!(
|
||||
{} as React.MouseEvent<{}, MouseEvent>
|
||||
{} as MouseEvent
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
import { EuiIcon } from '@elastic/eui';
|
||||
import {
|
||||
EuiLink,
|
||||
EuiFormRow,
|
||||
EuiSelect,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiButtonIcon,
|
||||
EuiText,
|
||||
EuiPopover,
|
||||
EuiButtonEmpty,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
adjustTimeScaleLabelSuffix,
|
||||
DEFAULT_TIME_SCALE,
|
||||
IndexPatternColumn,
|
||||
operationDefinitionMap,
|
||||
} from '../operations';
|
||||
import { unitSuffixesLong } from '../suffix_formatter';
|
||||
import { TimeScaleUnit } from '../time_scale';
|
||||
import { IndexPatternLayer } from '../types';
|
||||
|
||||
export function setTimeScaling(
|
||||
columnId: string,
|
||||
layer: IndexPatternLayer,
|
||||
timeScale: TimeScaleUnit | undefined
|
||||
) {
|
||||
const currentColumn = layer.columns[columnId];
|
||||
const label = currentColumn.customLabel
|
||||
? currentColumn.label
|
||||
: adjustTimeScaleLabelSuffix(currentColumn.label, currentColumn.timeScale, timeScale);
|
||||
return {
|
||||
...layer,
|
||||
columns: {
|
||||
...layer.columns,
|
||||
[columnId]: {
|
||||
...layer.columns[columnId],
|
||||
label,
|
||||
timeScale,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function TimeScaling({
|
||||
selectedColumn,
|
||||
columnId,
|
||||
layer,
|
||||
updateLayer,
|
||||
}: {
|
||||
selectedColumn: IndexPatternColumn;
|
||||
columnId: string;
|
||||
layer: IndexPatternLayer;
|
||||
updateLayer: (newLayer: IndexPatternLayer) => void;
|
||||
}) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const hasDateHistogram = layer.columnOrder.some(
|
||||
(colId) => layer.columns[colId].operationType === 'date_histogram'
|
||||
);
|
||||
const selectedOperation = operationDefinitionMap[selectedColumn.operationType];
|
||||
if (
|
||||
!selectedOperation.timeScalingMode ||
|
||||
selectedOperation.timeScalingMode === 'disabled' ||
|
||||
!hasDateHistogram
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!selectedColumn.timeScale) {
|
||||
return (
|
||||
<EuiText textAlign="right">
|
||||
<EuiSpacer size="s" />
|
||||
<EuiPopover
|
||||
ownFocus
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
data-test-subj="indexPattern-time-scaling-popover"
|
||||
onClick={() => {
|
||||
setPopoverOpen(true);
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.lens.indexPattern.timeScale.advancedSettings', {
|
||||
defaultMessage: 'Add advanced options',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={popoverOpen}
|
||||
closePopover={() => {
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<EuiText size="s">
|
||||
<EuiLink
|
||||
data-test-subj="indexPattern-time-scaling-enable"
|
||||
color="text"
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
updateLayer(setTimeScaling(columnId, layer, DEFAULT_TIME_SCALE));
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.lens.indexPattern.timeScale.enableTimeScale', {
|
||||
defaultMessage: 'Normalize by unit',
|
||||
})}
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
</EuiPopover>
|
||||
</EuiText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
display="columnCompressed"
|
||||
fullWidth
|
||||
label={
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.lens.indexPattern.timeScale.tooltip', {
|
||||
defaultMessage:
|
||||
'Normalize values to be always shown as rate per specified time unit, regardless of the underlying date interval.',
|
||||
})}
|
||||
>
|
||||
<span>
|
||||
{i18n.translate('xpack.lens.indexPattern.timeScale.label', {
|
||||
defaultMessage: 'Normalize by unit',
|
||||
})}{' '}
|
||||
<EuiIcon type="questionInCircle" color="subdued" size="s" className="eui-alignTop" />
|
||||
</span>
|
||||
</EuiToolTip>
|
||||
}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem>
|
||||
<EuiSelect
|
||||
compressed
|
||||
options={Object.entries(unitSuffixesLong).map(([unit, text]) => ({
|
||||
value: unit,
|
||||
text,
|
||||
}))}
|
||||
data-test-subj="indexPattern-time-scaling-unit"
|
||||
value={selectedColumn.timeScale}
|
||||
onChange={(e) => {
|
||||
updateLayer(setTimeScaling(columnId, layer, e.target.value as TimeScaleUnit));
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{selectedOperation.timeScalingMode === 'optional' && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="indexPattern-time-scaling-remove"
|
||||
color="danger"
|
||||
aria-label={i18n.translate('xpack.lens.timeScale.removeLabel', {
|
||||
defaultMessage: 'Remove normalizing by time unit',
|
||||
})}
|
||||
onClick={() => {
|
||||
updateLayer(setTimeScaling(columnId, layer, undefined));
|
||||
}}
|
||||
iconType="cross"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
|
@ -407,6 +407,86 @@ describe('IndexPattern Data Source', () => {
|
|||
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
|
||||
});
|
||||
|
||||
it('should add time_scale and format function if time scale is set and supported', async () => {
|
||||
const queryBaseState: IndexPatternBaseState = {
|
||||
currentIndexPatternId: '1',
|
||||
layers: {
|
||||
first: {
|
||||
indexPatternId: '1',
|
||||
columnOrder: ['col1', 'col2', 'col3'],
|
||||
columns: {
|
||||
col1: {
|
||||
label: 'Count of records',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'Records',
|
||||
operationType: 'count',
|
||||
timeScale: 'h',
|
||||
},
|
||||
col2: {
|
||||
label: 'Average of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
sourceField: 'bytes',
|
||||
operationType: 'avg',
|
||||
timeScale: 'h',
|
||||
},
|
||||
col3: {
|
||||
label: 'Date',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
operationType: 'date_histogram',
|
||||
sourceField: 'timestamp',
|
||||
params: {
|
||||
interval: 'auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = enrichBaseState(queryBaseState);
|
||||
|
||||
const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
|
||||
const timeScaleCalls = ast.chain.filter((fn) => fn.function === 'lens_time_scale');
|
||||
const formatCalls = ast.chain.filter((fn) => fn.function === 'lens_format_column');
|
||||
expect(timeScaleCalls).toHaveLength(1);
|
||||
expect(timeScaleCalls[0].arguments).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"dateColumnId": Array [
|
||||
"col3",
|
||||
],
|
||||
"inputColumnId": Array [
|
||||
"col1",
|
||||
],
|
||||
"outputColumnId": Array [
|
||||
"col1",
|
||||
],
|
||||
"targetUnit": Array [
|
||||
"h",
|
||||
],
|
||||
}
|
||||
`);
|
||||
expect(formatCalls[0]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"arguments": Object {
|
||||
"columnId": Array [
|
||||
"col1",
|
||||
],
|
||||
"format": Array [
|
||||
"",
|
||||
],
|
||||
"parentFormat": Array [
|
||||
"{\\"id\\":\\"suffix\\",\\"params\\":{\\"unit\\":\\"h\\"}}",
|
||||
],
|
||||
},
|
||||
"function": "lens_format_column",
|
||||
"type": "function",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should rename the output from esaggs when using flat query', () => {
|
||||
const queryBaseState: IndexPatternBaseState = {
|
||||
currentIndexPatternId: '1',
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
const actualOperations = jest.requireActual('../operations');
|
||||
const actualHelpers = jest.requireActual('../layer_helpers');
|
||||
const actualTimeScaleUtils = jest.requireActual('../time_scale_utils');
|
||||
const actualMocks = jest.requireActual('../mocks');
|
||||
|
||||
jest.spyOn(actualOperations.operationDefinitionMap.date_histogram, 'paramEditor');
|
||||
|
@ -41,4 +42,6 @@ export const {
|
|||
isReferenced,
|
||||
} = actualHelpers;
|
||||
|
||||
export const { adjustTimeScaleLabelSuffix, DEFAULT_TIME_SCALE } = actualTimeScaleUtils;
|
||||
|
||||
export const { createMockedReferenceOperation } = actualMocks;
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
sumOperation,
|
||||
averageOperation,
|
||||
countOperation,
|
||||
counterRateOperation,
|
||||
movingAverageOperation,
|
||||
derivativeOperation,
|
||||
} from './definitions';
|
||||
import { getFieldByNameFactory } from '../pure_helpers';
|
||||
import { documentField } from '../document_field';
|
||||
import { IndexPattern, IndexPatternLayer, IndexPatternField } from '../types';
|
||||
import { IndexPatternColumn } from '.';
|
||||
|
||||
const indexPatternFields = [
|
||||
{
|
||||
name: 'timestamp',
|
||||
displayName: 'timestampLabel',
|
||||
type: 'date',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'start_date',
|
||||
displayName: 'start_date',
|
||||
type: 'date',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'bytes',
|
||||
displayName: 'bytes',
|
||||
type: 'number',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
displayName: 'memory',
|
||||
type: 'number',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
displayName: 'source',
|
||||
type: 'string',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'dest',
|
||||
displayName: 'dest',
|
||||
type: 'string',
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
documentField,
|
||||
];
|
||||
|
||||
const indexPattern = {
|
||||
id: '1',
|
||||
title: 'my-fake-index-pattern',
|
||||
timeFieldName: 'timestamp',
|
||||
hasRestrictions: false,
|
||||
fields: indexPatternFields,
|
||||
getFieldByName: getFieldByNameFactory([...indexPatternFields]),
|
||||
};
|
||||
|
||||
const baseColumnArgs: {
|
||||
previousColumn: IndexPatternColumn;
|
||||
indexPattern: IndexPattern;
|
||||
layer: IndexPatternLayer;
|
||||
field: IndexPatternField;
|
||||
} = {
|
||||
previousColumn: {
|
||||
label: 'Count of records per hour',
|
||||
timeScale: 'h',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// Private
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
},
|
||||
indexPattern,
|
||||
layer: {
|
||||
columns: {},
|
||||
columnOrder: [],
|
||||
indexPatternId: '1',
|
||||
},
|
||||
field: indexPattern.fields[2],
|
||||
};
|
||||
|
||||
describe('time scale transition', () => {
|
||||
it('should carry over time scale and adjust label on operation from count to sum', () => {
|
||||
expect(
|
||||
sumOperation.buildColumn({
|
||||
...baseColumnArgs,
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
timeScale: 'h',
|
||||
label: 'Sum of bytes per hour',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should carry over time scale and adjust label on operation from count to calculation', () => {
|
||||
[counterRateOperation, movingAverageOperation, derivativeOperation].forEach(
|
||||
(calculationOperation) => {
|
||||
const result = calculationOperation.buildColumn({
|
||||
...baseColumnArgs,
|
||||
referenceIds: [],
|
||||
});
|
||||
expect(result.timeScale).toEqual('h');
|
||||
expect(result.label).toContain('per hour');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should carry over time scale and adjust label on operation from sum to count', () => {
|
||||
expect(
|
||||
countOperation.buildColumn({
|
||||
...baseColumnArgs,
|
||||
previousColumn: {
|
||||
label: 'Sum of bytes per hour',
|
||||
timeScale: 'h',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'sum',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
timeScale: 'h',
|
||||
label: 'Count of records per hour',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set time scale if it was not set previously', () => {
|
||||
expect(
|
||||
countOperation.buildColumn({
|
||||
...baseColumnArgs,
|
||||
previousColumn: {
|
||||
label: 'Sum of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
operationType: 'sum',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
timeScale: undefined,
|
||||
label: 'Count of records',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set time scale to default for counter rate', () => {
|
||||
expect(
|
||||
counterRateOperation.buildColumn({
|
||||
indexPattern,
|
||||
layer: {
|
||||
columns: {},
|
||||
columnOrder: [],
|
||||
indexPatternId: '1',
|
||||
},
|
||||
referenceIds: [],
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
timeScale: 's',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should adjust label on field change', () => {
|
||||
expect(
|
||||
sumOperation.onFieldChange(
|
||||
{
|
||||
label: 'Sum of bytes per hour',
|
||||
timeScale: 'h',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// Private
|
||||
operationType: 'sum',
|
||||
sourceField: 'bytes',
|
||||
},
|
||||
indexPattern.fields[3]
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
timeScale: 'h',
|
||||
label: 'Sum of memory per hour',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not carry over time scale if target does not support time scaling', () => {
|
||||
const result = averageOperation.buildColumn({
|
||||
...baseColumnArgs,
|
||||
});
|
||||
expect(result.timeScale).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -7,10 +7,16 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
|
||||
import { IndexPatternLayer } from '../../../types';
|
||||
import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils';
|
||||
import {
|
||||
buildLabelFunction,
|
||||
checkForDateHistogram,
|
||||
dateBasedOperationToExpression,
|
||||
hasDateField,
|
||||
} from './utils';
|
||||
import { DEFAULT_TIME_SCALE } from '../../time_scale_utils';
|
||||
import { OperationDefinition } from '..';
|
||||
|
||||
const ofName = (name?: string) => {
|
||||
const ofName = buildLabelFunction((name?: string) => {
|
||||
return i18n.translate('xpack.lens.indexPattern.CounterRateOf', {
|
||||
defaultMessage: 'Counter rate of {name}',
|
||||
values: {
|
||||
|
@ -21,7 +27,7 @@ const ofName = (name?: string) => {
|
|||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
export type CounterRateIndexPatternColumn = FormattedIndexPatternColumn &
|
||||
ReferenceBasedIndexPatternColumn & {
|
||||
|
@ -54,20 +60,22 @@ export const counterRateOperation: OperationDefinition<
|
|||
};
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) => {
|
||||
return ofName(columns[column.references[0]]?.label);
|
||||
return ofName(columns[column.references[0]]?.label, column.timeScale);
|
||||
},
|
||||
toExpression: (layer, columnId) => {
|
||||
return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate');
|
||||
},
|
||||
buildColumn: ({ referenceIds, previousColumn, layer }) => {
|
||||
const metric = layer.columns[referenceIds[0]];
|
||||
const timeScale = previousColumn?.timeScale || DEFAULT_TIME_SCALE;
|
||||
return {
|
||||
label: ofName(metric?.label),
|
||||
label: ofName(metric?.label, timeScale),
|
||||
dataType: 'number',
|
||||
operationType: 'counter_rate',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
references: referenceIds,
|
||||
timeScale,
|
||||
params:
|
||||
previousColumn?.dataType === 'number' &&
|
||||
previousColumn.params &&
|
||||
|
@ -88,4 +96,5 @@ export const counterRateOperation: OperationDefinition<
|
|||
})
|
||||
);
|
||||
},
|
||||
timeScalingMode: 'mandatory',
|
||||
};
|
||||
|
|
|
@ -7,10 +7,16 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
|
||||
import { IndexPatternLayer } from '../../../types';
|
||||
import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils';
|
||||
import {
|
||||
buildLabelFunction,
|
||||
checkForDateHistogram,
|
||||
dateBasedOperationToExpression,
|
||||
hasDateField,
|
||||
} from './utils';
|
||||
import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils';
|
||||
import { OperationDefinition } from '..';
|
||||
|
||||
const ofName = (name?: string) => {
|
||||
const ofName = buildLabelFunction((name?: string) => {
|
||||
return i18n.translate('xpack.lens.indexPattern.derivativeOf', {
|
||||
defaultMessage: 'Differences of {name}',
|
||||
values: {
|
||||
|
@ -21,7 +27,7 @@ const ofName = (name?: string) => {
|
|||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
export type DerivativeIndexPatternColumn = FormattedIndexPatternColumn &
|
||||
ReferenceBasedIndexPatternColumn & {
|
||||
|
@ -53,7 +59,7 @@ export const derivativeOperation: OperationDefinition<
|
|||
};
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) => {
|
||||
return ofName(columns[column.references[0]]?.label);
|
||||
return ofName(columns[column.references[0]]?.label, column.timeScale);
|
||||
},
|
||||
toExpression: (layer, columnId) => {
|
||||
return dateBasedOperationToExpression(layer, columnId, 'derivative');
|
||||
|
@ -61,12 +67,13 @@ export const derivativeOperation: OperationDefinition<
|
|||
buildColumn: ({ referenceIds, previousColumn, layer }) => {
|
||||
const metric = layer.columns[referenceIds[0]];
|
||||
return {
|
||||
label: ofName(metric?.label),
|
||||
label: ofName(metric?.label, previousColumn?.timeScale),
|
||||
dataType: 'number',
|
||||
operationType: 'derivative',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
references: referenceIds,
|
||||
timeScale: previousColumn?.timeScale,
|
||||
params:
|
||||
previousColumn?.dataType === 'number' &&
|
||||
previousColumn.params &&
|
||||
|
@ -79,6 +86,7 @@ export const derivativeOperation: OperationDefinition<
|
|||
isTransferable: (column, newIndexPattern) => {
|
||||
return hasDateField(newIndexPattern);
|
||||
},
|
||||
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
|
||||
getErrorMessage: (layer: IndexPatternLayer) => {
|
||||
return checkForDateHistogram(
|
||||
layer,
|
||||
|
@ -87,4 +95,5 @@ export const derivativeOperation: OperationDefinition<
|
|||
})
|
||||
);
|
||||
},
|
||||
timeScalingMode: 'optional',
|
||||
};
|
||||
|
|
|
@ -11,12 +11,18 @@ import { EuiFormRow } from '@elastic/eui';
|
|||
import { EuiFieldNumber } from '@elastic/eui';
|
||||
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
|
||||
import { IndexPatternLayer } from '../../../types';
|
||||
import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils';
|
||||
import {
|
||||
buildLabelFunction,
|
||||
checkForDateHistogram,
|
||||
dateBasedOperationToExpression,
|
||||
hasDateField,
|
||||
} from './utils';
|
||||
import { updateColumnParam } from '../../layer_helpers';
|
||||
import { useDebounceWithOptions } from '../helpers';
|
||||
import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils';
|
||||
import type { OperationDefinition, ParamEditorProps } from '..';
|
||||
|
||||
const ofName = (name?: string) => {
|
||||
const ofName = buildLabelFunction((name?: string) => {
|
||||
return i18n.translate('xpack.lens.indexPattern.movingAverageOf', {
|
||||
defaultMessage: 'Moving average of {name}',
|
||||
values: {
|
||||
|
@ -27,7 +33,7 @@ const ofName = (name?: string) => {
|
|||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
export type MovingAverageIndexPatternColumn = FormattedIndexPatternColumn &
|
||||
ReferenceBasedIndexPatternColumn & {
|
||||
|
@ -62,7 +68,7 @@ export const movingAverageOperation: OperationDefinition<
|
|||
};
|
||||
},
|
||||
getDefaultLabel: (column, indexPattern, columns) => {
|
||||
return ofName(columns[column.references[0]]?.label);
|
||||
return ofName(columns[column.references[0]]?.label, column.timeScale);
|
||||
},
|
||||
toExpression: (layer, columnId) => {
|
||||
return dateBasedOperationToExpression(layer, columnId, 'moving_average', {
|
||||
|
@ -72,12 +78,13 @@ export const movingAverageOperation: OperationDefinition<
|
|||
buildColumn: ({ referenceIds, previousColumn, layer }) => {
|
||||
const metric = layer.columns[referenceIds[0]];
|
||||
return {
|
||||
label: ofName(metric?.label),
|
||||
label: ofName(metric?.label, previousColumn?.timeScale),
|
||||
dataType: 'number',
|
||||
operationType: 'moving_average',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
references: referenceIds,
|
||||
timeScale: previousColumn?.timeScale,
|
||||
params:
|
||||
previousColumn?.dataType === 'number' &&
|
||||
previousColumn.params &&
|
||||
|
@ -91,6 +98,7 @@ export const movingAverageOperation: OperationDefinition<
|
|||
isTransferable: (column, newIndexPattern) => {
|
||||
return hasDateField(newIndexPattern);
|
||||
},
|
||||
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
|
||||
getErrorMessage: (layer: IndexPatternLayer) => {
|
||||
return checkForDateHistogram(
|
||||
layer,
|
||||
|
@ -99,6 +107,7 @@ export const movingAverageOperation: OperationDefinition<
|
|||
})
|
||||
);
|
||||
},
|
||||
timeScalingMode: 'optional',
|
||||
};
|
||||
|
||||
function MovingAverageParamEditor({
|
||||
|
|
|
@ -6,9 +6,19 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ExpressionFunctionAST } from '@kbn/interpreter/common';
|
||||
import { TimeScaleUnit } from '../../../time_scale';
|
||||
import { IndexPattern, IndexPatternLayer } from '../../../types';
|
||||
import { adjustTimeScaleLabelSuffix } from '../../time_scale_utils';
|
||||
import { ReferenceBasedIndexPatternColumn } from '../column_types';
|
||||
|
||||
export const buildLabelFunction = (ofName: (name?: string) => string) => (
|
||||
name?: string,
|
||||
timeScale?: TimeScaleUnit
|
||||
) => {
|
||||
const rawLabel = ofName(name);
|
||||
return adjustTimeScaleLabelSuffix(rawLabel, undefined, timeScale);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether the current layer includes a date histogram and returns an error otherwise
|
||||
*/
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
*/
|
||||
|
||||
import type { Operation } from '../../../types';
|
||||
import { TimeScaleUnit } from '../../time_scale';
|
||||
|
||||
export interface BaseIndexPatternColumn extends Operation {
|
||||
// Private
|
||||
operationType: string;
|
||||
customLabel?: boolean;
|
||||
timeScale?: TimeScaleUnit;
|
||||
}
|
||||
|
||||
// Formatting can optionally be added to any column
|
||||
|
|
|
@ -8,6 +8,10 @@ import { i18n } from '@kbn/i18n';
|
|||
import { OperationDefinition } from './index';
|
||||
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
|
||||
import { IndexPatternField } from '../../types';
|
||||
import {
|
||||
adjustTimeScaleLabelSuffix,
|
||||
adjustTimeScaleOnOtherColumnChange,
|
||||
} from '../time_scale_utils';
|
||||
|
||||
const countLabel = i18n.translate('xpack.lens.indexPattern.countOf', {
|
||||
defaultMessage: 'Count of records',
|
||||
|
@ -28,7 +32,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
|
|||
onFieldChange: (oldColumn, field) => {
|
||||
return {
|
||||
...oldColumn,
|
||||
label: field.displayName,
|
||||
label: adjustTimeScaleLabelSuffix(field.displayName, undefined, oldColumn.timeScale),
|
||||
sourceField: field.name,
|
||||
};
|
||||
},
|
||||
|
@ -41,15 +45,16 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
|
|||
};
|
||||
}
|
||||
},
|
||||
getDefaultLabel: () => countLabel,
|
||||
getDefaultLabel: (column) => adjustTimeScaleLabelSuffix(countLabel, undefined, column.timeScale),
|
||||
buildColumn({ field, previousColumn }) {
|
||||
return {
|
||||
label: countLabel,
|
||||
label: adjustTimeScaleLabelSuffix(countLabel, undefined, previousColumn?.timeScale),
|
||||
dataType: 'number',
|
||||
operationType: 'count',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
sourceField: field.name,
|
||||
timeScale: previousColumn?.timeScale,
|
||||
params:
|
||||
previousColumn?.dataType === 'number' &&
|
||||
previousColumn.params &&
|
||||
|
@ -59,6 +64,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
|
|||
: undefined,
|
||||
};
|
||||
},
|
||||
onOtherColumnChanged: adjustTimeScaleOnOtherColumnChange,
|
||||
toEsAggsConfig: (column, columnId) => ({
|
||||
id: columnId,
|
||||
enabled: true,
|
||||
|
@ -69,4 +75,5 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field
|
|||
isTransferable: () => {
|
||||
return true;
|
||||
},
|
||||
timeScalingMode: 'optional',
|
||||
};
|
||||
|
|
|
@ -99,6 +99,12 @@ export { filtersOperation } from './filters';
|
|||
export { dateHistogramOperation } from './date_histogram';
|
||||
export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics';
|
||||
export { countOperation } from './count';
|
||||
export {
|
||||
cumulativeSumOperation,
|
||||
counterRateOperation,
|
||||
derivativeOperation,
|
||||
movingAverageOperation,
|
||||
} from './calculations';
|
||||
|
||||
/**
|
||||
* Properties passed to the operation-specific part of the popover editor
|
||||
|
@ -117,6 +123,8 @@ export interface ParamEditorProps<C> {
|
|||
data: DataPublicPluginStart;
|
||||
}
|
||||
|
||||
export type TimeScalingMode = 'disabled' | 'mandatory' | 'optional';
|
||||
|
||||
interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
|
||||
type: C['operationType'];
|
||||
/**
|
||||
|
@ -164,6 +172,13 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
|
|||
* present on the new index pattern.
|
||||
*/
|
||||
transfer?: (column: C, newIndexPattern: IndexPattern) => C;
|
||||
/**
|
||||
* Flag whether this operation can be scaled by time unit if a date histogram is available.
|
||||
* If set to mandatory or optional, a UI element is shown in the config flyout to configure the time unit
|
||||
* to scale by. The chosen unit will be persisted as `timeScale` property of the column.
|
||||
* If set to optional, time scaling won't be enabled by default and can be removed.
|
||||
*/
|
||||
timeScalingMode?: TimeScalingMode;
|
||||
}
|
||||
|
||||
interface BaseBuildColumnArgs {
|
||||
|
|
|
@ -6,7 +6,15 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { OperationDefinition } from './index';
|
||||
import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
|
||||
import {
|
||||
FormattedIndexPatternColumn,
|
||||
FieldBasedIndexPatternColumn,
|
||||
BaseIndexPatternColumn,
|
||||
} from './column_types';
|
||||
import {
|
||||
adjustTimeScaleLabelSuffix,
|
||||
adjustTimeScaleOnOtherColumnChange,
|
||||
} from '../time_scale_utils';
|
||||
|
||||
type MetricColumn<T> = FormattedIndexPatternColumn &
|
||||
FieldBasedIndexPatternColumn & {
|
||||
|
@ -18,17 +26,28 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
displayName,
|
||||
ofName,
|
||||
priority,
|
||||
optionalTimeScaling,
|
||||
}: {
|
||||
type: T['operationType'];
|
||||
displayName: string;
|
||||
ofName: (name: string) => string;
|
||||
priority?: number;
|
||||
optionalTimeScaling?: boolean;
|
||||
}) {
|
||||
const labelLookup = (name: string, column?: BaseIndexPatternColumn) => {
|
||||
const rawLabel = ofName(name);
|
||||
if (!optionalTimeScaling) {
|
||||
return rawLabel;
|
||||
}
|
||||
return adjustTimeScaleLabelSuffix(rawLabel, undefined, column?.timeScale);
|
||||
};
|
||||
|
||||
return {
|
||||
type,
|
||||
priority,
|
||||
displayName,
|
||||
input: 'field',
|
||||
timeScalingMode: optionalTimeScaling ? 'optional' : undefined,
|
||||
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => {
|
||||
if (
|
||||
fieldType === 'number' &&
|
||||
|
@ -52,22 +71,25 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
(!newField.aggregationRestrictions || newField.aggregationRestrictions![type])
|
||||
);
|
||||
},
|
||||
onOtherColumnChanged: (column, otherColumns) =>
|
||||
optionalTimeScaling ? adjustTimeScaleOnOtherColumnChange(column, otherColumns) : column,
|
||||
getDefaultLabel: (column, indexPattern, columns) =>
|
||||
ofName(indexPattern.getFieldByName(column.sourceField)!.displayName),
|
||||
labelLookup(indexPattern.getFieldByName(column.sourceField)!.displayName, column),
|
||||
buildColumn: ({ field, previousColumn }) => ({
|
||||
label: ofName(field.displayName),
|
||||
label: labelLookup(field.displayName, previousColumn),
|
||||
dataType: 'number',
|
||||
operationType: type,
|
||||
sourceField: field.name,
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
timeScale: optionalTimeScaling ? previousColumn?.timeScale : undefined,
|
||||
params:
|
||||
previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined,
|
||||
}),
|
||||
onFieldChange: (oldColumn, field) => {
|
||||
return {
|
||||
...oldColumn,
|
||||
label: ofName(field.displayName),
|
||||
label: labelLookup(field.displayName, oldColumn),
|
||||
sourceField: field.name,
|
||||
};
|
||||
},
|
||||
|
@ -138,6 +160,7 @@ export const sumOperation = buildMetricOperation<SumIndexPatternColumn>({
|
|||
defaultMessage: 'Sum of {name}',
|
||||
values: { name },
|
||||
}),
|
||||
optionalTimeScaling: true,
|
||||
});
|
||||
|
||||
export const medianOperation = buildMetricOperation<MedianIndexPatternColumn>({
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
export * from './operations';
|
||||
export * from './layer_helpers';
|
||||
export * from './time_scale_utils';
|
||||
export {
|
||||
OperationType,
|
||||
IndexPatternColumn,
|
||||
|
|
|
@ -22,6 +22,7 @@ import type {
|
|||
import { getSortScoreByPriority } from './operations';
|
||||
import { mergeLayer } from '../state_helpers';
|
||||
import { generateId } from '../../id_generator';
|
||||
import { ReferenceBasedIndexPatternColumn } from './definitions/column_types';
|
||||
|
||||
interface ColumnChange {
|
||||
op: OperationType;
|
||||
|
@ -208,8 +209,7 @@ export function replaceColumn({
|
|||
let tempLayer = { ...layer };
|
||||
|
||||
if (previousDefinition.input === 'fullReference') {
|
||||
// @ts-expect-error references are not statically analyzed
|
||||
previousColumn.references.forEach((id: string) => {
|
||||
(previousColumn as ReferenceBasedIndexPatternColumn).references.forEach((id: string) => {
|
||||
tempLayer = deleteColumn({ layer: tempLayer, columnId: id });
|
||||
});
|
||||
}
|
||||
|
@ -237,11 +237,8 @@ export function replaceColumn({
|
|||
}
|
||||
|
||||
if (operationDefinition.input === 'none') {
|
||||
const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer });
|
||||
if (previousColumn.customLabel) {
|
||||
newColumn.customLabel = true;
|
||||
newColumn.label = previousColumn.label;
|
||||
}
|
||||
let newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer });
|
||||
newColumn = adjustLabel(newColumn, previousColumn);
|
||||
|
||||
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
|
||||
return {
|
||||
|
@ -255,12 +252,8 @@ export function replaceColumn({
|
|||
throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`);
|
||||
}
|
||||
|
||||
const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, field });
|
||||
|
||||
if (previousColumn.customLabel) {
|
||||
newColumn.customLabel = true;
|
||||
newColumn.label = previousColumn.label;
|
||||
}
|
||||
let newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, field });
|
||||
newColumn = adjustLabel(newColumn, previousColumn);
|
||||
|
||||
const newColumns = { ...tempLayer.columns, [columnId]: newColumn };
|
||||
return {
|
||||
|
@ -277,12 +270,7 @@ export function replaceColumn({
|
|||
// Same operation, new field
|
||||
const newColumn = operationDefinition.onFieldChange(previousColumn, field);
|
||||
|
||||
if (previousColumn.customLabel) {
|
||||
newColumn.customLabel = true;
|
||||
newColumn.label = previousColumn.label;
|
||||
}
|
||||
|
||||
const newColumns = { ...layer.columns, [columnId]: newColumn };
|
||||
const newColumns = { ...layer.columns, [columnId]: adjustLabel(newColumn, previousColumn) };
|
||||
return {
|
||||
...layer,
|
||||
columnOrder: getColumnOrder({ ...layer, columns: newColumns }),
|
||||
|
@ -293,6 +281,16 @@ export function replaceColumn({
|
|||
}
|
||||
}
|
||||
|
||||
function adjustLabel(newColumn: IndexPatternColumn, previousColumn: IndexPatternColumn) {
|
||||
const adjustedColumn = { ...newColumn };
|
||||
if (previousColumn.customLabel) {
|
||||
adjustedColumn.customLabel = true;
|
||||
adjustedColumn.label = previousColumn.label;
|
||||
}
|
||||
|
||||
return adjustedColumn;
|
||||
}
|
||||
|
||||
function addBucket(
|
||||
layer: IndexPatternLayer,
|
||||
column: IndexPatternColumn,
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { TimeScaleUnit } from '../time_scale';
|
||||
import { IndexPatternColumn } from './definitions';
|
||||
import { adjustTimeScaleLabelSuffix, adjustTimeScaleOnOtherColumnChange } from './time_scale_utils';
|
||||
|
||||
export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
|
||||
|
||||
describe('time scale utils', () => {
|
||||
describe('adjustTimeScaleLabelSuffix', () => {
|
||||
it('should should remove existing suffix', () => {
|
||||
expect(adjustTimeScaleLabelSuffix('abc per second', 's', undefined)).toEqual('abc');
|
||||
expect(adjustTimeScaleLabelSuffix('abc per hour', 'h', undefined)).toEqual('abc');
|
||||
});
|
||||
|
||||
it('should add suffix', () => {
|
||||
expect(adjustTimeScaleLabelSuffix('abc', undefined, 's')).toEqual('abc per second');
|
||||
expect(adjustTimeScaleLabelSuffix('abc', undefined, 'd')).toEqual('abc per day');
|
||||
});
|
||||
|
||||
it('should change suffix', () => {
|
||||
expect(adjustTimeScaleLabelSuffix('abc per second', 's', 'd')).toEqual('abc per day');
|
||||
expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 's')).toEqual('abc per second');
|
||||
});
|
||||
|
||||
it('should keep current state', () => {
|
||||
expect(adjustTimeScaleLabelSuffix('abc', undefined, undefined)).toEqual('abc');
|
||||
expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 'd')).toEqual('abc per day');
|
||||
});
|
||||
|
||||
it('should not fail on inconsistent input', () => {
|
||||
expect(adjustTimeScaleLabelSuffix('abc', 's', undefined)).toEqual('abc');
|
||||
expect(adjustTimeScaleLabelSuffix('abc', 's', 'd')).toEqual('abc per day');
|
||||
expect(adjustTimeScaleLabelSuffix('abc per day', 's', undefined)).toEqual('abc per day');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjustTimeScaleOnOtherColumnChange', () => {
|
||||
const baseColumn: IndexPatternColumn = {
|
||||
operationType: 'count',
|
||||
sourceField: 'Records',
|
||||
label: 'Count of records per second',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
timeScale: 's',
|
||||
};
|
||||
it('should keep column if there is no time scale', () => {
|
||||
const column = { ...baseColumn, timeScale: undefined };
|
||||
expect(adjustTimeScaleOnOtherColumnChange(column, { col1: column })).toBe(column);
|
||||
});
|
||||
|
||||
it('should keep time scale if there is a date histogram', () => {
|
||||
expect(
|
||||
adjustTimeScaleOnOtherColumnChange(baseColumn, {
|
||||
col1: baseColumn,
|
||||
col2: {
|
||||
operationType: 'date_histogram',
|
||||
dataType: 'date',
|
||||
isBucketed: true,
|
||||
label: '',
|
||||
},
|
||||
})
|
||||
).toBe(baseColumn);
|
||||
});
|
||||
|
||||
it('should remove time scale if there is no date histogram', () => {
|
||||
expect(adjustTimeScaleOnOtherColumnChange(baseColumn, { col1: baseColumn })).toHaveProperty(
|
||||
'timeScale',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove suffix from label', () => {
|
||||
expect(adjustTimeScaleOnOtherColumnChange(baseColumn, { col1: baseColumn })).toHaveProperty(
|
||||
'label',
|
||||
'Count of records'
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep custom label', () => {
|
||||
const column = { ...baseColumn, label: 'abc', customLabel: true };
|
||||
expect(adjustTimeScaleOnOtherColumnChange(column, { col1: column })).toHaveProperty(
|
||||
'label',
|
||||
'abc'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { unitSuffixesLong } from '../suffix_formatter';
|
||||
import { TimeScaleUnit } from '../time_scale';
|
||||
import { BaseIndexPatternColumn } from './definitions/column_types';
|
||||
|
||||
export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
|
||||
|
||||
export function adjustTimeScaleLabelSuffix(
|
||||
oldLabel: string,
|
||||
previousTimeScale: TimeScaleUnit | undefined,
|
||||
newTimeScale: TimeScaleUnit | undefined
|
||||
) {
|
||||
let cleanedLabel = oldLabel;
|
||||
// remove added suffix if column had a time scale previously
|
||||
if (previousTimeScale) {
|
||||
const suffixPosition = oldLabel.lastIndexOf(` ${unitSuffixesLong[previousTimeScale]}`);
|
||||
if (suffixPosition !== -1) {
|
||||
cleanedLabel = oldLabel.substring(0, suffixPosition);
|
||||
}
|
||||
}
|
||||
if (!newTimeScale) {
|
||||
return cleanedLabel;
|
||||
}
|
||||
// add new suffix if column has a time scale now
|
||||
return `${cleanedLabel} ${unitSuffixesLong[newTimeScale]}`;
|
||||
}
|
||||
|
||||
export function adjustTimeScaleOnOtherColumnChange<T extends BaseIndexPatternColumn>(
|
||||
column: T,
|
||||
columns: Partial<Record<string, BaseIndexPatternColumn>>
|
||||
) {
|
||||
if (!column.timeScale) {
|
||||
return column;
|
||||
}
|
||||
const hasDateHistogram = Object.values(columns).some(
|
||||
(col) => col?.operationType === 'date_histogram'
|
||||
);
|
||||
if (hasDateHistogram) {
|
||||
return column;
|
||||
}
|
||||
if (column.customLabel) {
|
||||
return column;
|
||||
}
|
||||
return {
|
||||
...column,
|
||||
timeScale: undefined,
|
||||
label: adjustTimeScaleLabelSuffix(column.label, column.timeScale, undefined),
|
||||
};
|
||||
}
|
|
@ -10,12 +10,19 @@ import { FormatFactory } from '../types';
|
|||
import { TimeScaleUnit } from './time_scale';
|
||||
|
||||
const unitSuffixes: Record<TimeScaleUnit, string> = {
|
||||
s: i18n.translate('xpack.lens.fieldFormats.suffix.s', { defaultMessage: '/h' }),
|
||||
s: i18n.translate('xpack.lens.fieldFormats.suffix.s', { defaultMessage: '/s' }),
|
||||
m: i18n.translate('xpack.lens.fieldFormats.suffix.m', { defaultMessage: '/m' }),
|
||||
h: i18n.translate('xpack.lens.fieldFormats.suffix.h', { defaultMessage: '/h' }),
|
||||
d: i18n.translate('xpack.lens.fieldFormats.suffix.d', { defaultMessage: '/d' }),
|
||||
};
|
||||
|
||||
export const unitSuffixesLong: Record<TimeScaleUnit, string> = {
|
||||
s: i18n.translate('xpack.lens.fieldFormats.longSuffix.s', { defaultMessage: 'per second' }),
|
||||
m: i18n.translate('xpack.lens.fieldFormats.longSuffix.m', { defaultMessage: 'per minute' }),
|
||||
h: i18n.translate('xpack.lens.fieldFormats.longSuffix.h', { defaultMessage: 'per hour' }),
|
||||
d: i18n.translate('xpack.lens.fieldFormats.longSuffix.d', { defaultMessage: 'per day' }),
|
||||
};
|
||||
|
||||
export function getSuffixFormatter(formatFactory: FormatFactory) {
|
||||
return class SuffixFormatter extends FieldFormat {
|
||||
static id = 'suffix';
|
||||
|
|
|
@ -87,7 +87,8 @@ export function getTimeScaleFunction(data: DataPublicPluginStart) {
|
|||
input,
|
||||
outputColumnId,
|
||||
inputColumnId,
|
||||
outputColumnName
|
||||
outputColumnName,
|
||||
{ allowColumnOverwrite: true }
|
||||
);
|
||||
|
||||
if (!resultColumns) {
|
||||
|
|
|
@ -59,7 +59,6 @@ function getExpressionForLayer(layer: IndexPatternLayer, indexPattern: IndexPatt
|
|||
}
|
||||
>
|
||||
>;
|
||||
|
||||
const columnsWithFormatters = columnEntries.filter(
|
||||
([, col]) =>
|
||||
col.params &&
|
||||
|
@ -87,6 +86,45 @@ function getExpressionForLayer(layer: IndexPatternLayer, indexPattern: IndexPatt
|
|||
}
|
||||
);
|
||||
|
||||
const firstDateHistogramColumn = columnEntries.find(
|
||||
([, col]) => col.operationType === 'date_histogram'
|
||||
);
|
||||
|
||||
const columnsWithTimeScale = firstDateHistogramColumn
|
||||
? columnEntries.filter(
|
||||
([, col]) =>
|
||||
col.timeScale &&
|
||||
operationDefinitionMap[col.operationType].timeScalingMode &&
|
||||
operationDefinitionMap[col.operationType].timeScalingMode !== 'disabled'
|
||||
)
|
||||
: [];
|
||||
const timeScaleFunctions: ExpressionFunctionAST[] = columnsWithTimeScale.flatMap(
|
||||
([id, col]) => {
|
||||
const scalingCall: ExpressionFunctionAST = {
|
||||
type: 'function',
|
||||
function: 'lens_time_scale',
|
||||
arguments: {
|
||||
dateColumnId: [firstDateHistogramColumn![0]],
|
||||
inputColumnId: [id],
|
||||
outputColumnId: [id],
|
||||
targetUnit: [col.timeScale!],
|
||||
},
|
||||
};
|
||||
|
||||
const formatCall: ExpressionFunctionAST = {
|
||||
type: 'function',
|
||||
function: 'lens_format_column',
|
||||
arguments: {
|
||||
format: [''],
|
||||
columnId: [id],
|
||||
parentFormat: [JSON.stringify({ id: 'suffix', params: { unit: col.timeScale } })],
|
||||
},
|
||||
};
|
||||
|
||||
return [scalingCall, formatCall];
|
||||
}
|
||||
);
|
||||
|
||||
const allDateHistogramFields = Object.values(columns)
|
||||
.map((column) =>
|
||||
column.operationType === dateHistogramOperation.type ? column.sourceField : null
|
||||
|
@ -117,6 +155,7 @@ function getExpressionForLayer(layer: IndexPatternLayer, indexPattern: IndexPatt
|
|||
},
|
||||
...formatterOverrides,
|
||||
...expressions,
|
||||
...timeScaleFunctions,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue