diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index cfb51a0adce1d..9efa5da6a6cf1 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -21,7 +21,7 @@ import { ReactWrapper } from 'enzyme'; const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); describe('workspace_panel', () => { - let mockVisualization: Visualization; + let mockVisualization: jest.Mocked; let mockDatasource: DatasourceMock; let expressionRendererMock: jest.Mock; @@ -274,4 +274,134 @@ Object { expect(instance.find(expressionRendererMock).length).toBe(1); }); }); + + describe('suggestions from dropping in workspace panel', () => { + let mockDispatch: jest.Mock; + + beforeEach(() => { + mockDispatch = jest.fn(); + instance = mount( + + ); + }); + + it('should immediately transition if exactly one suggestion is returned', () => { + const expectedTable = { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [], + }; + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: expectedTable, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'my title', + state: {}, + datasourceSuggestionId: 0, + }, + ]); + + instance.childAt(0).prop('onDrop')({ + name: '@timestamp', + type: 'date', + searchable: false, + aggregatable: false, + }); + + expect(mockDatasource.getDatasourceSuggestionsForField).toHaveBeenCalledTimes(1); + expect(mockVisualization.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + tables: [expectedTable], + }) + ); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'vis', + initialState: {}, + datasourceState: {}, + }); + }); + + it('should immediately transition to the first suggestion if there are multiple', () => { + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [], + }, + }, + { + state: {}, + table: { + datasourceSuggestionId: 1, + isMultiRow: true, + columns: [], + }, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.8, + title: 'first suggestion', + state: { + isFirst: true, + }, + datasourceSuggestionId: 1, + }, + { + score: 0.5, + title: 'second suggestion', + state: {}, + datasourceSuggestionId: 0, + }, + ]); + + instance.childAt(0).prop('onDrop')({ + name: '@timestamp', + type: 'date', + searchable: false, + aggregatable: false, + }); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'vis', + initialState: { + isFirst: true, + }, + datasourceState: {}, + }); + }); + + it("should do nothing when the visualization can't use the suggestions", () => { + instance.childAt(0).prop('onDrop')({ + name: '@timestamp', + type: 'date', + searchable: false, + aggregatable: false, + }); + + expect(mockDatasource.getDatasourceSuggestionsForField).toHaveBeenCalledTimes(1); + expect(mockVisualization.getSuggestions).toHaveBeenCalledTimes(1); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index 92ba491ab0576..6d1bb17b25624 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -37,9 +37,10 @@ export function WorkspacePanel({ dispatch, ExpressionRenderer: ExpressionRendererComponent, }: WorkspacePanelProps) { - function onDrop() { + function onDrop(item: unknown) { const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsForField( - datasourceState + datasourceState, + item ); const suggestions = getSuggestions( diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx index 5d2d9e5bc5309..d6e9e2f530fc0 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -35,7 +35,7 @@ export function createMockDatasource(): DatasourceMock { }; return { - getDatasourceSuggestionsForField: jest.fn(_state => []), + getDatasourceSuggestionsForField: jest.fn((_state, item) => []), getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), getPersistableState: jest.fn(), getPublicAPI: jest.fn((_state, _setState) => publicAPIMock), 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 f918a16fd8825..affddb88277a5 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -246,6 +246,206 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#getDatasourceSuggestionsForField', () => { + describe('with no previous selections', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + columnOrder: [], + columns: {}, + }); + }); + + it('should apply a bucketed aggregation for a string field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + }), + col2: expect.objectContaining({ + operationType: 'count', + sourceField: 'documents', + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + }); + }); + + it('should apply a bucketed aggregation for a date field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'count', + sourceField: 'documents', + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + }); + }); + + it('should select a metric for a number field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + sourceField: 'timestamp', + operationType: 'date_histogram', + }), + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'sum', + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + datasourceSuggestionId: 0, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + }); + }); + + it('should not make any suggestions for a number without a time field', async () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + columnOrder: [], + columns: {}, + indexPatterns: { + 1: { + id: '1', + title: 'no timefield', + fields: [ + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + ], + }, + }, + }; + + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(state, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }); + + expect(suggestions).toHaveLength(0); + }); + }); + + describe('with a prior column', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + initialState = await indexPatternDatasource.initialize(persistedState); + }); + + it('should not suggest for string', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }) + ).toHaveLength(0); + }); + + it('should not suggest for date', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }) + ).toHaveLength(0); + }); + + it('should not suggest for number', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }) + ).toHaveLength(0); + }); + }); + }); + describe('#getPublicAPI', () => { let publicAPI: DatasourcePublicAPI; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 671b3576c7271..94660e1335bea 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,6 +7,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; +import { i18n } from '@kbn/i18n'; import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; @@ -16,11 +17,13 @@ import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, DimensionPriority, + DatasourceSuggestion, } from '../types'; import { getIndexPatterns } from './loader'; import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; +import { makeOperation, getOperationTypesForField } from './operations'; export type OperationType = 'terms' | 'date_histogram' | 'sum' | 'avg' | 'min' | 'max' | 'count'; @@ -235,11 +238,105 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To }; }, - getDatasourceSuggestionsForField() { + getDatasourceSuggestionsForField( + state, + item + ): Array> { + const field: IndexPatternField = item as IndexPatternField; + + if (Object.keys(state.columns).length) { + // Not sure how to suggest multiple fields yet + return []; + } + + const operations = getOperationTypesForField(field); + const hasBucket = operations.find(op => op === 'date_histogram' || op === 'terms'); + + if (hasBucket) { + const column = makeOperation(0, hasBucket, field); + + const countColumn: IndexPatternColumn = { + operationId: 'count', + label: i18n.translate('xpack.lens.indexPatternOperations.countOfDocuments', { + defaultMessage: 'Count of Documents', + }), + dataType: 'number', + isBucketed: false, + + operationType: 'count', + sourceField: 'documents', + }; + + const suggestion: DatasourceSuggestion = { + state: { + ...state, + columns: { + col1: column, + col2: countColumn, + }, + columnOrder: ['col1', 'col2'], + }, + + table: { + columns: [ + { + columnId: 'col1', + operation: columnToOperation(column), + }, + { + columnId: 'col2', + operation: columnToOperation(countColumn), + }, + ], + isMultiRow: true, + datasourceSuggestionId: 0, + }, + }; + + return [suggestion]; + } else if (state.indexPatterns[state.currentIndexPatternId].timeFieldName) { + const currentIndexPattern = state.indexPatterns[state.currentIndexPatternId]; + const dateField = currentIndexPattern.fields.find( + f => f.name === currentIndexPattern.timeFieldName + )!; + + const column = makeOperation(0, operations[0], field); + + const dateColumn = makeOperation(1, 'date_histogram', dateField); + + const suggestion: DatasourceSuggestion = { + state: { + ...state, + columns: { + col1: dateColumn, + col2: column, + }, + columnOrder: ['col1', 'col2'], + }, + + table: { + columns: [ + { + columnId: 'col1', + operation: columnToOperation(column), + }, + { + columnId: 'col2', + operation: columnToOperation(dateColumn), + }, + ], + isMultiRow: true, + datasourceSuggestionId: 0, + }, + }; + + return [suggestion]; + } + return []; }, - getDatasourceSuggestionsFromCurrentState() { + getDatasourceSuggestionsFromCurrentState(state) { return []; }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts index 6b252eb65fc51..2a2e6a82722c1 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -146,28 +146,38 @@ export function getOperationResultType({ type }: IndexPatternField, op: Operatio } } +export function makeOperation( + index: number, + op: OperationType, + field: IndexPatternField, + suggestedOrder?: DimensionPriority +): IndexPatternColumn { + const operationPanels = getOperationDisplay(); + return { + operationId: `${index}${op}`, + label: operationPanels[op].ofName(field.name), + dataType: getOperationResultType(field, op), + isBucketed: op === 'terms' || op === 'date_histogram', + + operationType: op, + sourceField: field.name, + suggestedOrder, + }; +} + 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, - })); + return validOperations.map(op => { + return makeOperation(index, op, field, suggestedOrder); + }); }) .reduce((prev, current) => prev.concat(current)); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index dc7f2e04609ef..367d1bdd99c79 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -57,7 +57,7 @@ export interface Datasource { toExpression: (state: T) => Ast | string | null; - getDatasourceSuggestionsForField: (state: T) => Array>; + getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; getPublicAPI: (state: T, setState: (newState: T) => void) => DatasourcePublicAPI;