diff --git a/src/plugins/data/common/field_formats/types.ts b/src/plugins/data/common/field_formats/types.ts index 0c16d9f1ac8b..7c1d6a8522e5 100644 --- a/src/plugins/data/common/field_formats/types.ts +++ b/src/plugins/data/common/field_formats/types.ts @@ -87,6 +87,7 @@ export type IFieldFormatType = (new ( getConfig?: FieldFormatsGetConfigFn ) => FieldFormat) & { id: FieldFormatId; + title: string; fieldType: string | string[]; }; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts b/x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts new file mode 100644 index 000000000000..dfb725fef49b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.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 { ExpressionFunctionDefinition, KibanaDatatable } from 'src/plugins/expressions/public'; + +interface FormatColumn { + format: string; + columnId: string; + decimals?: number; +} + +const supportedFormats: Record string }> = { + number: { + decimalsToPattern: (decimals = 2) => { + if (decimals === 0) { + return `0,0`; + } + return `0,0.${'0'.repeat(decimals)}`; + }, + }, + percent: { + decimalsToPattern: (decimals = 2) => { + if (decimals === 0) { + return `0,0%`; + } + return `0,0.${'0'.repeat(decimals)}%`; + }, + }, + bytes: { + decimalsToPattern: (decimals = 2) => { + if (decimals === 0) { + return `0,0b`; + } + return `0,0.${'0'.repeat(decimals)}b`; + }, + }, +}; + +export const formatColumn: ExpressionFunctionDefinition< + 'lens_format_column', + KibanaDatatable, + FormatColumn, + KibanaDatatable +> = { + name: 'lens_format_column', + type: 'kibana_datatable', + help: '', + args: { + format: { + types: ['string'], + help: '', + required: true, + }, + columnId: { + types: ['string'], + help: '', + required: true, + }, + decimals: { + types: ['number'], + help: '', + }, + }, + inputTypes: ['kibana_datatable'], + fn(input, { format, columnId, decimals }: FormatColumn) { + return { + ...input, + columns: input.columns.map(col => { + if (col.id === columnId) { + if (supportedFormats[format]) { + return { + ...col, + formatHint: { + id: format, + params: { pattern: supportedFormats[format].decimalsToPattern(decimals) }, + }, + }; + } else { + return { + ...col, + formatHint: { id: format, params: {} }, + }; + } + } + return col; + }), + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx index 7a0bb3a2cc50..5347be47e145 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx @@ -29,6 +29,7 @@ import { } from '../types'; import { EditorFrame } from './editor_frame'; import { mergeTables } from './merge_tables'; +import { formatColumn } from './format_column'; import { EmbeddableFactory } from './embeddable/embeddable_factory'; import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management'; @@ -64,6 +65,7 @@ export class EditorFrameService { public setup(core: CoreSetup, plugins: EditorFrameSetupPlugins): EditorFrameSetup { plugins.expressions.registerFunction(() => mergeTables); + plugins.expressions.registerFunction(() => formatColumn); return { registerDatasource: datasource => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 98cf862e1fd2..56f75ae4b17b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -7,7 +7,14 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import { EuiComboBox, EuiSideNav, EuiSideNavItemType, EuiPopover } from '@elastic/eui'; +import { + EuiComboBox, + EuiSideNav, + EuiSideNavItemType, + EuiPopover, + EuiFieldNumber, +} from '@elastic/eui'; +import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { changeColumn } from '../state_helpers'; import { IndexPatternDimensionPanel, @@ -139,6 +146,18 @@ describe('IndexPatternDimensionPanel', () => { uiSettings: {} as IUiSettingsClient, savedObjectsClient: {} as SavedObjectsClientContract, http: {} as HttpSetup, + data: ({ + fieldFormats: ({ + getType: jest.fn().mockReturnValue({ + id: 'number', + title: 'Number', + }), + getDefaultType: jest.fn().mockReturnValue({ + id: 'bytes', + title: 'Bytes', + }), + } as unknown) as DataPublicPluginStart['fieldFormats'], + } as unknown) as DataPublicPluginStart, }; jest.clearAllMocks(); @@ -175,7 +194,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - expect(wrapper.find(EuiComboBox)).toHaveLength(1); + expect( + wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') + ).toHaveLength(1); }); it('should not show any choices if the filter returns false', () => { @@ -189,7 +210,12 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - expect(wrapper.find(EuiComboBox)!.prop('options')!).toHaveLength(0); + expect( + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')! + .prop('options')! + ).toHaveLength(0); }); it('should list all field names and document as a whole in prioritized order', () => { @@ -197,7 +223,10 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - const options = wrapper.find(EuiComboBox).prop('options'); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); expect(options).toHaveLength(2); @@ -228,7 +257,10 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - const options = wrapper.find(EuiComboBox).prop('options'); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); expect(options![1].options!.map(({ label }) => label)).toEqual(['timestamp', 'source']); }); @@ -262,7 +294,10 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - const options = wrapper.find(EuiComboBox).prop('options'); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records'); @@ -335,6 +370,7 @@ describe('IndexPatternDimensionPanel', () => { // Private operationType: 'max', sourceField: 'bytes', + params: { format: { id: 'bytes' } }, }, }, }, @@ -345,7 +381,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - const comboBox = wrapper.find(EuiComboBox)!; + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; act(() => { @@ -362,6 +400,7 @@ describe('IndexPatternDimensionPanel', () => { col1: expect.objectContaining({ operationType: 'max', sourceField: 'memory', + params: { format: { id: 'bytes' } }, // Other parts of this don't matter for this test }), }, @@ -375,7 +414,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - const comboBox = wrapper.find(EuiComboBox)!; + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; act(() => { @@ -419,6 +460,7 @@ describe('IndexPatternDimensionPanel', () => { // Private operationType: 'max', sourceField: 'bytes', + params: { format: { id: 'bytes' } }, }, }, }, @@ -443,6 +485,7 @@ describe('IndexPatternDimensionPanel', () => { col1: expect.objectContaining({ operationType: 'min', sourceField: 'bytes', + params: { format: { id: 'bytes' } }, // Other parts of this don't matter for this test }), }, @@ -565,7 +608,10 @@ describe('IndexPatternDimensionPanel', () => { .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') .simulate('click'); - const options = wrapper.find(EuiComboBox).prop('options'); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); expect(options![0]['data-test-subj']).toContain('Incompatible'); @@ -584,7 +630,9 @@ describe('IndexPatternDimensionPanel', () => { wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); - const comboBox = wrapper.find(EuiComboBox); + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); const options = comboBox.prop('options'); // options[1][2] is a `source` field of type `string` which doesn't support `avg` operation @@ -674,7 +722,10 @@ describe('IndexPatternDimensionPanel', () => { .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') .simulate('click'); - const options = wrapper.find(EuiComboBox).prop('options'); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); expect(options![0]['data-test-subj']).toContain('Incompatible'); @@ -697,7 +748,9 @@ describe('IndexPatternDimensionPanel', () => { .simulate('click'); }); - const comboBox = wrapper.find(EuiComboBox)!; + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; act(() => { @@ -729,7 +782,9 @@ describe('IndexPatternDimensionPanel', () => { wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); - const comboBox = wrapper.find(EuiComboBox); + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); const options = comboBox.prop('options'); act(() => { @@ -825,7 +880,10 @@ describe('IndexPatternDimensionPanel', () => { wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); - const options = wrapper.find(EuiComboBox).prop('options'); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); expect(options![0]['data-test-subj']).toContain('Incompatible'); @@ -865,7 +923,10 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - const options = wrapper.find(EuiComboBox).prop('options'); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); expect(options![0]['data-test-subj']).not.toContain('Incompatible'); @@ -905,7 +966,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - const comboBox = wrapper.find(EuiComboBox)!; + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; const option = comboBox.prop('options')![1].options![0]; act(() => { @@ -1002,7 +1065,10 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); act(() => { - wrapper.find(EuiComboBox).prop('onChange')!([]); + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('onChange')!([]); }); expect(setState).toHaveBeenCalledWith({ @@ -1017,6 +1083,159 @@ describe('IndexPatternDimensionPanel', () => { }); }); + it('allows custom format', () => { + const stateWithNumberCol: IndexPatternPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + }, + }, + }, + }, + }; + + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'bytes', label: 'Bytes' }]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }), + }, + }, + }, + }); + }); + + it('keeps decimal places while switching', () => { + const stateWithNumberCol: IndexPatternPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: '', label: 'Default' }]); + }); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'number', label: 'Number' }]); + }); + + expect( + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('value') + ).toEqual(0); + }); + + it('allows custom format with number of decimal places', () => { + const stateWithNumberCol: IndexPatternPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('onChange')!({ target: { value: '0' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }), + }, + }, + }, + }); + }); + describe('drag and drop', () => { function dragDropState(): IndexPatternPrivateState { return { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index 972c396f93b4..59350ff215c2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -10,6 +10,7 @@ import { EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { DatasourceDimensionPanelProps, StateSetter } from '../../types'; import { IndexPatternColumn, OperationType } from '../indexpattern'; import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; @@ -30,6 +31,7 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { savedObjectsClient: SavedObjectsClientContract; layerId: string; http: HttpSetup; + data: DataPublicPluginStart; uniqueLabel: string; dateRange: DateRange; }; @@ -128,6 +130,7 @@ export const IndexPatternDimensionPanelComponent = function IndexPatternDimensio layerId, suggestedPriority: props.suggestedPriority, field: droppedItem.field, + previousColumn: selectedColumn, }); trackUiEvent('drop_onto_dimension'); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx new file mode 100644 index 000000000000..ed68a93c51ca --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx @@ -0,0 +1,136 @@ +/* + * 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 React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber, EuiComboBox } from '@elastic/eui'; +import { IndexPatternColumn } from '../indexpattern'; + +const supportedFormats: Record = { + number: { + title: i18n.translate('xpack.lens.indexPattern.numberFormatLabel', { + defaultMessage: 'Number', + }), + }, + percent: { + title: i18n.translate('xpack.lens.indexPattern.percentFormatLabel', { + defaultMessage: 'Percent', + }), + }, + bytes: { + title: i18n.translate('xpack.lens.indexPattern.bytesFormatLabel', { + defaultMessage: 'Bytes (1024)', + }), + }, +}; + +interface FormatSelectorProps { + selectedColumn: IndexPatternColumn; + onChange: (newFormat?: { id: string; params?: Record }) => void; +} + +interface State { + decimalPlaces: number; +} + +export function FormatSelector(props: FormatSelectorProps) { + const { selectedColumn, onChange } = props; + + const currentFormat = + 'params' in selectedColumn && selectedColumn.params && 'format' in selectedColumn.params + ? selectedColumn.params.format + : undefined; + const [state, setState] = useState({ + decimalPlaces: + typeof currentFormat?.params?.decimals === 'number' ? currentFormat.params.decimals : 2, + }); + + const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined; + + const defaultOption = { + value: '', + label: i18n.translate('xpack.lens.indexPattern.defaultFormatLabel', { + defaultMessage: 'Default', + }), + }; + + return ( + <> + + ({ + value: id, + label: format.title ?? id, + })), + ]} + selectedOptions={ + currentFormat + ? [ + { + value: currentFormat.id, + label: selectedFormat?.title ?? currentFormat.id, + }, + ] + : [defaultOption] + } + onChange={choices => { + if (choices.length === 0) { + return; + } + + if (!choices[0].value) { + onChange(); + return; + } + onChange({ + id: choices[0].value, + params: { decimals: state.decimalPlaces }, + }); + }} + /> + + + {currentFormat ? ( + + { + setState({ decimalPlaces: Number(e.target.value) }); + onChange({ + id: (selectedColumn.params as { format: { id: string } }).format.id, + params: { + decimals: Number(e.target.value), + }, + }); + }} + compressed + fullWidth + /> + + ) : null} + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx index 98773c04db4a..ec2acd73cc1c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx @@ -29,12 +29,13 @@ import { buildColumn, changeField, } from '../operations'; -import { deleteColumn, changeColumn } from '../state_helpers'; +import { deleteColumn, changeColumn, updateColumnParam } from '../state_helpers'; import { FieldSelect } from './field_select'; import { hasField } from '../utils'; import { BucketNestingEditor } from './bucket_nesting_editor'; import { IndexPattern, IndexPatternField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; +import { FormatSelector } from './format_selector'; const operationPanels = getOperationDisplay(); @@ -143,6 +144,7 @@ export function PopoverEditor(props: PopoverEditorProps) { op: operationType, indexPattern: currentIndexPattern, field: fieldMap[possibleFields[0]], + previousColumn: selectedColumn, }), }) ); @@ -165,7 +167,9 @@ export function PopoverEditor(props: PopoverEditorProps) { op: operationType, indexPattern: currentIndexPattern, field: fieldMap[selectedColumn.sourceField], + previousColumn: selectedColumn, }); + trackUiEvent( `indexpattern_dimension_operation_from_${selectedColumn.operationType}_to_${operationType}` ); @@ -293,6 +297,7 @@ export function PopoverEditor(props: PopoverEditorProps) { layerId: props.layerId, suggestedPriority: props.suggestedPriority, op: operation as OperationType, + previousColumn: selectedColumn, }); } @@ -400,6 +405,23 @@ export function PopoverEditor(props: PopoverEditorProps) { }} /> )} + + {selectedColumn && selectedColumn.dataType === 'number' ? ( + { + setState( + updateColumnParam({ + state, + layerId, + currentColumn: selectedColumn, + paramName: 'format', + value: newFormat, + }) + ); + }} + /> + ) : null} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 41be22f2c72e..25121eec30f2 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -264,7 +264,7 @@ describe('IndexPattern Data Source', () => { metricsAtAllLevels=false partialRows=false includeFormatHints=true - aggConfigs={lens_auto_date aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]'} | lens_rename_columns idMap='{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}'" + aggConfigs={lens_auto_date aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]'} | lens_rename_columns idMap='{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}' " `); }); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index afb88d1af795..00f52d6a1747 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -239,6 +239,7 @@ export function getIndexPatternDatasource({ savedObjectsClient={core.savedObjects.client} layerId={props.layerId} http={core.http} + data={data} uniqueLabel={columnLabelMap[props.columnId]} dateRange={dateRange} {...props} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index 3e591c875465..33325016deae 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { OperationDefinition } from '.'; -import { FieldBasedIndexPatternColumn } from './column_types'; +import { FormattedIndexPatternColumn } from './column_types'; const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']); @@ -21,7 +21,7 @@ function ofName(name: string) { }); } -export interface CardinalityIndexPatternColumn extends FieldBasedIndexPatternColumn { +export interface CardinalityIndexPatternColumn extends FormattedIndexPatternColumn { operationType: 'cardinality'; } @@ -49,7 +49,7 @@ export const cardinalityOperation: OperationDefinition ({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index fe8a3d34d1c1..639e982142f5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -18,6 +18,18 @@ export interface BaseIndexPatternColumn extends Operation { suggestedPriority?: DimensionPriority; } +// Formatting can optionally be added to any column +export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn { + params?: { + format: { + id: string; + params?: { + decimals: number; + }; + }; + }; +} + /** * Base type for a column that doesn't have additional parameter. * diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index d86e688fca01..1592b1049f66 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -6,17 +6,16 @@ import { i18n } from '@kbn/i18n'; import { OperationDefinition } from '.'; -import { ParameterlessIndexPatternColumn, BaseIndexPatternColumn } from './column_types'; +import { FormattedIndexPatternColumn } from './column_types'; import { IndexPatternField } from '../../types'; const countLabel = i18n.translate('xpack.lens.indexPattern.countOf', { defaultMessage: 'Count of records', }); -export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< - 'count', - BaseIndexPatternColumn ->; +export type CountIndexPatternColumn = FormattedIndexPatternColumn & { + operationType: 'count'; +}; export const countOperation: OperationDefinition = { type: 'count', @@ -40,7 +39,7 @@ export const countOperation: OperationDefinition = { }; } }, - buildColumn({ suggestedPriority, field }) { + buildColumn({ suggestedPriority, field, previousColumn }) { return { label: countLabel, dataType: 'number', @@ -49,6 +48,8 @@ export const countOperation: OperationDefinition = { isBucketed: false, scale: 'ratio', sourceField: field.name, + params: + previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined, }; }, toEsAggsConfig: (column, columnId) => ({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index f357038be41a..cbced3bfc870 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -117,6 +117,7 @@ interface FieldBasedOperationDefinition buildColumn: ( arg: BaseBuildColumnArgs & { field: IndexPatternField; + previousColumn?: C; } ) => C; /** @@ -169,7 +170,7 @@ export type OperationType = typeof internalOperationDefinitions[number]['type']; /** * This is an operation definition of an unspecified column out of all possible - * column types. It + * column types. */ export type GenericOperationDefinition = FieldBasedOperationDefinition; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 09bb427d9559..c2d9478c6ea1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -6,9 +6,13 @@ import { i18n } from '@kbn/i18n'; import { OperationDefinition } from '.'; -import { ParameterlessIndexPatternColumn } from './column_types'; +import { FormattedIndexPatternColumn } from './column_types'; -function buildMetricOperation>({ +type MetricColumn = FormattedIndexPatternColumn & { + operationType: T; +}; + +function buildMetricOperation>({ type, displayName, ofName, @@ -46,7 +50,7 @@ function buildMetricOperation> (!newField.aggregationRestrictions || newField.aggregationRestrictions![type]) ); }, - buildColumn: ({ suggestedPriority, field }) => ({ + buildColumn: ({ suggestedPriority, field, previousColumn }) => ({ label: ofName(field.name), dataType: 'number', operationType: type, @@ -54,6 +58,8 @@ function buildMetricOperation> sourceField: field.name, isBucketed: false, scale: 'ratio', + params: + previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined, }), onFieldChange: (oldColumn, indexPattern, field) => { return { @@ -75,10 +81,10 @@ function buildMetricOperation> } as OperationDefinition; } -export type SumIndexPatternColumn = ParameterlessIndexPatternColumn<'sum'>; -export type AvgIndexPatternColumn = ParameterlessIndexPatternColumn<'avg'>; -export type MinIndexPatternColumn = ParameterlessIndexPatternColumn<'min'>; -export type MaxIndexPatternColumn = ParameterlessIndexPatternColumn<'max'>; +export type SumIndexPatternColumn = MetricColumn<'sum'>; +export type AvgIndexPatternColumn = MetricColumn<'avg'>; +export type MinIndexPatternColumn = MetricColumn<'min'>; +export type MaxIndexPatternColumn = MetricColumn<'max'>; export const minOperation = buildMetricOperation({ type: 'min', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.ts index ecd0942eef7b..ce8ea55c445d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/operations/operations.ts @@ -202,6 +202,7 @@ export function buildColumn({ layerId, indexPattern, suggestedPriority, + previousColumn, }: { op?: OperationType; columns: Partial>; @@ -209,6 +210,7 @@ export function buildColumn({ layerId: string; indexPattern: IndexPattern; field: IndexPatternField; + previousColumn?: IndexPatternColumn; }): IndexPatternColumn { let operationDefinition: GenericOperationDefinition | undefined; @@ -229,16 +231,19 @@ export function buildColumn({ suggestedPriority, layerId, indexPattern, + previousColumn, }; if (!field) { throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`); } - return operationDefinition.buildColumn({ + const newColumn = operationDefinition.buildColumn({ ...baseOptions, field, }); + + return newColumn; } export { operationDefinitionMap } from './definitions'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts index 28486c8201da..0a58853f1ef4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts @@ -173,6 +173,47 @@ describe('state_helpers', () => { params: { interval: 'M' }, }); }); + + it('should set optional params', () => { + const currentColumn: AvgIndexPatternColumn = { + label: 'Avg of bytes', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: IndexPatternPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'format', + value: { id: 'bytes' }, + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { format: { id: 'bytes' } }, + }); + }); }); describe('changeColumn', () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.ts index f56f8089ea58..a2d64e8f2eb8 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/state_helpers.ts @@ -9,10 +9,7 @@ import { isColumnTransferable } from './operations'; import { operationDefinitionMap, IndexPatternColumn } from './operations'; import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from './types'; -export function updateColumnParam< - C extends IndexPatternColumn & { params: object }, - K extends keyof C['params'] ->({ +export function updateColumnParam({ state, layerId, currentColumn, @@ -22,17 +19,13 @@ export function updateColumnParam< state: IndexPatternPrivateState; layerId: string; currentColumn: C; - paramName: K; - value: C['params'][K]; + paramName: string; + value: unknown; }): IndexPatternPrivateState { const columnId = Object.entries(state.layers[layerId].columns).find( ([_columnId, column]) => column === currentColumn )![0]; - if (!('params' in state.layers[layerId].columns[columnId])) { - throw new Error('Invariant: no params in this column'); - } - return { ...state, layers: { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/to_expression.ts index 96006ae6b6ed..3747deaa6059 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -40,6 +40,21 @@ function getExpressionForLayer( }; }, {} as Record); + const formatterOverrides = columnEntries + .map(([id, col]) => { + const format = col.params && 'format' in col.params ? col.params.format : undefined; + if (!format) { + return null; + } + const base = `| lens_format_column format="${format.id}" columnId="${id}"`; + if (typeof format.params?.decimals === 'number') { + return base + ` decimals=${format.params.decimals}`; + } + return base; + }) + .filter(expr => !!expr) + .join(' '); + return `esaggs index="${indexPattern.id}" metricsAtAllLevels=false @@ -47,7 +62,7 @@ function getExpressionForLayer( includeFormatHints=true aggConfigs={lens_auto_date aggConfigs='${JSON.stringify( aggs - )}'} | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; + )}'} | lens_rename_columns idMap='${JSON.stringify(idMap)}' ${formatterOverrides}`; } return null;