From f0023ed8797d79b8d8972cc93488e254a60070d1 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 19 Oct 2020 10:51:21 -0400 Subject: [PATCH] [Lens] Split up dimension panel code (#80423) * [Lens] Split up dimension panel code * Fix test failures * Style updates Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__mocks__/state_helpers.ts | 1 + .../dimension_panel/dimension_editor.tsx | 20 +- .../dimension_panel/dimension_panel.test.tsx | 2224 +++++++---------- .../dimension_panel/dimension_panel.tsx | 202 +- .../dimension_panel/droppable.test.ts | 594 +++++ .../dimension_panel/droppable.ts | 149 ++ .../dimension_panel/field_select.tsx | 2 +- .../dimension_panel/index.ts | 2 + .../dimension_panel/operation_support.ts | 60 + .../state_helpers.test.ts | 164 +- .../indexpattern_datasource/state_helpers.ts | 103 +- 11 files changed, 1855 insertions(+), 1666 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts index 0b7e4e6b3e22..47687ef10f88 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/state_helpers.ts @@ -17,4 +17,5 @@ export const { sortByField, hasField, updateLayerIndexPattern, + mergeLayer, } = actual; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index ed0591219b55..310548e5ab81 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -16,7 +16,8 @@ import { EuiListGroupItemProps, EuiFormLabel, } from '@elastic/eui'; -import { IndexPatternDimensionEditorProps, OperationSupportMatrix } from './dimension_panel'; +import { IndexPatternDimensionEditorProps } from './dimension_panel'; +import { OperationSupportMatrix } from './operation_support'; import { IndexPatternColumn, OperationType } from '../indexpattern'; import { operationDefinitionMap, @@ -24,7 +25,7 @@ import { buildColumn, changeField, } from '../operations'; -import { deleteColumn, changeColumn, updateColumnParam } from '../state_helpers'; +import { deleteColumn, changeColumn, updateColumnParam, mergeLayer } from '../state_helpers'; import { FieldSelect } from './field_select'; import { hasField, fieldIsInvalid } from '../utils'; import { BucketNestingEditor } from './bucket_nesting_editor'; @@ -394,12 +395,11 @@ export function DimensionEditor(props: DimensionEditorProps) { { - setState({ - ...state, - layers: { - ...state.layers, - [layerId]: { - ...state.layers[layerId], + setState( + mergeLayer({ + state, + layerId, + newLayer: { columns: { ...state.layers[layerId].columns, [columnId]: { @@ -409,8 +409,8 @@ export function DimensionEditor(props: DimensionEditorProps) { }, }, }, - }, - }); + }) + ); }} /> )} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 1bf5039ef05f..829bd333ce2c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -13,11 +13,7 @@ import { changeColumn } from '../state_helpers'; import { IndexPatternDimensionEditorComponent, IndexPatternDimensionEditorProps, - onDrop, - canHandleDrop, } from './dimension_panel'; -import { DragContextState } from '../../drag_drop'; -import { createMockedDragDropContext } from '../mocks'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; @@ -110,7 +106,6 @@ describe('IndexPatternDimensionEditorPanel', () => { let state: IndexPatternPrivateState; let setState: jest.Mock; let defaultProps: IndexPatternDimensionEditorProps; - let dragDropContext: DragContextState; function getStateWithColumns(columns: Record) { return { ...state, layers: { first: { ...state.layers.first, columns } } }; @@ -154,8 +149,6 @@ describe('IndexPatternDimensionEditorPanel', () => { setState = jest.fn(); - dragDropContext = createMockedDragDropContext(); - defaultProps = { state, setState, @@ -186,46 +179,437 @@ describe('IndexPatternDimensionEditorPanel', () => { jest.clearAllMocks(); }); - describe('Editor component', () => { - let wrapper: ReactWrapper | ShallowWrapper; + let wrapper: ReactWrapper | ShallowWrapper; - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - } + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + wrapper = shallow( + + ); + + expect(filterOperations).toBeCalled(); + }); + + it('should show field select', () => { + wrapper = mount(); + + expect( + wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') + ).toHaveLength(1); + }); + + it('should not show field select on fieldless operation', () => { + wrapper = mount( + + ); + + expect( + wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') + ).toHaveLength(0); + }); + + it('should not show any choices if the filter returns false', () => { + wrapper = mount( + false} + /> + ); + + 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', () => { + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options).toHaveLength(2); + + expect(options![0].label).toEqual('Records'); + expect(options![1].options!.map(({ label }) => label)).toEqual([ + 'timestampLabel', + 'bytes', + 'memory', + 'source', + ]); + }); + + it('should hide fields that have no data', () => { + const props = { + ...defaultProps, + state: { + ...defaultProps.state, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + source: true, + }, + }, + }, + }; + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![1].options!.map(({ label }) => label)).toEqual(['timestampLabel', 'source']); + }); + + it('should indicate fields which are incompatible for the operation of the current column', () => { + wrapper = mount( + + ); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestampLabel')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate operations which are incompatible for the field of the current column', () => { + wrapper = mount( + + ); + + const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; + + expect(items.find(({ label }) => label === 'Minimum')!['data-test-subj']).not.toContain( + 'incompatible' + ); + + expect(items.find(({ label }) => label === 'Date histogram')!['data-test-subj']).toContain( + 'incompatible' + ); + + // Fieldless operation is compatible with field + expect(items.find(({ label }) => label === 'Filters')!['data-test-subj']).toContain( + 'compatible' + ); + }); + + it('should keep the operation when switching to another field compatible with this operation', () => { + const initialState: IndexPatternPrivateState = getStateWithColumns({ col1: bytesColumn }); + + wrapper = mount( + + ); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; + + act(() => { + comboBox.prop('onChange')!([option]); }); - it('should call the filterOperations function', () => { - const filterOperations = jest.fn().mockReturnValue(true); + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'max', + sourceField: 'memory', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); - wrapper = shallow( - - ); + it('should switch operations when selecting a field that requires another operation', () => { + wrapper = mount(); - expect(filterOperations).toBeCalled(); + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); }); - it('should show field select', () => { + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should keep the field when switching to another operation compatible for this field', () => { + wrapper = mount( + + ); + + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should not set the state if selecting the currently active operation', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should update label and custom label flag on label input changes', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'New Label', + customLabel: true, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should not keep the label as long as it is the default label', () => { + wrapper = mount( + + ); + + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'Minimum of bytes', + }), + }, + }, + }, + }); + }); + + it('should keep the label on operation change if it is custom', () => { + wrapper = mount( + + ); + + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'Custom label', + customLabel: true, + }), + }, + }, + }, + }); + }); + + describe('transient invalid state', () => { + it('should not set the state if selecting an operation incompatible with the current field', () => { wrapper = mount(); - expect( - wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') - ).toHaveLength(1); + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); }); - it('should not show field select on fieldless operation', () => { + it('should show error message in invalid state', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + + expect( + wrapper.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') + ).toBeDefined(); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should leave error state if a compatible operation is selected', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should leave error state if the original operation is re-selected', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should leave error state when switching from incomplete state to fieldless operation', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-filters incompatible"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should leave error state when re-selecting the original fieldless function', () => { wrapper = mount( { /> ); - expect( - wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') - ).toHaveLength(0); + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-filters"]').simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); }); - it('should not show any choices if the filter returns false', () => { - wrapper = mount( - false} - /> - ); - - 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', () => { + it('should indicate fields compatible with selected operation', () => { wrapper = mount(); - const options = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('options'); - - expect(options).toHaveLength(2); - - expect(options![0].label).toEqual('Records'); - expect(options![1].options!.map(({ label }) => label)).toEqual([ - 'timestampLabel', - 'bytes', - 'memory', - 'source', - ]); - }); - - it('should hide fields that have no data', () => { - const props = { - ...defaultProps, - state: { - ...defaultProps.state, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - source: true, - }, - }, - }, - }; - wrapper = mount(); + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); const options = wrapper .find(EuiComboBox) .filter('[data-test-subj="indexPattern-dimension-field"]') .prop('options'); - expect(options![1].options!.map(({ label }) => label)).toEqual(['timestampLabel', 'source']); - }); - - it('should indicate fields which are incompatible for the operation of the current column', () => { - wrapper = mount( - - ); - - const options = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('options'); - - expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records'); + expect(options![0]['data-test-subj']).toContain('Incompatible'); expect( options![1].options!.filter(({ label }) => label === 'timestampLabel')[0]['data-test-subj'] ).toContain('Incompatible'); expect( - options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] ).not.toContain('Incompatible'); }); - it('should indicate operations which are incompatible for the field of the current column', () => { - wrapper = mount( - - ); + it('should select compatible operation if field not compatible with selected operation', () => { + wrapper = mount(); - const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; - - expect(items.find(({ label }) => label === 'Minimum')!['data-test-subj']).not.toContain( - 'incompatible' - ); - - expect(items.find(({ label }) => label === 'Date histogram')!['data-test-subj']).toContain( - 'incompatible' - ); - - // Fieldless operation is compatible with field - expect(items.find(({ label }) => label === 'Filters')!['data-test-subj']).toContain( - 'compatible' - ); - }); - - it('should keep the operation when switching to another field compatible with this operation', () => { - const initialState: IndexPatternPrivateState = getStateWithColumns({ col1: bytesColumn }); - - wrapper = mount( - - ); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); const comboBox = wrapper .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]')!; - const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; + .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 act(() => { - comboBox.prop('onChange')!([option]); + comboBox.prop('onChange')!([options![1].options![2]]); }); expect(setState).toHaveBeenCalledWith({ - ...initialState, + ...state, layers: { first: { ...state.layers.first, columns: { ...state.layers.first.columns, - col1: expect.objectContaining({ - operationType: 'max', - sourceField: 'memory', - params: { format: { id: 'bytes' } }, + col2: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', // Other parts of this don't matter for this test }), }, + columnOrder: ['col1', 'col2'], }, }, }); }); - it('should switch operations when selecting a field that requires another operation', () => { + it('should select the Records field when count is selected', () => { + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-count incompatible"]') + .simulate('click'); + + const newColumnState = setState.mock.calls[0][0].layers.first.columns.col2; + expect(newColumnState.operationType).toEqual('count'); + expect(newColumnState.sourceField).toEqual('Records'); + }); + + it('should indicate document and field compatibility with selected document operation', () => { + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestampLabel')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should set datasource state if compatible field is selected for operation', () => { wrapper = mount(); + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') + .simulate('click'); + }); + const comboBox = wrapper .find(EuiComboBox) .filter('[data-test-subj="indexPattern-dimension-field"]')!; @@ -400,802 +771,8 @@ describe('IndexPatternDimensionEditorPanel', () => { columns: { ...state.layers.first.columns, col1: expect.objectContaining({ - operationType: 'terms', sourceField: 'source', - // Other parts of this don't matter for this test - }), - }, - }, - }, - }); - }); - - it('should keep the field when switching to another operation compatible for this field', () => { - wrapper = mount( - - ); - - act(() => { - wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col1: expect.objectContaining({ - operationType: 'min', - sourceField: 'bytes', - params: { format: { id: 'bytes' } }, - // Other parts of this don't matter for this test - }), - }, - }, - }, - }); - }); - - it('should not set the state if selecting the currently active operation', () => { - wrapper = mount(); - - act(() => { - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') - .simulate('click'); - }); - - expect(setState).not.toHaveBeenCalled(); - }); - - it('should update label and custom label flag on label input changes', () => { - wrapper = mount(); - - act(() => { - wrapper - .find('input[data-test-subj="indexPattern-label-edit"]') - .simulate('change', { target: { value: 'New Label' } }); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col1: expect.objectContaining({ - label: 'New Label', - customLabel: true, - // Other parts of this don't matter for this test - }), - }, - }, - }, - }); - }); - - it('should not keep the label as long as it is the default label', () => { - wrapper = mount( - - ); - - act(() => { - wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col1: expect.objectContaining({ - label: 'Minimum of bytes', - }), - }, - }, - }, - }); - }); - - it('should keep the label on operation change if it is custom', () => { - wrapper = mount( - - ); - - act(() => { - wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col1: expect.objectContaining({ - label: 'Custom label', - customLabel: true, - }), - }, - }, - }, - }); - }); - - describe('transient invalid state', () => { - it('should not set the state if selecting an operation incompatible with the current field', () => { - wrapper = mount(); - - act(() => { - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - }); - - expect(setState).not.toHaveBeenCalled(); - }); - - it('should show error message in invalid state', () => { - wrapper = mount(); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - - expect( - wrapper.find('[data-test-subj="indexPattern-field-selection-row"]').first().prop('error') - ).toBeDefined(); - - expect(setState).not.toHaveBeenCalled(); - }); - - it('should leave error state if a compatible operation is selected', () => { - wrapper = mount(); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') - .simulate('click'); - - expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); - }); - - it('should leave error state if the original operation is re-selected', () => { - wrapper = mount(); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') - .simulate('click'); - - expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); - }); - - it('should leave error state when switching from incomplete state to fieldless operation', () => { - wrapper = mount(); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-filters incompatible"]') - .simulate('click'); - - expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); - }); - - it('should leave error state when re-selecting the original fieldless function', () => { - wrapper = mount( - - ); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-filters"]') - .simulate('click'); - - expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); - }); - - it('should indicate fields compatible with selected operation', () => { - wrapper = mount(); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - - const options = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('options'); - - expect(options![0]['data-test-subj']).toContain('Incompatible'); - - expect( - options![1].options!.filter(({ label }) => label === 'timestampLabel')[0][ - 'data-test-subj' - ] - ).toContain('Incompatible'); - expect( - options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] - ).not.toContain('Incompatible'); - }); - - it('should select compatible operation if field not compatible with selected operation', () => { - wrapper = mount( - - ); - - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); - - 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 - act(() => { - comboBox.prop('onChange')!([options![1].options![2]]); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - sourceField: 'source', - operationType: 'terms', - // Other parts of this don't matter for this test - }), - }, - columnOrder: ['col1', 'col2'], - }, - }, - }); - }); - - it('should select the Records field when count is selected', () => { - wrapper = mount( - - ); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-count incompatible"]') - .simulate('click'); - - const newColumnState = setState.mock.calls[0][0].layers.first.columns.col2; - expect(newColumnState.operationType).toEqual('count'); - expect(newColumnState.sourceField).toEqual('Records'); - }); - - it('should indicate document and field compatibility with selected document operation', () => { - wrapper = mount( - - ); - - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - - const options = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('options'); - - expect(options![0]['data-test-subj']).toContain('Incompatible'); - - expect( - options![1].options!.filter(({ label }) => label === 'timestampLabel')[0][ - 'data-test-subj' - ] - ).toContain('Incompatible'); - expect( - options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] - ).not.toContain('Incompatible'); - }); - - it('should set datasource state if compatible field is selected for operation', () => { - wrapper = mount(); - - act(() => { - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-terms incompatible"]') - .simulate('click'); - }); - - const comboBox = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]')!; - const option = comboBox - .prop('options')![1] - .options!.find(({ label }) => label === 'source')!; - - act(() => { - comboBox.prop('onChange')!([option]); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col1: expect.objectContaining({ - sourceField: 'source', - operationType: 'terms', - }), - }, - }, - }, - }); - }); - }); - - it('should render invalid field if field reference is broken', () => { - wrapper = mount( - - ); - - expect(wrapper.find(EuiComboBox).prop('selectedOptions')).toEqual([ - { - label: 'nonexistent', - value: { type: 'field', field: 'nonexistent' }, - }, - ]); - }); - - it('should support selecting the operation before the field', () => { - wrapper = mount(); - - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); - - const comboBox = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]'); - const options = comboBox.prop('options'); - - act(() => { - comboBox.prop('onChange')!([options![1].options![0]]); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - sourceField: 'bytes', - operationType: 'avg', - // Other parts of this don't matter for this test - }), - }, - columnOrder: ['col1', 'col2'], - }, - }, - }); - }); - - it('should select operation directly if only one field is possible', () => { - const initialState = { - ...state, - indexPatterns: { - 1: { - ...state.indexPatterns['1'], - fields: state.indexPatterns['1'].fields.filter((field) => field.name !== 'memory'), - }, - }, - }; - - wrapper = mount( - - ); - - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); - - expect(setState).toHaveBeenCalledWith({ - ...initialState, - layers: { - first: { - ...initialState.layers.first, - columns: { - ...initialState.layers.first.columns, - col2: expect.objectContaining({ - sourceField: 'bytes', - operationType: 'avg', - // Other parts of this don't matter for this test - }), - }, - columnOrder: ['col1', 'col2'], - }, - }, - }); - }); - - it('should select operation directly if only document is possible', () => { - wrapper = mount(); - - wrapper.find('button[data-test-subj="lns-indexPatternDimension-count"]').simulate('click'); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - operationType: 'count', - // Other parts of this don't matter for this test - }), - }, - columnOrder: ['col1', 'col2'], - }, - }, - }); - }); - - it('should indicate compatible fields when selecting the operation first', () => { - wrapper = mount(); - - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); - - const options = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('options'); - - expect(options![0]['data-test-subj']).toContain('Incompatible'); - - expect( - options![1].options!.filter(({ label }) => label === 'timestampLabel')[0]['data-test-subj'] - ).toContain('Incompatible'); - expect( - options![1].options!.filter(({ label }) => label === 'bytes')[0]['data-test-subj'] - ).not.toContain('Incompatible'); - expect( - options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] - ).not.toContain('Incompatible'); - }); - - it('should indicate document compatibility when document operation is selected', () => { - wrapper = mount( - - ); - - const options = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('options'); - - expect(options![0]['data-test-subj']).not.toContain('Incompatible'); - - options![1].options!.map((operation) => - expect(operation['data-test-subj']).toContain('Incompatible') - ); - }); - - it('should show all operations that are not filtered out', () => { - wrapper = mount( - !op.isBucketed && op.dataType === 'number'} - /> - ); - - const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; - - expect(items.map(({ label }: { label: React.ReactNode }) => label)).toEqual([ - 'Average', - 'Count', - 'Maximum', - 'Median', - 'Minimum', - 'Sum', - 'Unique count', - '\u00a0', - ]); - }); - - it('should add a column on selection of a field', () => { - wrapper = mount(); - - const comboBox = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]')!; - const option = comboBox.prop('options')![1].options![0]; - - act(() => { - comboBox.prop('onChange')!([option]); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - 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 initialState: IndexPatternPrivateState = getStateWithColumns({ - col1: bytesColumn, - }); - wrapper = mount( - - ); - - act(() => { - wrapper.find('[data-test-subj="lns-indexPatternDimension-min"]').first().prop('onClick')!( - {} as React.MouseEvent<{}, MouseEvent> - ); - }); - - expect(changeColumn).toHaveBeenCalledWith({ - state: initialState, - columnId: 'col1', - layerId: 'first', - newColumn: expect.objectContaining({ - sourceField: 'bytes', - operationType: 'min', - }), - }); - }); - - it('should clear the dimension when removing the selection in field combobox', () => { - wrapper = mount(); - - act(() => { - wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('onChange')!([]); - }); - - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - indexPatternId: '1', - columns: {}, - columnOrder: [], - }, - }, - }); - }); - - it('allows custom format', () => { - const stateWithNumberCol: IndexPatternPrivateState = getStateWithColumns({ - col1: { - label: 'Average of memory', - dataType: 'number', - isBucketed: false, - // Private - operationType: 'avg', - sourceField: 'memory', - }, - }); - - wrapper = mount( - - ); - - 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 = getStateWithColumns({ - col1: { - label: 'Average of memory', - dataType: 'number', - isBucketed: false, - // Private - operationType: 'avg', - sourceField: 'memory', - params: { - format: { id: 'bytes', params: { decimals: 0 } }, - }, - }, - }); - wrapper = mount( - - ); - - 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(EuiRange) - .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') - .prop('value') - ).toEqual(0); - }); - - it('allows custom format with number of decimal places', () => { - const stateWithNumberCol: IndexPatternPrivateState = getStateWithColumns({ - col1: { - label: 'Average of memory', - dataType: 'number', - isBucketed: false, - // Private - operationType: 'avg', - sourceField: 'memory', - params: { - format: { id: 'bytes', params: { decimals: 2 } }, - }, - }, - }); - - wrapper = mount( - - ); - - act(() => { - wrapper - .find(EuiRange) - .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') - .prop('onChange')!({ currentTarget: { 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 } }, - }, + operationType: 'terms', }), }, }, @@ -1204,447 +781,404 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); - describe('Drag and drop', () => { - function dragDropState(): IndexPatternPrivateState { - return { - indexPatternRefs: [], - existingFields: {}, - indexPatterns: { - foo: { - id: 'foo', - title: 'Foo pattern', - hasRestrictions: false, - fields: [ - { - aggregatable: true, - name: 'bar', - displayName: 'bar', - searchable: true, - type: 'number', - }, - { - aggregatable: true, - name: 'mystring', - displayName: 'mystring', - searchable: true, - type: 'string', - }, - ], - }, - }, - currentIndexPatternId: '1', - isFirstExistenceFetch: false, - layers: { - myLayer: { - indexPatternId: 'foo', - columnOrder: ['col1'], - columns: { - col1: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', + it('should render invalid field if field reference is broken', () => { + wrapper = mount( + + ); - it('is not droppable if no drag is happening', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext, - state: dragDropState(), - layerId: 'myLayer', - }) - ).toBe(false); + expect(wrapper.find(EuiComboBox).prop('selectedOptions')).toEqual([ + { + label: 'nonexistent', + value: { type: 'field', field: 'nonexistent' }, + }, + ]); + }); + + it('should support selecting the operation before the field', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); + const options = comboBox.prop('options'); + + act(() => { + comboBox.prop('onChange')!([options![1].options![0]]); }); - it('is not droppable if the dragged item has no field', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { name: 'bar' }, - }, - }) - ).toBe(false); - }); - - it('is not droppable if field is not supported by filterOperations', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - indexPatternId: 'foo', - field: { type: 'string', name: 'mystring', aggregatable: true }, - }, - }, - state: dragDropState(), - filterOperations: () => false, - layerId: 'myLayer', - }) - ).toBe(false); - }); - - it('is droppable if the field is supported by filterOperations', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo', - }, - }, - state: dragDropState(), - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - layerId: 'myLayer', - }) - ).toBe(true); - }); - - it('is not droppable if the field belongs to another index pattern', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo2', - }, - }, - state: dragDropState(), - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - layerId: 'myLayer', - }) - ).toBe(false); - }); - - it('is droppable if the dragged column is compatible', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'a', - layerId: 'myLayer', - }, - }, - state: dragDropState(), - columnId: 'col2', - filterOperations: (op: OperationMetadata) => true, - layerId: 'myLayer', - }) - ).toBe(true); - }); - - it('is not droppable if the dragged column is the same as the current column', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'a', - layerId: 'myLayer', - }, - }, - state: dragDropState(), - columnId: 'col1', - filterOperations: (op: OperationMetadata) => true, - layerId: 'myLayer', - }) - ).toBe(false); - }); - - it('is not droppable if the dragged column is incompatible', () => { - expect( - canHandleDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging: { - columnId: 'col1', - groupId: 'a', - layerId: 'myLayer', - }, - }, - state: dragDropState(), - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - layerId: 'myLayer', - }) - ).toBe(false); - }); - - it('appends the dropped column when a field is dropped', () => { - const dragging = { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo', - }; - const testState = dragDropState(); - - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: testState, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - layerId: 'myLayer', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - myLayer: { - ...testState.layers.myLayer, - columnOrder: ['col1', 'col2'], - columns: { - ...testState.layers.myLayer.columns, - col2: expect.objectContaining({ - dataType: 'number', - sourceField: 'bar', - }), - }, - }, - }, - }); - }); - - it('selects the specific operation that was valid on drop', () => { - const dragging = { - field: { type: 'string', name: 'mystring', aggregatable: true }, - indexPatternId: 'foo', - }; - const testState = dragDropState(); - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: testState, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.isBucketed, - layerId: 'myLayer', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - myLayer: { - ...testState.layers.myLayer, - columnOrder: ['col1', 'col2'], - columns: { - ...testState.layers.myLayer.columns, - col2: expect.objectContaining({ - dataType: 'string', - sourceField: 'mystring', - }), - }, - }, - }, - }); - }); - - it('updates a column when a field is dropped', () => { - const dragging = { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo', - }; - const testState = dragDropState(); - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: testState, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - layerId: 'myLayer', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - myLayer: expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - dataType: 'number', - sourceField: 'bar', - }), + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test }), - }), - }, - }); - }); - - it('does not set the size of the terms aggregation', () => { - const dragging = { - field: { type: 'string', name: 'mystring', aggregatable: true }, - indexPatternId: 'foo', - }; - const testState = dragDropState(); - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: testState, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.isBucketed, - layerId: 'myLayer', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - myLayer: { - ...testState.layers.myLayer, - columnOrder: ['col1', 'col2'], - columns: { - ...testState.layers.myLayer.columns, - col2: expect.objectContaining({ - operationType: 'terms', - params: expect.objectContaining({ size: 3 }), - }), - }, }, + columnOrder: ['col1', 'col2'], }, - }); + }, }); + }); - it('updates the column id when moving an operation to an empty dimension', () => { - const dragging = { - columnId: 'col1', - groupId: 'a', - layerId: 'myLayer', - }; - const testState = dragDropState(); - - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, + it('should select operation directly if only one field is possible', () => { + const initialState = { + ...state, + indexPatterns: { + 1: { + ...state.indexPatterns['1'], + fields: state.indexPatterns['1'].fields.filter((field) => field.name !== 'memory'), }, - droppedItem: dragging, - state: testState, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => true, - layerId: 'myLayer', - }); + }, + }; - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - myLayer: { - ...testState.layers.myLayer, - columnOrder: ['col2'], - columns: { - col2: testState.layers.myLayer.columns.col1, - }, + wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...initialState.layers.first, + columns: { + ...initialState.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), }, + columnOrder: ['col1', 'col2'], }, - }); + }, }); + }); - it('replaces an operation when moving to a populated dimension', () => { - const dragging = { - columnId: 'col2', - groupId: 'a', - layerId: 'myLayer', - }; - const testState = dragDropState(); - testState.layers.myLayer = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.myLayer.columns.col1, + it('should select operation directly if only document is possible', () => { + wrapper = mount(); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-count"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + operationType: 'count', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should indicate compatible fields when selecting the operation first', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestampLabel')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'bytes')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate document compatibility when document operation is selected', () => { + wrapper = mount( + + ); - onDrop({ - ...defaultProps, - dragDropContext: { - ...dragDropContext, - dragging, - }, - droppedItem: dragging, - state: testState, - columnId: 'col1', - filterOperations: (op: OperationMetadata) => true, - layerId: 'myLayer', - }); + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - myLayer: { - ...testState.layers.myLayer, - columnOrder: ['col1', 'col3'], - columns: { - col1: testState.layers.myLayer.columns.col2, - col3: testState.layers.myLayer.columns.col3, - }, + expect(options![0]['data-test-subj']).not.toContain('Incompatible'); + + options![1].options!.map((operation) => + expect(operation['data-test-subj']).toContain('Incompatible') + ); + }); + + it('should show all operations that are not filtered out', () => { + wrapper = mount( + !op.isBucketed && op.dataType === 'number'} + /> + ); + + const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; + + expect(items.map(({ label }: { label: React.ReactNode }) => label)).toEqual([ + 'Average', + 'Count', + 'Maximum', + 'Median', + 'Minimum', + 'Sum', + 'Unique count', + '\u00a0', + ]); + }); + + it('should add a column on selection of a field', () => { + wrapper = mount(); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options![0]; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + 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 initialState: IndexPatternPrivateState = getStateWithColumns({ + col1: bytesColumn, + }); + wrapper = mount( + + ); + + act(() => { + wrapper.find('[data-test-subj="lns-indexPatternDimension-min"]').first().prop('onClick')!( + {} as React.MouseEvent<{}, MouseEvent> + ); + }); + + expect(changeColumn).toHaveBeenCalledWith({ + state: initialState, + columnId: 'col1', + layerId: 'first', + newColumn: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'min', + }), + }); + }); + + it('should clear the dimension when removing the selection in field combobox', () => { + wrapper = mount(); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('onChange')!([]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + it('allows custom format', () => { + const stateWithNumberCol: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Average of memory', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'memory', + }, + }); + + wrapper = mount( + + ); + + 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 = getStateWithColumns({ + col1: { + label: 'Average of memory', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'memory', + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }, + }); + wrapper = mount( + + ); + + 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(EuiRange) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('value') + ).toEqual(0); + }); + + it('allows custom format with number of decimal places', () => { + const stateWithNumberCol: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Average of memory', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'memory', + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }, + }); + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiRange) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('onChange')!({ currentTarget: { 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 } }, + }, + }), + }, + }, + }, }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index ff6840bc16a5..2efdedceb4db 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -4,28 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiLink, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { - DatasourceDimensionTriggerProps, - DatasourceDimensionEditorProps, - DatasourceDimensionDropProps, - DatasourceDimensionDropHandlerProps, - isDraggedOperation, -} from '../../types'; +import { DatasourceDimensionTriggerProps, DatasourceDimensionEditorProps } from '../../types'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; -import { IndexPatternColumn, OperationType } from '../indexpattern'; -import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; +import { IndexPatternColumn } from '../indexpattern'; +import { fieldIsInvalid } from '../utils'; +import { IndexPatternPrivateState } from '../types'; import { DimensionEditor } from './dimension_editor'; -import { changeColumn } from '../state_helpers'; -import { isDraggedField, hasField, fieldIsInvalid } from '../utils'; -import { IndexPatternPrivateState, IndexPatternField } from '../types'; -import { trackUiEvent } from '../../lens_ui_telemetry'; import { DateRange } from '../../../common'; +import { getOperationSupportMatrix } from './operation_support'; export type IndexPatternDimensionTriggerProps = DatasourceDimensionTriggerProps< IndexPatternPrivateState @@ -46,189 +37,6 @@ export type IndexPatternDimensionEditorProps = DatasourceDimensionEditorProps< dateRange: DateRange; }; -export interface OperationSupportMatrix { - operationByField: Partial>; - operationWithoutField: OperationType[]; - fieldByOperation: Partial>; -} - -type Props = Pick< - DatasourceDimensionDropProps, - 'layerId' | 'columnId' | 'state' | 'filterOperations' ->; - -// TODO: This code has historically been memoized, as a potentially performance -// sensitive task. If we can add memoization without breaking the behavior, we should. -const getOperationSupportMatrix = (props: Props): OperationSupportMatrix => { - const layerId = props.layerId; - const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId]; - - const filteredOperationsByMetadata = getAvailableOperationsByMetadata( - currentIndexPattern - ).filter((operation) => props.filterOperations(operation.operationMetaData)); - - const supportedOperationsByField: Partial> = {}; - const supportedOperationsWithoutField: OperationType[] = []; - const supportedFieldsByOperation: Partial> = {}; - - filteredOperationsByMetadata.forEach(({ operations }) => { - operations.forEach((operation) => { - if (operation.type === 'field') { - if (supportedOperationsByField[operation.field]) { - supportedOperationsByField[operation.field]!.push(operation.operationType); - } else { - supportedOperationsByField[operation.field] = [operation.operationType]; - } - - if (supportedFieldsByOperation[operation.operationType]) { - supportedFieldsByOperation[operation.operationType]!.push(operation.field); - } else { - supportedFieldsByOperation[operation.operationType] = [operation.field]; - } - } else if (operation.type === 'none') { - supportedOperationsWithoutField.push(operation.operationType); - } - }); - }); - return { - operationByField: _.mapValues(supportedOperationsByField, _.uniq), - operationWithoutField: _.uniq(supportedOperationsWithoutField), - fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq), - }; -}; - -export function canHandleDrop(props: DatasourceDimensionDropProps) { - const operationSupportMatrix = getOperationSupportMatrix(props); - - const { dragging } = props.dragDropContext; - const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; - - function hasOperationForField(field: IndexPatternField) { - return Boolean(operationSupportMatrix.operationByField[field.name]); - } - - if (isDraggedField(dragging)) { - return ( - layerIndexPatternId === dragging.indexPatternId && - Boolean(hasOperationForField(dragging.field)) - ); - } - - if ( - isDraggedOperation(dragging) && - dragging.layerId === props.layerId && - props.columnId !== dragging.columnId - ) { - const op = props.state.layers[props.layerId].columns[dragging.columnId]; - return props.filterOperations(op); - } - return false; -} - -export function onDrop(props: DatasourceDimensionDropHandlerProps) { - const operationSupportMatrix = getOperationSupportMatrix(props); - const droppedItem = props.droppedItem; - - function hasOperationForField(field: IndexPatternField) { - return Boolean(operationSupportMatrix.operationByField[field.name]); - } - - if (isDraggedOperation(droppedItem) && droppedItem.layerId === props.layerId) { - const layer = props.state.layers[props.layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - if (!props.filterOperations(op)) { - return false; - } - - const newColumns = { ...layer.columns }; - delete newColumns[droppedItem.columnId]; - newColumns[props.columnId] = op; - - const newColumnOrder = [...layer.columnOrder]; - const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); - const newIndex = newColumnOrder.findIndex((c) => c === props.columnId); - - if (newIndex === -1) { - newColumnOrder[oldIndex] = props.columnId; - } else { - newColumnOrder.splice(oldIndex, 1); - } - - // Time to replace - props.setState({ - ...props.state, - layers: { - ...props.state.layers, - [props.layerId]: { - ...layer, - columnOrder: newColumnOrder, - columns: newColumns, - }, - }, - }); - return { deleted: droppedItem.columnId }; - } - - if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { - // TODO: What do we do if we couldn't find a column? - return false; - } - - const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name]; - - const layerId = props.layerId; - const selectedColumn: IndexPatternColumn | null = - props.state.layers[layerId].columns[props.columnId] || null; - const currentIndexPattern = - props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; - - // We need to check if dragging in a new field, was just a field change on the same - // index pattern and on the same operations (therefore checking if the new field supports - // our previous operation) - const hasFieldChanged = - selectedColumn && - hasField(selectedColumn) && - selectedColumn.sourceField !== droppedItem.field.name && - operationsForNewField && - operationsForNewField.includes(selectedColumn.operationType); - - if (!operationsForNewField || operationsForNewField.length === 0) { - return false; - } - - // If only the field has changed use the onFieldChange method on the operation to get the - // new column, otherwise use the regular buildColumn to get a new column. - const newColumn = hasFieldChanged - ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) - : buildColumn({ - op: operationsForNewField[0], - columns: props.state.layers[props.layerId].columns, - indexPattern: currentIndexPattern, - layerId, - suggestedPriority: props.suggestedPriority, - field: droppedItem.field, - previousColumn: selectedColumn, - }); - - trackUiEvent('drop_onto_dimension'); - const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length); - trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); - - props.setState( - changeColumn({ - state: props.state, - layerId, - columnId: props.columnId, - newColumn, - // If the field has changed, the onFieldChange method needs to take care of everything including moving - // over params. If we create a new column above we want changeColumn to move over params. - keepParams: !hasFieldChanged, - }) - ); - - return true; -} - function wrapOnDot(str?: string) { // u200B is a non-width white-space character, which allows // the browser to efficiently word-wrap right after the dot diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts new file mode 100644 index 000000000000..f943246ebc48 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -0,0 +1,594 @@ +/* + * 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 { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { IndexPatternDimensionEditorProps } from './dimension_panel'; +import { onDrop, canHandleDrop } from './droppable'; +import { DragContextState } from '../../drag_drop'; +import { createMockedDragDropContext } from '../mocks'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { IndexPatternPrivateState } from '../types'; +import { documentField } from '../document_field'; +import { OperationMetadata } from '../../types'; + +jest.mock('../state_helpers'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + hasExistence: true, + hasRestrictions: false, + fields: [ + { + name: 'timestamp', + displayName: 'timestampLabel', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'bytes', + displayName: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'memory', + displayName: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'source', + displayName: 'source', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + documentField, + ], + }, +}; + +/** + * The datasource exposes four main pieces of code which are tested at + * an integration test level. The main reason for this fairly high level + * of testing is that there is a lot of UI logic that isn't easily + * unit tested, such as the transient invalid state. + * + * - Dimension trigger: Not tested here + * - Dimension editor component: First half of the tests + * + * - canHandleDrop: Tests for dropping of fields or other dimensions + * - onDrop: Correct application of drop logic + */ +describe('IndexPatternDimensionEditorPanel', () => { + let state: IndexPatternPrivateState; + let setState: jest.Mock; + let defaultProps: IndexPatternDimensionEditorProps; + let dragDropContext: DragContextState; + + beforeEach(() => { + state = { + indexPatternRefs: [], + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + isFirstExistenceFetch: false, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + + setState = jest.fn(); + + dragDropContext = createMockedDragDropContext(); + + defaultProps = { + state, + setState, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + columnId: 'col1', + layerId: 'first', + uniqueLabel: 'stuff', + filterOperations: () => true, + storage: {} as IStorageWrapper, + 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, + core: {} as CoreSetup, + }; + + jest.clearAllMocks(); + }); + + function dragDropState(): IndexPatternPrivateState { + return { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: { + foo: { + id: 'foo', + title: 'Foo pattern', + hasRestrictions: false, + fields: [ + { + aggregatable: true, + name: 'bar', + displayName: 'bar', + searchable: true, + type: 'number', + }, + { + aggregatable: true, + name: 'mystring', + displayName: 'mystring', + searchable: true, + type: 'string', + }, + ], + }, + }, + currentIndexPatternId: '1', + isFirstExistenceFetch: false, + layers: { + myLayer: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + } + + it('is not droppable if no drag is happening', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext, + state: dragDropState(), + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is not droppable if the dragged item has no field', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { name: 'bar' }, + }, + }) + ).toBe(false); + }); + + it('is not droppable if field is not supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + indexPatternId: 'foo', + field: { type: 'string', name: 'mystring', aggregatable: true }, + }, + }, + state: dragDropState(), + filterOperations: () => false, + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is droppable if the field is supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(true); + }); + + it('is not droppable if the field belongs to another index pattern', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is droppable if the dragged column is compatible', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col2', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }) + ).toBe(true); + }); + + it('is not droppable if the dragged column is the same as the current column', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col1', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is not droppable if the dragged column is incompatible', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('appends the dropped column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }, + }, + }, + }); + }); + + it('selects the specific operation that was valid on drop', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'string', + sourceField: 'mystring', + }), + }, + }, + }, + }); + }); + + it('updates a column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }), + }), + }, + }); + }); + + it('does not set the size of the terms aggregation', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + operationType: 'terms', + params: expect.objectContaining({ size: 3 }), + }), + }, + }, + }, + }); + }); + + it('updates the column id when moving an operation to an empty dimension', () => { + const dragging = { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }; + const testState = dragDropState(); + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col2'], + columns: { + col2: testState.layers.myLayer.columns.col1, + }, + }, + }, + }); + }); + + it('replaces an operation when moving to a populated dimension', () => { + const dragging = { + columnId: 'col2', + groupId: 'a', + layerId: 'myLayer', + }; + const testState = dragDropState(); + testState.layers.myLayer = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.myLayer.columns.col1, + + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col3' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'Records', + }, + }, + }; + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col1', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col3'], + columns: { + col1: testState.layers.myLayer.columns.col2, + col3: testState.layers.myLayer.columns.col3, + }, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts new file mode 100644 index 000000000000..01674a7411b9 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts @@ -0,0 +1,149 @@ +/* + * 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 { + DatasourceDimensionDropProps, + DatasourceDimensionDropHandlerProps, + isDraggedOperation, +} from '../../types'; +import { IndexPatternColumn } from '../indexpattern'; +import { buildColumn, changeField } from '../operations'; +import { changeColumn, mergeLayer } from '../state_helpers'; +import { isDraggedField, hasField } from '../utils'; +import { IndexPatternPrivateState, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { getOperationSupportMatrix } from './operation_support'; + +export function canHandleDrop(props: DatasourceDimensionDropProps) { + const operationSupportMatrix = getOperationSupportMatrix(props); + + const { dragging } = props.dragDropContext; + const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationSupportMatrix.operationByField[field.name]); + } + + if (isDraggedField(dragging)) { + return ( + layerIndexPatternId === dragging.indexPatternId && + Boolean(hasOperationForField(dragging.field)) + ); + } + + if ( + isDraggedOperation(dragging) && + dragging.layerId === props.layerId && + props.columnId !== dragging.columnId + ) { + const op = props.state.layers[props.layerId].columns[dragging.columnId]; + return props.filterOperations(op); + } + return false; +} + +export function onDrop(props: DatasourceDimensionDropHandlerProps) { + const operationSupportMatrix = getOperationSupportMatrix(props); + const droppedItem = props.droppedItem; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationSupportMatrix.operationByField[field.name]); + } + + if (isDraggedOperation(droppedItem) && droppedItem.layerId === props.layerId) { + const layer = props.state.layers[props.layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + if (!props.filterOperations(op)) { + return false; + } + + const newColumns = { ...layer.columns }; + delete newColumns[droppedItem.columnId]; + newColumns[props.columnId] = op; + + const newColumnOrder = [...layer.columnOrder]; + const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); + const newIndex = newColumnOrder.findIndex((c) => c === props.columnId); + + if (newIndex === -1) { + newColumnOrder[oldIndex] = props.columnId; + } else { + newColumnOrder.splice(oldIndex, 1); + } + + // Time to replace + props.setState( + mergeLayer({ + state: props.state, + layerId: props.layerId, + newLayer: { + columnOrder: newColumnOrder, + columns: newColumns, + }, + }) + ); + return { deleted: droppedItem.columnId }; + } + + if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { + // TODO: What do we do if we couldn't find a column? + return false; + } + + const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name]; + + const layerId = props.layerId; + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + const currentIndexPattern = + props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; + + // We need to check if dragging in a new field, was just a field change on the same + // index pattern and on the same operations (therefore checking if the new field supports + // our previous operation) + const hasFieldChanged = + selectedColumn && + hasField(selectedColumn) && + selectedColumn.sourceField !== droppedItem.field.name && + operationsForNewField && + operationsForNewField.includes(selectedColumn.operationType); + + if (!operationsForNewField || operationsForNewField.length === 0) { + return false; + } + + // If only the field has changed use the onFieldChange method on the operation to get the + // new column, otherwise use the regular buildColumn to get a new column. + const newColumn = hasFieldChanged + ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) + : buildColumn({ + op: operationsForNewField[0], + columns: props.state.layers[props.layerId].columns, + indexPattern: currentIndexPattern, + layerId, + suggestedPriority: props.suggestedPriority, + field: droppedItem.field, + previousColumn: selectedColumn, + }); + + trackUiEvent('drop_onto_dimension'); + const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length); + trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); + + props.setState( + changeColumn({ + state: props.state, + layerId, + columnId: props.columnId, + newColumn, + // If the field has changed, the onFieldChange method needs to take care of everything including moving + // over params. If we create a new column above we want changeColumn to move over params. + keepParams: !hasFieldChanged, + }) + ); + + return true; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index c2e179fd13a2..b1b77f193012 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -20,7 +20,7 @@ import { EuiHighlight } from '@elastic/eui'; import { OperationType } from '../indexpattern'; import { LensFieldIcon } from '../lens_field_icon'; import { DataType } from '../../types'; -import { OperationSupportMatrix } from './dimension_panel'; +import { OperationSupportMatrix } from './operation_support'; import { IndexPattern, IndexPatternField, IndexPatternPrivateState } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { fieldExists } from '../pure_helpers'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts index 88e5588ce0e0..92a91d5b5086 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/index.ts @@ -5,3 +5,5 @@ */ export * from './dimension_panel'; +export * from './droppable'; +export * from './operation_support'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts new file mode 100644 index 000000000000..2ea28da20155 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts @@ -0,0 +1,60 @@ +/* + * 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 { DatasourceDimensionDropProps } from '../../types'; +import { OperationType } from '../indexpattern'; +import { getAvailableOperationsByMetadata } from '../operations'; +import { IndexPatternPrivateState } from '../types'; + +export interface OperationSupportMatrix { + operationByField: Partial>; + operationWithoutField: OperationType[]; + fieldByOperation: Partial>; +} + +type Props = Pick< + DatasourceDimensionDropProps, + 'layerId' | 'columnId' | 'state' | 'filterOperations' +>; + +// TODO: This code has historically been memoized, as a potentially performance +// sensitive task. If we can add memoization without breaking the behavior, we should. +export const getOperationSupportMatrix = (props: Props): OperationSupportMatrix => { + const layerId = props.layerId; + const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId]; + + const filteredOperationsByMetadata = getAvailableOperationsByMetadata( + currentIndexPattern + ).filter((operation) => props.filterOperations(operation.operationMetaData)); + + const supportedOperationsByField: Partial> = {}; + const supportedOperationsWithoutField: OperationType[] = []; + const supportedFieldsByOperation: Partial> = {}; + + filteredOperationsByMetadata.forEach(({ operations }) => { + operations.forEach((operation) => { + if (operation.type === 'field') { + supportedOperationsByField[operation.field] = [ + ...(supportedOperationsByField[operation.field] ?? []), + operation.operationType, + ]; + + supportedFieldsByOperation[operation.operationType] = [ + ...(supportedFieldsByOperation[operation.operationType] ?? []), + operation.field, + ]; + } else if (operation.type === 'none') { + supportedOperationsWithoutField.push(operation.operationType); + } + }); + }); + return { + operationByField: _.mapValues(supportedOperationsByField, _.uniq), + operationWithoutField: _.uniq(supportedOperationsWithoutField), + fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq), + }; +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts index 7b6eb11efc49..da90a2ce5fce 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts @@ -452,22 +452,32 @@ describe('state_helpers', () => { describe('getColumnOrder', () => { it('should work for empty columns', () => { - expect(getColumnOrder({})).toEqual([]); + expect( + getColumnOrder({ + indexPatternId: '', + columnOrder: [], + columns: {}, + }) + ).toEqual([]); }); it('should work for one column', () => { expect( getColumnOrder({ - col1: { - label: 'Value of timestamp', - dataType: 'string', - isBucketed: false, + columnOrder: [], + indexPatternId: '', + columns: { + col1: { + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - params: { - interval: 'h', + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, }, }, }) @@ -477,41 +487,45 @@ describe('state_helpers', () => { it('should put any number of aggregations before metrics', () => { expect( getColumnOrder({ - col1: { - label: 'Top values of category', - dataType: 'string', - isBucketed: true, + columnOrder: [], + indexPatternId: '', + columns: { + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, - // Private - operationType: 'terms', - sourceField: 'category', - params: { - size: 5, - orderBy: { - type: 'alphabetical', + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', }, - orderDirection: 'asc', }, - }, - col2: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, - // Private - operationType: 'avg', - sourceField: 'bytes', - }, - col3: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - params: { - interval: '1d', + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, }, }, }) @@ -521,44 +535,48 @@ describe('state_helpers', () => { it('should reorder aggregations based on suggested priority', () => { expect( getColumnOrder({ - col1: { - label: 'Top values of category', - dataType: 'string', - isBucketed: true, + indexPatternId: '', + columnOrder: [], + columns: { + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, - // Private - operationType: 'terms', - sourceField: 'category', - params: { - size: 5, - orderBy: { - type: 'alphabetical', + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', }, - orderDirection: 'asc', + suggestedPriority: 2, }, - suggestedPriority: 2, - }, - col2: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, - // Private - operationType: 'avg', - sourceField: 'bytes', - suggestedPriority: 0, - }, - col3: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, + // Private + operationType: 'avg', + sourceField: 'bytes', + suggestedPriority: 0, + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, - // Private - operationType: 'date_histogram', - sourceField: 'timestamp', - suggestedPriority: 1, - params: { - interval: '1d', + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedPriority: 1, + params: { + interval: '1d', + }, }, }, }) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.ts index c977a7e0fa37..2e92d4ad8f88 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.ts @@ -25,25 +25,24 @@ export function updateColumnParam column === currentColumn )![0]; - return { - ...state, - layers: { - ...state.layers, - [layerId]: { - ...state.layers[layerId], - columns: { - ...state.layers[layerId].columns, - [columnId]: { - ...currentColumn, - params: { - ...currentColumn.params, - [paramName]: value, - }, + const layer = state.layers[layerId]; + + return mergeLayer({ + state, + layerId, + newLayer: { + columns: { + ...layer.columns, + [columnId]: { + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, }, }, }, }, - }; + }); } function adjustColumnReferencesForChangedColumn( @@ -91,25 +90,29 @@ export function changeColumn({ updatedColumn.label = oldColumn.label; } + const layer = { + ...state.layers[layerId], + }; + const newColumns = adjustColumnReferencesForChangedColumn( { - ...state.layers[layerId].columns, + ...layer.columns, [columnId]: updatedColumn, }, columnId ); - return { - ...state, - layers: { - ...state.layers, - [layerId]: { - ...state.layers[layerId], - columnOrder: getColumnOrder(newColumns), + return mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: getColumnOrder({ + ...layer, columns: newColumns, - }, + }), + columns: newColumns, }, - }; + }); } export function deleteColumn({ @@ -125,24 +128,26 @@ export function deleteColumn({ delete hypotheticalColumns[columnId]; const newColumns = adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId); - - return { - ...state, - layers: { - ...state.layers, - [layerId]: { - ...state.layers[layerId], - columnOrder: getColumnOrder(newColumns), - columns: newColumns, - }, - }, + const layer = { + ...state.layers[layerId], + columns: newColumns, }; + + return mergeLayer({ + state, + layerId, + newLayer: { + ...layer, + columnOrder: getColumnOrder(layer), + }, + }); } -export function getColumnOrder(columns: Record): string[] { - const entries = Object.entries(columns); - - const [aggregations, metrics] = _.partition(entries, ([id, col]) => col.isBucketed); +export function getColumnOrder(layer: IndexPatternLayer): string[] { + const [aggregations, metrics] = _.partition( + Object.entries(layer.columns), + ([id, col]) => col.isBucketed + ); return aggregations .sort(([id, col], [id2, col2]) => { @@ -156,6 +161,24 @@ export function getColumnOrder(columns: Record): str .concat(metrics.map(([id]) => id)); } +export function mergeLayer({ + state, + layerId, + newLayer, +}: { + state: IndexPatternPrivateState; + layerId: string; + newLayer: Partial; +}) { + return { + ...state, + layers: { + ...state.layers, + [layerId]: { ...state.layers[layerId], ...newLayer }, + }, + }; +} + export function updateLayerIndexPattern( layer: IndexPatternLayer, newIndexPattern: IndexPattern