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;