diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx index e1df6286bd120..a2eaff8ca36f0 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.tsx @@ -381,5 +381,4 @@ export class QueryBarUI extends Component { } } -// @ts-ignore export const QueryBar = injectI18n(QueryBarUI); diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index af870208f4865..782d7545803a2 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -18,3 +18,4 @@ */ export * from './expressions'; +export * from './query'; diff --git a/src/plugins/data/common/query/index.ts b/src/plugins/data/common/query/index.ts new file mode 100644 index 0000000000000..d8f7b5091eb8f --- /dev/null +++ b/src/plugins/data/common/query/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './types'; diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index fa8cae2b6b86e..006e5c1d31243 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -46,6 +46,7 @@ export function createJestConfig({ ], transform: { '^.+\\.(js|tsx?)$': `${kibanaDirectory}/src/dev/jest/babel_transform.js`, + '^.+\\.html?$': 'jest-raw-loader', }, transformIgnorePatterns: [ // ignore all node_modules except @elastic/eui which requires babel transforms to handle dynamic import() diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx index 356e18ddc8419..7bcddae13e1ac 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -11,7 +11,6 @@ import { datatableVisualization } from './visualization'; import { renderersRegistry, functionsRegistry, - // @ts-ignore untyped dependency } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { InterpreterSetup, RenderFunction } from '../interpreter_types'; import { datatable, datatableColumns, datatableRenderer } from './expression'; diff --git a/x-pack/legacy/plugins/lens/public/index.ts b/x-pack/legacy/plugins/lens/public/index.ts index c71a3adf22485..aec29edaa728a 100644 --- a/x-pack/legacy/plugins/lens/public/index.ts +++ b/x-pack/legacy/plugins/lens/public/index.ts @@ -7,6 +7,8 @@ export * from './types'; import 'ui/autoload/all'; +// Used for kuery autocomplete +import 'uiExports/autocompleteProviders'; // Used to run esaggs queries import 'uiExports/fieldFormats'; import 'uiExports/search'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx index b5bd083472566..0f0ba36fbf8ee 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx @@ -6,7 +6,10 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React from 'react'; +import { act } from 'react-dom/test-utils'; import { EuiComboBox, EuiSideNav, EuiPopover } from '@elastic/eui'; +import { data } from '../../../../../../../src/legacy/core_plugins/data/public/setup'; +import { localStorage } from 'ui/storage/storage_service'; import { IndexPatternPrivateState } from '../indexpattern'; import { changeColumn } from '../state_helpers'; import { getPotentialColumns } from '../operations'; @@ -15,9 +18,17 @@ import { DropHandler, DragContextState } from '../../drag_drop'; import { createMockedDragDropContext } from '../mocks'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +jest.mock('../loader'); jest.mock('../state_helpers'); jest.mock('../operations'); +// Used by indexpattern plugin, which is a dependency of a dependency +jest.mock('ui/chrome'); +jest.mock('ui/storage/storage_service'); +// Contains old and new platform data plugins, used for interpreter and filter ratio +jest.mock('ui/new_platform'); +jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); + const expectedIndexPatterns = { 1: { id: '1', @@ -98,6 +109,8 @@ describe('IndexPatternDimensionPanel', () => { setState, columnId: 'col1', filterOperations: () => true, + dataPlugin: data, + storage: localStorage, }; jest.clearAllMocks(); @@ -164,6 +177,8 @@ describe('IndexPatternDimensionPanel', () => { const options = wrapper.find(EuiComboBox).prop('options'); + expect(options).toHaveLength(2); + expect(options![0].label).toEqual('Document'); expect(options![1].options!.map(({ label }) => label)).toEqual([ @@ -273,7 +288,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...initialState, @@ -296,7 +313,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -336,7 +355,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -356,9 +377,11 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper - .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') - .simulate('click'); + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + }); expect(setState).not.toHaveBeenCalled(); }); @@ -368,9 +391,11 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper - .find('input[data-test-subj="indexPattern-label-edit"]') - .simulate('change', { target: { value: 'New Label' } }); + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -390,7 +415,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + }); expect(setState).not.toHaveBeenCalled(); }); @@ -428,7 +455,9 @@ describe('IndexPatternDimensionPanel', () => { wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); - wrapper.find(EuiPopover).prop('closePopover')!(); + act(() => { + wrapper.find(EuiPopover).prop('closePopover')!(); + }); openPopover(); @@ -459,12 +488,16 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-terms"]').simulate('click'); + }); const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -488,7 +521,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox); const options = comboBox.prop('options'); - comboBox.prop('onChange')!([options![1].options![0]]); + act(() => { + comboBox.prop('onChange')!([options![1].options![0]]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -541,7 +576,7 @@ describe('IndexPatternDimensionPanel', () => { .find(EuiSideNav) .prop('items')[0] .items.map(({ name }) => name) - ).toEqual(['Count', 'Maximum', 'Average', 'Sum', 'Minimum']); + ).toEqual(['Maximum', 'Average', 'Sum', 'Minimum', 'Count', 'Filter Ratio']); }); it('should add a column on selection of a field', () => { @@ -552,7 +587,9 @@ describe('IndexPatternDimensionPanel', () => { const comboBox = wrapper.find(EuiComboBox)!; const option = comboBox.prop('options')![1].options![0]; - comboBox.prop('onChange')!([option]); + act(() => { + comboBox.prop('onChange')!([option]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -588,10 +625,12 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper - .find('[data-test-subj="lns-indexPatternDimension-min"]') - .first() - .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + }); expect(changeColumn).toHaveBeenCalledWith( initialState, @@ -610,7 +649,9 @@ describe('IndexPatternDimensionPanel', () => { 'EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-remove"]' ); - clearButton.simulate('click'); + act(() => { + clearButton.simulate('click'); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -624,7 +665,9 @@ describe('IndexPatternDimensionPanel', () => { openPopover(); - wrapper.find(EuiComboBox).prop('onChange')!([]); + act(() => { + wrapper.find(EuiComboBox).prop('onChange')!([]); + }); expect(setState).toHaveBeenCalledWith({ ...state, @@ -642,7 +685,14 @@ describe('IndexPatternDimensionPanel', () => { foo: { id: 'foo', title: 'Foo pattern', - fields: [{ aggregatable: true, name: 'bar', searchable: true, type: 'number' }], + fields: [ + { + aggregatable: true, + name: 'bar', + searchable: true, + type: 'number', + }, + ], }, }, }; @@ -737,12 +787,14 @@ describe('IndexPatternDimensionPanel', () => { /> ); - const onDrop = wrapper - .find('[data-test-subj="indexPattern-dropTarget"]') - .first() - .prop('onDrop') as DropHandler; + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; - onDrop(dragging); + onDrop(dragging); + }); expect(setState).toBeCalledTimes(1); expect(setState).toHaveBeenCalledWith( @@ -773,12 +825,14 @@ describe('IndexPatternDimensionPanel', () => { /> ); - const onDrop = wrapper - .find('[data-test-subj="indexPattern-dropTarget"]') - .first() - .prop('onDrop') as DropHandler; + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; - onDrop(dragging); + onDrop(dragging); + }); expect(setState).toBeCalledTimes(1); expect(setState).toHaveBeenCalledWith( @@ -808,12 +862,14 @@ describe('IndexPatternDimensionPanel', () => { /> ); - const onDrop = wrapper - .find('[data-test-subj="indexPattern-dropTarget"]') - .first() - .prop('onDrop') as DropHandler; + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; - onDrop(dragging); + onDrop(dragging); + }); expect(setState).not.toBeCalled(); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx index 2671da17bfd3a..55c7cddd7f527 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx @@ -7,7 +7,9 @@ import _ from 'lodash'; import React from 'react'; import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; +import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; +import { DataSetup } from '../../../../../../../src/legacy/core_plugins/data/public'; import { DatasourceDimensionPanelProps } from '../../types'; import { IndexPatternColumn, @@ -25,6 +27,8 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; setState: (newState: IndexPatternPrivateState) => void; dragDropContext: DragContextState; + dataPlugin: DataSetup; + storage: Storage; }; export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx index 9cb3232aeb295..215419cd3e999 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx @@ -48,22 +48,26 @@ export function FieldSelect({ ); function isCompatibleWithCurrentOperation(col: BaseIndexPatternColumn) { - return incompatibleSelectedOperationType - ? col.operationType === incompatibleSelectedOperationType - : !selectedColumn || col.operationType === selectedColumn.operationType; + if (incompatibleSelectedOperationType) { + return col.operationType === incompatibleSelectedOperationType; + } + return !selectedColumn || col.operationType === selectedColumn.operationType; } const fieldOptions = []; - const fieldLessColumn = filteredColumns.find(column => !hasField(column)); - if (fieldLessColumn) { + const fieldlessColumn = + filteredColumns.find(column => !hasField(column) && isCompatibleWithCurrentOperation(column)) || + filteredColumns.find(column => !hasField(column)); + + if (fieldlessColumn) { fieldOptions.push({ label: i18n.translate('xpack.lens.indexPattern.documentField', { defaultMessage: 'Document', }), - value: fieldLessColumn.operationId, + value: fieldlessColumn.operationId, className: classNames({ 'lnsConfigPanel__fieldOption--incompatible': !isCompatibleWithCurrentOperation( - fieldLessColumn + fieldlessColumn ), }), }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx index 2254f1b474644..053c8e08349b1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx @@ -179,7 +179,13 @@ export function PopoverEditor(props: PopoverEditorProps) { )} {!incompatibleSelectedOperationType && ParamEditor && ( - + )} {!incompatibleSelectedOperationType && selectedColumn && ( diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts new file mode 100644 index 0000000000000..11102cf7a5a07 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { calculateFilterRatio } from './filter_ratio'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/types'; + +describe('calculate_filter_ratio', () => { + it('should collapse two rows and columns into a single row and column', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }], + rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 10 }], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [{ id: 'bucket', name: 'A' }], + rows: [{ bucket: 0.5 }], + type: 'kibana_datatable', + }); + }); + + it('should return 0 when the denominator is undefined', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }], + rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b' }], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [{ id: 'bucket', name: 'A' }], + rows: [{ bucket: 0 }], + type: 'kibana_datatable', + }); + }); + + it('should return 0 when the denominator is 0', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }], + rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 0 }], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [{ id: 'bucket', name: 'A' }], + rows: [{ bucket: 0 }], + type: 'kibana_datatable', + }); + }); + + it('should keep columns which are not mapped', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'bucket', name: 'A' }, + { id: 'filter-ratio', name: 'B' }, + { id: 'extra', name: 'C' }, + ], + rows: [ + { bucket: 'a', 'filter-ratio': 5, extra: 'first' }, + { bucket: 'b', 'filter-ratio': 10, extra: 'second' }, + ], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [{ id: 'bucket', name: 'A' }, { id: 'extra', name: 'C' }], + rows: [{ bucket: 0.5, extra: 'first' }], + type: 'kibana_datatable', + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts new file mode 100644 index 0000000000000..1fe57f42fc987 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from '../types'; + +interface FilterRatioKey { + id: string; +} + +export const calculateFilterRatio: ExpressionFunction< + 'lens_calculate_filter_ratio', + KibanaDatatable, + FilterRatioKey, + KibanaDatatable +> = { + name: 'lens_calculate_filter_ratio', + type: 'kibana_datatable', + help: i18n.translate('xpack.lens.functions.calculateFilterRatio.help', { + defaultMessage: 'A helper to collapse two filter ratio rows into a single row', + }), + args: { + id: { + types: ['string'], + help: i18n.translate('xpack.lens.functions.calculateFilterRatio.id.help', { + defaultMessage: 'The column ID which has the filter ratio', + }), + }, + }, + context: { + types: ['kibana_datatable'], + }, + fn(data: KibanaDatatable, { id }: FilterRatioKey) { + const newRows: KibanaDatatable['rows'] = []; + + if (data.rows.length === 0) { + return data; + } + + if (data.rows.length % 2 === 1) { + throw new Error('Cannot divide an odd number of rows'); + } + + const [[valueKey]] = Object.entries(data.rows[0]).filter(([key]) => + key.includes('filter-ratio') + ); + + for (let i = 0; i < data.rows.length; i += 2) { + const row1 = data.rows[i]; + const row2 = data.rows[i + 1]; + + const calculatedRatio = row2[valueKey] + ? (row1[valueKey] as number) / (row2[valueKey] as number) + : 0; + + const result = { ...row1 }; + delete result[valueKey]; + + result[id] = calculatedRatio; + + newRows.push(result); + } + + return { + type: 'kibana_datatable', + rows: newRows, + columns: data.columns.filter(col => !col.id.includes('filter-ratio')), + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index b39f00af1e1fd..db4bced265b0c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -7,6 +7,11 @@ import { shallow } from 'enzyme'; import React from 'react'; import { EuiComboBox } from '@elastic/eui'; +import chromeMock from 'ui/chrome'; +import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; +import { localStorage as storageMock } from 'ui/storage/storage_service'; +import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { toastNotifications as notificationsMock } from 'ui/notify'; import { getIndexPatternDatasource, IndexPatternPersistedState, @@ -18,6 +23,13 @@ import { DatasourcePublicAPI, Operation, Datasource } from '../types'; import { createMockedDragDropContext } from './mocks'; jest.mock('./loader'); +// chrome, notify, storage are used by ./plugin +jest.mock('ui/chrome'); +jest.mock('ui/notify'); +jest.mock('ui/storage/storage_service'); +// Contains old and new platform data plugins, used for interpreter and filter ratio +jest.mock('ui/new_platform'); +jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); const expectedIndexPatterns = { 1: { @@ -109,8 +121,13 @@ describe('IndexPattern Data Source', () => { let indexPatternDatasource: Datasource; beforeEach(() => { - // @ts-ignore - indexPatternDatasource = getIndexPatternDatasource(); + indexPatternDatasource = getIndexPatternDatasource({ + chrome: chromeMock, + storage: storageMock, + interpreter: { functionsRegistry }, + toastNotifications: notificationsMock, + data: dataMock, + }); persistedState = { currentIndexPatternId: '1', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 69774e24c1388..7328684d09777 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,9 +7,8 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { Chrome } from 'ui/chrome'; -import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, @@ -17,11 +16,13 @@ import { DatasourceSuggestion, Operation, } from '../types'; +import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { getIndexPatterns } from './loader'; import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; import { buildColumnForOperationType, getOperationTypesForField } from './operations'; +import { IndexPatternDatasourcePluginPlugins } from './plugin'; import { Datasource, DataType } from '..'; export type OperationType = IndexPatternColumn['operationType']; @@ -33,7 +34,8 @@ export type IndexPatternColumn = | AvgIndexPatternColumn | MinIndexPatternColumn | MaxIndexPatternColumn - | CountIndexPatternColumn; + | CountIndexPatternColumn + | FilterRatioIndexPatternColumn; export interface BaseIndexPatternColumn { // Public @@ -74,6 +76,14 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { }; } +export interface FilterRatioIndexPatternColumn extends BaseIndexPatternColumn { + operationType: 'filter_ratio'; + params: { + numerator: Query; + denominator: Query; + }; +} + export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< 'count', BaseIndexPatternColumn @@ -217,7 +227,12 @@ function removeProperty(prop: string, object: Record): Record = { async initialize(state?: IndexPatternPersistedState) { @@ -270,11 +285,15 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To }, renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( - setState(newState)} - {...props} - />, + + setState(newState)} + dataPlugin={data} + storage={storage} + {...props} + /> + , domElement ); }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx new file mode 100644 index 0000000000000..c48a381426664 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.test.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { filterRatioOperation } from './filter_ratio'; +import { FilterRatioIndexPatternColumn, IndexPatternPrivateState } from '../indexpattern'; + +describe('filter_ratio', () => { + let state: IndexPatternPrivateState; + let storageMock: any; + let dataMock: any; + const InlineOptions = filterRatioOperation.paramEditor!; + + beforeEach(() => { + state = { + indexPatterns: { + 1: { + id: '1', + title: 'Mock Indexpattern', + fields: [], + }, + }, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Filter Ratio', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'filter_ratio', + params: { + numerator: { query: '', language: 'kuery' }, + denominator: { query: '', language: 'kuery' }, + }, + }, + }, + }; + + class QueryBarInput { + props: any; + constructor(props: any) { + this.props = props; + } + render() { + return <>; + } + } + + storageMock = { + getItem() {}, + }; + dataMock = { + query: { ui: { QueryBarInput } }, + }; + }); + + describe('buildColumn', () => { + it('should create column object with default params', () => { + const column = filterRatioOperation.buildColumn('op', 0); + expect(column.params.numerator).toEqual({ query: '', language: 'kuery' }); + expect(column.params.denominator).toEqual({ query: '', language: 'kuery' }); + }); + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const esAggsConfig = filterRatioOperation.toEsAggsConfig( + state.columns.col1 as FilterRatioIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + filters: [ + { + input: { query: '', language: 'kuery' }, + label: '', + }, + { + input: { query: '', language: 'kuery' }, + label: '', + }, + ], + }), + }) + ); + }); + }); + + describe('param editor', () => { + it('should render current value', () => { + expect(() => { + shallowWithIntl( + + ); + }).not.toThrow(); + }); + + it('should show only the numerator by default', () => { + const wrapper = shallowWithIntl( + + ); + + expect(wrapper.find('QueryBarInput')).toHaveLength(1); + expect(wrapper.find('QueryBarInput').prop('indexPatterns')).toEqual(['1']); + }); + + it('should update the state when typing into the query bar', () => { + const setState = jest.fn(); + const wrapper = shallowWithIntl( + + ); + + wrapper.find('QueryBarInput').prop('onChange')!({ + query: 'geo.src : "US"', + language: 'kuery', + } as any); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + col1: { + ...state.columns.col1, + params: { + numerator: { query: 'geo.src : "US"', language: 'kuery' }, + denominator: { query: '', language: 'kuery' }, + }, + }, + }, + }); + }); + + it('should allow editing the denominator', () => { + const setState = jest.fn(); + const wrapper = shallowWithIntl( + + ); + + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternFilterRatio-showDenominatorButton"]') + .first() + .simulate('click'); + }); + + expect(wrapper.find('QueryBarInput')).toHaveLength(2); + + wrapper + .find('QueryBarInput') + .at(1) + .prop('onChange')!({ + query: 'geo.src : "US"', + language: 'kuery', + } as any); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + col1: { + ...state.columns.col1, + params: { + numerator: { query: '', language: 'kuery' }, + denominator: { query: 'geo.src : "US"', language: 'kuery' }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx new file mode 100644 index 0000000000000..56a5cd77c5afd --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operation_definitions/filter_ratio.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiFormRow } from '@elastic/eui'; +import { Query } from '../../../../../../../src/legacy/core_plugins/data/public/query'; +import { FilterRatioIndexPatternColumn } from '../indexpattern'; +import { DimensionPriority } from '../../types'; +import { OperationDefinition } from '../operations'; +import { updateColumnParam } from '../state_helpers'; + +export const filterRatioOperation: OperationDefinition = { + type: 'filter_ratio', + displayName: i18n.translate('xpack.lens.indexPattern.filterRatio', { + defaultMessage: 'Filter Ratio', + }), + isApplicableWithoutField: true, + isApplicableForField: () => false, + buildColumn( + operationId: string, + suggestedOrder?: DimensionPriority + ): FilterRatioIndexPatternColumn { + return { + operationId, + label: i18n.translate('xpack.lens.indexPattern.filterRatio', { + defaultMessage: 'Filter Ratio', + }), + dataType: 'number', + operationType: 'filter_ratio', + suggestedOrder, + isBucketed: false, + params: { + numerator: { language: 'kuery', query: '' }, + denominator: { language: 'kuery', query: '' }, + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'filters', + schema: 'segment', + params: { + filters: [ + { + input: column.params.numerator, + label: '', + }, + { + input: column.params.denominator, + label: '', + }, + ], + }, + }), + paramEditor: ({ state, setState, columnId: currentColumnId, dataPlugin, storage }) => { + const [hasDenominator, setDenominator] = useState(false); + + const { QueryBarInput } = dataPlugin!.query.ui; + + return ( +
+ + { + setState( + updateColumnParam( + state, + state.columns[currentColumnId] as FilterRatioIndexPatternColumn, + 'numerator', + newQuery + ) + ); + }} + /> + + + + {hasDenominator ? ( + { + setState( + updateColumnParam( + state, + state.columns[currentColumnId] as FilterRatioIndexPatternColumn, + 'denominator', + newQuery + ) + ); + }} + /> + ) : ( + <> + + + + setDenominator(true)} + > + + + + + )} + +
+ ); + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index c42ae4def66e1..f016515db32ea 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -8,6 +8,8 @@ import { getOperationTypesForField, getPotentialColumns } from './operations'; import { IndexPatternPrivateState } from './indexpattern'; import { hasField } from './state_helpers'; +jest.mock('./loader'); + const expectedIndexPatterns = { 1: { id: '1', @@ -206,6 +208,10 @@ Array [ "timestamp", "date_histogram", ], + Array [ + "_documents_", + "filter_ratio", + ], ] `); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index b5a0591084599..8471e2b0ad8f4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Storage } from 'ui/storage'; +import { DataSetup } from '../../../../../../src/legacy/core_plugins/data/public'; import { DimensionPriority } from '../types'; import { IndexPatternColumn, @@ -21,6 +23,7 @@ import { } from './operation_definitions/metrics'; import { dateHistogramOperation } from './operation_definitions/date_histogram'; import { countOperation } from './operation_definitions/count'; +import { filterRatioOperation } from './operation_definitions/filter_ratio'; import { sortByField } from './state_helpers'; type PossibleOperationDefinitions< @@ -46,6 +49,7 @@ export const operationDefinitionMap: AllOperationDefinitions = { avg: averageOperation, sum: sumOperation, count: countOperation, + filter_ratio: filterRatioOperation, }; const operationDefinitions: PossibleOperationDefinitions[] = Object.values(operationDefinitionMap); @@ -57,6 +61,8 @@ export interface ParamEditorProps { state: IndexPatternPrivateState; setState: (newState: IndexPatternPrivateState) => void; columnId: string; + dataPlugin?: DataSetup; + storage?: Storage; } export interface OperationDefinition { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx index 7c186f35dc71a..ea969219b3e1f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -6,23 +6,29 @@ import { Registry } from '@kbn/interpreter/target/common'; import { CoreSetup } from 'src/core/public'; -import chrome from 'ui/chrome'; +// The following dependencies on ui/* and src/legacy/core_plugins must be mocked when testing +import chrome, { Chrome } from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; -import { getIndexPatternDatasource } from './indexpattern'; - -import { - functionsRegistry, - // @ts-ignore untyped dependency -} from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { Storage } from 'ui/storage'; +import { localStorage } from 'ui/storage/storage_service'; +import { DataSetup } from '../../../../../../src/legacy/core_plugins/data/public'; +import { data as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/setup'; import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { getIndexPatternDatasource } from './indexpattern'; import { renameColumns } from './rename_columns'; +import { calculateFilterRatio } from './filter_ratio'; // TODO these are intermediary types because interpreter is not typed yet // They can get replaced by references to the real interfaces as soon as they // are available export interface IndexPatternDatasourcePluginPlugins { + chrome: Chrome; interpreter: InterpreterSetup; + data: DataSetup; + storage: Storage; + toastNotifications: typeof toastNotifications; } export interface InterpreterSetup { @@ -35,9 +41,19 @@ export interface InterpreterSetup { class IndexPatternDatasourcePlugin { constructor() {} - setup(_core: CoreSetup | null, { interpreter }: IndexPatternDatasourcePluginPlugins) { + setup( + _core: CoreSetup | null, + { interpreter, data, storage, toastNotifications: toast }: IndexPatternDatasourcePluginPlugins + ) { interpreter.functionsRegistry.register(() => renameColumns); - return getIndexPatternDatasource(chrome, toastNotifications); + interpreter.functionsRegistry.register(() => calculateFilterRatio); + return getIndexPatternDatasource({ + chrome, + interpreter, + toastNotifications: toast, + data, + storage, + }); } stop() {} @@ -47,8 +63,12 @@ const plugin = new IndexPatternDatasourcePlugin(); export const indexPatternDatasourceSetup = () => plugin.setup(null, { + chrome, interpreter: { functionsRegistry, }, + data: dataSetup, + storage: localStorage, + toastNotifications, }); export const indexPatternDatasourceStop = () => plugin.stop(); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts index 6f39b521d20d8..67887ef186f09 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -7,7 +7,11 @@ import _ from 'lodash'; import { IndexPatternPrivateState, IndexPatternColumn } from './indexpattern'; -import { operationDefinitionMap, OperationDefinition } from './operations'; +import { + buildColumnForOperationType, + operationDefinitionMap, + OperationDefinition, +} from './operations'; export function toExpression(state: IndexPatternPrivateState) { if (state.columnOrder.length === 0) { @@ -42,6 +46,23 @@ export function toExpression(state: IndexPatternPrivateState) { {} as Record ); + const filterRatios = columnEntries.filter( + ([colId, col]) => col.operationType === 'filter_ratio' + ); + + if (filterRatios.length) { + const countColumn = buildColumnForOperationType(columnEntries.length, 'count', 2); + aggs.push(getEsAggsConfig(countColumn, 'filter-ratio')); + + return `esaggs + index="${state.currentIndexPatternId}" + metricsAtAllLevels=false + partialRows=false + aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify( + idMap + )}' | ${filterRatios.map(([id]) => `lens_calculate_filter_ratio id=${id}`).join(' | ')}`; + } + return `esaggs index="${state.currentIndexPatternId}" metricsAtAllLevels=false diff --git a/x-pack/legacy/plugins/lens/public/interpreter_types.ts b/x-pack/legacy/plugins/lens/public/interpreter_types.ts index b24f39080f827..fe02ab11757cc 100644 --- a/x-pack/legacy/plugins/lens/public/interpreter_types.ts +++ b/x-pack/legacy/plugins/lens/public/interpreter_types.ts @@ -5,7 +5,6 @@ */ import { Registry } from '@kbn/interpreter/target/common'; -// @ts-ignore untyped module import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; // TODO these are intermediary types because interpreter is not typed yet diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx index bb89646715645..9c9482a05e8f1 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -10,7 +10,6 @@ import { xyVisualization } from './xy_visualization'; import { renderersRegistry, functionsRegistry, - // @ts-ignore untyped dependency } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { InterpreterSetup, RenderFunction } from '../interpreter_types'; import { xyChart, xyChartRenderer } from './xy_expression';