[Lens] Time scale ui (#83904)

This commit is contained in:
Joe Reuter 2020-11-30 16:08:19 +01:00 committed by GitHub
parent 3af64cac34
commit 6828859cac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1066 additions and 51 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@
export * from './operations';
export * from './layer_helpers';
export * from './time_scale_utils';
export {
OperationType,
IndexPatternColumn,

View file

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

View file

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

View file

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

View file

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

View file

@ -87,7 +87,8 @@ export function getTimeScaleFunction(data: DataPublicPluginStart) {
input,
outputColumnId,
inputColumnId,
outputColumnName
outputColumnName,
{ allowColumnOverwrite: true }
);
if (!resultColumns) {

View file

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