From 604c6ed68ca394441ddafa662bdfc5f421de300c Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 12 Jun 2019 18:16:04 -0400 Subject: [PATCH] [lens] Index pattern suggest on drop --- .../editor_frame/workspace_panel.test.tsx | 134 +++++++++++++- .../editor_frame/workspace_panel.tsx | 5 +- .../lens/public/editor_frame_plugin/mocks.tsx | 2 +- .../indexpattern_plugin/indexpattern.test.tsx | 169 ++++++++++++++++++ .../indexpattern_plugin/indexpattern.tsx | 101 ++++++++++- .../public/indexpattern_plugin/operations.ts | 34 ++-- x-pack/plugins/lens/public/types.ts | 2 +- 7 files changed, 428 insertions(+), 19 deletions(-) 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..01b7deeb9f41a 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,136 @@ 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", () => { + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([]); + + 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 fef3a3f72948d..85722f47ac285 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -279,6 +279,175 @@ 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', + }), + ], + }); + }); + }); + + 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 04bee936d7ff3..571676a3f6b51 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 = | 'value' @@ -243,11 +246,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 8ac0b382eecca..198c872b19ed8 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/operations.ts @@ -159,28 +159,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;