diff --git a/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts b/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts index 99ad2098ab10a0..71e528f78ec81f 100644 --- a/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts +++ b/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts @@ -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: diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 0a67c157bd8379..62de601bb7888e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -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(() => { + const callback = _.debounce((val: string) => { + onChange(val); + unflushedChanges.current = false; + }, 256); + return (val: string) => { + unflushedChanges.current = true; + callback(val); + }; + }, [onChange]); - const onChangeDebounced = useMemo(() => _.debounce(onChange, 256), [onChange]); + useEffect(() => { + if (!unflushedChanges.current && value !== inputValue) { + setInputValue(value); + } + }, [value, inputValue]); const handleInputChange = (e: React.ChangeEvent) => { const val = String(e.target.value); @@ -329,6 +350,17 @@ export function DimensionEditor(props: DimensionEditorProps) { ) : null} + {!currentFieldIsInvalid && !incompatibleSelectedOperationType && selectedColumn && ( + + setState(mergeLayer({ layerId, state, newLayer })) + } + /> + )} + {!currentFieldIsInvalid && !incompatibleSelectedOperationType && selectedColumn && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 2e57ecee860334..29a0586c92ffe9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -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) { - 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) { + 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( + + ); + expect(wrapper.find('[data-test-subj="indexPattern-time-scaling"]')).toHaveLength(0); + }); + + it('should show custom options if time scaling is available', () => { + wrapper = mount(); + 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(); + 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(); + 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(); + 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(); + 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(); + wrapper + .find('[data-test-subj="indexPattern-time-scaling-unit"]') + .find(EuiSelect) + .prop('onChange')!(({ + target: { value: 'h' }, + } as unknown) as ChangeEvent); + 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(); + wrapper + .find('[data-test-subj="indexPattern-time-scaling-unit"]') + .find(EuiSelect) + .prop('onChange')!(({ + target: { value: 'h' }, + } as unknown) as ChangeEvent); + 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(); + 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( { act(() => { wrapper.find('[data-test-subj="lns-indexPatternDimension-min"]').first().prop('onClick')!( - {} as React.MouseEvent<{}, MouseEvent> + {} as MouseEvent ); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx new file mode 100644 index 00000000000000..d5a90f72752797 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx @@ -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 ( + + + { + setPopoverOpen(true); + }} + > + {i18n.translate('xpack.lens.indexPattern.timeScale.advancedSettings', { + defaultMessage: 'Add advanced options', + })} + + } + isOpen={popoverOpen} + closePopover={() => { + setPopoverOpen(false); + }} + > + + { + setPopoverOpen(false); + updateLayer(setTimeScaling(columnId, layer, DEFAULT_TIME_SCALE)); + }} + > + {i18n.translate('xpack.lens.indexPattern.timeScale.enableTimeScale', { + defaultMessage: 'Normalize by unit', + })} + + + + + ); + } + + return ( + + + {i18n.translate('xpack.lens.indexPattern.timeScale.label', { + defaultMessage: 'Normalize by unit', + })}{' '} + + + + } + > + + + ({ + value: unit, + text, + }))} + data-test-subj="indexPattern-time-scaling-unit" + value={selectedColumn.timeScale} + onChange={(e) => { + updateLayer(setTimeScaling(columnId, layer, e.target.value as TimeScaleUnit)); + }} + /> + + {selectedOperation.timeScalingMode === 'optional' && ( + + { + updateLayer(setTimeScaling(columnId, layer, undefined)); + }} + iconType="cross" + /> + + )} + + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index c3247b251d88ab..5153a74409bee3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -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', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts index f27fb8d4642f6b..385f2ab941ef27 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts @@ -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; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts new file mode 100644 index 00000000000000..18aa15badec4ff --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts @@ -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(); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index d256b74696a4c5..0cfba4cfc739f5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -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', }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx index 7398f7e07ea4ed..41fe361c7ba9cf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx @@ -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', }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 795281d0fd9947..522899662fbd10 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -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({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts index c64a292280603d..bac45f683e4449 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -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 */ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index aef9bb7731d4cb..d0a0fb4b285880 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -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 diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index 30f64929fc1afd..8cb95de72f97e9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -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 { return { ...oldColumn, - label: field.displayName, + label: adjustTimeScaleLabelSuffix(field.displayName, undefined, oldColumn.timeScale), sourceField: field.name, }; }, @@ -41,15 +45,16 @@ export const countOperation: OperationDefinition 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 ({ id: columnId, enabled: true, @@ -69,4 +75,5 @@ export const countOperation: OperationDefinition { return true; }, + timeScalingMode: 'optional', }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 392377234d76db..31bb332f791da6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -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 { data: DataPublicPluginStart; } +export type TimeScalingMode = 'disabled' | 'mandatory' | 'optional'; + interface BaseOperationDefinitionProps { type: C['operationType']; /** @@ -164,6 +172,13 @@ interface BaseOperationDefinitionProps { * 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 { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 96df72ba8b7c15..45ba721981ed53 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -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 = FormattedIndexPatternColumn & FieldBasedIndexPatternColumn & { @@ -18,17 +26,28 @@ function buildMetricOperation>({ 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>({ (!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({ defaultMessage: 'Sum of {name}', values: { name }, }), + optionalTimeScaling: true, }); export const medianOperation = buildMetricOperation({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts index 3ad9a1e5b36749..7123becf71b4dd 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts @@ -6,6 +6,7 @@ export * from './operations'; export * from './layer_helpers'; +export * from './time_scale_utils'; export { OperationType, IndexPatternColumn, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 58a066c81a1a72..260ed180da921a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -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, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts new file mode 100644 index 00000000000000..841011c5884336 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts @@ -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' + ); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts new file mode 100644 index 00000000000000..5d525e573a6177 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts @@ -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( + column: T, + columns: Partial> +) { + 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), + }; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/suffix_formatter.ts b/x-pack/plugins/lens/public/indexpattern_datasource/suffix_formatter.ts index 5594976738efee..f5d764acab0869 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/suffix_formatter.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/suffix_formatter.ts @@ -10,12 +10,19 @@ import { FormatFactory } from '../types'; import { TimeScaleUnit } from './time_scale'; const unitSuffixes: Record = { - 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 = { + 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'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/time_scale.ts b/x-pack/plugins/lens/public/indexpattern_datasource/time_scale.ts index 06ff8058b1d09e..ca0745493f768f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/time_scale.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/time_scale.ts @@ -87,7 +87,8 @@ export function getTimeScaleFunction(data: DataPublicPluginStart) { input, outputColumnId, inputColumnId, - outputColumnName + outputColumnName, + { allowColumnOverwrite: true } ); if (!resultColumns) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 5b66d4aae77abd..7b7fc0468cf868 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -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, ], }; }