[Lens] Allow number formatting within Lens (#56253)

* [Lens] Allow custom number formats on dimensions

* Fix merge issues

* Text and decimal changes from review

* Persist number format across operations

* Respond to review comments

* Change label

* Add persistence

* Fix import

* 2 decimals

* Persist number formatting on drop too

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Wylie Conlon 2020-02-28 13:08:37 -05:00 committed by GitHub
parent 8620f437d0
commit 6c6bc1f48a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 600 additions and 48 deletions

View file

@ -87,6 +87,7 @@ export type IFieldFormatType = (new (
getConfig?: FieldFormatsGetConfigFn
) => FieldFormat) & {
id: FieldFormatId;
title: string;
fieldType: string | string[];
};

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 { ExpressionFunctionDefinition, KibanaDatatable } from 'src/plugins/expressions/public';
interface FormatColumn {
format: string;
columnId: string;
decimals?: number;
}
const supportedFormats: Record<string, { decimalsToPattern: (decimals?: number) => 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;
}),
};
},
};

View file

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

View file

@ -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(<IndexPatternDimensionPanel {...defaultProps} state={stateWithNumberCol} />);
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(<IndexPatternDimensionPanel {...defaultProps} state={stateWithNumberCol} />);
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(<IndexPatternDimensionPanel {...defaultProps} state={stateWithNumberCol} />);
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 {

View file

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

View file

@ -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<string, { title: string }> = {
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<string, unknown> }) => 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<State>({
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 (
<>
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.columnFormatLabel', {
defaultMessage: 'Value format',
})}
display="rowCompressed"
>
<EuiComboBox
fullWidth
compressed
isClearable={false}
data-test-subj="indexPattern-dimension-format"
singleSelection={{ asPlainText: true }}
options={[
defaultOption,
...Object.entries(supportedFormats).map(([id, format]) => ({
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 },
});
}}
/>
</EuiFormRow>
{currentFormat ? (
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.decimalPlacesLabel', {
defaultMessage: 'Decimals',
})}
display="rowCompressed"
>
<EuiFieldNumber
data-test-subj="indexPattern-dimension-formatDecimals"
value={state.decimalPlaces}
min={0}
max={20}
onChange={e => {
setState({ decimalPlaces: Number(e.target.value) });
onChange({
id: (selectedColumn.params as { format: { id: string } }).format.id,
params: {
decimals: Number(e.target.value),
},
});
}}
compressed
fullWidth
/>
</EuiFormRow>
) : null}
</>
);
}

View file

@ -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' ? (
<FormatSelector
selectedColumn={selectedColumn}
onChange={newFormat => {
setState(
updateColumnParam({
state,
layerId,
currentColumn: selectedColumn,
paramName: 'format',
value: newFormat,
})
);
}}
/>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

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

View file

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

View file

@ -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<CardinalityIndexPatternCo
(!newField.aggregationRestrictions || newField.aggregationRestrictions.cardinality)
);
},
buildColumn({ suggestedPriority, field }) {
buildColumn({ suggestedPriority, field, previousColumn }) {
return {
label: ofName(field.name),
dataType: 'number',
@ -58,6 +58,8 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo
suggestedPriority,
sourceField: field.name,
isBucketed: IS_BUCKETED,
params:
previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined,
};
},
toEsAggsConfig: (column, columnId) => ({

View file

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

View file

@ -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<CountIndexPatternColumn> = {
type: 'count',
@ -40,7 +39,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn> = {
};
}
},
buildColumn({ suggestedPriority, field }) {
buildColumn({ suggestedPriority, field, previousColumn }) {
return {
label: countLabel,
dataType: 'number',
@ -49,6 +48,8 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn> = {
isBucketed: false,
scale: 'ratio',
sourceField: field.name,
params:
previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined,
};
},
toEsAggsConfig: (column, columnId) => ({

View file

@ -117,6 +117,7 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn>
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<IndexPatternColumn>;

View file

@ -6,9 +6,13 @@
import { i18n } from '@kbn/i18n';
import { OperationDefinition } from '.';
import { ParameterlessIndexPatternColumn } from './column_types';
import { FormattedIndexPatternColumn } from './column_types';
function buildMetricOperation<T extends ParameterlessIndexPatternColumn<string>>({
type MetricColumn<T> = FormattedIndexPatternColumn & {
operationType: T;
};
function buildMetricOperation<T extends MetricColumn<string>>({
type,
displayName,
ofName,
@ -46,7 +50,7 @@ function buildMetricOperation<T extends ParameterlessIndexPatternColumn<string>>
(!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<T extends ParameterlessIndexPatternColumn<string>>
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<T extends ParameterlessIndexPatternColumn<string>>
} as OperationDefinition<T>;
}
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<MinIndexPatternColumn>({
type: 'min',

View file

@ -202,6 +202,7 @@ export function buildColumn({
layerId,
indexPattern,
suggestedPriority,
previousColumn,
}: {
op?: OperationType;
columns: Partial<Record<string, IndexPatternColumn>>;
@ -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';

View file

@ -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', () => {

View file

@ -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<C extends IndexPatternColumn, K extends keyof C['params']>({
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: {

View file

@ -40,6 +40,21 @@ function getExpressionForLayer(
};
}, {} as Record<string, OriginalColumn>);
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;