diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx index 12e7e49dc2d4c..cae1b6b90ccd9 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -20,9 +20,9 @@ export function FrameLayout(props: FrameLayoutProps) { {/* TODO style this and add workspace prop and loading flags */} - {props.dataPanel} - {props.workspacePanel} - + {props.dataPanel} + {props.workspacePanel} + {props.configPanel} {props.suggestionsPanel} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts index b048b6e840484..850cdfc2b3c0f 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts @@ -30,7 +30,7 @@ describe('suggestion helpers', () => { 'vis1', {} ); - expect(suggestions.length).toBe(1); + expect(suggestions).toHaveLength(1); expect(suggestions[0].state).toBe(suggestedState); }); @@ -57,7 +57,7 @@ describe('suggestion helpers', () => { 'vis1', {} ); - expect(suggestions.length).toBe(3); + expect(suggestions).toHaveLength(3); }); it('should rank the visualizations by score', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index 9efa5da6a6cf1..d294e9741f373 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -56,8 +56,8 @@ describe('workspace_panel', () => { /> ); - expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); }); it('should render an explanatory text if the visualization does not produce an expression', () => { @@ -76,8 +76,8 @@ describe('workspace_panel', () => { /> ); - expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); }); it('should render an explanatory text if the datasource does not produce an expression', () => { @@ -96,8 +96,8 @@ describe('workspace_panel', () => { /> ); - expect(instance.find('[data-test-subj="empty-workspace"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); }); it('should render the resulting expression using the expression renderer', () => { @@ -158,8 +158,8 @@ Object { /> ); - expect(instance.find('[data-test-subj="expression-failure"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + expect(instance.find('[data-test-subj="expression-failure"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); }); it('should show an error message if the expression fails to render', async () => { @@ -191,8 +191,8 @@ Object { instance.update(); - expect(instance.find('[data-test-subj="expression-failure"]').length).toBe(1); - expect(instance.find(expressionRendererMock).length).toBe(0); + expect(instance.find('[data-test-subj="expression-failure"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); }); it('should not attempt to run the expression again if it does not change', async () => { @@ -271,7 +271,7 @@ Object { expect(expressionRendererMock).toHaveBeenCalledTimes(2); - expect(instance.find(expressionRendererMock).length).toBe(1); + expect(instance.find(expressionRendererMock)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx index 9875c7a8396d2..59c3c85f9dd28 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -17,7 +17,7 @@ import { const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); // mock away actual data plugin to prevent all of it being loaded -jest.mock('../../../../../src/legacy/core_plugins/data/public', () => {}); +jest.mock('../../../../../src/legacy/core_plugins/data/public/setup', () => {}); describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 0dc6636b255b2..5fd1e169be42d 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -8,11 +8,8 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { CoreSetup } from 'src/core/public'; -import { - DataSetup, - data, - ExpressionRenderer, -} from '../../../../../src/legacy/core_plugins/data/public'; +import { DataSetup, ExpressionRenderer } from '../../../../../src/legacy/core_plugins/data/public'; +import { data } from '../../../../../src/legacy/core_plugins/data/public/setup'; import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts index 0d7fcdecbc340..9c07b07ae27fa 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts @@ -7,7 +7,7 @@ const actual = jest.requireActual('../operations'); jest.spyOn(actual, 'getPotentialColumns'); -jest.spyOn(actual, 'getColumnOrder'); +jest.spyOn(actual.operationDefinitionMap.date_histogram, 'inlineOptions'); export const { getPotentialColumns, @@ -16,4 +16,5 @@ export const { getOperationDisplay, getOperationTypesForField, getOperationResultType, + operationDefinitionMap, } = actual; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts new file mode 100644 index 0000000000000..1df52a3fd80ea --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +const actual = jest.requireActual('../state_helpers'); + +jest.spyOn(actual, 'changeColumn'); + +export const { + getColumnOrder, + changeColumn, + deleteColumn, + updateColumnParam, + sortByField, + hasField, +} = actual; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx deleted file mode 100644 index 43292d806f73a..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/* - * 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 _ from 'lodash'; -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiButtonIcon, - EuiComboBox, - EuiPopover, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, -} from '@elastic/eui'; -import { DatasourceDimensionPanelProps } from '../types'; -import { - IndexPatternColumn, - IndexPatternPrivateState, - columnToOperation, - IndexPatternField, -} from './indexpattern'; - -import { - getOperationDisplay, - getOperations, - getPotentialColumns, - getColumnOrder, -} from './operations'; -import { DragContextState, DragDrop, ChildDragDropProvider } from '../drag_drop'; - -export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { - state: IndexPatternPrivateState; - setState: (newState: IndexPatternPrivateState) => void; - dragDropContext: DragContextState; -}; - -export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { - const [isOpen, setOpen] = useState(false); - - const operations = getOperations(); - const operationPanels = getOperationDisplay(); - - const columns = getPotentialColumns(props.state, props.suggestedPriority); - - const filteredColumns = columns.filter(col => { - return props.filterOperations(columnToOperation(col)); - }); - - const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; - - function canHandleDrop() { - const { dragging } = props.dragDropContext; - const field = dragging as IndexPatternField; - - return ( - !!field && - !!field.type && - filteredColumns.some(({ sourceField }) => sourceField === (field as IndexPatternField).name) - ); - } - - function changeColumn(column: IndexPatternColumn) { - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - [props.columnId]: column, - }; - - props.setState({ - ...props.state, - columnOrder: getColumnOrder(newColumns), - columns: newColumns, - }); - } - - const uniqueColumnsByField = _.uniq(filteredColumns, col => col.sourceField); - - const functionsFromField = selectedColumn - ? filteredColumns.filter(col => { - return col.sourceField === selectedColumn.sourceField; - }) - : filteredColumns; - - return ( - - { - const column = columns.find( - ({ sourceField }) => sourceField === (field as IndexPatternField).name - ); - - if (!column) { - // TODO: What do we do if we couldn't find a column? - return; - } - - changeColumn(column); - }} - > - - - { - setOpen(false); - }} - ownFocus - anchorPosition="rightCenter" - button={ - - { - setOpen(!isOpen); - }} - > - - {selectedColumn - ? selectedColumn.label - : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { - defaultMessage: 'Configure dimension', - })} - - - - } - > - - - ({ - label: col.sourceField, - value: col.operationId, - }))} - selectedOptions={ - selectedColumn - ? [ - { - label: selectedColumn.sourceField, - value: selectedColumn.operationId, - }, - ] - : [] - } - singleSelection={{ asPlainText: true }} - isClearable={false} - onChange={choices => { - const column: IndexPatternColumn = columns.find( - ({ operationId }) => operationId === choices[0].value - )!; - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - [props.columnId]: column, - }; - - props.setState({ - ...props.state, - columns: newColumns, - columnOrder: getColumnOrder(newColumns), - }); - }} - /> - - -
- {operations.map(o => ( - col.operationType === o)} - onClick={() => { - if (!selectedColumn) { - return; - } - - const newColumn: IndexPatternColumn = filteredColumns.find( - col => - col.operationType === o && - col.sourceField === selectedColumn.sourceField - )!; - - changeColumn(newColumn); - }} - > - {operationPanels[o].displayName} - - ))} -
-
-
-
-
- {selectedColumn && ( - - { - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - }; - delete newColumns[props.columnId]; - - props.setState({ - ...props.state, - columns: newColumns, - columnOrder: getColumnOrder(newColumns), - }); - }} - /> - - )} -
-
-
- ); -} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx similarity index 59% rename from x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx rename to x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx index 6a3020dffcee0..863bd005a5a5c 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx @@ -6,14 +6,16 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; -import { EuiComboBox } from '@elastic/eui'; -import { IndexPatternPrivateState } from './indexpattern'; -import { getColumnOrder, getPotentialColumns } from './operations'; +import { EuiComboBox, EuiContextMenuItem } from '@elastic/eui'; +import { IndexPatternPrivateState } from '../indexpattern'; +import { changeColumn } from '../state_helpers'; +import { getPotentialColumns, operationDefinitionMap } from '../operations'; import { IndexPatternDimensionPanel } from './dimension_panel'; -import { DragContextState, DropHandler } from '../drag_drop'; -import { createMockedDragDropContext } from './mocks'; +import { DropHandler, DragContextState } from '../../drag_drop'; +import { createMockedDragDropContext } from '../mocks'; -jest.mock('./operations'); +jest.mock('../state_helpers'); +jest.mock('../operations'); const expectedIndexPatterns = { 1: { @@ -61,6 +63,9 @@ describe('IndexPatternDimensionPanel', () => { // Private operationType: 'date_histogram', + params: { + interval: '1d', + }, sourceField: 'timestamp', }, }, @@ -71,7 +76,7 @@ describe('IndexPatternDimensionPanel', () => { jest.clearAllMocks(); }); - it('should display a call to action in the popover button', () => { + it('should display a configure button if dimension has no column yet', () => { const wrapper = mount( { ); expect( wrapper - .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .find('[data-test-subj="indexPattern-configure-dimension"]') .first() .text() ).toEqual('Configure dimension'); @@ -120,8 +125,27 @@ describe('IndexPatternDimensionPanel', () => { expect(filterOperations).toBeCalled(); }); + it('should show field select combo box on click', () => { + const wrapper = mount( + {}} + columnId={'col2'} + filterOperations={() => true} + /> + ); + + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + + expect(wrapper.find(EuiComboBox)).toHaveLength(1); + }); + it('should not show any choices if the filter returns false', () => { - const wrapper = shallow( + const wrapper = mount( { /> ); - expect(wrapper.find(EuiComboBox)!.prop('options')!.length).toEqual(0); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + + expect(wrapper.find(EuiComboBox)!.prop('options')!).toHaveLength(0); + }); + + it('should render the inline options directly', () => { + mount( + {}} + columnId={'col1'} + filterOperations={() => false} + /> + ); + + expect(operationDefinitionMap.date_histogram.inlineOptions as jest.Mock).toHaveBeenCalledTimes( + 1 + ); + }); + + it('should not render the settings button if there are no settings or options', () => { + const wrapper = mount( + {}} + columnId={'col1'} + filterOperations={() => false} + /> + ); + + expect(wrapper.find('[data-test-subj="indexPattern-dimensionPopover-button"]')).toHaveLength(0); }); - it('should list all field names in sorted order', () => { - const wrapper = shallow( + it('should render the settings button if there are settings', () => { + const wrapper = mount( + {}} + columnId={'col1'} + filterOperations={() => false} + /> + ); + + expect( + wrapper.find('EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-button"]').length + ).toBe(1); + }); + + it('should list all field names and document as a whole in sorted order', () => { + const wrapper = mount( { /> ); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + const options = wrapper.find(EuiComboBox).prop('options'); - expect(options!.map(({ label }) => label)).toEqual([ + expect(options![0].label).toEqual('Document'); + + expect(options![1].options!.map(({ label }) => label)).toEqual([ 'bytes', - 'documents', 'source', 'timestamp', ]); }); - it("should disable functions that won't work with the current column", () => { + it('should show all functions that work with the current column', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( true} /> ); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('color') - ).toEqual('primary'); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('isDisabled') - ).toEqual(false); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-terms"]').prop('isDisabled') - ).toEqual(true); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-sum"]').prop('isDisabled') - ).toEqual(true); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-avg"]').prop('isDisabled') - ).toEqual(true); - expect( - wrapper.find('[data-test-subj="lns-indexPatternDimension-count"]').prop('isDisabled') - ).toEqual(true); + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .simulate('click'); + + expect(wrapper.find(EuiContextMenuItem).map(instance => instance.text())).toEqual([ + 'Minimum', + 'Maximum', + 'Average', + 'Sum', + ]); }); - it('should update the datasource state on selection of a value operation', () => { + it('should update the datasource state on selection of an operation', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( true} suggestedPriority={1} /> ); - const comboBox = wrapper.find(EuiComboBox)!; - const firstOption = comboBox.prop('options')![0]; + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .simulate('click'); - comboBox.prop('onChange')!([firstOption]); + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .simulate('click'); expect(setState).toHaveBeenCalledWith({ ...state, columns: { ...state.columns, - col2: expect.objectContaining({ - sourceField: firstOption.label, + col1: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', // Other parts of this don't matter for this test }), }, - columnOrder: ['col1', 'col2'], }); }); - it('should always request the new sort order when changing the function', () => { + it('should update the datasource state on selection of a field', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( { /> ); - wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').simulate('click'); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); - expect(getColumnOrder).toHaveBeenCalledWith({ - col1: expect.objectContaining({ - sourceField: 'timestamp', - operationType: 'date_histogram', - }), + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options![1]; + + comboBox.prop('onChange')!([option]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + // Other parts of this don't matter for this test + }), + }, }); }); - it('should update the datasource state when the user makes a selection', () => { + it('should add a column on selection of a field', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( op.dataType === 'number'} + filterOperations={() => true} + suggestedPriority={1} /> ); + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + const comboBox = wrapper.find(EuiComboBox)!; - const firstField = comboBox.prop('options')![0]; + const option = comboBox.prop('options')![1].options![0]; - comboBox.prop('onChange')!([firstField]); + comboBox.prop('onChange')!([option]); expect(setState).toHaveBeenCalledWith({ ...state, columns: { ...state.columns, col2: expect.objectContaining({ - operationId: firstField.value, + sourceField: 'bytes', + // Other parts of this don't matter for this test }), }, columnOrder: ['col1', 'col2'], }); }); + it('should use helper function when changing the function', () => { + const setState = jest.fn(); + + const wrapper = mount( + true} + suggestedPriority={1} + /> + ); + + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .simulate('click'); + + expect(changeColumn).toHaveBeenCalledWith( + expect.anything(), + 'col1', + expect.objectContaining({ + sourceField: 'bytes', + operationType: 'min', + }) + ); + }); + it('should clear the dimension with the clear button', () => { const setState = jest.fn(); - const wrapper = shallow( + const wrapper = mount( { /> ); - const clearButton = wrapper.find('[data-test-subj="indexPattern-dimensionPopover-remove"]'); + const clearButton = wrapper.find( + 'EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-remove"]' + ); clearButton.simulate('click'); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx new file mode 100644 index 0000000000000..bd318be148e17 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx @@ -0,0 +1,97 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { DatasourceDimensionPanelProps } from '../../types'; +import { + IndexPatternColumn, + IndexPatternPrivateState, + columnToOperation, + IndexPatternField, +} from '../indexpattern'; + +import { getPotentialColumns, operationDefinitionMap } from '../operations'; +import { FieldSelect } from './field_select'; +import { Settings } from './settings'; +import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; +import { changeColumn, hasField } from '../state_helpers'; + +export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; + dragDropContext: DragContextState; +}; + +export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { + const columns = getPotentialColumns(props.state, props.suggestedPriority); + + const filteredColumns = columns.filter(col => { + return props.filterOperations(columnToOperation(col)); + }); + + const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; + + const ParamEditor = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].inlineOptions; + + function findColumnByField(field: IndexPatternField) { + return filteredColumns.find(col => hasField(col) && col.sourceField === field.name); + } + + function canHandleDrop() { + const { dragging } = props.dragDropContext; + const field = dragging as IndexPatternField; + + return !!field && !!field.type && !!findColumnByField(field as IndexPatternField); + } + + return ( + + { + const column = findColumnByField(field as IndexPatternField); + + if (!column) { + // TODO: What do we do if we couldn't find a column? + return; + } + + props.setState(changeColumn(props.state, props.columnId, column)); + }} + > + + + + + + + + {ParamEditor && ( + + + + )} + + + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx new file mode 100644 index 0000000000000..b14f9503dadf3 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx @@ -0,0 +1,139 @@ +/* + * 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 _ from 'lodash'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiButtonEmpty, EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; +import { IndexPatternColumn, FieldBasedIndexPatternColumn } from '../indexpattern'; +import { IndexPatternDimensionPanelProps } from './dimension_panel'; +import { changeColumn, deleteColumn, hasField, sortByField } from '../state_helpers'; + +export interface FieldSelectProps extends IndexPatternDimensionPanelProps { + selectedColumn: IndexPatternColumn; + filteredColumns: IndexPatternColumn[]; +} + +export function FieldSelect({ + selectedColumn, + filteredColumns, + state, + columnId, + setState, +}: FieldSelectProps) { + const [isFieldSelectOpen, setFieldSelectOpen] = useState(false); + const fieldColumns = filteredColumns.filter(hasField) as FieldBasedIndexPatternColumn[]; + + const uniqueColumnsByField = sortByField( + _.uniq( + fieldColumns + .filter(col => selectedColumn && col.operationType === selectedColumn.operationType) + .concat(fieldColumns), + col => col.sourceField + ) + ); + + const fieldOptions = []; + const fieldLessColumn = filteredColumns.find(column => !hasField(column)); + if (fieldLessColumn) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.documentField', { + defaultMessage: 'Document', + }), + value: fieldLessColumn.operationId, + }); + } + + if (uniqueColumnsByField.length > 0) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', { + defaultMessage: 'Individual fields', + }), + options: uniqueColumnsByField.map(col => ({ + label: col.sourceField, + value: col.operationId, + })), + }); + } + + return ( + <> + + {!isFieldSelectOpen ? ( + setFieldSelectOpen(true)} + > + {selectedColumn + ? selectedColumn.label + : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { + defaultMessage: 'Configure dimension', + })} + + ) : ( + { + if (el) { + el.focus(); + } + }} + onBlur={() => { + setFieldSelectOpen(false); + }} + data-test-subj="indexPattern-dimension-field" + placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { + defaultMessage: 'Field', + })} + options={fieldOptions} + selectedOptions={ + selectedColumn && hasField(selectedColumn) + ? [ + { + label: selectedColumn.sourceField, + value: selectedColumn.operationId, + }, + ] + : [] + } + singleSelection={{ asPlainText: true }} + isClearable={true} + onChange={choices => { + setFieldSelectOpen(false); + + if (choices.length === 0) { + setState(deleteColumn(state, columnId)); + return; + } + + const column: IndexPatternColumn = filteredColumns.find( + ({ operationId }) => operationId === choices[0].value + )!; + + setState(changeColumn(state, columnId, column)); + }} + /> + )} + + {selectedColumn && ( + + { + setState(deleteColumn(state, columnId)); + }} + /> + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts new file mode 100644 index 0000000000000..88e5588ce0e01 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export * from './dimension_panel'; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx new file mode 100644 index 0000000000000..b9f0cf771a34a --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel/settings.tsx @@ -0,0 +1,111 @@ +/* + * 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 _ from 'lodash'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPopover, + EuiButtonIcon, + EuiFlexItem, + EuiContextMenuItem, + EuiContextMenuPanel, +} from '@elastic/eui'; +import { IndexPatternColumn } from '../indexpattern'; +import { IndexPatternDimensionPanelProps } from './dimension_panel'; +import { operationDefinitionMap, getOperations, getOperationDisplay } from '../operations'; +import { changeColumn, hasField } from '../state_helpers'; + +export interface SettingsProps extends IndexPatternDimensionPanelProps { + selectedColumn: IndexPatternColumn; + filteredColumns: IndexPatternColumn[]; +} + +export function Settings({ + selectedColumn, + filteredColumns, + state, + columnId, + setState, +}: SettingsProps) { + const [isSettingsOpen, setSettingsOpen] = useState(false); + const contextOptionBuilder = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].contextMenu; + const contextOptions = contextOptionBuilder + ? contextOptionBuilder({ + state, + setState, + columnId, + }) + : []; + const operations = getOperations(); + const operationPanels = getOperationDisplay(); + const functionsFromField = selectedColumn + ? filteredColumns.filter(col => { + return ( + (!hasField(selectedColumn) && !hasField(col)) || + (hasField(selectedColumn) && + hasField(col) && + col.sourceField === selectedColumn.sourceField) + ); + }) + : filteredColumns; + + const operationMenuItems = operations + .filter(o => selectedColumn && functionsFromField.some(col => col.operationType === o)) + .map(o => ( + { + const newColumn: IndexPatternColumn = filteredColumns.find( + col => + col.operationType === o && + (!hasField(col) || + !hasField(selectedColumn) || + col.sourceField === selectedColumn.sourceField) + )!; + + setState(changeColumn(state, columnId, newColumn)); + }} + > + {operationPanels[o].displayName} + + )); + + return selectedColumn && (operationMenuItems.length > 1 || contextOptions.length > 0) ? ( + + { + setSettingsOpen(false); + }} + ownFocus + anchorPosition="leftCenter" + panelPaddingSize="none" + button={ + + { + setSettingsOpen(!isSettingsOpen); + }} + iconType="gear" + aria-label={i18n.translate('xpack.lens.indexPattern.settingsLabel', { + defaultMessage: 'Settings', + })} + /> + + } + > + {operationMenuItems.concat(contextOptions)} + + + ) : null; +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index affddb88277a5..cb2c610aff548 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -124,6 +124,10 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'terms', sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + }, }, }, }; @@ -221,7 +225,6 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'count', - sourceField: 'document', }, col2: { operationId: 'op2', @@ -232,6 +235,9 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'date_histogram', sourceField: 'timestamp', + params: { + interval: '1d', + }, }, }, }; @@ -241,7 +247,7 @@ describe('IndexPattern Data Source', () => { index=\\"1\\" metricsAtAllLevels=\\"false\\" partialRows=\\"false\\" - aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1h\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]' | lens_rename_columns idMap='{\\"col-0-col1\\":\\"col1\\",\\"col-1-col2\\":\\"col2\\"}'" + aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]' | lens_rename_columns idMap='{\\"col-0-col1\\":\\"col1\\",\\"col-1-col2\\":\\"col2\\"}'" `); }); }); @@ -277,7 +283,6 @@ describe('IndexPattern Data Source', () => { }), col2: expect.objectContaining({ operationType: 'count', - sourceField: 'documents', }), }, }) @@ -315,7 +320,6 @@ describe('IndexPattern Data Source', () => { }), col2: expect.objectContaining({ operationType: 'count', - sourceField: 'documents', }), }, }) @@ -353,7 +357,7 @@ describe('IndexPattern Data Source', () => { }), col2: expect.objectContaining({ sourceField: 'bytes', - operationType: 'sum', + operationType: 'min', }), }, }) diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 94660e1335bea..3ae7a20c24540 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,7 +7,6 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { i18n } from '@kbn/i18n'; import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; @@ -23,11 +22,20 @@ import { getIndexPatterns } from './loader'; import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; -import { makeOperation, getOperationTypesForField } from './operations'; +import { buildColumnForOperationType, getOperationTypesForField } from './operations'; -export type OperationType = 'terms' | 'date_histogram' | 'sum' | 'avg' | 'min' | 'max' | 'count'; +export type OperationType = IndexPatternColumn['operationType']; -export interface IndexPatternColumn { +export type IndexPatternColumn = + | DateHistogramIndexPatternColumn + | TermsIndexPatternColumn + | SumIndexPatternColumn + | AvgIndexPatternColumn + | MinIndexPatternColumn + | MaxIndexPatternColumn + | CountIndexPatternColumn; + +export interface BaseIndexPatternColumn { // Public operationId: string; label: string; @@ -36,10 +44,45 @@ export interface IndexPatternColumn { // Private operationType: OperationType; + suggestedOrder?: DimensionPriority; +} + +type Omit = Pick>; +type ParameterlessIndexPatternColumn< + TOperationType extends OperationType, + TBase extends BaseIndexPatternColumn = FieldBasedIndexPatternColumn +> = Omit & { operationType: TOperationType }; + +export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn { sourceField: string; suggestedOrder?: DimensionPriority; } +export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'date_histogram'; + params: { + interval: string; + timeZone?: string; + }; +} + +export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'terms'; + params: { + size: number; + orderBy: { type: 'alphabetical' } | { type: 'column'; columnId: string }; + }; +} + +export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< + 'count', + BaseIndexPatternColumn +>; +export type SumIndexPatternColumn = ParameterlessIndexPatternColumn<'sum'>; +export type AvgIndexPatternColumn = ParameterlessIndexPatternColumn<'avg'>; +export type MinIndexPatternColumn = ParameterlessIndexPatternColumn<'min'>; +export type MaxIndexPatternColumn = ParameterlessIndexPatternColumn<'max'>; + export interface IndexPattern { id: string; fields: IndexPatternField[]; @@ -253,19 +296,9 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To const hasBucket = operations.find(op => op === 'date_histogram' || op === 'terms'); if (hasBucket) { - const column = makeOperation(0, hasBucket, field); - - const countColumn: IndexPatternColumn = { - operationId: 'count', - label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { - defaultMessage: 'Count of Documents', - }), - dataType: 'number', - isBucketed: false, - - operationType: 'count', - sourceField: 'documents', - }; + const column = buildColumnForOperationType(0, hasBucket, undefined, field); + + const countColumn = buildColumnForOperationType(1, 'count'); const suggestion: DatasourceSuggestion = { state: { @@ -300,9 +333,9 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To f => f.name === currentIndexPattern.timeFieldName )!; - const column = makeOperation(0, operations[0], field); + const column = buildColumnForOperationType(0, operations[0], undefined, field); - const dateColumn = makeOperation(1, 'date_histogram', dateField); + const dateColumn = buildColumnForOperationType(1, 'date_histogram', undefined, dateField); const suggestion: DatasourceSuggestion = { state: { diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts index 41aa3737cde9b..8ad9e1beb8bf9 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts @@ -24,8 +24,8 @@ interface SavedRestrictionsObject { string, { agg: string; - interval?: number; fixed_interval?: string; + calendar_interval?: string; delay?: string; time_zone?: string; } diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx new file mode 100644 index 0000000000000..d37504ad32fe5 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/count.tsx @@ -0,0 +1,40 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { CountIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; + +export const countOperation: OperationDefinition = { + type: 'count', + displayName: i18n.translate('xpack.lens.indexPattern.count', { + defaultMessage: 'Count', + }), + isApplicableWithoutField: true, + isApplicableForField: ({ aggregationRestrictions, type }) => { + return false; + }, + buildColumn(operationId: string, suggestedOrder?: DimensionPriority): CountIndexPatternColumn { + return { + operationId, + label: i18n.translate('xpack.lens.indexPattern.countOf', { + defaultMessage: 'Count of documents', + }), + dataType: 'number', + operationType: 'count', + suggestedOrder, + isBucketed: false, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }), +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx new file mode 100644 index 0000000000000..94cd3e6495672 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.test.tsx @@ -0,0 +1,170 @@ +/* + * 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 from 'react'; +import { dateHistogramOperation } from './date_histogram'; +import { shallow } from 'enzyme'; +import { DateHistogramIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; +import { EuiRange } from '@elastic/eui'; + +describe('date_histogram', () => { + let state: IndexPatternPrivateState; + const InlineOptions = dateHistogramOperation.inlineOptions!; + + beforeEach(() => { + state = { + indexPatterns: { + 1: { + id: '1', + title: 'Mock Indexpattern', + fields: [ + { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }, + ], + }, + }, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: 'w', + }, + sourceField: 'timestamp', + }, + }, + }; + }); + + describe('buildColumn', () => { + it('should create column object with default params', () => { + const column = dateHistogramOperation.buildColumn('op', 0, { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }); + expect(column.params.interval).toEqual('h'); + }); + + it('should create column object with restrictions', () => { + const column = dateHistogramOperation.buildColumn('op', 0, { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'UTC', + calendar_interval: '1y', + }, + }, + }); + expect(column.params.interval).toEqual('1y'); + expect(column.params.timeZone).toEqual('UTC'); + }); + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const esAggsConfig = dateHistogramOperation.toEsAggsConfig( + state.columns.col1 as DateHistogramIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + interval: 'w', + field: 'timestamp', + }), + }) + ); + }); + }); + + describe('param editor', () => { + it('should render current value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange).prop('value')).toEqual(1); + }); + + it('should update state with the interval value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + instance.find(EuiRange).prop('onChange')!({ + target: { + value: '2', + }, + } as React.ChangeEvent); + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col1: { + ...state.columns.col1, + params: { + interval: 'd', + }, + }, + }, + }); + }); + + it('should not render options if they are restricted', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx new file mode 100644 index 0000000000000..35a7de3ef6b6e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/date_histogram.tsx @@ -0,0 +1,158 @@ +/* + * 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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiForm, EuiFormRow, EuiRange } from '@elastic/eui'; +import { IndexPatternField, DateHistogramIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; +import { updateColumnParam } from '../state_helpers'; + +type PropType = C extends React.ComponentType ? P : unknown; + +// Add ticks to EuiRange component props +const FixedEuiRange = (EuiRange as unknown) as React.ComponentType< + PropType & { + ticks?: Array<{ + label: string; + value: number; + }>; + } +>; + +function ofName(name: string) { + return i18n.translate('xpack.lens.indexPattern.dateHistogramOf', { + defaultMessage: 'Date Histogram of {name}', + values: { name }, + }); +} + +export const dateHistogramOperation: OperationDefinition = { + type: 'date_histogram', + displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { + defaultMessage: 'Date Histogram', + }), + isApplicableWithoutField: false, + isApplicableForField: ({ aggregationRestrictions, type }) => { + return Boolean( + type === 'date' && (!aggregationRestrictions || aggregationRestrictions.date_histogram) + ); + }, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ): DateHistogramIndexPatternColumn { + if (!field) { + throw new Error('Invariant error: date histogram operation requires field'); + } + let interval = 'h'; + let timeZone: string | undefined; + if (field.aggregationRestrictions && field.aggregationRestrictions.date_histogram) { + interval = (field.aggregationRestrictions.date_histogram.calendar_interval || + field.aggregationRestrictions.date_histogram.fixed_interval) as string; + timeZone = field.aggregationRestrictions.date_histogram.time_zone; + } + return { + operationId, + label: ofName(field.name), + dataType: 'date', + operationType: 'date_histogram', + suggestedOrder, + sourceField: field.name, + isBucketed: true, + params: { + interval, + timeZone, + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: column.sourceField, + // TODO: This range should be passed in from somewhere else + timeRange: { + from: 'now-1d', + to: 'now', + }, + time_zone: column.params.timeZone, + useNormalizedEsInterval: true, + interval: column.params.interval, + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + }), + inlineOptions: ({ state, setState, columnId }) => { + const column = state.columns[columnId] as DateHistogramIndexPatternColumn; + + const field = + column && + state.indexPatterns[state.currentIndexPatternId].fields.find( + currentField => currentField.name === column.sourceField + ); + const intervalIsRestricted = + field!.aggregationRestrictions && field!.aggregationRestrictions.date_histogram; + + const intervals = ['M', 'w', 'd', 'h']; + + function intervalToNumeric(interval: string) { + return intervals.indexOf(interval); + } + + function numericToInterval(i: number) { + return intervals[i]; + } + + return ( + + + {intervalIsRestricted ? ( + + ) : ( + ({ label: interval, value: index }))} + onChange={(e: React.ChangeEvent) => + setState( + updateColumnParam( + state, + column, + 'interval', + numericToInterval(Number(e.target.value)) + ) + ) + } + aria-label={i18n.translate('xpack.lens.indexPattern.dateHistogram.interval', { + defaultMessage: 'Level of detail', + })} + /> + )} + + + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx new file mode 100644 index 0000000000000..4e5bb97276e20 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/metrics.tsx @@ -0,0 +1,110 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + IndexPatternField, + FieldBasedIndexPatternColumn, + MinIndexPatternColumn, + SumIndexPatternColumn, + AvgIndexPatternColumn, + MaxIndexPatternColumn, +} from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; + +function buildMetricOperation( + type: T['operationType'], + displayName: string, + ofName: (name: string) => string +) { + const operationDefinition: OperationDefinition = { + type, + displayName, + isApplicableWithoutField: false, + isApplicableForField: ({ aggregationRestrictions, type: fieldType }: IndexPatternField) => { + return Boolean( + fieldType === 'number' && (!aggregationRestrictions || aggregationRestrictions[type]) + ); + }, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ): T { + if (!field) { + throw new Error(`Invariant: A ${type} operation can only be built with a field`); + } + return { + operationId, + label: ofName(field ? field.name : ''), + dataType: 'number', + operationType: type, + suggestedOrder, + sourceField: field ? field.name : '', + isBucketed: false, + } as T; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: column.operationType, + schema: 'metric', + params: { + field: column.sourceField, + }, + }), + }; + return operationDefinition; +} + +export const minOperation = buildMetricOperation( + 'min', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Minimum', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Minimum of {name}', + values: { name }, + }) +); + +export const maxOperation = buildMetricOperation( + 'max', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Maximum', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Maximum of {name}', + values: { name }, + }) +); + +export const averageOperation = buildMetricOperation( + 'avg', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Average', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Average of {name}', + values: { name }, + }) +); + +export const sumOperation = buildMetricOperation( + 'sum', + i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Sum', + }), + name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Sum of {name}', + values: { name }, + }) +); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx new file mode 100644 index 0000000000000..4876ea42fc787 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.test.tsx @@ -0,0 +1,154 @@ +/* + * 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 from 'react'; +import { termsOperation } from './terms'; +import { shallow } from 'enzyme'; +import { IndexPatternPrivateState, TermsIndexPatternColumn } from '../indexpattern'; +import { EuiRange, EuiSelect } from '@elastic/eui'; + +describe('terms', () => { + let state: IndexPatternPrivateState; + const InlineOptions = termsOperation.inlineOptions!; + const contextMenu = termsOperation.contextMenu!; + + beforeEach(() => { + state = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 5, + }, + sourceField: 'category', + }, + col2: { + operationId: 'op1', + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }; + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const esAggsConfig = termsOperation.toEsAggsConfig( + state.columns.col1 as TermsIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + orderBy: '_key', + field: 'category', + size: 5, + }), + }) + ); + }); + }); + + describe('popover param editor', () => { + it('should render current value and options', () => { + const setStateSpy = jest.fn(); + const PartialMenu = () => ( + <>{contextMenu({ state, setState: setStateSpy, columnId: 'col1' })}/> + ); + const instance = shallow(); + + expect(instance.find(EuiSelect).prop('value')).toEqual('alphabetical'); + expect( + instance + .find(EuiSelect) + .prop('options') + .map(({ value }) => value) + ).toEqual(['column$$$col2', 'alphabetical']); + }); + + it('should update state with the order value', () => { + const setStateSpy = jest.fn(); + const PartialMenu = () => ( + <>{contextMenu({ state, setState: setStateSpy, columnId: 'col1' })}/> + ); + const instance = shallow(); + + instance.find(EuiSelect).prop('onChange')!({ + target: { + value: 'column$$$col2', + }, + } as React.ChangeEvent); + + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col1: { + ...state.columns.col1, + params: { + ...(state.columns.col1 as TermsIndexPatternColumn).params, + orderBy: { + type: 'column', + columnId: 'col2', + }, + }, + }, + }, + }); + }); + }); + + describe('inline param editor', () => { + it('should render current value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange).prop('value')).toEqual(5); + }); + + it('should update state with the size value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + instance.find(EuiRange).prop('onChange')!({ + target: { + value: '7', + }, + } as React.ChangeEvent); + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col1: { + ...state.columns.col1, + params: { + ...(state.columns.col1 as TermsIndexPatternColumn).params, + size: 7, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx new file mode 100644 index 0000000000000..d8a98eba90f86 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operation_definitions/terms.tsx @@ -0,0 +1,165 @@ +/* + * 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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiForm, EuiFormRow, EuiRange, EuiSelect, EuiContextMenuItem } from '@elastic/eui'; +import { IndexPatternField, TermsIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; +import { updateColumnParam } from '../state_helpers'; + +type PropType = C extends React.ComponentType ? P : unknown; + +// Add ticks to EuiRange component props +const FixedEuiRange = (EuiRange as unknown) as React.ComponentType< + PropType & { + ticks?: Array<{ + label: string; + value: number; + }>; + } +>; + +function ofName(name: string) { + return i18n.translate('xpack.lens.indexPattern.termsOf', { + defaultMessage: 'Top Values of {name}', + values: { name }, + }); +} + +export const termsOperation: OperationDefinition = { + type: 'terms', + displayName: i18n.translate('xpack.lens.indexPattern.terms', { + defaultMessage: 'Top Values', + }), + isApplicableWithoutField: false, + isApplicableForField: ({ aggregationRestrictions, type }) => { + return Boolean( + type === 'string' && (!aggregationRestrictions || aggregationRestrictions.terms) + ); + }, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ): TermsIndexPatternColumn { + return { + operationId, + label: ofName(field ? field.name : ''), + dataType: 'string', + operationType: 'terms', + suggestedOrder, + sourceField: field ? field.name : '', + isBucketed: true, + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'terms', + schema: 'segment', + params: { + field: column.sourceField, + orderBy: + column.params.orderBy.type === 'alphabetical' ? '_key' : column.params.orderBy.columnId, + order: 'desc', + size: column.params.size, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }), + contextMenu: ({ state, setState, columnId: currentColumnId }) => { + const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; + const SEPARATOR = '$$$'; + function toValue(orderBy: TermsIndexPatternColumn['params']['orderBy']) { + if (orderBy.type === 'alphabetical') { + return orderBy.type; + } + return `${orderBy.type}${SEPARATOR}${orderBy.columnId}`; + } + + function fromValue(value: string): TermsIndexPatternColumn['params']['orderBy'] { + if (value === 'alphabetical') { + return { type: 'alphabetical' }; + } + const parts = value.split(SEPARATOR); + return { + type: 'column', + columnId: parts[1], + }; + } + + const orderOptions = Object.entries(state.columns) + .filter(([_columnId, column]) => !column.isBucketed) + .map(([columnId, column]) => { + return { + value: toValue({ type: 'column', columnId }), + text: column.label, + }; + }); + orderOptions.push({ + value: toValue({ type: 'alphabetical' }), + text: i18n.translate('xpack.lens.indexPattern.terms.orderAlphabetical', { + defaultMessage: 'Alphabetical', + }), + }); + return [ + + + ) => + setState( + updateColumnParam(state, currentColumn, 'orderBy', fromValue(e.target.value)) + ) + } + /> + + , + ]; + }, + inlineOptions: ({ state, setState, columnId: currentColumnId }) => { + const currentColumn = state.columns[currentColumnId] as TermsIndexPatternColumn; + return ( + + + ) => + setState(updateColumnParam(state, currentColumn, 'size', Number(e.target.value))) + } + aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { + defaultMessage: 'Number of values', + })} + /> + + + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts index 09ff0ed27832b..c42ae4def66e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOperationTypesForField, getPotentialColumns, getColumnOrder } from './operations'; +import { getOperationTypesForField, getPotentialColumns } from './operations'; import { IndexPatternPrivateState } from './indexpattern'; +import { hasField } from './state_helpers'; const expectedIndexPatterns = { 1: { @@ -121,7 +122,7 @@ describe('getOperationTypesForField', () => { it('should return operations on dates', () => { expect( getOperationTypesForField({ - type: 'dates', + type: 'date', name: 'a', aggregatable: true, searchable: true, @@ -156,6 +157,9 @@ describe('getOperationTypesForField', () => { // Private operationType: 'date_histogram', sourceField: 'timestamp', + params: { + interval: 'h', + }, }, }, }; @@ -170,27 +174,29 @@ describe('getOperationTypesForField', () => { it('should list operations by field for a regular index pattern', () => { const columns = getPotentialColumns(state); - expect(columns.map(col => [col.sourceField, col.operationType])).toMatchInlineSnapshot(` + expect( + columns.map(col => [hasField(col) ? col.sourceField : '_documents_', col.operationType]) + ).toMatchInlineSnapshot(` Array [ Array [ "bytes", - "sum", + "min", ], Array [ "bytes", - "avg", + "max", ], Array [ "bytes", - "min", + "avg", ], Array [ - "bytes", - "max", + "_documents_", + "count", ], Array [ - "documents", - "count", + "bytes", + "sum", ], Array [ "source", @@ -205,103 +211,3 @@ Array [ }); }); }); - -describe('getColumnOrder', () => { - it('should work for empty columns', () => { - expect(getColumnOrder({})).toEqual([]); - }); - - it('should work for one column', () => { - expect( - getColumnOrder({ - col1: { - operationId: 'op1', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - }, - }) - ).toEqual(['col1']); - }); - - it('should put any number of aggregations before metrics', () => { - expect( - getColumnOrder({ - col1: { - operationId: 'op1', - label: 'Top Values of category', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - sourceField: 'category', - }, - col2: { - operationId: 'op2', - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'avg', - sourceField: 'bytes', - }, - col3: { - operationId: 'op3', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - }, - }) - ).toEqual(['col1', 'col3', 'col2']); - }); - - it('should reorder aggregations based on suggested priority', () => { - expect( - getColumnOrder({ - col1: { - operationId: 'op1', - label: 'Top Values of category', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - sourceField: 'category', - suggestedOrder: 2, - }, - col2: { - operationId: 'op2', - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'avg', - sourceField: 'bytes', - suggestedOrder: 0, - }, - col3: { - operationId: 'op3', - label: 'Date Histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - suggestedOrder: 1, - }, - }) - ).toEqual(['col3', 'col1', 'col2']); - }); -}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts index 2a2e6a82722c1..20edaf338fd61 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -4,165 +4,106 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { DataType, DimensionPriority } from '../types'; +import { DimensionPriority } from '../types'; import { IndexPatternColumn, IndexPatternField, IndexPatternPrivateState, OperationType, + BaseIndexPatternColumn, } from './indexpattern'; +import { termsOperation } from './operation_definitions/terms'; +import { + minOperation, + averageOperation, + sumOperation, + maxOperation, +} from './operation_definitions/metrics'; +import { dateHistogramOperation } from './operation_definitions/date_histogram'; +import { countOperation } from './operation_definitions/count'; +import { sortByField } from './state_helpers'; + +type PossibleOperationDefinitions< + U extends IndexPatternColumn = IndexPatternColumn +> = U extends IndexPatternColumn ? OperationDefinition : never; + +type PossibleOperationDefinitionMapEntyries< + U extends PossibleOperationDefinitions = PossibleOperationDefinitions +> = U extends PossibleOperationDefinitions ? { [K in U['type']]: U } : never; + +type UnionToIntersection = (U extends U ? (k: U) => void : never) extends ((k: infer I) => void) + ? I + : never; + +// this type makes sure that there is an operation definition for each column type +export type AllOperationDefinitions = UnionToIntersection; + +export const operationDefinitionMap: AllOperationDefinitions = { + terms: termsOperation, + date_histogram: dateHistogramOperation, + min: minOperation, + max: maxOperation, + avg: averageOperation, + sum: sumOperation, + count: countOperation, +}; +const operationDefinitions: PossibleOperationDefinitions[] = Object.values(operationDefinitionMap); export function getOperations(): OperationType[] { - // Raw value is not listed in the MVP - return ['terms', 'date_histogram', 'sum', 'avg', 'min', 'max', 'count']; + return Object.keys(operationDefinitionMap) as OperationType[]; } -export function getOperationDisplay(): Record< - OperationType, - { - type: OperationType; - displayName: string; - ofName: (name: string) => string; - } -> { - return { - terms: { - type: 'terms', - displayName: i18n.translate('xpack.lens.indexPattern.terms', { - defaultMessage: 'Top Values', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.termsOf', { - defaultMessage: 'Top Values of {name}', - values: { name }, - }), - }, - date_histogram: { - type: 'date_histogram', - displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { - defaultMessage: 'Date Histogram', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.dateHistogramOf', { - defaultMessage: 'Date Histogram of {name}', - values: { name }, - }), - }, - sum: { - type: 'sum', - displayName: i18n.translate('xpack.lens.indexPattern.sum', { - defaultMessage: 'Sum', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.sumOf', { - defaultMessage: 'Sum of {name}', - values: { name }, - }), - }, - avg: { - type: 'avg', - displayName: i18n.translate('xpack.lens.indexPattern.average', { - defaultMessage: 'Average', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.averageOf', { - defaultMessage: 'Average of {name}', - values: { name }, - }), - }, - min: { - type: 'min', - displayName: i18n.translate('xpack.lens.indexPattern.min', { - defaultMessage: 'Minimum', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.minOf', { - defaultMessage: 'Minimum of {name}', - values: { name }, - }), - }, - max: { - type: 'max', - displayName: i18n.translate('xpack.lens.indexPattern.max', { - defaultMessage: 'Maximum', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.maxOf', { - defaultMessage: 'Maximum of {name}', - values: { name }, - }), - }, - count: { - type: 'count', - displayName: i18n.translate('xpack.lens.indexPattern.count', { - defaultMessage: 'Count', - }), - ofName: name => - i18n.translate('xpack.lens.indexPattern.countOf', { - defaultMessage: 'Count of {name}', - values: { name }, - }), - }, - }; +export interface ParamEditorProps { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; + columnId: string; +} +export interface OperationDefinition { + type: C['operationType']; + displayName: string; + // TODO make this a function dependend on the indexpattern with typeMeta information + isApplicableWithoutField: boolean; + isApplicableForField: (field: IndexPatternField) => boolean; + buildColumn: ( + operationId: string, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField + ) => C; + inlineOptions?: React.ComponentType; + contextMenu?: (props: ParamEditorProps) => JSX.Element[]; + toEsAggsConfig: (column: C, columnId: string) => unknown; } -export function getOperationTypesForField({ - type, - aggregationRestrictions, -}: IndexPatternField): OperationType[] { - if (aggregationRestrictions) { - const validOperations = getOperations(); - return Object.keys(aggregationRestrictions).filter(key => - // Filter out operations that are available, but that aren't yet supported by the client - validOperations.includes(key as OperationType) - ) as OperationType[]; - } - - switch (type) { - case 'date': - return ['date_histogram']; - case 'number': - return ['sum', 'avg', 'min', 'max']; - case 'string': - return ['terms']; - } - return []; +export function getOperationDisplay() { + const display = {} as Record< + OperationType, + { + type: OperationType; + displayName: string; + } + >; + operationDefinitions.forEach(({ type, displayName }) => { + display[type] = { + type, + displayName, + }; + }); + return display; } -export function getOperationResultType({ type }: IndexPatternField, op: OperationType): DataType { - switch (op) { - case 'avg': - case 'min': - case 'max': - case 'count': - case 'sum': - return 'number'; - case 'date_histogram': - return 'date'; - case 'terms': - return 'string'; - } +export function getOperationTypesForField(field: IndexPatternField): OperationType[] { + return operationDefinitions + .filter(definition => definition.isApplicableForField(field)) + .map(({ type }) => type); } -export function makeOperation( +export function buildColumnForOperationType( index: number, - op: OperationType, - field: IndexPatternField, - suggestedOrder?: DimensionPriority + op: T, + suggestedOrder?: DimensionPriority, + field?: IndexPatternField ): IndexPatternColumn { - const operationPanels = getOperationDisplay(); - return { - operationId: `${index}${op}`, - label: operationPanels[op].ofName(field.name), - dataType: getOperationResultType(field, op), - isBucketed: op === 'terms' || op === 'date_histogram', - - operationType: op, - sourceField: field.name, - suggestedOrder, - }; + return operationDefinitionMap[op].buildColumn(`${index}${op}`, suggestedOrder, field); } export function getPotentialColumns( @@ -175,45 +116,17 @@ export function getPotentialColumns( .map((field, index) => { const validOperations = getOperationTypesForField(field); - return validOperations.map(op => { - return makeOperation(index, op, field, suggestedOrder); - }); + return validOperations.map(op => + buildColumnForOperationType(index, op, suggestedOrder, field) + ); }) .reduce((prev, current) => prev.concat(current)); - columns.push({ - operationId: 'count', - label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { - defaultMessage: 'Count of Documents', - }), - dataType: 'number', - isBucketed: false, - - operationType: 'count', - sourceField: 'documents', - suggestedOrder, + operationDefinitions.forEach(operation => { + if (operation.isApplicableWithoutField) { + columns.push(operation.buildColumn(operation.type, suggestedOrder)); + } }); - columns.sort(({ sourceField }, { sourceField: sourceField2 }) => - sourceField.localeCompare(sourceField2) - ); - - return columns; -} - -export function getColumnOrder(columns: Record): string[] { - const entries = Object.entries(columns); - - const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); - - return aggregations - .sort(([id, col], [id2, col2]) => { - return ( - // Sort undefined orders last - (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - - (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) - ); - }) - .map(([id]) => id) - .concat(metrics.map(([id]) => id)); + return sortByField(columns); } diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts new file mode 100644 index 0000000000000..88ba0b923d6ee --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts @@ -0,0 +1,214 @@ +/* + * 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 { updateColumnParam, getColumnOrder, changeColumn } from './state_helpers'; +import { IndexPatternPrivateState, DateHistogramIndexPatternColumn } from './indexpattern'; + +describe('state_helpers', () => { + describe('updateColumnParam', () => { + it('should set the param for the given column', () => { + const currentColumn: DateHistogramIndexPatternColumn = { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }; + + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }; + + expect(updateColumnParam(state, currentColumn, 'interval', 'M').columns.col1).toEqual({ + ...currentColumn, + params: { interval: 'M' }, + }); + }); + }); + + describe('changeColumn', () => { + it('should update order on changing the column', () => { + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col2: { + operationId: 'op1', + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }; + expect( + changeColumn(state, 'col2', { + operationId: 'op1', + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }) + ).toEqual( + expect.objectContaining({ + columnOrder: ['col2', 'col1'], + }) + ); + }); + }); + + describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }) + ).toEqual(['col1']); + }); + + it('should put any number of aggregations before metrics', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col3: { + operationId: 'op3', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col1', 'col3', 'col2']); + }); + + it('should reorder aggregations based on suggested priority', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + }, + suggestedOrder: 2, + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + suggestedOrder: 0, + }, + col3: { + operationId: 'op3', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedOrder: 1, + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.ts new file mode 100644 index 0000000000000..aec5bf5a3b7b4 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -0,0 +1,105 @@ +/* + * 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 _ from 'lodash'; +import { + IndexPatternPrivateState, + IndexPatternColumn, + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './indexpattern'; + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - + (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} + +export function updateColumnParam< + C extends BaseIndexPatternColumn & { params: object }, + K extends keyof C['params'] +>( + state: IndexPatternPrivateState, + currentColumn: C, + paramName: K, + value: C['params'][K] +): IndexPatternPrivateState { + const columnId = Object.entries(state.columns).find( + ([_columnId, column]) => column === currentColumn + )![0]; + + if (!('params' in state.columns[columnId])) { + throw new Error('Invariant: no params in this column'); + } + + return { + ...state, + columns: { + ...state.columns, + [columnId]: ({ + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, + }, + } as unknown) as IndexPatternColumn, + }, + }; +} + +export function changeColumn( + state: IndexPatternPrivateState, + columnId: string, + newColumn: IndexPatternColumn +) { + const newColumns: IndexPatternPrivateState['columns'] = { + ...state.columns, + [columnId]: newColumn, + }; + + return { + ...state, + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }; +} + +export function deleteColumn(state: IndexPatternPrivateState, columnId: string) { + const newColumns: IndexPatternPrivateState['columns'] = { + ...state.columns, + }; + delete newColumns[columnId]; + + return { + ...state, + columns: newColumns, + columnOrder: getColumnOrder(newColumns), + }; +} + +export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function sortByField(columns: C[]) { + return [...columns].sort((column1, column2) => { + if (hasField(column1) && hasField(column2)) { + return column1.sourceField.localeCompare(column2.sourceField); + } + return column1.operationType.localeCompare(column2.operationType); + }); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts index 3890eb32a2468..c284f51bffe65 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -6,7 +6,8 @@ import _ from 'lodash'; -import { IndexPatternPrivateState } from './indexpattern'; +import { IndexPatternPrivateState, IndexPatternColumn } from './indexpattern'; +import { operationDefinitionMap, OperationDefinition } from './operations'; export function toExpression(state: IndexPatternPrivateState) { if (state.columnOrder.length === 0) { @@ -15,65 +16,18 @@ export function toExpression(state: IndexPatternPrivateState) { const sortedColumns = state.columnOrder.map(col => state.columns[col]); + function getEsAggsConfig(column: C, columnId: string) { + // Typescript is not smart enough to infer that definitionMap[C['operationType']] is always OperationDefinition, + // but this is made sure by the typing of the operation map + const operationDefinition = (operationDefinitionMap[ + column.operationType + ] as unknown) as OperationDefinition; + return operationDefinition.toEsAggsConfig(column, columnId); + } + if (sortedColumns.length) { - const firstMetric = sortedColumns.findIndex(({ isBucketed }) => !isBucketed); const aggs = sortedColumns.map((col, index) => { - if (col.operationType === 'date_histogram') { - return { - id: state.columnOrder[index], - enabled: true, - type: 'date_histogram', - schema: 'segment', - params: { - field: col.sourceField, - // TODO: This range should be passed in from somewhere else - timeRange: { - from: 'now-1d', - to: 'now', - }, - useNormalizedEsInterval: true, - interval: '1h', - drop_partials: false, - min_doc_count: 1, - extended_bounds: {}, - }, - }; - } else if (col.operationType === 'terms') { - return { - id: state.columnOrder[index], - enabled: true, - type: 'terms', - schema: 'segment', - params: { - field: col.sourceField, - orderBy: state.columnOrder[firstMetric] || undefined, - order: 'desc', - size: 5, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - }; - } else if (col.operationType === 'count') { - return { - id: state.columnOrder[index], - enabled: true, - type: 'count', - schema: 'metric', - params: {}, - }; - } else { - return { - id: state.columnOrder[index], - enabled: true, - type: col.operationType, - schema: 'metric', - params: { - field: col.sourceField, - }, - }; - } + return getEsAggsConfig(col, state.columnOrder[index]); }); const idMap = state.columnOrder.reduce( diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts index 83665dd8a6fe4..b034c9fe78b27 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -89,7 +89,7 @@ describe('xy_suggestions', () => { ], }); - expect(rest.length).toEqual(0); + expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { "seriesType": "line", @@ -114,7 +114,7 @@ Object { ], }); - expect(rest.length).toEqual(0); + expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Object { "seriesType": "line", @@ -147,7 +147,7 @@ Object { ], }); - expect(rest.length).toEqual(0); + expect(rest).toHaveLength(0); expect([suggestionSubset(s1), suggestionSubset(s2)]).toMatchInlineSnapshot(` Array [ Object {