mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
* [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> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
4db8acd770
commit
1dfb5386e1
18 changed files with 600 additions and 48 deletions
|
@ -87,6 +87,7 @@ export type IFieldFormatType = (new (
|
|||
getConfig?: FieldFormatsGetConfigFn
|
||||
) => FieldFormat) & {
|
||||
id: FieldFormatId;
|
||||
title: string;
|
||||
fieldType: string | string[];
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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 => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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\\"}}' "
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue