diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 29f1c2eec03f0d..16860142e28d0b 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -7,16 +7,16 @@ import { I18nProvider } from '@kbn/i18n/react'; import React from 'react'; -import { EditorFrameSetup } from '../types'; +import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; -export function App({ editorFrame }: { editorFrame: EditorFrameSetup }) { +export function App({ editorFrame }: { editorFrame: EditorFrameInstance }) { return (

Lens

- +
); diff --git a/x-pack/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/plugins/lens/public/app_plugin/plugin.tsx index 1a096d7c1326cf..857cee9adbc64d 100644 --- a/x-pack/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/app_plugin/plugin.tsx @@ -9,8 +9,11 @@ import { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin'; import { App } from './app'; +import { EditorFrameInstance } from '../types'; export class AppPlugin { + private instance: EditorFrameInstance | null = null; + constructor() {} setup() { @@ -23,10 +26,17 @@ export class AppPlugin { editorFrame.registerDatasource('indexpattern', indexPattern); editorFrame.registerVisualization('xy', xyVisualization); - return ; + this.instance = editorFrame.createInstance({}); + + return ; } stop() { + if (this.instance) { + this.instance.unmount(); + } + + // TODO this will be handled by the plugin platform itself indexPatternDatasourceStop(); xyVisualizationStop(); editorFrameStop(); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx deleted file mode 100644 index 9ab026db082a27..00000000000000 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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, { useReducer, useEffect } from 'react'; -import { Datasource, Visualization } from '../types'; -import { NativeRenderer } from '../native_renderer'; - -interface EditorFrameProps { - datasources: { [key: string]: Datasource }; - visualizations: { [key: string]: Visualization }; - - initialDatasource?: string; -} - -interface DatasourceState { - datasourceName: string; - visualizationName: string; - - datasourceState: unknown; - visualizationState: unknown; -} - -interface UpdateDatasourceAction { - type: 'UPDATE_DATASOURCE'; - payload: unknown; -} - -interface UpdateVisualizationAction { - type: 'UPDATE_VISUALIZATION'; - payload: unknown; -} - -type Action = UpdateDatasourceAction | UpdateVisualizationAction; - -function stateReducer(state: DatasourceState, action: Action): DatasourceState { - switch (action.type) { - case 'UPDATE_DATASOURCE': - return { - ...state, - datasourceState: action.payload, - }; - case 'UPDATE_VISUALIZATION': - return { - ...state, - visualizationState: action.payload, - }; - } - return state; -} - -export function EditorFrame(props: EditorFrameProps) { - const dsKeys = Object.keys(props.datasources); - const vKeys = Object.keys(props.visualizations); - - const [state, dispatch] = useReducer(stateReducer, { - datasourceName: props.initialDatasource || dsKeys[0], - visualizationName: vKeys[0], - - datasourceState: null, - visualizationState: null, - }); - - useEffect(() => { - const vState = props.visualizations[state.visualizationName].initialize(); - props.datasources[state.datasourceName].initialize().then(dsState => { - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: dsState, - }); - }); - - dispatch({ - type: 'UPDATE_VISUALIZATION', - payload: vState, - }); - }, []); - - return ( -
-

Editor Frame

- - - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }), - }} - /> - - - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }) - ), - state: state.visualizationState, - setState: (newState: unknown) => - dispatch({ - type: 'UPDATE_VISUALIZATION', - payload: newState, - }), - }} - /> -
- ); -} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx new file mode 100644 index 00000000000000..b1329ee6fc2a85 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -0,0 +1,63 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiSelect } from '@elastic/eui'; +import { NativeRenderer } from '../../native_renderer'; +import { Action } from './state_management'; +import { Visualization, DatasourcePublicAPI } from '../../types'; + +interface ConfigPanelWrapperProps { + visualizationState: unknown; + visualizationMap: Record; + activeVisualizationId: string | null; + dispatch: (action: Action) => void; + datasourcePublicAPI: DatasourcePublicAPI; +} + +export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { + const setVisualizationState = useMemo( + () => (newState: unknown) => { + props.dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + newState, + }); + }, + [props.dispatch] + ); + + return ( + <> + ({ + value: visualizationId, + text: visualizationId, + }))} + value={props.activeVisualizationId || undefined} + onChange={e => { + props.dispatch({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: e.target.value, + // TODO we probably want to have a separate API to "force" a visualization switch + // which isn't a result of a picked suggestion + initialState: props.visualizationMap[e.target.value].initialize(), + }); + }} + /> + {props.activeVisualizationId && ( + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx new file mode 100644 index 00000000000000..4ad0fe32858021 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx @@ -0,0 +1,58 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiSelect } from '@elastic/eui'; +import { DatasourceDataPanelProps, Datasource } from '../..'; +import { NativeRenderer } from '../../native_renderer'; +import { Action } from './state_management'; + +interface DataPanelWrapperProps { + datasourceState: unknown; + datasourceMap: Record; + activeDatasource: string | null; + datasourceIsLoading: boolean; + dispatch: (action: Action) => void; +} + +export function DataPanelWrapper(props: DataPanelWrapperProps) { + const setDatasourceState = useMemo( + () => (newState: unknown) => { + props.dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState, + }); + }, + [props.dispatch] + ); + + const datasourceProps: DatasourceDataPanelProps = { + state: props.datasourceState, + setState: setDatasourceState, + }; + + return ( + <> + ({ + value: datasourceId, + text: datasourceId, + }))} + value={props.activeDatasource || undefined} + onChange={e => { + props.dispatch({ type: 'SWITCH_DATASOURCE', newDatasourceId: e.target.value }); + }} + /> + {props.activeDatasource && !props.datasourceIsLoading && ( + + )} + + ); +} 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 new file mode 100644 index 00000000000000..32785d2d95753b --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -0,0 +1,442 @@ +/* + * 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 { mount, ReactWrapper } from 'enzyme'; +import { EditorFrame } from './editor_frame'; +import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; +import { act } from 'react-dom/test-utils'; + +// 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)); + +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; + + let mockVisualization2: Visualization; + let mockDatasource2: Datasource; + + beforeEach(() => { + mockVisualization = getMockVisualization(); + mockVisualization2 = getMockVisualization(); + + mockDatasource = getMockDatasource(); + mockDatasource2 = getMockDatasource(); + }); + + describe('initialization', () => { + it('should initialize initial datasource and visualization if present', () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + expect(mockDatasource.initialize).toHaveBeenCalled(); + }); + + it('should not initialize datasource and visualization if no initial one is specificed', () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.initialize).not.toHaveBeenCalled(); + expect(mockDatasource.initialize).not.toHaveBeenCalled(); + }); + + it('should not render something before datasource is initialized', () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.renderConfigPanel).not.toHaveBeenCalled(); + expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); + }); + + it('should render data panel after initialization is complete', async () => { + const initialState = {}; + let databaseInitialized: ({}) => void; + + act(() => { + mount( + + new Promise(resolve => { + databaseInitialized = resolve; + }), + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + }); + + databaseInitialized!(initialState); + + await waitForPromises(); + expect(mockDatasource.renderDataPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + + it('should initialize visualization state and render config panel', async () => { + const initialState = {}; + + mount( + initialState }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + initialize: () => Promise.resolve(), + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + /> + ); + + await waitForPromises(); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + }); + + describe('state update', () => { + it('should re-render config panel after state update', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedState = {}; + const setVisualizationState = (mockVisualization.renderConfigPanel as jest.Mock).mock + .calls[0][1].setState; + act(() => { + setVisualizationState(updatedState); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + state: updatedState, + }) + ); + + // don't re-render datasource when visulization changes + expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(1); + }); + + it('should re-render data panel after state update', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedState = {}; + const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] + .setState; + act(() => { + setDatasourceState(updatedState); + }); + + expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(2); + expect(mockDatasource.renderDataPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + state: updatedState, + }) + ); + }); + + it('should re-render config panel with updated datasource api after datasource state update', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedPublicAPI = {}; + mockDatasource.getPublicAPI = jest.fn( + () => (updatedPublicAPI as unknown) as DatasourcePublicAPI + ); + + const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] + .setState; + act(() => { + setDatasourceState({}); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + datasource: updatedPublicAPI, + }) + ); + }); + }); + + describe('datasource public api communication', () => { + it('should pass the datasource api to the visualization', async () => { + const publicAPI = ({} as unknown) as DatasourcePublicAPI; + + mockDatasource.getPublicAPI = () => publicAPI; + + mount( + + ); + + await waitForPromises(); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ datasource: publicAPI }) + ); + }); + + it('should give access to the datasource state in the datasource factory function', async () => { + const datasourceState = {}; + mockDatasource.initialize = () => Promise.resolve(datasourceState); + + mount( + + ); + + await waitForPromises(); + + expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith( + datasourceState, + expect.any(Function) + ); + }); + + it('should re-create the public api after state has been set', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedState = {}; + const setDatasourceState = (mockDatasource.getPublicAPI as jest.Mock).mock.calls[0][1]; + act(() => { + setDatasourceState(updatedState); + }); + + expect(mockDatasource.getPublicAPI).toHaveBeenCalledTimes(2); + expect(mockDatasource.getPublicAPI).toHaveBeenLastCalledWith( + updatedState, + expect.any(Function) + ); + }); + }); + + describe('switching', () => { + let instance: ReactWrapper; + beforeEach(async () => { + instance = mount( + + ); + await waitForPromises(); + + // necessary to flush elements to dom synchronously + instance.update(); + }); + + it('should have initialized only the initial datasource and visualization', () => { + expect(mockDatasource.initialize).toHaveBeenCalled(); + expect(mockDatasource2.initialize).not.toHaveBeenCalled(); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + expect(mockVisualization2.initialize).not.toHaveBeenCalled(); + }); + + it('should initialize other datasource on switch', async () => { + act(() => { + instance + .find('select[data-test-subj="datasource-switch"]') + .simulate('change', { target: { value: 'testDatasource2' } }); + }); + expect(mockDatasource2.initialize).toHaveBeenCalled(); + }); + + it('should call datasource render with new state on switch', async () => { + const initialState = {}; + mockDatasource2.initialize = () => Promise.resolve(initialState); + + instance + .find('select[data-test-subj="datasource-switch"]') + .simulate('change', { target: { value: 'testDatasource2' } }); + + await waitForPromises(); + + expect(mockDatasource2.renderDataPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + + it('should initialize other visualization on switch', async () => { + act(() => { + instance + .find('select[data-test-subj="visualization-switch"]') + .simulate('change', { target: { value: 'testVis2' } }); + }); + expect(mockVisualization2.initialize).toHaveBeenCalled(); + }); + + it('should call visualization render with new state on switch', async () => { + const initialState = {}; + mockVisualization2.initialize = () => initialState; + + act(() => { + instance + .find('select[data-test-subj="visualization-switch"]') + .simulate('change', { target: { value: 'testVis2' } }); + }); + + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + }); +}); 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 new file mode 100644 index 00000000000000..ee5023c06f3f7f --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -0,0 +1,95 @@ +/* + * 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, { useEffect, useReducer, useMemo } from 'react'; +import { Datasource, Visualization } from '../../types'; +import { reducer, getInitialState } from './state_management'; +import { DataPanelWrapper } from './data_panel_wrapper'; +import { ConfigPanelWrapper } from './config_panel_wrapper'; +import { FrameLayout } from './frame_layout'; + +export interface EditorFrameProps { + datasourceMap: Record; + visualizationMap: Record; + + initialDatasourceId: string | null; + initialVisualizationId: string | null; +} + +export function EditorFrame(props: EditorFrameProps) { + const [state, dispatch] = useReducer(reducer, props, getInitialState); + + // Initialize current datasource + useEffect( + () => { + let datasourceGotSwitched = false; + if (state.datasource.isLoading && state.datasource.activeId) { + props.datasourceMap[state.datasource.activeId].initialize().then(datasourceState => { + if (!datasourceGotSwitched) { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState: datasourceState, + }); + } + }); + + return () => { + datasourceGotSwitched = true; + }; + } + }, + [state.datasource.activeId, state.datasource.isLoading] + ); + + // create public datasource api for current state + // as soon as datasource is available and memoize it + const datasourcePublicAPI = useMemo( + () => + state.datasource.activeId && !state.datasource.isLoading + ? props.datasourceMap[state.datasource.activeId].getPublicAPI( + state.datasource.state, + (newState: unknown) => { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + newState, + }); + } + ) + : undefined, + [ + props.datasourceMap, + state.datasource.isLoading, + state.datasource.activeId, + state.datasource.state, + ] + ); + + 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 new file mode 100644 index 00000000000000..182d0c9f0b5929 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -0,0 +1,23 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export interface FrameLayoutProps { + dataPanel: React.ReactNode; + configPanel: React.ReactNode; +} + +export function FrameLayout(props: FrameLayoutProps) { + return ( + + {/* TODO style this and add workspace prop and loading flags */} + {props.dataPanel} + {props.configPanel} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts new file mode 100644 index 00000000000000..41558caafc64cf --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export * from './editor_frame'; 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 new file mode 100644 index 00000000000000..373b321309586a --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -0,0 +1,145 @@ +/* + * 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 { getInitialState, reducer } from './state_management'; +import { EditorFrameProps } from '.'; +import { Datasource, Visualization } from '../../types'; + +describe('editor_frame state management', () => { + describe('initialization', () => { + let props: EditorFrameProps; + + beforeEach(() => { + props = { + datasourceMap: { testDatasource: ({} as unknown) as Datasource }, + visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization }, + initialDatasourceId: 'testDatasource', + initialVisualizationId: 'testVis', + }; + }); + + it('should store initial datasource and visualization', () => { + const initialState = getInitialState(props); + expect(initialState.datasource.activeId).toEqual('testDatasource'); + expect(initialState.visualization.activeId).toEqual('testVis'); + }); + + it('should initialize visualization', () => { + const initialVisState = {}; + props.visualizationMap.testVis.initialize = jest.fn(() => initialVisState); + + const initialState = getInitialState(props); + + expect(initialState.visualization.state).toBe(initialVisState); + expect(props.visualizationMap.testVis.initialize).toHaveBeenCalled(); + }); + + it('should not initialize visualization if no initial visualization is passed in', () => { + const initialState = getInitialState({ ...props, initialVisualizationId: null }); + + expect(initialState.visualization.state).toEqual(null); + expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled(); + }); + }); + + describe('state update', () => { + it('should update the corresponding visualization state on update', () => { + const newVisState = {}; + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_VISUALIZATION_STATE', + newState: newVisState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + }); + + it('should update the datasource state on update', () => { + const newDatasourceState = {}; + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_DATASOURCE_STATE', + newState: newDatasourceState, + } + ); + + expect(newState.datasource.state).toBe(newDatasourceState); + }); + + it('should should switch active visualization', () => { + const testVisState = {}; + const newVisState = {}; + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: testVisState, + }, + }, + { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'testVis2', + initialState: newVisState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + }); + + it('should should switch active datasource and purge visualization state', () => { + const newState = reducer( + { + datasource: { + activeId: 'testDatasource', + state: {}, + isLoading: false, + }, + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'SWITCH_DATASOURCE', + newDatasourceId: 'testDatasource2', + } + ); + + expect(newState.visualization.state).toEqual(null); + expect(newState.visualization.activeId).toBe(null); + expect(newState.datasource.activeId).toBe('testDatasource2'); + expect(newState.datasource.state).toBe(null); + }); + }); +}); 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 new file mode 100644 index 00000000000000..2358da104378b5 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -0,0 +1,107 @@ +/* + * 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 { EditorFrameProps } from '.'; + +export interface EditorFrameState { + visualization: { + activeId: string | null; + state: unknown; + }; + datasource: { + activeId: string | null; + state: unknown; + isLoading: boolean; + }; +} + +export type Action = + | { + type: 'UPDATE_DATASOURCE_STATE'; + newState: unknown; + } + | { + type: 'UPDATE_VISUALIZATION_STATE'; + newState: unknown; + } + | { + type: 'SWITCH_VISUALIZATION'; + newVisualizationId: string; + initialState: unknown; + } + | { + type: 'SWITCH_DATASOURCE'; + newDatasourceId: string; + }; + +export const getInitialState = (props: EditorFrameProps): EditorFrameState => { + return { + datasource: { + state: null, + isLoading: Boolean(props.initialDatasourceId), + activeId: props.initialDatasourceId, + }, + visualization: { + state: props.initialVisualizationId + ? props.visualizationMap[props.initialVisualizationId].initialize() + : null, + activeId: props.initialVisualizationId, + }, + }; +}; + +export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => { + switch (action.type) { + case 'SWITCH_DATASOURCE': + return { + ...state, + datasource: { + ...state.datasource, + isLoading: true, + state: null, + activeId: action.newDatasourceId, + }, + visualization: { + ...state.visualization, + // purge visualization on datasource switch + state: null, + activeId: null, + }, + }; + case 'SWITCH_VISUALIZATION': + return { + ...state, + visualization: { + ...state.visualization, + activeId: action.newVisualizationId, + state: action.initialState, + }, + }; + case 'UPDATE_DATASOURCE_STATE': + return { + ...state, + datasource: { + ...state.datasource, + // when the datasource state is updated, the initialization is complete + isLoading: false, + state: action.newState, + }, + }; + case 'UPDATE_VISUALIZATION_STATE': + if (!state.visualization.activeId) { + throw new Error('Invariant: visualization state got updated without active visualization'); + } + return { + ...state, + visualization: { + ...state.visualization, + state: action.newState, + }, + }; + default: + return state; + } +}; 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 new file mode 100644 index 00000000000000..1ca641b2e6e371 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 { EditorFramePlugin } from './plugin'; +import { Visualization, Datasource } from '../types'; + +const nextTick = () => new Promise(resolve => setTimeout(resolve)); + +describe('editor_frame plugin', () => { + let pluginInstance: EditorFramePlugin; + let mountpoint: Element; + + beforeEach(() => { + pluginInstance = new EditorFramePlugin(); + mountpoint = document.createElement('div'); + }); + + afterEach(() => { + mountpoint.remove(); + }); + + it('should create an editor frame instance which mounts and unmounts', () => { + expect(() => { + const publicAPI = pluginInstance.setup(); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + instance.unmount(); + }).not.toThrowError(); + }); + + it('should render something in the provided dom element', () => { + const publicAPI = pluginInstance.setup(); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + + expect(mountpoint.hasChildNodes()).toBe(true); + + instance.unmount(); + }); + + it('should not have child nodes after unmount', () => { + const publicAPI = pluginInstance.setup(); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + instance.unmount(); + + expect(mountpoint.hasChildNodes()).toBe(false); + }); + + it('should initialize and render provided datasource', async () => { + 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(); + + expect(mockDatasource.initialize).toHaveBeenCalled(); + expect(mockDatasource.renderDataPanel).toHaveBeenCalled(); + + instance.unmount(); + }); + + it('should initialize visualization and render config panel', async () => { + 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); + + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint); + + await nextTick(); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); + + instance.unmount(); + }); +}); 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 6a0a82877cefba..07c18416011403 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -6,48 +6,48 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Datasource, Visualization, EditorFrameSetup } from '../types'; +import { Datasource, Visualization, EditorFrameSetup, EditorFrameInstance } from '../types'; import { EditorFrame } from './editor_frame'; -class EditorFramePlugin { +export class EditorFramePlugin { constructor() {} - private datasources: { - [key: string]: Datasource; - } = {}; - private visualizations: { - [key: string]: Visualization; - } = {}; + private datasources: Record = {}; + private visualizations: Record = {}; - private initialDatasource?: string; + private createInstance(): EditorFrameInstance { + let domElement: Element; - private element: Element | null = null; + function unmount() { + if (domElement) { + unmountComponentAtNode(domElement); + } + } - public setup(): EditorFrameSetup { return { - render: domElement => { - this.element = domElement; + mount: element => { + unmount(); + domElement = element; render( , domElement ); }, - registerDatasource: (name, datasource) => { - // casting it to an unknown datasource. This doesn't introduce runtime errors - // because each type T is always also an unknown, but typescript won't do it - // on it's own because we are loosing type information here. - // So it's basically explicitly saying "I'm dropping the information about type T here - // because this information isn't useful to me." but without using any which can leak - this.datasources[name] = datasource as Datasource; + unmount, + }; + } - if (!this.initialDatasource) { - this.initialDatasource = name; - } + public setup(): EditorFrameSetup { + return { + createInstance: this.createInstance.bind(this), + registerDatasource: (name, datasource) => { + this.datasources[name] = datasource as Datasource; }, registerVisualization: (name, visualization) => { this.visualizations[name] = visualization as Visualization; @@ -56,9 +56,6 @@ class EditorFramePlugin { } public stop() { - if (this.element) { - unmountComponentAtNode(this.element); - } return {}; } } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index ab70ff10e1cb54..a0328cb3bd9881 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -4,8 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +// eslint-disable-next-line +export interface EditorFrameOptions {} + +export interface EditorFrameInstance { + mount: (element: Element) => void; + unmount: () => void; +} export interface EditorFrameSetup { - render: (domElement: Element) => void; + createInstance: (options: EditorFrameOptions) => EditorFrameInstance; // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation registerDatasource: (name: string, datasource: Datasource) => void; registerVisualization: (name: string, visualization: Visualization) => void; @@ -139,12 +146,9 @@ export interface VisualizationSuggestion { } export interface Visualization { - // For initializing, either from an empty state or from persisted state - // Because this will be called at runtime, state might have a type of `any` and - // visualizations should validate their arguments + // For initializing from saved object initialize: (state?: P) => T; - // Given the current state, which parts should be saved? getPersistableState: (state: T) => P; renderConfigPanel: (domElement: Element, props: VisualizationProps) => void;