- {props.dataPanel}
-
- {props.workspacePanel}
-
-
- {props.configPanel}
- {props.suggestionsPanel}
-
+
+
+
+ {props.dataPanel}
+
+ {props.workspacePanel}
+
+
+ {props.configPanel}
+ {props.suggestionsPanel}
+
+
);
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss
index d384c03195538..b57fe73adb8b2 100644
--- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss
@@ -7,6 +7,16 @@
right: 0;
bottom: 0;
overflow: hidden;
+ flex-direction: column;
+}
+
+.lnsHeader {
+ padding: $euiSize;
+ padding-bottom: 0;
+}
+
+.lnsPageMainContent {
+ display: flex;
}
.lnsSidebar {
@@ -73,4 +83,14 @@
overflow-x: hidden;
}
+.lnsTitleInput {
+ width: 100%;
+ min-width: 100%;
+ border: 0;
+ font: inherit;
+ background: transparent;
+ box-shadow: none;
+ font-size: 1.2em;
+}
+
@import './suggestion_panel.scss';
\ No newline at end of file
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts
new file mode 100644
index 0000000000000..ef4eed2b63a5d
--- /dev/null
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts
@@ -0,0 +1,177 @@
+/*
+ * 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 { save, Props } from './save';
+import { Action } from './state_management';
+
+describe('save editor frame state', () => {
+ const saveArgs: Props = {
+ dispatch: jest.fn(),
+ redirectTo: jest.fn(),
+ datasource: { getPersistableState: x => x },
+ visualization: { getPersistableState: x => x },
+ state: {
+ title: 'aaa',
+ datasource: { activeId: '1', isLoading: false, state: {} },
+ saving: false,
+ visualization: { activeId: '2', state: {} },
+ },
+ store: {
+ async save() {
+ return { id: 'foo' };
+ },
+ },
+ };
+
+ it('dispatches saved status actions before and after saving', async () => {
+ let saved = false;
+
+ const dispatch = jest.fn((action: Action) => {
+ if (
+ (action.type === 'SAVING' && action.isSaving && saved) ||
+ (action.type === 'SAVING' && !action.isSaving && !saved)
+ ) {
+ throw new Error('Saving status was incorrectly set');
+ }
+ });
+
+ await save({
+ ...saveArgs,
+ dispatch,
+ state: {
+ title: 'aaa',
+ datasource: { activeId: '1', isLoading: false, state: {} },
+ saving: false,
+ visualization: { activeId: '2', state: {} },
+ },
+ store: {
+ async save() {
+ saved = true;
+ return { id: 'foo' };
+ },
+ },
+ });
+
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: true });
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: false });
+ });
+
+ it('allows saves if an error occurs', async () => {
+ const dispatch = jest.fn();
+
+ await expect(
+ save({
+ ...saveArgs,
+ dispatch,
+ state: {
+ title: 'aaa',
+ datasource: { activeId: '1', isLoading: false, state: {} },
+ saving: false,
+ visualization: { activeId: '2', state: {} },
+ },
+ store: {
+ async save() {
+ throw new Error('aw shnap!');
+ },
+ },
+ })
+ ).rejects.toThrow();
+
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: true });
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SAVING', isSaving: false });
+ });
+
+ it('transforms from internal state to persisted doc format', async () => {
+ const store = {
+ save: jest.fn(async () => ({ id: 'bar' })),
+ };
+ await save({
+ ...saveArgs,
+ store,
+ datasource: {
+ getPersistableState(state) {
+ return {
+ stuff: `${state}_datsource_persisted`,
+ };
+ },
+ },
+ state: {
+ title: 'bbb',
+ datasource: { activeId: '1', isLoading: false, state: '2' },
+ saving: false,
+ visualization: { activeId: '3', state: '4' },
+ },
+ visualization: {
+ getPersistableState(state) {
+ return {
+ things: `${state}_vis_persisted`,
+ };
+ },
+ },
+ });
+
+ expect(store.save).toHaveBeenCalledWith({
+ datasourceType: '1',
+ id: undefined,
+ state: {
+ datasource: { stuff: '2_datsource_persisted' },
+ visualization: { things: '4_vis_persisted' },
+ },
+ title: 'bbb',
+ type: 'lens',
+ visualizationType: '3',
+ });
+ });
+
+ it('redirects to the edit screen if the id changes', async () => {
+ const redirectTo = jest.fn();
+ const dispatch = jest.fn();
+ await save({
+ ...saveArgs,
+ dispatch,
+ redirectTo,
+ state: {
+ title: 'ccc',
+ datasource: { activeId: '1', isLoading: false, state: {} },
+ saving: false,
+ visualization: { activeId: '2', state: {} },
+ },
+ store: {
+ async save() {
+ return { id: 'bazinga' };
+ },
+ },
+ });
+
+ expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PERSISTED_ID', id: 'bazinga' });
+ expect(redirectTo).toHaveBeenCalledWith('/edit/bazinga');
+ });
+
+ it('does not redirect to the edit screen if the id does not change', async () => {
+ const redirectTo = jest.fn();
+ const dispatch = jest.fn();
+ await save({
+ ...saveArgs,
+ dispatch,
+ redirectTo,
+ state: {
+ title: 'ddd',
+ datasource: { activeId: '1', isLoading: false, state: {} },
+ persistedId: 'foo',
+ saving: false,
+ visualization: { activeId: '2', state: {} },
+ },
+ store: {
+ async save() {
+ return { id: 'foo' };
+ },
+ },
+ });
+
+ expect(dispatch.mock.calls.some(({ type }) => type === 'UPDATE_PERSISTED_ID')).toBeFalsy();
+ expect(redirectTo).not.toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts
new file mode 100644
index 0000000000000..472220e83a44e
--- /dev/null
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts
@@ -0,0 +1,49 @@
+/*
+ * 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 { Action, EditorFrameState } from './state_management';
+import { Document } from '../../persistence/saved_object_store';
+
+export interface Props {
+ datasource: { getPersistableState: (state: unknown) => unknown };
+ dispatch: (value: Action) => void;
+ redirectTo: (path: string) => void;
+ state: EditorFrameState;
+ store: { save: (doc: Document) => Promise<{ id: string }> };
+ visualization: { getPersistableState: (state: unknown) => unknown };
+}
+
+export async function save({
+ datasource,
+ dispatch,
+ redirectTo,
+ state,
+ store,
+ visualization,
+}: Props) {
+ try {
+ dispatch({ type: 'SAVING', isSaving: true });
+
+ const doc = await store.save({
+ id: state.persistedId,
+ title: state.title,
+ type: 'lens',
+ visualizationType: state.visualization.activeId,
+ datasourceType: state.datasource.activeId,
+ state: {
+ datasource: datasource.getPersistableState(state.datasource.state),
+ visualization: visualization.getPersistableState(state.visualization.state),
+ },
+ });
+
+ if (doc.id !== state.persistedId) {
+ dispatch({ type: 'UPDATE_PERSISTED_ID', id: doc.id });
+ redirectTo(`/edit/${doc.id}`);
+ }
+ } finally {
+ dispatch({ type: 'SAVING', isSaving: false });
+ }
+}
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts
index 5b767d2d05582..5f1861269dc0e 100644
--- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts
@@ -15,6 +15,12 @@ describe('editor_frame state management', () => {
beforeEach(() => {
props = {
+ onError: jest.fn(),
+ redirectTo: jest.fn(),
+ store: {
+ load: jest.fn(),
+ save: jest.fn(),
+ },
datasourceMap: { testDatasource: ({} as unknown) as Datasource },
visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization },
initialDatasourceId: 'testDatasource',
@@ -56,6 +62,8 @@ describe('editor_frame state management', () => {
state: {},
isLoading: false,
},
+ saving: false,
+ title: 'aaa',
visualization: {
activeId: 'testVis',
state: {},
@@ -79,6 +87,8 @@ describe('editor_frame state management', () => {
state: {},
isLoading: false,
},
+ saving: false,
+ title: 'bbb',
visualization: {
activeId: 'testVis',
state: {},
@@ -103,6 +113,8 @@ describe('editor_frame state management', () => {
state: {},
isLoading: false,
},
+ saving: false,
+ title: 'ccc',
visualization: {
activeId: 'testVis',
state: testVisState,
@@ -129,6 +141,8 @@ describe('editor_frame state management', () => {
state: {},
isLoading: false,
},
+ saving: false,
+ title: 'ddd',
visualization: {
activeId: 'testVis',
state: testVisState,
@@ -154,6 +168,8 @@ describe('editor_frame state management', () => {
state: {},
isLoading: false,
},
+ saving: false,
+ title: 'eee',
visualization: {
activeId: 'testVis',
state: {},
@@ -170,5 +186,177 @@ describe('editor_frame state management', () => {
expect(newState.datasource.activeId).toBe('testDatasource2');
expect(newState.datasource.state).toBe(null);
});
+
+ it('should mark as saving', () => {
+ const newState = reducer(
+ {
+ datasource: {
+ activeId: 'a',
+ state: {},
+ isLoading: false,
+ },
+ saving: false,
+ title: 'fff',
+ visualization: {
+ activeId: 'b',
+ state: {},
+ },
+ },
+ {
+ type: 'SAVING',
+ isSaving: true,
+ }
+ );
+
+ expect(newState.saving).toBeTruthy();
+ });
+
+ it('should mark as saved', () => {
+ const newState = reducer(
+ {
+ datasource: {
+ activeId: 'a',
+ state: {},
+ isLoading: false,
+ },
+ saving: false,
+ title: 'hhh',
+ visualization: {
+ activeId: 'b',
+ state: {},
+ },
+ },
+ {
+ type: 'SAVING',
+ isSaving: false,
+ }
+ );
+
+ expect(newState.saving).toBeFalsy();
+ });
+
+ it('should change the persisted id', () => {
+ const newState = reducer(
+ {
+ datasource: {
+ activeId: 'a',
+ state: {},
+ isLoading: false,
+ },
+ saving: false,
+ title: 'iii',
+ visualization: {
+ activeId: 'b',
+ state: {},
+ },
+ },
+ {
+ type: 'UPDATE_PERSISTED_ID',
+ id: 'baz',
+ }
+ );
+
+ expect(newState.persistedId).toEqual('baz');
+ });
+
+ it('should reset the state', () => {
+ const newState = reducer(
+ {
+ datasource: {
+ activeId: 'a',
+ state: {},
+ isLoading: false,
+ },
+ saving: false,
+ title: 'jjj',
+ visualization: {
+ activeId: 'b',
+ state: {},
+ },
+ },
+ {
+ type: 'RESET',
+ state: {
+ datasource: {
+ activeId: 'z',
+ isLoading: false,
+ state: { hola: 'muchacho' },
+ },
+ persistedId: 'bar',
+ saving: false,
+ title: 'lll',
+ visualization: {
+ activeId: 'q',
+ state: { my: 'viz' },
+ },
+ },
+ }
+ );
+
+ expect(newState).toMatchObject({
+ datasource: {
+ activeId: 'z',
+ isLoading: false,
+ state: { hola: 'muchacho' },
+ },
+ persistedId: 'bar',
+ saving: false,
+ visualization: {
+ activeId: 'q',
+ state: { my: 'viz' },
+ },
+ });
+ });
+
+ it('should load the state from the doc', () => {
+ const newState = reducer(
+ {
+ datasource: {
+ activeId: 'a',
+ state: {},
+ isLoading: false,
+ },
+ saving: false,
+ title: 'mmm',
+ visualization: {
+ activeId: 'b',
+ state: {},
+ },
+ },
+ {
+ type: 'VISUALIZATION_LOADED',
+ doc: {
+ datasourceType: 'a',
+ id: 'b',
+ state: {
+ datasource: { foo: 'c' },
+ visualization: { bar: 'd' },
+ },
+ title: 'heyo!',
+ type: 'lens',
+ visualizationType: 'line',
+ },
+ }
+ );
+
+ expect(newState).toEqual({
+ datasource: {
+ activeId: 'a',
+ isLoading: true,
+ state: {
+ foo: 'c',
+ },
+ },
+ persistedId: 'b',
+ saving: false,
+ title: 'heyo!',
+ visualization: {
+ activeId: 'line',
+ state: {
+ bar: 'd',
+ },
+ },
+ });
+ });
});
});
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts
index 5d999337038a1..df56544e5c134 100644
--- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts
@@ -4,9 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import { EditorFrameProps } from '../editor_frame';
+import { Document } from '../../persistence/saved_object_store';
export interface EditorFrameState {
+ persistedId?: string;
+ saving: boolean;
+ title: string;
visualization: {
activeId: string | null;
state: unknown;
@@ -19,6 +24,22 @@ export interface EditorFrameState {
}
export type Action =
+ | {
+ type: 'RESET';
+ state: EditorFrameState;
+ }
+ | {
+ type: 'SAVING';
+ isSaving: boolean;
+ }
+ | {
+ type: 'UPDATE_TITLE';
+ title: string;
+ }
+ | {
+ type: 'UPDATE_PERSISTED_ID';
+ id: string;
+ }
| {
type: 'UPDATE_DATASOURCE_STATE';
newState: unknown;
@@ -27,6 +48,10 @@ export type Action =
type: 'UPDATE_VISUALIZATION_STATE';
newState: unknown;
}
+ | {
+ type: 'VISUALIZATION_LOADED';
+ doc: Document;
+ }
| {
type: 'SWITCH_VISUALIZATION';
newVisualizationId: string;
@@ -40,6 +65,8 @@ export type Action =
export const getInitialState = (props: EditorFrameProps): EditorFrameState => {
return {
+ saving: false,
+ title: i18n.translate('xpack.lens.chartTitle', { defaultMessage: 'New visualization' }),
datasource: {
state: null,
isLoading: Boolean(props.initialDatasourceId),
@@ -54,6 +81,31 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => {
export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => {
switch (action.type) {
+ case 'SAVING':
+ return { ...state, saving: action.isSaving };
+ case 'RESET':
+ return action.state;
+ case 'UPDATE_PERSISTED_ID':
+ return { ...state, persistedId: action.id };
+ case 'UPDATE_TITLE':
+ return { ...state, title: action.title };
+ case 'VISUALIZATION_LOADED':
+ return {
+ ...state,
+ persistedId: action.doc.id,
+ title: action.doc.title,
+ datasource: {
+ ...state.datasource,
+ activeId: action.doc.datasourceType || null,
+ isLoading: true,
+ state: action.doc.state.datasource,
+ },
+ visualization: {
+ ...state.visualization,
+ activeId: action.doc.visualizationType,
+ state: action.doc.state.visualization,
+ },
+ };
case 'SWITCH_DATASOURCE':
return {
...state,
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx
index 7a0017d83ad3a..796fce7ddbcf5 100644
--- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx
@@ -6,16 +6,7 @@
import React, { useState, useEffect, useMemo, useContext } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
-import {
- EuiCodeBlock,
- EuiSpacer,
- EuiPageContent,
- EuiPageContentHeader,
- EuiPageContentHeaderSection,
- EuiTitle,
- EuiPageContentBody,
-} from '@elastic/eui';
-
+import { EuiCodeBlock, EuiSpacer } from '@elastic/eui';
import { toExpression } from '@kbn/interpreter/common';
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public';
import { Action } from './state_management';
@@ -157,19 +148,8 @@ export function WorkspacePanel({
}
return (
-