diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index 32785d2d95753..904c11d2f29de 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -5,35 +5,18 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EditorFrame } from './editor_frame'; import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; import { act } from 'react-dom/test-utils'; +import { createMockVisualization, createMockDatasource } from '../mock_extensions'; // calling this function will wait for all pending Promises from mock // datasources to be processed by its callers. -const waitForPromises = () => new Promise(resolve => setImmediate(resolve)); +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); describe('editor_frame', () => { - const getMockVisualization = () => ({ - getMappingOfTableToRoles: jest.fn(), - getPersistableState: jest.fn(), - getSuggestions: jest.fn(), - initialize: jest.fn(), - renderConfigPanel: jest.fn(), - toExpression: jest.fn(), - }); - - const getMockDatasource = () => ({ - getDatasourceSuggestionsForField: jest.fn(), - getDatasourceSuggestionsFromCurrentState: jest.fn(), - getPersistableState: jest.fn(), - getPublicAPI: jest.fn(), - initialize: jest.fn(() => Promise.resolve()), - renderDataPanel: jest.fn(), - toExpression: jest.fn(), - }); - let mockVisualization: Visualization; let mockDatasource: Datasource; @@ -41,11 +24,11 @@ describe('editor_frame', () => { let mockDatasource2: Datasource; beforeEach(() => { - mockVisualization = getMockVisualization(); - mockVisualization2 = getMockVisualization(); + mockVisualization = createMockVisualization(); + mockVisualization2 = createMockVisualization(); - mockDatasource = getMockDatasource(); - mockDatasource2 = getMockDatasource(); + mockDatasource = createMockDatasource(); + mockDatasource2 = createMockDatasource(); }); describe('initialization', () => { @@ -439,4 +422,284 @@ describe('editor_frame', () => { ); }); }); + + describe('suggestions', () => { + it('should fetch suggestions of currently active datasource', async () => { + mount( + + ); + + await waitForPromises(); + + expect(mockDatasource.getDatasourceSuggestionsFromCurrentState).toHaveBeenCalled(); + expect(mockDatasource2.getDatasourceSuggestionsFromCurrentState).not.toHaveBeenCalled(); + }); + + it('should fetch suggestions of all visualizations', async () => { + mount( + + ); + + await waitForPromises(); + + expect(mockVisualization.getSuggestions).toHaveBeenCalled(); + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); + }); + + it('should display suggestions in descending order', async () => { + const instance = mount( + [ + { + tableIndex: 0, + score: 0.5, + state: {}, + title: 'Suggestion2', + }, + { + tableIndex: 0, + score: 0.8, + state: {}, + title: 'Suggestion1', + }, + ], + }, + testVis2: { + ...mockVisualization, + getSuggestions: () => [ + { + tableIndex: 0, + score: 0.4, + state: {}, + title: 'Suggestion4', + }, + { + tableIndex: 0, + score: 0.45, + state: {}, + title: 'Suggestion3', + }, + ], + }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + const suggestions = instance.find('[data-test-subj="suggestion"]'); + expect(suggestions.map(el => el.text())).toEqual([ + 'Suggestion1', + 'Suggestion2', + 'Suggestion3', + 'Suggestion4', + ]); + }); + + it('should switch to suggested visualization', async () => { + const newDatasourceState = {}; + const suggestionVisState = {}; + const instance = mount( + [ + { + tableIndex: 0, + score: 0.8, + state: suggestionVisState, + title: 'Suggestion1', + }, + ], + }, + testVis2: mockVisualization2, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsFromCurrentState: () => [ + { state: newDatasourceState, tableColumns: [] }, + ], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis2" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance.find('[data-test-subj="suggestion"]').simulate('click'); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(1); + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + state: suggestionVisState, + }) + ); + expect(mockDatasource.renderDataPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + state: newDatasourceState, + }) + ); + }); + + it('should switch to best suggested visualization on field drop', async () => { + const suggestionVisState = {}; + const instance = mount( + [ + { + tableIndex: 0, + score: 0.2, + state: {}, + title: 'Suggestion1', + }, + { + tableIndex: 0, + score: 0.8, + state: suggestionVisState, + title: 'Suggestion2', + }, + ], + }, + testVis2: mockVisualization2, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [{ state: {}, tableColumns: [] }], + getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance.find('[data-test-subj="lnsDragDrop"]').simulate('drop'); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + state: suggestionVisState, + }) + ); + }); + + it('should switch to best suggested visualization regardless extension on field drop', async () => { + const suggestionVisState = {}; + const instance = mount( + [ + { + tableIndex: 0, + score: 0.2, + state: {}, + title: 'Suggestion1', + }, + { + tableIndex: 0, + score: 0.6, + state: {}, + title: 'Suggestion2', + }, + ], + }, + testVis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + tableIndex: 0, + score: 0.8, + state: suggestionVisState, + title: 'Suggestion3', + }, + ], + }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [{ state: {}, tableColumns: [] }], + getDatasourceSuggestionsFromCurrentState: () => [{ state: {}, tableColumns: [] }], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance.find('[data-test-subj="lnsDragDrop"]').simulate('drop'); + }); + + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + state: suggestionVisState, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx index ee5023c06f3f7..fb0bb662915ba 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -10,6 +10,8 @@ import { reducer, getInitialState } from './state_management'; import { DataPanelWrapper } from './data_panel_wrapper'; import { ConfigPanelWrapper } from './config_panel_wrapper'; import { FrameLayout } from './frame_layout'; +import { SuggestionPanel } from './suggestion_panel'; +import { WorkspacePanel } from './workspace_panel'; export interface EditorFrameProps { datasourceMap: Record; @@ -67,6 +69,52 @@ export function EditorFrame(props: EditorFrameProps) { ] ); + if (state.datasource.activeId && !state.datasource.isLoading) { + return ( + + } + configPanel={ + + } + workspacePanel={ + + } + suggestionsPanel={ + + } + /> + ); + } + return ( } - configPanel={ - state.datasource.activeId && - !state.datasource.isLoading && ( - - ) - } /> ); } diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx index 182d0c9f0b592..f62722bf71b85 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -9,7 +9,9 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; export interface FrameLayoutProps { dataPanel: React.ReactNode; - configPanel: React.ReactNode; + configPanel?: React.ReactNode; + suggestionsPanel?: React.ReactNode; + workspacePanel?: React.ReactNode; } export function FrameLayout(props: FrameLayoutProps) { @@ -17,7 +19,11 @@ export function FrameLayout(props: FrameLayoutProps) { {/* TODO style this and add workspace prop and loading flags */} {props.dataPanel} - {props.configPanel} + {props.workspacePanel} + + {props.configPanel} + {props.suggestionsPanel} + ); } diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts index 373b321309586..615c9607877ed 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -117,6 +117,34 @@ describe('editor_frame state management', () => { expect(newState.visualization.state).toBe(newVisState); }); + it('should should switch active visualization and update datasource state', () => { + const testVisState = {}; + const newVisState = {}; + const newDatasourceState = {}; + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: testVisState, + }, + }, + { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'testVis2', + initialState: newVisState, + datasourceState: newDatasourceState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + expect(newState.datasource.state).toBe(newDatasourceState); + }); + it('should should switch active datasource and purge visualization state', () => { const newState = reducer( { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts index 2358da104378b..ec24a0269c58c 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -31,6 +31,7 @@ export type Action = type: 'SWITCH_VISUALIZATION'; newVisualizationId: string; initialState: unknown; + datasourceState?: unknown; } | { type: 'SWITCH_DATASOURCE'; @@ -79,6 +80,10 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta activeId: action.newVisualizationId, state: action.initialState, }, + datasource: { + ...state.datasource, + state: action.datasourceState ? action.datasourceState : state.datasource.state, + }, }; case 'UPDATE_DATASOURCE_STATE': return { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts new file mode 100644 index 0000000000000..f79a4b1000991 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts @@ -0,0 +1,154 @@ +/* + * 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 { getSuggestions } from './suggestion_helpers'; +import { createMockVisualization } from '../mock_extensions'; +import { TableColumn } from '../../types'; + +describe('suggestion helpers', () => { + it('should return suggestions array', () => { + const mockVisualization = createMockVisualization(); + const suggestedState = {}; + const suggestions = getSuggestions( + [{ state: {}, tableColumns: [] }], + { + vis1: { + ...mockVisualization, + getSuggestions: () => [ + { tableIndex: 0, score: 0.5, title: 'Test', state: suggestedState }, + ], + }, + }, + 'vis1', + {} + ); + expect(suggestions.length).toBe(1); + expect(suggestions[0].state).toBe(suggestedState); + }); + + it('should concatenate suggestions from all visualizations', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const suggestions = getSuggestions( + [{ state: {}, tableColumns: [] }], + { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { tableIndex: 0, score: 0.5, title: 'Test', state: {} }, + { tableIndex: 0, score: 0.5, title: 'Test2', state: {} }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [{ tableIndex: 0, score: 0.5, title: 'Test3', state: {} }], + }, + }, + 'vis1', + {} + ); + expect(suggestions.length).toBe(3); + }); + + it('should rank the visualizations by score', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const suggestions = getSuggestions( + [{ state: {}, tableColumns: [] }], + { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { tableIndex: 0, score: 0.2, title: 'Test', state: {} }, + { tableIndex: 0, score: 0.8, title: 'Test2', state: {} }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [{ tableIndex: 0, score: 0.6, title: 'Test3', state: {} }], + }, + }, + 'vis1', + {} + ); + expect(suggestions[0].score).toBe(0.8); + expect(suggestions[1].score).toBe(0.6); + expect(suggestions[2].score).toBe(0.2); + }); + + it('should call all suggestion getters with all available data tables', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const table1: TableColumn[] = []; + const table2: TableColumn[] = []; + getSuggestions( + [{ state: {}, tableColumns: table1 }, { state: {}, tableColumns: table2 }], + { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + 'vis1', + {} + ); + expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); + expect(mockVisualization1.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); + expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[0]).toBe(table1); + expect(mockVisualization2.getSuggestions.mock.calls[0][0].tables[1]).toBe(table2); + }); + + it('should map the suggestion ids back to the correct datasource states', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const tableState1 = {}; + const tableState2 = {}; + const suggestions = getSuggestions( + [{ state: tableState1, tableColumns: [] }, { state: tableState2, tableColumns: [] }], + { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { tableIndex: 0, score: 0.3, title: 'Test', state: {} }, + { tableIndex: 1, score: 0.2, title: 'Test2', state: {} }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [{ tableIndex: 1, score: 0.1, title: 'Test3', state: {} }], + }, + }, + 'vis1', + {} + ); + expect(suggestions[0].datasourceState).toBe(tableState1); + expect(suggestions[1].datasourceState).toBe(tableState2); + expect(suggestions[1].datasourceState).toBe(tableState2); + }); + + it('should pass the state of the currently active visualization to getSuggestions', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const currentState = {}; + getSuggestions( + [{ state: {}, tableColumns: [] }, { state: {}, tableColumns: [] }], + { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + 'vis1', + currentState + ); + expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + state: currentState, + }) + ); + expect(mockVisualization2.getSuggestions).not.toHaveBeenCalledWith( + expect.objectContaining({ + state: currentState, + }) + ); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts new file mode 100644 index 0000000000000..c6f5d4d4538f3 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts @@ -0,0 +1,61 @@ +/* + * 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 { Visualization, DatasourceSuggestion } from '../../types'; +import { Action } from './state_management'; + +export interface Suggestion { + visualizationId: string; + datasourceState: unknown; + score: number; + title: string; + state: unknown; +} + +/** + * This function takes a list of available data tables and a list of visualization + * extensions and creates a ranked list of suggestions which contain a pair of a data table + * and a visualization. + * + * Each suggestion represents a valid state of the editor and can be applied by creating an + * action with `toSwitchAction` and dispatching it + */ +export function getSuggestions( + datasourceTableSuggestions: DatasourceSuggestion[], + visualizationMap: Record, + activeVisualizationId: string | null, + visualizationState: unknown +): Suggestion[] { + const datasourceTables = datasourceTableSuggestions.map(({ tableColumns }) => tableColumns); + + return ( + Object.entries(visualizationMap) + .map(([visualizationId, visualization]) => { + return visualization + .getSuggestions({ + tables: datasourceTables, + state: visualizationId === activeVisualizationId ? visualizationState : undefined, + }) + .map(({ tableIndex: datasourceSuggestionId, ...suggestion }) => ({ + ...suggestion, + visualizationId, + datasourceState: datasourceTableSuggestions[datasourceSuggestionId].state, + })); + }) + // TODO why is flatMap not available here? + .reduce((globalList, currentList) => [...globalList, ...currentList], []) + .sort(({ score: scoreA }, { score: scoreB }) => scoreB - scoreA) + ); +} + +export function toSwitchAction(suggestion: Suggestion): Action { + return { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: suggestion.visualizationId, + initialState: suggestion.state, + datasourceState: suggestion.datasourceState, + }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx new file mode 100644 index 0000000000000..9d9730db37651 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx @@ -0,0 +1,65 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; + +import { Action } from './state_management'; +import { Datasource, Visualization } from '../../types'; +import { getSuggestions, toSwitchAction } from './suggestion_helpers'; + +export interface SuggestionPanelProps { + activeDatasource: Datasource; + datasourceState: unknown; + activeVisualizationId: string | null; + visualizationMap: Record; + visualizationState: unknown; + dispatch: (action: Action) => void; +} + +export function SuggestionPanel({ + activeDatasource, + datasourceState, + activeVisualizationId, + visualizationMap, + visualizationState, + dispatch, +}: SuggestionPanelProps) { + const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsFromCurrentState( + datasourceState + ); + + const suggestions = getSuggestions( + datasourceSuggestions, + visualizationMap, + activeVisualizationId, + visualizationState + ); + + return ( + <> +

+ +

+ {suggestions.map((suggestion, index) => { + return ( + + ); + })} + + ); +} 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 new file mode 100644 index 0000000000000..761b77757df62 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -0,0 +1,99 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; + +import { Action } from './state_management'; +import { Datasource, Visualization, DatasourcePublicAPI } from '../../types'; +import { DragDrop } from '../../drag_drop'; +import { getSuggestions, toSwitchAction } from './suggestion_helpers'; + +export interface WorkspacePanelProps { + activeDatasource: Datasource; + datasourceState: unknown; + activeVisualizationId: string | null; + visualizationMap: Record; + visualizationState: unknown; + datasourcePublicAPI: DatasourcePublicAPI; + dispatch: (action: Action) => void; +} + +interface ExpressionRendererProps { + expression: string; +} + +function ExpressionRenderer(props: ExpressionRendererProps) { + // TODO: actually render the expression and move this to a generic folder as it can be re-used for + // suggestion rendering + return {props.expression}; +} + +export function WorkspacePanel({ + activeDatasource, + activeVisualizationId, + datasourceState, + visualizationMap, + visualizationState, + datasourcePublicAPI, + dispatch, +}: WorkspacePanelProps) { + function onDrop() { + const datasourceSuggestions = activeDatasource.getDatasourceSuggestionsForField( + datasourceState + ); + + const suggestions = getSuggestions( + datasourceSuggestions, + visualizationMap, + activeVisualizationId, + visualizationState + ); + + if (suggestions.length === 0) { + // TODO specify and implement behavior in case of no valid suggestions + return; + } + + const suggestion = suggestions[0]; + + // TODO heuristically present the suggestions in a modal instead of just picking the first one + dispatch(toSwitchAction(suggestion)); + } + + function renderEmptyWorkspace() { + return ( +

+ +

+ ); + } + + function renderVisualization() { + if (activeVisualizationId === null) { + return renderEmptyWorkspace(); + } + + const activeVisualization = visualizationMap[activeVisualizationId]; + const datasourceExpression = activeDatasource.toExpression(datasourceState); + const visualizationExpression = activeVisualization.toExpression( + visualizationState, + datasourcePublicAPI + ); + const expression = `${datasourceExpression} | ${visualizationExpression}`; + + return ; + } + + return ( + + {renderVisualization()} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts b/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts new file mode 100644 index 0000000000000..45162d7c07960 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mock_extensions.ts @@ -0,0 +1,46 @@ +/* + * 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 { DatasourcePublicAPI, Visualization, Datasource } from '../types'; + +export function createMockVisualization(): jest.Mocked { + return { + getPersistableState: jest.fn(_state => ({})), + getSuggestions: jest.fn(_options => []), + initialize: jest.fn(_state => ({})), + renderConfigPanel: jest.fn(), + toExpression: jest.fn((_state, _datasource) => ''), + }; +} + +export type DatasourceMock = jest.Mocked & { + publicAPIMock: jest.Mocked; +}; + +export function createMockDatasource(): DatasourceMock { + const publicAPIMock: jest.Mocked = { + getTableSpec: jest.fn(() => []), + getOperationForColumnId: jest.fn(), + renderDimensionPanel: jest.fn(), + removeColumnInTableSpec: jest.fn(), + moveColumnTo: jest.fn(), + duplicateColumn: jest.fn(), + }; + + return { + getDatasourceSuggestionsForField: jest.fn(_state => []), + getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), + getPersistableState: jest.fn(), + getPublicAPI: jest.fn((_state, _setState) => publicAPIMock), + initialize: jest.fn(_state => Promise.resolve()), + renderDataPanel: jest.fn(), + toExpression: jest.fn(_state => ''), + + // this is an additional property which doesn't exist on real datasources + // but can be used to validate whether specific API mock functions are called + publicAPIMock, + }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx index 1ca641b2e6e37..1d81f315bf525 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -5,9 +5,11 @@ */ import { EditorFramePlugin } from './plugin'; -import { Visualization, Datasource } from '../types'; +import { createMockDatasource, createMockVisualization } from './mock_extensions'; -const nextTick = () => new Promise(resolve => setTimeout(resolve)); +// calling this function will wait for all pending Promises from mock +// datasources to be processed by its callers. +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); describe('editor_frame plugin', () => { let pluginInstance: EditorFramePlugin; @@ -51,23 +53,14 @@ describe('editor_frame plugin', () => { }); it('should initialize and render provided datasource', async () => { + const mockDatasource = createMockDatasource(); const publicAPI = pluginInstance.setup(); - const mockDatasource = { - getDatasourceSuggestionsForField: jest.fn(), - getDatasourceSuggestionsFromCurrentState: jest.fn(), - getPersistableState: jest.fn(), - getPublicAPI: jest.fn(), - initialize: jest.fn(() => Promise.resolve()), - renderDataPanel: jest.fn(), - toExpression: jest.fn(), - }; - publicAPI.registerDatasource('test', mockDatasource); const instance = publicAPI.createInstance({}); instance.mount(mountpoint); - await nextTick(); + await waitForPromises(); expect(mockDatasource.initialize).toHaveBeenCalled(); expect(mockDatasource.renderDataPanel).toHaveBeenCalled(); @@ -76,25 +69,9 @@ describe('editor_frame plugin', () => { }); it('should initialize visualization and render config panel', async () => { + const mockDatasource = createMockDatasource(); + const mockVisualization = createMockVisualization(); const publicAPI = pluginInstance.setup(); - const mockDatasource: Datasource = { - getDatasourceSuggestionsForField: jest.fn(), - getDatasourceSuggestionsFromCurrentState: jest.fn(), - getPersistableState: jest.fn(), - getPublicAPI: jest.fn(), - initialize: jest.fn(() => Promise.resolve()), - renderDataPanel: jest.fn(), - toExpression: jest.fn(), - }; - - const mockVisualization: Visualization = { - getMappingOfTableToRoles: jest.fn(), - getPersistableState: jest.fn(), - getSuggestions: jest.fn(), - initialize: jest.fn(), - renderConfigPanel: jest.fn(), - toExpression: jest.fn(), - }; publicAPI.registerDatasource('test', mockDatasource); publicAPI.registerVisualization('test', mockVisualization); @@ -102,7 +79,7 @@ describe('editor_frame plugin', () => { const instance = publicAPI.createInstance({}); instance.mount(mountpoint); - await nextTick(); + await waitForPromises(); expect(mockVisualization.initialize).toHaveBeenCalled(); expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx index 07c1841601140..7c12d95cb69c1 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -6,8 +6,9 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; export class EditorFramePlugin { @@ -29,13 +30,19 @@ export class EditorFramePlugin { mount: element => { unmount(); domElement = element; + + const firstDatasourceId = Object.keys(this.datasources)[0]; + const firstVisualizationId = Object.keys(this.visualizations)[0]; + render( - , + + + , domElement ); }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a0328cb3bd988..4e2c623416c9a 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -21,24 +21,14 @@ export interface EditorFrameSetup { // Hints the default nesting to the data source. 0 is the highest priority export type DimensionPriority = 0 | 1 | 2; -// For switching between visualizations and correctly matching columns -export type DimensionRole = - | 'splitChart' - | 'series' - | 'primary' - | 'secondary' - | 'color' - | 'size' - | string; // Some visualizations will use custom names that have other meaning - -export interface TableColumns { +export interface TableColumn { columnId: string; operation: Operation; } export interface DatasourceSuggestion { state: T; - tableColumns: TableColumns[]; + tableColumns: TableColumn[]; } /** @@ -131,10 +121,8 @@ export interface VisualizationProps { } export interface SuggestionRequest { - // Roles currently being used - roles: DimensionRole[]; // It is up to the Visualization to rank these tables - tableColumns: { [datasourceSuggestionId: string]: TableColumns }; + tables: TableColumn[][]; state?: T; // State is only passed if the visualization is active } @@ -142,7 +130,7 @@ export interface VisualizationSuggestion { score: number; title: string; state: T; - datasourceSuggestionId: string; + tableIndex: number; } export interface Visualization { @@ -155,9 +143,6 @@ export interface Visualization { toExpression: (state: T, datasource: DatasourcePublicAPI) => string; - // Frame will request the list of roles currently being used when calling `getInitialStateFromOtherVisualization` - getMappingOfTableToRoles: (state: T, datasource: DatasourcePublicAPI) => DimensionRole[]; - // The frame will call this function on all visualizations when the table changes, or when // rendering additional ways of using the data getSuggestions: (options: SuggestionRequest) => Array>; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 7ec2994161216..c5d69175e90cd 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -6,10 +6,10 @@ import React from 'react'; import { render } from 'react-dom'; -import { Visualization, DimensionRole } from '../types'; +import { Visualization } from '../types'; export interface XyVisualizationState { - roles: DimensionRole[]; + roles: string[]; } export type XyVisualizationPersistedState = XyVisualizationState; @@ -31,7 +31,5 @@ export const xyVisualization: Visualization [], - getMappingOfTableToRoles: (state, datasource) => [], - toExpression: state => '', };