diff --git a/x-pack/plugins/lens/public/index.scss b/x-pack/plugins/lens/public/index.scss index 6c01d745e0202..760223bd61642 100644 --- a/x-pack/plugins/lens/public/index.scss +++ b/x-pack/plugins/lens/public/index.scss @@ -1,5 +1,6 @@ // Import the EUI global scope so we can use EUI constants @import 'src/legacy/ui/public/styles/_styling_constants'; -@import "./drag_drop/drag_drop.scss"; -@import "./xy_visualization_plugin/xy_expression.scss"; \ No newline at end of file +@import './drag_drop/drag_drop.scss'; +@import './xy_visualization_plugin/xy_expression.scss'; +@import './indexpattern_plugin/indexpattern'; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index ad0b78c63e710..532b6e66d2b27 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -7,6 +7,7 @@ export * from './types'; import 'ui/autoload/all'; +// Used to run esaggs queries import 'uiExports/fieldFormats'; import 'uiExports/search'; import 'uiExports/visRequestHandlers'; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts index 1bb56464138d1..7823768896d64 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts @@ -9,7 +9,7 @@ export function getIndexPatterns() { resolve([ { id: '1', - title: 'Fake Index Pattern', + title: 'my-fake-index-pattern', timeFieldName: 'timestamp', fields: [ { @@ -34,7 +34,7 @@ export function getIndexPatterns() { }, { id: '2', - title: 'Fake Rollup Pattern', + title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', fields: [ { @@ -56,6 +56,52 @@ export function getIndexPatterns() { searchable: true, }, ], + typeMeta: { + params: { + rollup_index: 'my-fake-index-pattern', + }, + aggs: { + terms: { + source: { + agg: 'terms', + }, + }, + date_histogram: { + timestamp: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + histogram: { + bytes: { + agg: 'histogram', + interval: 1000, + }, + }, + avg: { + bytes: { + agg: 'avg', + }, + }, + max: { + bytes: { + agg: 'max', + }, + }, + min: { + bytes: { + agg: 'min', + }, + }, + sum: { + bytes: { + agg: 'sum', + }, + }, + }, + }, }, ]); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts new file mode 100644 index 0000000000000..0d7fcdecbc340 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__mocks__/operations.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const actual = jest.requireActual('../operations'); + +jest.spyOn(actual, 'getPotentialColumns'); +jest.spyOn(actual, 'getColumnOrder'); + +export const { + getPotentialColumns, + getColumnOrder, + getOperations, + getOperationDisplay, + getOperationTypesForField, + getOperationResultType, +} = actual; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap index 45760f0bf5efb..578eae643574f 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap +++ b/x-pack/plugins/lens/public/indexpattern_plugin/__snapshots__/indexpattern.test.tsx.snap @@ -1,40 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`IndexPattern Data Source #getPublicAPI renderDimensionPanel should render a dimension panel 1`] = ` -
- Dimension Panel - -
-`; - exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = `
Index Pattern Data Source @@ -48,11 +13,11 @@ exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = ` options={ Array [ Object { - "label": "Fake Index Pattern", + "label": "my-fake-index-pattern", "value": "1", }, Object { - "label": "Fake Rollup Pattern", + "label": "my-fake-restricted-pattern", "value": "2", }, ] @@ -60,7 +25,7 @@ exports[`IndexPattern Data Source #renderDataPanel should match snapshot 1`] = ` selectedOptions={ Array [ Object { - "label": "Fake Index Pattern", + "label": "my-fake-index-pattern", "value": "1", }, ] diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx new file mode 100644 index 0000000000000..2e1fb83a90571 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.test.tsx @@ -0,0 +1,293 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { EuiComboBox } from '@elastic/eui'; +import { IndexPatternPrivateState } from './indexpattern'; +import { getColumnOrder, getPotentialColumns } from './operations'; +import { IndexPatternDimensionPanel } from './dimension_panel'; + +jest.mock('./operations'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, +}; + +describe('IndexPatternDimensionPanel', () => { + let state: IndexPatternPrivateState; + + beforeEach(() => { + state = { + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + }, + }; + + jest.clearAllMocks(); + }); + + it('should display a call to action in the popover button', () => { + const wrapper = mount( + {}} + columnId={'col2'} + filterOperations={() => true} + /> + ); + expect( + wrapper + .find('[data-test-subj="indexPattern-dimensionPopover-button"]') + .first() + .text() + ).toEqual('Configure dimension'); + }); + + it('should pass the right arguments to getPotentialColumns', async () => { + shallow( + {}} + columnId={'col1'} + filterOperations={() => true} + suggestedPriority={1} + /> + ); + + expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith(state, 1); + }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + shallow( + {}} + columnId={'col2'} + filterOperations={filterOperations} + /> + ); + + expect(filterOperations).toBeCalled(); + }); + + it('should not show any choices if the filter returns false', () => { + const wrapper = shallow( + {}} + columnId={'col2'} + filterOperations={() => false} + /> + ); + + expect(wrapper.find(EuiComboBox)!.prop('options')!.length).toEqual(0); + }); + + it('should list all field names in sorted order', () => { + const wrapper = shallow( + {}} + columnId={'col1'} + filterOperations={() => true} + /> + ); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options!.map(({ label }) => label)).toEqual([ + 'bytes', + 'documents', + 'source', + 'timestamp', + ]); + }); + + it("should disable functions that won't work with the current column", () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + /> + ); + + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-value"]').prop('color') + ).toEqual('primary'); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-value"]').prop('isDisabled') + ).toEqual(false); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-terms"]').prop('isDisabled') + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').prop('isDisabled') + ).toEqual(false); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-sum"]').prop('isDisabled') + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-avg"]').prop('isDisabled') + ).toEqual(true); + expect( + wrapper.find('[data-test-subj="lns-indexPatternDimension-count"]').prop('isDisabled') + ).toEqual(true); + }); + + it('should update the datasource state on selection of a value operation', () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + suggestedPriority={1} + /> + ); + + const comboBox = wrapper.find(EuiComboBox)!; + const firstOption = comboBox.prop('options')![0]; + + comboBox.prop('onChange')!([firstOption]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col2: expect.objectContaining({ + sourceField: firstOption.label, + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }); + }); + + it('should always request the new sort order when changing the function', () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + suggestedPriority={1} + /> + ); + + wrapper.find('[data-test-subj="lns-indexPatternDimension-date_histogram"]').simulate('click'); + + expect(getColumnOrder).toHaveBeenCalledWith({ + col1: expect.objectContaining({ + sourceField: 'timestamp', + operationType: 'date_histogram', + }), + }); + }); + + it('should update the datasource state when the user makes a selection', () => { + const setState = jest.fn(); + + const wrapper = shallow( + op.dataType === 'number'} + /> + ); + + const comboBox = wrapper.find(EuiComboBox)!; + const firstField = comboBox.prop('options')![0]; + + comboBox.prop('onChange')!([firstField]); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: { + ...state.columns, + col2: expect.objectContaining({ + operationId: firstField.value, + label: 'Value of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'value', + sourceField: 'bytes', + }), + }, + columnOrder: ['col1', 'col2'], + }); + }); + + it('should clear the dimension with the clear button', () => { + const setState = jest.fn(); + + const wrapper = shallow( + true} + /> + ); + + const clearButton = wrapper.find('[data-test-subj="indexPattern-dimensionPopover-remove"]'); + + clearButton.simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: {}, + columnOrder: [], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx new file mode 100644 index 0000000000000..fd3600a3a616a --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/dimension_panel.tsx @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiComboBox, + EuiPopover, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, +} from '@elastic/eui'; +import { DatasourceDimensionPanelProps } from '../types'; +import { IndexPatternColumn, IndexPatternPrivateState, columnToOperation } from './indexpattern'; + +import { + getOperationDisplay, + getOperations, + getPotentialColumns, + getColumnOrder, +} from './operations'; + +export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { + state: IndexPatternPrivateState; + setState: (newState: IndexPatternPrivateState) => void; +}; + +export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { + const [isOpen, setOpen] = useState(false); + + const operations = getOperations(); + const operationPanels = getOperationDisplay(); + + const columns = getPotentialColumns(props.state, props.suggestedPriority); + + const filteredColumns = columns.filter(col => { + return props.filterOperations(columnToOperation(col)); + }); + + const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; + + const uniqueColumnsByField = _.uniq(filteredColumns, col => col.sourceField); + + const functionsFromField = selectedColumn + ? filteredColumns.filter(col => { + return col.sourceField === selectedColumn.sourceField; + }) + : filteredColumns; + + return ( + + + { + setOpen(false); + }} + ownFocus + anchorPosition="rightCenter" + button={ + + { + setOpen(!isOpen); + }} + > + + {selectedColumn + ? selectedColumn.label + : i18n.translate('xpack.lens.indexPattern.configureDimensionLabel', { + defaultMessage: 'Configure dimension', + })} + + + + } + > + + + ({ + label: col.sourceField, + value: col.operationId, + }))} + selectedOptions={ + selectedColumn + ? [ + { + label: selectedColumn.sourceField, + value: selectedColumn.operationId, + }, + ] + : [] + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onChange={choices => { + const column: IndexPatternColumn = columns.find( + ({ operationId }) => operationId === choices[0].value + )!; + const newColumns: IndexPatternPrivateState['columns'] = { + ...props.state.columns, + [props.columnId]: column, + }; + + props.setState({ + ...props.state, + columns: newColumns, + columnOrder: getColumnOrder(newColumns), + }); + }} + /> + + +
+ {operations.map(o => ( + col.operationType === o)} + onClick={() => { + if (!selectedColumn) { + return; + } + + const newColumn: IndexPatternColumn = filteredColumns.find( + col => + col.operationType === o && col.sourceField === selectedColumn.sourceField + )!; + + const newColumns = { + ...props.state.columns, + [props.columnId]: newColumn, + }; + + props.setState({ + ...props.state, + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }); + }} + > + {operationPanels[o].displayName} + + ))} +
+
+
+
+
+ {selectedColumn && ( + + { + const newColumns: IndexPatternPrivateState['columns'] = { + ...props.state.columns, + }; + delete newColumns[props.columnId]; + + props.setState({ + ...props.state, + columns: newColumns, + columnOrder: getColumnOrder(newColumns), + }); + }} + /> + + )} +
+ ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.scss b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.scss new file mode 100644 index 0000000000000..ac1b7d4ab754b --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.scss @@ -0,0 +1,3 @@ +.lnsIndexPattern__dimensionPopover { + max-width: 600px; +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index ac0cc03fbd3ab..f5fa6da12eb3c 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -12,7 +12,6 @@ import { IndexPatternPersistedState, IndexPatternPrivateState, IndexPatternDataPanel, - IndexPatternDimensionPanel, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; @@ -21,7 +20,7 @@ jest.mock('./loader'); const expectedIndexPatterns = { 1: { id: '1', - title: 'Fake Index Pattern', + title: 'my-fake-index-pattern', timeFieldName: 'timestamp', fields: [ { @@ -46,7 +45,7 @@ const expectedIndexPatterns = { }, 2: { id: '2', - title: 'Fake Rollup Pattern', + title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', fields: [ { @@ -54,18 +53,50 @@ const expectedIndexPatterns = { type: 'date', aggregatable: true, searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, }, { name: 'bytes', type: 'number', aggregatable: true, searchable: true, + aggregationRestrictions: { + // Ignored in the UI + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, }, { name: 'source', type: 'string', aggregatable: true, searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, }, ], }, @@ -178,7 +209,7 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'value', - sourceField: 'op', + sourceField: 'source', }, col2: { operationId: 'op2', @@ -188,15 +219,52 @@ describe('IndexPattern Data Source', () => { // Private operationType: 'value', - sourceField: 'op2', + sourceField: 'bytes', }, }, }; const state = await indexPatternDatasource.initialize(queryPersistedState); expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot( - `"esdocs index=\\"1\\" fields=\\"op, op2\\" sort=\\"op, DESC\\""` + `"esdocs index=\\"my-fake-index-pattern\\" fields=\\"source, bytes\\" sort=\\"source, DESC\\""` ); }); + + it('should generate an expression for an aggregated query', async () => { + const queryPersistedState: IndexPatternPersistedState = { + currentIndexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + operationId: 'op1', + label: 'Count of Documents', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'document', + }, + col2: { + operationId: 'op2', + label: 'Date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + }, + }, + }; + const state = await indexPatternDatasource.initialize(queryPersistedState); + expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot(` +"esaggs + index=\\"1\\" + metricsAtAllLevels=\\"false\\" + partialRows=\\"false\\" + aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1h\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]'" +`); + }); }); describe('#getPublicAPI', () => { @@ -227,88 +295,5 @@ describe('IndexPattern Data Source', () => { } as Operation); }); }); - - describe('renderDimensionPanel', () => { - let state: IndexPatternPrivateState; - - beforeEach(async () => { - state = await indexPatternDatasource.initialize(persistedState); - }); - - it('should render a dimension panel', () => { - const wrapper = shallow( - {}} - columnId={'col2'} - filterOperations={(operation: Operation) => true} - /> - ); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should call the filterOperations function', () => { - const filterOperations = jest.fn().mockReturnValue(true); - - shallow( - {}} - columnId={'col2'} - filterOperations={filterOperations} - /> - ); - - expect(filterOperations).toBeCalledTimes(3); - }); - - it('should filter out all selections if the filter returns false', () => { - const wrapper = shallow( - {}} - columnId={'col2'} - filterOperations={() => false} - /> - ); - - expect(wrapper.find(EuiComboBox)!.prop('options')!.length).toEqual(0); - }); - - it('should update the datasource state on selection', () => { - const setState = jest.fn(); - - const wrapper = shallow( - true} - /> - ); - - const comboBox = wrapper.find(EuiComboBox)!; - const firstOption = comboBox.prop('options')![0]; - - comboBox.prop('onChange')!([firstOption]); - - expect(setState).toHaveBeenCalledWith({ - ...state, - columns: { - ...state.columns, - col2: { - operationId: firstOption.value, - label: 'Value of timestamp', - dataType: 'date', - isBucketed: false, - operationType: 'value', - sourceField: 'timestamp', - }, - }, - columnOrder: ['col1', 'col2'], - }); - }); - }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 22614914d4dda..940f89ae1d660 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -4,19 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ +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 { Datasource, DataType } from '..'; import uuid from 'uuid'; -import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; +import { Datasource, DataType } from '..'; +import { + DatasourceDimensionPanelProps, + DatasourceDataPanelProps, + DimensionPriority, +} from '../types'; import { getIndexPatterns } from './loader'; - -type OperationType = 'value' | 'terms' | 'date_histogram'; - -interface IndexPatternColumn { +import { toExpression } from './to_expression'; +import { IndexPatternDimensionPanel } from './dimension_panel'; + +export type OperationType = + | 'value' + | 'terms' + | 'date_histogram' + | 'sum' + | 'avg' + | 'min' + | 'max' + | 'count'; + +export interface IndexPatternColumn { // Public operationId: string; label: string; @@ -26,13 +41,14 @@ interface IndexPatternColumn { // Private operationType: OperationType; sourceField: string; + suggestedOrder?: DimensionPriority; } export interface IndexPattern { id: string; fields: IndexPatternField[]; title: string; - timeFieldName?: string; + timeFieldName?: string | null; } export interface IndexPatternField { @@ -41,6 +57,19 @@ export interface IndexPatternField { esTypes?: string[]; aggregatable: boolean; searchable: boolean; + aggregationRestrictions?: Partial< + Record< + string, + { + agg: string; + interval?: number; + fixed_interval?: string; + calendar_interval?: string; + delay?: string; + time_zone?: string; + } + > + >; } export interface IndexPatternPersistedState { @@ -95,76 +124,50 @@ export function IndexPatternDataPanel(props: DatasourceDataPanelProps void; -}; - -export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { - const fields = props.state.indexPatterns[props.state.currentIndexPatternId].fields; - const columns: IndexPatternColumn[] = fields.map((field, index) => ({ - operationId: `${index}`, - label: `Value of ${field.name}`, - dataType: field.type as DataType, - isBucketed: false, - - operationType: 'value' as OperationType, - sourceField: field.name, - })); - - const filteredColumns = columns.filter(col => { - const { operationId, label, dataType, isBucketed } = col; +export function columnToOperation(column: IndexPatternColumn) { + const { dataType, label, isBucketed, operationId } = column; + return { + id: operationId, + label, + dataType, + isBucketed, + }; +} - return props.filterOperations({ - id: operationId, - label, - dataType, - isBucketed, +type UnwrapPromise = T extends Promise ? P : T; +type InferFromArray = T extends Array ? P : T; + +function addRestrictionsToFields( + indexPattern: InferFromArray>, void>> +): IndexPattern { + const { typeMeta } = indexPattern; + if (!typeMeta) { + return indexPattern; + } + + const aggs = Object.keys(typeMeta.aggs); + + const newFields = [...(indexPattern.fields as IndexPatternField[])]; + newFields.forEach((field, index) => { + const restrictionsObj: IndexPatternField['aggregationRestrictions'] = {}; + aggs.forEach(agg => { + if (typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]) { + restrictionsObj[agg] = typeMeta.aggs[agg][field.name]; + } }); + if (Object.keys(restrictionsObj).length) { + newFields[index] = { ...field, aggregationRestrictions: restrictionsObj }; + } }); - const selectedColumn: IndexPatternColumn | null = props.state.columns[props.columnId] || null; - - return ( -
- Dimension Panel - ({ - label: col.label, - value: col.operationId, - }))} - selectedOptions={ - selectedColumn - ? [ - { - label: selectedColumn.label, - value: selectedColumn.operationId, - }, - ] - : [] - } - singleSelection={{ asPlainText: true }} - isClearable={false} - onChange={choices => { - const column: IndexPatternColumn = columns.find( - ({ operationId }) => operationId === choices[0].value - )!; - const newColumns: IndexPatternPrivateState['columns'] = { - ...props.state.columns, - [props.columnId]: column, - }; + const { id, title, timeFieldName } = indexPattern; - props.setState({ - ...props.state, - columns: newColumns, - // Order is not meaningful until we aggregate - columnOrder: Object.keys(newColumns), - }); - }} - /> -
- ); + return { + id, + title, + timeFieldName: timeFieldName || undefined, + fields: newFields, + }; } export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: ToastNotifications) { @@ -176,7 +179,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To if (indexPatternObjects) { indexPatternObjects.forEach(obj => { - indexPatterns[obj.id] = obj; + indexPatterns[obj.id] = addRestrictionsToFields(obj); }); } @@ -198,18 +201,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return { currentIndexPatternId, columns, columnOrder }; }, - toExpression(state: IndexPatternPrivateState) { - if (state.columnOrder.length === 0) { - return null; - } - - const fieldNames = state.columnOrder.map(col => state.columns[col].sourceField); - const expression = `esdocs index="${state.currentIndexPatternId}" fields="${fieldNames.join( - ', ' - )}" sort="${fieldNames[0]}, DESC"`; - - return expression; - }, + toExpression, renderDataPanel( domElement: Element, @@ -224,14 +216,7 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return state.columnOrder.map(colId => ({ columnId: colId })); }, getOperationForColumnId: (columnId: string) => { - const column = state.columns[columnId]; - const { dataType, label, isBucketed, operationId } = column; - return { - id: operationId, - label, - dataType, - isBucketed, - }; + return columnToOperation(state.columns[columnId]); }, generateColumnId: () => { // TODO: Come up with a more compact form of generating unique column ids @@ -240,7 +225,11 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( - , + setState(newState)} + {...props} + />, domElement ); }, diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts index 3de7d511c4b49..41aa3737cde9b 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/loader.ts @@ -9,30 +9,54 @@ import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { SavedObjectAttributes } from 'src/legacy/server/saved_objects/service/saved_objects_client'; import { IndexPatternField } from './indexpattern'; -interface IndexPatternAttributes extends SavedObjectAttributes { +interface SavedIndexPatternAttributes extends SavedObjectAttributes { title: string; timeFieldName: string | null; fields: string; fieldFormatMap: string; + typeMeta: string; } +interface SavedRestrictionsObject { + aggs: Record< + string, + Record< + string, + { + agg: string; + interval?: number; + fixed_interval?: string; + delay?: string; + time_zone?: string; + } + > + >; +} +type SavedRestrictionsInfo = SavedRestrictionsObject | undefined; + export const getIndexPatterns = (chrome: Chrome, toastNotifications: ToastNotifications) => { const savedObjectsClient = chrome.getSavedObjectsClient(); return savedObjectsClient - .find({ + .find({ type: 'index-pattern', perPage: 1000, // TODO: Paginate index patterns }) .then(resp => { return resp.savedObjects.map(savedObject => { - const { id, attributes } = savedObject; - return Object.assign(attributes, { + const { id, attributes, type } = savedObject; + return { + ...attributes, id, + type, title: attributes.title, fields: (JSON.parse(attributes.fields) as IndexPatternField[]).filter( - ({ type, esTypes }) => type !== 'string' || (esTypes && esTypes.includes('keyword')) + ({ type: fieldType, esTypes }) => + fieldType !== 'string' || (esTypes && esTypes.includes('keyword')) ), - }); + typeMeta: attributes.typeMeta + ? (JSON.parse(attributes.typeMeta) as SavedRestrictionsInfo) + : undefined, + }; }); }) .catch(err => { diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts new file mode 100644 index 0000000000000..9bc64b8b24e38 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -0,0 +1,319 @@ +/* + * 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 { getOperationTypesForField, getPotentialColumns, getColumnOrder } from './operations'; +import { IndexPatternPrivateState } from './indexpattern'; + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, +}; + +describe('getOperationTypesForField', () => { + describe('with aggregatable fields', () => { + it('should return operations on strings', () => { + expect( + getOperationTypesForField({ + type: 'string', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['value', 'terms'])); + }); + + it('should return operations on numbers', () => { + expect( + getOperationTypesForField({ + type: 'number', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['value', 'avg', 'sum', 'min', 'max'])); + }); + + it('should return operations on dates', () => { + expect( + getOperationTypesForField({ + type: 'date', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['value', 'date_histogram'])); + }); + + it('should return no operations on unknown types', () => { + expect( + getOperationTypesForField({ + type: '_source', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual([]); + }); + }); + + describe('with restrictions', () => { + it('should return operations on strings', () => { + expect( + getOperationTypesForField({ + type: 'string', + name: 'a', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }) + ).toEqual(expect.arrayContaining(['terms'])); + }); + + it('should return operations on numbers', () => { + expect( + getOperationTypesForField({ + type: 'number', + name: 'a', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + min: { + agg: 'min', + }, + max: { + agg: 'max', + }, + }, + }) + ).toEqual(expect.arrayContaining(['min', 'max'])); + }); + + it('should return operations on dates', () => { + expect( + getOperationTypesForField({ + type: 'dates', + name: 'a', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '60m', + delay: '1d', + time_zone: 'UTC', + }, + }, + }) + ).toEqual(expect.arrayContaining(['date_histogram'])); + }); + }); + + describe('getPotentialColumns', () => { + let state: IndexPatternPrivateState; + + beforeEach(() => { + state = { + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'date', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + }, + }; + }); + + it('should include priority', () => { + const columns = getPotentialColumns(state, 1); + + expect(columns.every(col => col.suggestedOrder === 1)).toEqual(true); + }); + + it('should list operations by field for a regular index pattern', () => { + const columns = getPotentialColumns(state); + + expect(columns.map(col => [col.sourceField, col.operationType])).toMatchInlineSnapshot(` +Array [ + Array [ + "bytes", + "value", + ], + Array [ + "bytes", + "sum", + ], + Array [ + "bytes", + "avg", + ], + Array [ + "bytes", + "min", + ], + Array [ + "bytes", + "max", + ], + Array [ + "documents", + "count", + ], + Array [ + "source", + "value", + ], + Array [ + "source", + "terms", + ], + Array [ + "timestamp", + "value", + ], + Array [ + "timestamp", + "date_histogram", + ], +] +`); + }); + }); +}); + +describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + }) + ).toEqual(['col1']); + }); + + it('should put any number of aggregations before metrics', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'value', + sourceField: 'timestamp', + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'bytes', + }, + col3: { + operationId: 'op3', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + }, + }) + ).toEqual(['col1', 'col3', 'col2']); + }); + + it('should reorder aggregations based on suggested priority', () => { + expect( + getColumnOrder({ + col1: { + operationId: 'op1', + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'value', + sourceField: 'timestamp', + suggestedOrder: 2, + }, + col2: { + operationId: 'op2', + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'value', + sourceField: 'bytes', + suggestedOrder: 0, + }, + col3: { + operationId: 'op3', + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedOrder: 1, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts new file mode 100644 index 0000000000000..4a0df7a6a977a --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -0,0 +1,221 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { DataType, DimensionPriority } from '../types'; +import { + IndexPatternColumn, + IndexPatternField, + IndexPatternPrivateState, + OperationType, +} from './indexpattern'; + +export function getOperations(): OperationType[] { + return ['value', 'terms', 'date_histogram', 'sum', 'avg', 'min', 'max', 'count']; +} + +export function getOperationDisplay(): Record< + OperationType, + { + type: OperationType; + displayName: string; + ofName: (name: string) => string; + } +> { + return { + value: { + type: 'value', + displayName: i18n.translate('xpack.lens.indexPattern.value', { + defaultMessage: 'Value', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.valueOf', { + defaultMessage: 'Value of {name}', + values: { name }, + }), + }, + terms: { + type: 'terms', + displayName: i18n.translate('xpack.lens.indexPattern.terms', { + defaultMessage: 'Top Values', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.termsOf', { + defaultMessage: 'Top Values of {name}', + values: { name }, + }), + }, + date_histogram: { + type: 'date_histogram', + displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { + defaultMessage: 'Date Histogram', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.dateHistogramOf', { + defaultMessage: 'Date Histogram of {name}', + values: { name }, + }), + }, + sum: { + type: 'sum', + displayName: i18n.translate('xpack.lens.indexPattern.sum', { + defaultMessage: 'Sum', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.sumOf', { + defaultMessage: 'Sum of {name}', + values: { name }, + }), + }, + avg: { + type: 'avg', + displayName: i18n.translate('xpack.lens.indexPattern.average', { + defaultMessage: 'Average', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.averageOf', { + defaultMessage: 'Average of {name}', + values: { name }, + }), + }, + min: { + type: 'min', + displayName: i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Minimum', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Minimum of {name}', + values: { name }, + }), + }, + max: { + type: 'max', + displayName: i18n.translate('xpack.lens.indexPattern.max', { + defaultMessage: 'Maximum', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.maxOf', { + defaultMessage: 'Maximum of {name}', + values: { name }, + }), + }, + count: { + type: 'count', + displayName: i18n.translate('xpack.lens.indexPattern.count', { + defaultMessage: 'Count', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.countOf', { + defaultMessage: 'Count of {name}', + values: { name }, + }), + }, + }; +} + +export function getOperationTypesForField({ + type, + aggregationRestrictions, +}: IndexPatternField): OperationType[] { + if (aggregationRestrictions) { + const validOperations = getOperations(); + return Object.keys(aggregationRestrictions).filter(key => + // Filter out operations that are available, but that aren't yet supported by the client + validOperations.includes(key as OperationType) + ) as OperationType[]; + } + + switch (type) { + case 'date': + return ['value', 'date_histogram']; + case 'number': + return ['value', 'sum', 'avg', 'min', 'max']; + case 'string': + return ['value', 'terms']; + } + return []; +} + +export function getOperationResultType({ type }: IndexPatternField, op: OperationType): DataType { + switch (op) { + case 'value': + return type as DataType; + case 'avg': + case 'min': + case 'max': + case 'count': + case 'sum': + return 'number'; + case 'date_histogram': + return 'date'; + case 'terms': + return 'string'; + } +} + +export function getPotentialColumns( + state: IndexPatternPrivateState, + suggestedOrder?: DimensionPriority +): IndexPatternColumn[] { + const fields = state.indexPatterns[state.currentIndexPatternId].fields; + + const operationPanels = getOperationDisplay(); + + const columns: IndexPatternColumn[] = fields + .map((field, index) => { + const validOperations = getOperationTypesForField(field); + + return validOperations.map(op => ({ + operationId: `${index}${op}`, + label: operationPanels[op].ofName(field.name), + dataType: getOperationResultType(field, op), + isBucketed: op === 'terms' || op === 'date_histogram', + + operationType: op, + sourceField: field.name, + suggestedOrder, + })); + }) + .reduce((prev, current) => prev.concat(current)); + + columns.push({ + operationId: 'count', + label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { + defaultMessage: 'Count of Documents', + }), + dataType: 'number', + isBucketed: false, + + operationType: 'count', + sourceField: 'documents', + suggestedOrder, + }); + + columns.sort(({ sourceField }, { sourceField: sourceField2 }) => + sourceField.localeCompare(sourceField2) + ); + + return columns; +} + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, col => col[1].isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedOrder !== undefined ? col.suggestedOrder : Number.MAX_SAFE_INTEGER) - + (col2.suggestedOrder !== undefined ? col2.suggestedOrder : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts new file mode 100644 index 0000000000000..9e9f113665fdb --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; + +import { IndexPatternPrivateState } from './indexpattern'; + +export function toExpression(state: IndexPatternPrivateState) { + if (state.columnOrder.length === 0) { + return null; + } + + const fieldNames = state.columnOrder.map(col => state.columns[col].sourceField); + const sortedColumns = state.columnOrder.map(col => state.columns[col]); + + const indexName = state.indexPatterns[state.currentIndexPatternId].title; + + if (sortedColumns.every(({ operationType }) => operationType === 'value')) { + return `esdocs index="${indexName}" fields="${fieldNames.join(', ')}" sort="${ + fieldNames[0] + }, DESC"`; + } else if (sortedColumns.length) { + const firstMetric = sortedColumns.findIndex(({ isBucketed }) => !isBucketed); + const aggs = sortedColumns.map((col, index) => { + if (col.operationType === 'date_histogram') { + return { + id: state.columnOrder[index], + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: col.sourceField, + // TODO: This range should be passed in from somewhere else + timeRange: { + from: 'now-1d', + to: 'now', + }, + useNormalizedEsInterval: true, + interval: '1h', + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + }; + } else if (col.operationType === 'terms') { + return { + id: state.columnOrder[index], + enabled: true, + type: 'terms', + schema: 'segment', + params: { + field: col.sourceField, + orderBy: state.columnOrder[firstMetric] || undefined, + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }; + } else if (col.operationType === 'count') { + return { + id: state.columnOrder[index], + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }; + } else { + return { + id: state.columnOrder[index], + enabled: true, + type: col.operationType, + schema: 'metric', + params: { + field: col.sourceField, + }, + }; + } + }); + + return `esaggs + index="${state.currentIndexPatternId}" + metricsAtAllLevels="false" + partialRows="false" + aggConfigs='${JSON.stringify(aggs)}'`; + } + + return ''; +}