diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx index 4f9aa75f52105..b3fc462fd1c50 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx @@ -34,6 +34,7 @@ import { import { KibanaContextProvider } from '../../../../../kibana_react/public'; // eslint-disable-next-line import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { applicationServiceMock } from '../../../../../../core/public/mocks'; let dashboardContainer: DashboardContainer | undefined; @@ -50,7 +51,7 @@ function getProps( const start = doStart(); const options: DashboardContainerOptions = { - application: {} as any, + application: applicationServiceMock.createStartContract(), embeddable: { getTriggerCompatibleActions: (() => []) as any, getEmbeddableFactories: start.getEmbeddableFactories, diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx index 40231de7597f1..6eb85faeea014 100644 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx @@ -38,6 +38,7 @@ import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { KibanaContextProvider } from '../../../../kibana_react/public'; import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; test('DashboardContainer in edit mode shows edit mode actions', async () => { const inspector = inspectorPluginMock.createStartContract(); @@ -56,7 +57,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW }); const options: DashboardContainerOptions = { - application: {} as any, + application: applicationServiceMock.createStartContract(), embeddable: start, notifications: {} as any, overlays: {} as any, @@ -84,7 +85,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => null) as any} notifications={{} as any} - application={{} as any} + application={options.application} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 0820ebd371004..490ddbed933d9 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -18,7 +18,6 @@ */ export const DashboardConstants = { - ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM: 'addToDashboard', LANDING_PAGE_PATH: '/dashboards', CREATE_NEW_DASHBOARD_URL: '/dashboard', ADD_EMBEDDABLE_ID: 'addEmbeddableId', diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index e61ad2a6eefed..84c6eea7c4ff1 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -22,6 +22,7 @@ import './index.scss'; import { PluginInitializerContext } from 'src/core/public'; import { EmbeddablePublicPlugin } from './plugin'; +export { EMBEDDABLE_ORIGINATING_APP_PARAM } from './types'; export { ACTION_ADD_PANEL, ACTION_APPLY_FILTER, diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx index fc5438b8c8dcb..196bd593eb8d5 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx @@ -22,10 +22,12 @@ import { Embeddable, EmbeddableInput } from '../embeddables'; import { ViewMode } from '../types'; import { ContactCardEmbeddable } from '../test_samples'; import { embeddablePluginMock } from '../../mocks'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; const { doStart } = embeddablePluginMock.createInstance(); const start = doStart(); const getFactory = start.getEmbeddableFactory; +const applicationMock = applicationServiceMock.createStartContract(); class EditableEmbeddable extends Embeddable { public readonly type = 'EDITABLE_EMBEDDABLE'; @@ -41,7 +43,7 @@ class EditableEmbeddable extends Embeddable { } test('is compatible when edit url is available, in edit mode and editable', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true), @@ -50,7 +52,7 @@ test('is compatible when edit url is available, in edit mode and editable', asyn }); test('getHref returns the edit urls', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect(action.getHref).toBeDefined(); if (action.getHref) { @@ -64,7 +66,7 @@ test('getHref returns the edit urls', async () => { }); test('is not compatible when edit url is not available', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); const embeddable = new ContactCardEmbeddable( { id: '123', @@ -83,7 +85,7 @@ test('is not compatible when edit url is not available', async () => { }); test('is not visible when edit url is available but in view mode', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( @@ -98,7 +100,7 @@ test('is not visible when edit url is available but in view mode', async () => { }); test('is not compatible when edit url is available, in edit mode, but not editable', async () => { - const action = new EditPanelAction(getFactory, {} as any); + const action = new EditPanelAction(getFactory, applicationMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index d57867900c24b..d1edddb2aa86b 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -20,10 +20,11 @@ import { i18n } from '@kbn/i18n'; import { ApplicationStart } from 'kibana/public'; import { Action } from 'src/plugins/ui_actions/public'; +import { take } from 'rxjs/operators'; import { ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; -import { IEmbeddable } from '../embeddables'; import { EmbeddableStart } from '../../plugin'; +import { EMBEDDABLE_ORIGINATING_APP_PARAM, IEmbeddable } from '../..'; export const ACTION_EDIT_PANEL = 'editPanel'; @@ -35,11 +36,18 @@ export class EditPanelAction implements Action { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; public order = 50; + public currentAppId: string | undefined; constructor( private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], private readonly application: ApplicationStart - ) {} + ) { + if (this.application?.currentAppId$) { + this.application.currentAppId$ + .pipe(take(1)) + .subscribe((appId: string | undefined) => (this.currentAppId = appId)); + } + } public getDisplayName({ embeddable }: ActionContext) { const factory = this.getEmbeddableFactory(embeddable.type); @@ -93,7 +101,15 @@ export class EditPanelAction implements Action { } public async getHref({ embeddable }: ActionContext): Promise { - const editUrl = embeddable ? embeddable.getOutput().editUrl : undefined; + let editUrl = embeddable ? embeddable.getOutput().editUrl : undefined; + if (editUrl && this.currentAppId) { + editUrl += `?${EMBEDDABLE_ORIGINATING_APP_PARAM}=${this.currentAppId}`; + + // TODO: Remove this after https://github.com/elastic/kibana/pull/63443 + if (this.currentAppId === 'kibana') { + editUrl += `:${window.location.hash.split(/[\/\?]/)[1]}`; + } + } return editUrl ? editUrl : ''; } } diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 9dd4c74c624d9..384297d8dee7d 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -44,6 +44,7 @@ import { import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; import { embeddablePluginMock } from '../../mocks'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; const actionRegistry = new Map(); const triggerRegistry = new Map(); @@ -55,6 +56,7 @@ const trigger: Trigger = { id: CONTEXT_MENU_TRIGGER, }; const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); +const applicationMock = applicationServiceMock.createStartContract(); actionRegistry.set(editModeAction.id, editModeAction); triggerRegistry.set(trigger.id, trigger); @@ -159,7 +161,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => { getAllEmbeddableFactories={start.getEmbeddableFactories} getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} - application={{} as any} + application={applicationMock} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} @@ -199,7 +201,7 @@ const renderInEditModeAndOpenContextMenu = async ( getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} /> @@ -306,7 +308,7 @@ test('HelloWorldContainer in edit mode shows edit mode actions', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} /> @@ -369,7 +371,7 @@ test('Updates when hidePanelTitles is toggled', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} /> @@ -422,7 +424,7 @@ test('Check when hide header option is false', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} - application={{} as any} + application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} hideHeader={false} diff --git a/src/plugins/embeddable/public/types.ts b/src/plugins/embeddable/public/types.ts index 2d112b2359818..a57af862f2a34 100644 --- a/src/plugins/embeddable/public/types.ts +++ b/src/plugins/embeddable/public/types.ts @@ -26,6 +26,8 @@ import { EmbeddableFactoryDefinition, } from './lib/embeddables'; +export const EMBEDDABLE_ORIGINATING_APP_PARAM = 'embeddableOriginatingApp'; + export type EmbeddableFactoryRegistry = Map; export type EmbeddableFactoryProvider = < diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index 9e0a7c40c043f..e38a0ef9830ea 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -19,7 +19,14 @@ import { SavedObjectsPublicPlugin } from './plugin'; -export { OnSaveProps, SavedObjectSaveModal, SaveResult, showSaveModal } from './save_modal'; +export { + OnSaveProps, + SavedObjectSaveModal, + SavedObjectSaveModalOrigin, + SaveModalState, + SaveResult, + showSaveModal, +} from './save_modal'; export { getSavedObjectFinder, SavedObjectFinderUi, SavedObjectMetaData } from './finder'; export { SavedObjectLoader, diff --git a/src/plugins/saved_objects/public/save_modal/index.ts b/src/plugins/saved_objects/public/save_modal/index.ts index f26aa732f30a1..7c32337bb314a 100644 --- a/src/plugins/saved_objects/public/save_modal/index.ts +++ b/src/plugins/saved_objects/public/save_modal/index.ts @@ -17,5 +17,6 @@ * under the License. */ -export { SavedObjectSaveModal, OnSaveProps } from './saved_object_save_modal'; +export { SavedObjectSaveModal, OnSaveProps, SaveModalState } from './saved_object_save_modal'; +export { SavedObjectSaveModalOrigin } from './saved_object_save_modal_origin'; export { showSaveModal, SaveResult } from './show_saved_object_save_modal'; diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 95eb56c0e874b..962f993633e6f 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -53,14 +53,15 @@ interface Props { onClose: () => void; title: string; showCopyOnSave: boolean; + initialCopyOnSave?: boolean; objectType: string; confirmButtonLabel?: React.ReactNode; - options?: React.ReactNode; + options?: React.ReactNode | ((state: SaveModalState) => React.ReactNode); description?: string; showDescription: boolean; } -interface State { +export interface SaveModalState { title: string; copyOnSave: boolean; isTitleDuplicateConfirmed: boolean; @@ -71,11 +72,11 @@ interface State { const generateId = htmlIdGenerator(); -export class SavedObjectSaveModal extends React.Component { +export class SavedObjectSaveModal extends React.Component { private warning = React.createRef(); public readonly state = { title: this.props.title, - copyOnSave: false, + copyOnSave: Boolean(this.props.initialCopyOnSave), isTitleDuplicateConfirmed: false, hasTitleDuplicate: false, isLoading: false, @@ -139,7 +140,9 @@ export class SavedObjectSaveModal extends React.Component { {this.renderViewDescription()} - {this.props.options} + {typeof this.props.options === 'function' + ? this.props.options(this.state) + : this.props.options} diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx new file mode 100644 index 0000000000000..34f4bc593fdc4 --- /dev/null +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal_origin.tsx @@ -0,0 +1,117 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Fragment, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { OnSaveProps, SaveModalState, SavedObjectSaveModal } from '.'; + +interface SaveModalDocumentInfo { + id?: string; + title: string; + description?: string; +} + +interface OriginSaveModalProps { + originatingApp?: string; + documentInfo: SaveModalDocumentInfo; + objectType: string; + onClose: () => void; + onSave: (props: OnSaveProps & { returnToOrigin: boolean }) => void; +} + +export function SavedObjectSaveModalOrigin(props: OriginSaveModalProps) { + const [returnToOriginMode, setReturnToOriginMode] = useState(Boolean(props.originatingApp)); + const { documentInfo } = props; + + const returnLabel = i18n.translate('savedObjects.saveModalOrigin.returnToOriginLabel', { + defaultMessage: 'Return', + }); + const addLabel = i18n.translate('savedObjects.saveModalOrigin.addToOriginLabel', { + defaultMessage: 'Add', + }); + + const getReturnToOriginSwitch = (state: SaveModalState) => { + if (!props.originatingApp) { + return; + } + let origin = props.originatingApp!; + + // TODO: Remove this after https://github.com/elastic/kibana/pull/63443 + if (origin.startsWith('kibana:')) { + origin = origin.split(':')[1]; + } + + if ( + !state.copyOnSave || + origin === 'dashboard' // dashboard supports adding a copied panel on save... + ) { + const originVerb = !documentInfo.id || state.copyOnSave ? addLabel : returnLabel; + return ( + + + { + setReturnToOriginMode(event.target.checked); + }} + label={ + + } + /> + + + ); + } else { + setReturnToOriginMode(false); + } + }; + + const onModalSave = (onSaveProps: OnSaveProps) => { + props.onSave({ ...onSaveProps, returnToOrigin: returnToOriginMode }); + }; + + const confirmButtonLabel = returnToOriginMode + ? i18n.translate('savedObjects.saveModalOrigin.saveAndReturnLabel', { + defaultMessage: 'Save and return', + }) + : null; + + return ( + + ); +} diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 6ab1c98645988..c6d43a4ef2f80 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -19,12 +19,14 @@ import { i18n } from '@kbn/i18n'; import { SavedObjectMetaData } from 'src/plugins/saved_objects/public'; +import { first } from 'rxjs/operators'; import { SavedObjectAttributes } from '../../../../core/public'; import { EmbeddableFactoryDefinition, EmbeddableOutput, ErrorEmbeddable, IContainer, + EMBEDDABLE_ORIGINATING_APP_PARAM, } from '../../../embeddable/public'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; import { VisualizeEmbeddable, VisualizeInput, VisualizeOutput } from './visualize_embeddable'; @@ -59,6 +61,7 @@ export class VisualizeEmbeddableFactory VisualizationAttributes > { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; + public readonly savedObjectMetaData: SavedObjectMetaData = { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), includeFields: ['visState'], @@ -98,6 +101,18 @@ export class VisualizeEmbeddableFactory }); } + public async getCurrentAppId() { + let currentAppId = await this.deps + .start() + .core.application.currentAppId$.pipe(first()) + .toPromise(); + // TODO: Remove this after https://github.com/elastic/kibana/pull/63443 + if (currentAppId === 'kibana') { + currentAppId += `:${window.location.hash.split(/[\/\?]/)[1]}`; + } + return currentAppId; + } + public async createFromSavedObject( savedObjectId: string, input: Partial & { id: string }, @@ -118,8 +133,9 @@ export class VisualizeEmbeddableFactory public async create() { // TODO: This is a bit of a hack to preserve the original functionality. Ideally we will clean this up // to allow for in place creation of visualizations without having to navigate away to a new URL. + const originatingAppParam = await this.getCurrentAppId(); showNewVisModal({ - editorParams: ['addToDashboard'], + editorParams: [`${EMBEDDABLE_ORIGINATING_APP_PARAM}=${originatingAppParam}`], }); return undefined; } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index d6eeffdb01459..70c3bc2c1ed05 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -20,7 +20,7 @@ import { PluginInitializerContext } from '../../../core/public'; import { VisualizationsSetup, VisualizationsStart } from './'; import { VisualizationsPlugin } from './plugin'; -import { coreMock } from '../../../core/public/mocks'; +import { coreMock, applicationServiceMock } from '../../../core/public/mocks'; import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks'; import { expressionsPluginMock } from '../../../plugins/expressions/public/mocks'; import { dataPluginMock } from '../../../plugins/data/public/mocks'; @@ -65,6 +65,7 @@ const createInstance = async () => { expressions: expressionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), uiActions: uiActionsPluginMock.createStartContract(), + application: applicationServiceMock.createStartContract(), }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index b3e8c9b5b61b3..29d66ea963a66 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -17,7 +17,13 @@ * under the License. */ -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + ApplicationStart, +} from '../../../core/public'; import { TypesService, TypesSetup, TypesStart } from './vis_types'; import { setUISettings, @@ -95,6 +101,7 @@ export interface VisualizationsStartDeps { expressions: ExpressionsStart; inspector: InspectorStart; uiActions: UiActionsStart; + application: ApplicationStart; } /** @@ -131,7 +138,6 @@ export class VisualizationsPlugin expressions.registerRenderer(visualizationRenderer); expressions.registerFunction(rangeExpressionFunction); expressions.registerFunction(visDimensionExpressionFunction); - const embeddableFactory = new VisualizeEmbeddableFactory({ start }); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx index 5637aeafc6f14..2fdbdedd5b590 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx @@ -144,7 +144,7 @@ describe('NewVisModal', () => { isOpen={true} onClose={onClose} visTypesRegistry={visTypes} - editorParams={['foo=true', 'bar=42', 'addToDashboard']} + editorParams={['foo=true', 'bar=42', 'embeddableOriginatingApp=notAnApp']} addBasePath={addBasePath} uiSettings={uiSettings} savedObjects={{} as SavedObjectsStart} @@ -152,7 +152,9 @@ describe('NewVisModal', () => { ); const visButton = wrapper.find('button[data-test-subj="visType-visWithAliasUrl"]'); visButton.simulate('click'); - expect(window.location.assign).toBeCalledWith('testbasepath/aliasUrl?addToDashboard'); + expect(window.location.assign).toBeCalledWith( + 'testbasepath/aliasUrl?embeddableOriginatingApp=notAnApp' + ); expect(onClose).toHaveBeenCalled(); }); diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index 448077819bb8d..6fd65da7e88d2 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -28,6 +28,7 @@ import { SearchSelection } from './search_selection'; import { TypeSelection } from './type_selection'; import { TypesStart, VisType, VisTypeAlias } from '../vis_types'; import { UsageCollectionSetup } from '../../../../plugins/usage_collection/public'; +import { EMBEDDABLE_ORIGINATING_APP_PARAM } from '../../../embeddable/public'; interface TypeSelectionProps { isOpen: boolean; @@ -143,8 +144,11 @@ class NewVisModal extends React.Component + param.startsWith(EMBEDDABLE_ORIGINATING_APP_PARAM) + ); + params = originatingAppParam ? `${params}?${originatingAppParam}` : params; } this.props.onClose(); window.location.assign(params); diff --git a/src/plugins/visualize/public/application/editor/editor.js b/src/plugins/visualize/public/application/editor/editor.js index 1c4f0c5090347..0e243f58f758c 100644 --- a/src/plugins/visualize/public/application/editor/editor.js +++ b/src/plugins/visualize/public/application/editor/editor.js @@ -25,11 +25,12 @@ import { i18n } from '@kbn/i18n'; import { EventEmitter } from 'events'; import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; import { makeStateful, useVisualizeAppState, addEmbeddableToDashboardUrl } from './lib'; import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from '../breadcrumbs'; +import { EMBEDDABLE_ORIGINATING_APP_PARAM } from '../../../../embeddable/public'; + import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; import { unhashUrl, removeQueryParam } from '../../../../kibana_utils/public'; import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public'; @@ -38,9 +39,8 @@ import { subscribeWithScope, migrateLegacyQuery, } from '../../../../kibana_legacy/public'; -import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; +import { showSaveModal, SavedObjectSaveModalOrigin } from '../../../../saved_objects/public'; import { esFilters, connectToQueryState, syncQueryStateWithUrl } from '../../../../data/public'; -import { DashboardConstants } from '../../../../dashboard/public'; import { initVisEditorDirective } from './visualization_editor'; import { initVisualizationDirective } from './visualization'; @@ -110,6 +110,11 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), }; + const originatingApp = $route.current.params[EMBEDDABLE_ORIGINATING_APP_PARAM]; + removeQueryParam(history, EMBEDDABLE_ORIGINATING_APP_PARAM); + + $scope.getOriginatingApp = () => originatingApp; + const visStateToEditorState = () => { const savedVisState = visualizations.convertFromSerializedVis(vis.serialize()); return { @@ -144,13 +149,58 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.embeddableHandler = embeddableHandler; $scope.topNavMenu = [ + ...($scope.getOriginatingApp() && savedVis.id + ? [ + { + id: 'saveAndReturn', + label: i18n.translate('visualize.topNavMenu.saveAndReturnVisualizationButtonLabel', { + defaultMessage: 'Save and return', + }), + emphasize: true, + iconType: 'check', + description: i18n.translate( + 'visualize.topNavMenu.saveAndReturnVisualizationButtonAriaLabel', + { + defaultMessage: 'Finish editing visualization and return to the last app', + } + ), + testId: 'visualizesaveAndReturnButton', + disableButton() { + return Boolean($scope.dirty); + }, + tooltip() { + if ($scope.dirty) { + return i18n.translate( + 'visualize.topNavMenu.saveAndReturnVisualizationDisabledButtonTooltip', + { + defaultMessage: 'Apply or Discard your changes before finishing', + } + ); + } + }, + run: async () => { + const saveOptions = { + confirmOverwrite: false, + returnToOrigin: true, + }; + return doSave(saveOptions); + }, + }, + ] + : []), ...(visualizeCapabilities.save ? [ { id: 'save', - label: i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { - defaultMessage: 'save', - }), + label: + savedVis.id && $scope.getOriginatingApp() + ? i18n.translate('visualize.topNavMenu.saveVisualizationAsButtonLabel', { + defaultMessage: 'save as', + }) + : i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { + defaultMessage: 'save', + }), + emphasize: !savedVis.id || !$scope.getOriginatingApp(), description: i18n.translate('visualize.topNavMenu.saveVisualizationButtonAriaLabel', { defaultMessage: 'Save Visualization', }), @@ -175,6 +225,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState isTitleDuplicateConfirmed, onTitleDuplicate, newDescription, + returnToOrigin, }) => { const currentTitle = savedVis.title; savedVis.title = newTitle; @@ -184,6 +235,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState confirmOverwrite: false, isTitleDuplicateConfirmed, onTitleDuplicate, + returnToOrigin, }; return doSave(saveOptions).then(response => { // If the save wasn't successful, put the original values back. @@ -194,23 +246,13 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }); }; - const confirmButtonLabel = $scope.isAddToDashMode() ? ( - - ) : null; - const saveModal = ( - {}} - title={savedVis.title} - showCopyOnSave={savedVis.id ? true : false} - objectType="visualization" - confirmButtonLabel={confirmButtonLabel} - description={savedVis.description} - showDescription={true} + originatingApp={$scope.getOriginatingApp()} /> ); showSaveModal(saveModal, I18nContext); @@ -395,12 +437,6 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.refreshInterval = timefilter.getRefreshInterval(); handleLinkedSearch(initialState.linked); - const addToDashMode = - $route.current.params[DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM]; - removeQueryParam(history, DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); - - $scope.isAddToDashMode = () => addToDashMode; - $scope.showFilterBar = () => { return vis.type.options.showFilterBar; }; @@ -601,6 +637,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState */ function doSave(saveOptions) { // vis.title was not bound and it's needed to reflect title into visState + const firstSave = !Boolean(savedVis.id); stateContainer.transitions.setVis({ title: savedVis.title, type: savedVis.type || stateContainer.getState().vis.type, @@ -628,15 +665,23 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState 'data-test-subj': 'saveVisualizationSuccess', }); - if ($scope.isAddToDashMode()) { + if ($scope.getOriginatingApp() && saveOptions.returnToOrigin) { const appPath = `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(savedVis.id)}`; + // Manually insert a new url so the back button will open the saved visualization. history.replace(appPath); setActiveUrl(appPath); - - const lastDashboardUrl = chrome.navLinks.get('kibana:dashboard').url; - const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardUrl, savedVis.id); - history.push(dashboardUrl); + const lastAppType = $scope.getOriginatingApp(); + let href = chrome.navLinks.get(lastAppType).url; + + // TODO: Remove this and use application.redirectTo after https://github.com/elastic/kibana/pull/63443 + if (lastAppType === 'kibana:dashboard') { + const savedVisId = firstSave || savedVis.copyOnSave ? savedVis.id : ''; + href = addEmbeddableToDashboardUrl(href, savedVisId); + history.push(href); + } else { + window.location.href = href; + } } else if (savedVis.id === $route.current.params.id) { chrome.docTitle.change(savedVis.lastSavedTitle); chrome.setBreadcrumbs($injector.invoke(getEditBreadcrumbs)); diff --git a/src/plugins/visualize/public/application/editor/lib/url_helper.ts b/src/plugins/visualize/public/application/editor/lib/url_helper.ts index 84e1ef9687cd0..9f8a0075118ae 100644 --- a/src/plugins/visualize/public/application/editor/lib/url_helper.ts +++ b/src/plugins/visualize/public/application/editor/lib/url_helper.ts @@ -33,8 +33,10 @@ export function addEmbeddableToDashboardUrl(dashboardUrl: string, embeddableId: const { url, query } = parseUrl(dashboardUrl); const [, dashboardId] = url.split(DashboardConstants.CREATE_NEW_DASHBOARD_URL); - query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = VISUALIZE_EMBEDDABLE_TYPE; - query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + if (embeddableId) { + query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = VISUALIZE_EMBEDDABLE_TYPE; + query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + } return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}${dashboardId}?${stringify(query)}`; } diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 410acdcb5680d..8180051f56e44 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -48,7 +48,8 @@ export default function({ getService, getPageObjects }) { await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( - 'visualization from top nav add new panel' + 'visualization from top nav add new panel', + { redirectToOrigin: true } ); await retry.try(async () => { const panelCount = await PageObjects.dashboard.getPanelCount(); @@ -64,7 +65,8 @@ export default function({ getService, getPageObjects }) { await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( - 'visualization from add new link' + 'visualization from add new link', + { redirectToOrigin: true } ); await retry.try(async () => { diff --git a/test/functional/apps/dashboard/edit_embeddable_redirects.js b/test/functional/apps/dashboard/edit_embeddable_redirects.js new file mode 100644 index 0000000000000..b45dcc2cedf9b --- /dev/null +++ b/test/functional/apps/dashboard/edit_embeddable_redirects.js @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import expect from '@kbn/expect'; + +export default function({ getService, getPageObjects }) { + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + describe('edit embeddable redirects', () => { + before(async () => { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.switchToEditMode(); + }); + + it('redirects via save and return button after edit', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.visualize.saveVisualizationAndReturn(); + }); + + it('redirects via save as button after edit, renaming itself', async () => { + const newTitle = 'wowee, looks like I have a new title'; + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.visualize.saveVisualizationExpectSuccess(newTitle, { + saveAsNew: false, + redirectToOrigin: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount); + const titles = await PageObjects.dashboard.getPanelTitles(); + expect(titles.indexOf(newTitle)).to.not.be(-1); + }); + + it('redirects via save as button after edit, adding a new panel', async () => { + const newTitle = 'wowee, my title just got cooler'; + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.visualize.saveVisualizationExpectSuccess(newTitle, { + saveAsNew: true, + redirectToOrigin: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount + 1); + const titles = await PageObjects.dashboard.getPanelTitles(); + expect(titles.indexOf(newTitle)).to.not.be(-1); + }); + }); +} diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index bd8e6812147e1..3b81a4d974bec 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -51,6 +51,7 @@ export default function({ getService, loadTestFile }) { loadTestFile(require.resolve('./empty_dashboard')); loadTestFile(require.resolve('./embeddable_rendering')); loadTestFile(require.resolve('./create_and_add_embeddables')); + loadTestFile(require.resolve('./edit_embeddable_redirects')); loadTestFile(require.resolve('./time_zones')); loadTestFile(require.resolve('./dashboard_options')); loadTestFile(require.resolve('./data_shared_attributes')); diff --git a/test/functional/apps/dashboard/view_edit.js b/test/functional/apps/dashboard/view_edit.js index a0b972f3ab63c..c8eb10d43ea83 100644 --- a/test/functional/apps/dashboard/view_edit.js +++ b/test/functional/apps/dashboard/view_edit.js @@ -136,7 +136,10 @@ export default function({ getService, getPageObjects }) { await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); - await PageObjects.visualize.saveVisualizationExpectSuccess('new viz panel'); + await PageObjects.visualize.saveVisualizationExpectSuccess('new viz panel', { + saveAsNew: false, + redirectToOrigin: true, + }); await PageObjects.dashboard.clickCancelOutOfEditMode(); // for this sleep see https://github.com/elastic/kibana/issues/22299 diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 220c2d8f6b363..8fa15fc8268ed 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -300,12 +300,25 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide } } - public async saveVisualization(vizName: string, { saveAsNew = false } = {}) { + public async saveVisualization( + vizName: string, + { saveAsNew = false, redirectToOrigin = false } = {} + ) { await this.ensureSavePanelOpen(); await testSubjects.setValue('savedObjectTitle', vizName); - if (saveAsNew) { - log.debug('Check save as new visualization'); - await testSubjects.click('saveAsNewCheckbox'); + + const saveAsNewCheckboxExists = await testSubjects.exists('saveAsNewCheckbox'); + if (saveAsNewCheckboxExists) { + const state = saveAsNew ? 'check' : 'uncheck'; + log.debug('save as new checkbox exists. Setting its state to', state); + await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } + + const redirectToOriginCheckboxExists = await testSubjects.exists('returnToOriginModeSwitch'); + if (redirectToOriginCheckboxExists) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + log.debug('redirect to origin checkbox exists. Setting its state to', state); + await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); } log.debug('Click Save Visualization button'); @@ -320,8 +333,11 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide return message; } - public async saveVisualizationExpectSuccess(vizName: string, { saveAsNew = false } = {}) { - const saveMessage = await this.saveVisualization(vizName, { saveAsNew }); + public async saveVisualizationExpectSuccess( + vizName: string, + { saveAsNew = false, redirectToOrigin = false } = {} + ) { + const saveMessage = await this.saveVisualization(vizName, { saveAsNew, redirectToOrigin }); if (!saveMessage) { throw new Error( `Expected saveVisualization to respond with the saveMessage from the toast, got ${saveMessage}` @@ -331,14 +347,20 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide public async saveVisualizationExpectSuccessAndBreadcrumb( vizName: string, - { saveAsNew = false } = {} + { saveAsNew = false, redirectToOrigin = false } = {} ) { - await this.saveVisualizationExpectSuccess(vizName, { saveAsNew }); + await this.saveVisualizationExpectSuccess(vizName, { saveAsNew, redirectToOrigin }); await retry.waitFor( 'last breadcrumb to have new vis name', async () => (await globalNav.getLastBreadcrumb()) === vizName ); } + + public async saveVisualizationAndReturn() { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('visualizesaveAndReturnButton'); + await testSubjects.click('visualizesaveAndReturnButton'); + } } return new VisualizePage(); diff --git a/test/functional/services/dashboard/visualizations.js b/test/functional/services/dashboard/visualizations.js index f7a6fb7d2f694..676e4c384fe36 100644 --- a/test/functional/services/dashboard/visualizations.js +++ b/test/functional/services/dashboard/visualizations.js @@ -116,7 +116,10 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }) { await PageObjects.visualize.clickMarkdownWidget(); await PageObjects.visEditor.setMarkdownTxt(markdown); await PageObjects.visEditor.clickGo(); - await PageObjects.visualize.saveVisualizationExpectSuccess(name); + await PageObjects.visualize.saveVisualizationExpectSuccess(name, { + saveAsNew: false, + redirectToOrigin: true, + }); } })(); } diff --git a/test/functional/services/test_subjects.ts b/test/functional/services/test_subjects.ts index e5c2e61c48a0b..090dc995ddc11 100644 --- a/test/functional/services/test_subjects.ts +++ b/test/functional/services/test_subjects.ts @@ -307,6 +307,7 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { await element.scrollIntoViewIfNecessary(); } + // isChecked always returns false when run on an euiSwitch, because they use the aria-checked attribute public async isChecked(selector: string) { const checkbox = await this.find(selector); return await checkbox.isSelected(); @@ -316,7 +317,22 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { const isChecked = await this.isChecked(selector); const states = { check: true, uncheck: false }; if (isChecked !== states[state]) { - log.debug(`updating checkbox ${selector}`); + log.debug(`updating checkbox ${selector} from ${isChecked} to ${states[state]}`); + await this.click(selector); + } + } + + public async isEuiSwitchChecked(selector: string) { + const euiSwitch = await this.find(selector); + const isChecked = await euiSwitch.getAttribute('aria-checked'); + return isChecked === 'true'; + } + + public async setEuiSwitch(selector: string, state: 'check' | 'uncheck') { + const isChecked = await this.isEuiSwitchChecked(selector); + const states = { check: true, uncheck: false }; + if (isChecked !== states[state]) { + log.debug(`updating checkbox ${selector} from ${isChecked} to ${states[state]}`); await this.click(selector); } } diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 41d0e3a7aa9a0..888854a4e83b8 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -104,8 +104,8 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp: string | undefined; }> { return ({ navigation: navigationStartMock, @@ -140,7 +140,7 @@ describe('Lens App', () => { load: jest.fn(), save: jest.fn(), }, - redirectTo: jest.fn(id => {}), + redirectTo: jest.fn((id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => {}), } as unknown) as jest.Mocked<{ navigation: typeof navigationStartMock; editorFrame: EditorFrameInstance; @@ -149,8 +149,8 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp: string | undefined; }>; } @@ -336,6 +336,7 @@ describe('Lens App', () => { describe('save button', () => { interface SaveProps { newCopyOnSave: boolean; + returnToOrigin?: boolean; newTitle: string; } @@ -347,8 +348,8 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp: string | undefined; }>; beforeEach(() => { @@ -374,32 +375,25 @@ describe('Lens App', () => { async function testSave(inst: ReactWrapper, saveProps: SaveProps) { await getButton(inst).run(inst.getDOMNode()); - inst.update(); - - const handler = inst.findWhere(el => el.prop('onSave')).prop('onSave') as ( + const handler = inst.find('[data-test-subj="lnsApp_saveModalOrigin"]').prop('onSave') as ( p: unknown ) => void; handler(saveProps); } async function save({ - initialDocId, - addToDashboardMode, lastKnownDoc = { expression: 'kibana 3' }, + initialDocId, ...saveProps }: SaveProps & { lastKnownDoc?: object; initialDocId?: string; - addToDashboardMode?: boolean; }) { const args = { ...defaultArgs, docId: initialDocId, }; - if (addToDashboardMode) { - args.addToDashboardMode = addToDashboardMode; - } args.editorFrame = frame; (args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', @@ -438,7 +432,7 @@ describe('Lens App', () => { expect(getButton(instance).disableButton).toEqual(false); await act(async () => { - testSave(instance, saveProps); + testSave(instance, { ...saveProps }); }); return { args, instance }; @@ -527,7 +521,7 @@ describe('Lens App', () => { expression: 'kibana 3', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); inst.setProps({ docId: 'aaa' }); @@ -547,7 +541,7 @@ describe('Lens App', () => { expression: 'kibana 3', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); inst.setProps({ docId: 'aaa' }); @@ -601,10 +595,10 @@ describe('Lens App', () => { expect(getButton(instance).disableButton).toEqual(false); }); - it('saves new doc and redirects to dashboard', async () => { + it('saves new doc and redirects to originating app', async () => { const { args } = await save({ initialDocId: undefined, - addToDashboardMode: true, + returnToOrigin: true, newCopyOnSave: false, newTitle: 'hello there', }); @@ -615,7 +609,7 @@ describe('Lens App', () => { title: 'hello there', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, true); }); it('saves app filters and does not save pinned filters', async () => { @@ -666,7 +660,6 @@ describe('Lens App', () => { }) ); instance.update(); - await act(async () => getButton(instance).run(instance.getDOMNode())); instance.update(); @@ -684,7 +677,7 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; }>; beforeEach(() => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 28135dd12a724..6b8248fa2030b 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -12,9 +12,11 @@ import { Query, DataPublicPluginStart } from 'src/plugins/data/public'; import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { AppMountContext, NotificationsStart } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { FormattedMessage } from '@kbn/i18n/react'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { SavedObjectSaveModal } from '../../../../../src/plugins/saved_objects/public'; +import { + SavedObjectSaveModalOrigin, + OnSaveProps, +} from '../../../../../src/plugins/saved_objects/public'; import { Document, SavedObjectStore } from '../persistence'; import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; @@ -52,7 +54,7 @@ export function App({ docId, docStorage, redirectTo, - addToDashboardMode, + originatingApp, navigation, }: { editorFrame: EditorFrameInstance; @@ -62,8 +64,8 @@ export function App({ storage: IStorageWrapper; docId?: string; docStorage: SavedObjectStore; - redirectTo: (id?: string) => void; - addToDashboardMode?: boolean; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp?: string | undefined; }) { const language = storage.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); @@ -182,6 +184,63 @@ export function App({ lastKnownDoc.expression.length > 0 && core.application.capabilities.visualize.save; + const runSave = ( + saveProps: Omit & { + returnToOrigin: boolean; + } + ) => { + if (!lastKnownDoc) { + return; + } + const [pinnedFilters, appFilters] = _.partition( + lastKnownDoc.state?.filters, + esFilters.isFilterPinned + ); + const lastDocWithoutPinned = pinnedFilters?.length + ? { + ...lastKnownDoc, + state: { + ...lastKnownDoc.state, + filters: appFilters, + }, + } + : lastKnownDoc; + + const doc = { + ...lastDocWithoutPinned, + id: saveProps.newCopyOnSave ? undefined : lastKnownDoc.id, + title: saveProps.newTitle, + }; + + const newlyCreated: boolean = saveProps.newCopyOnSave || !lastKnownDoc?.id; + docStorage + .save(doc) + .then(({ id }) => { + // Prevents unnecessary network request and disables save button + const newDoc = { ...doc, id }; + setState(s => ({ + ...s, + isSaveModalVisible: false, + persistedDoc: newDoc, + lastKnownDoc: newDoc, + })); + if (docId !== id || saveProps.returnToOrigin) { + redirectTo(id, saveProps.returnToOrigin, newlyCreated); + } + }) + .catch(e => { + // eslint-disable-next-line no-console + console.dir(e); + trackUiEvent('save_failed'); + core.notifications.toasts.addDanger( + i18n.translate('xpack.lens.app.docSavingError', { + defaultMessage: 'Error saving document', + }) + ); + setState(s => ({ ...s, isSaveModalVisible: false })); + }); + }; + const onError = useCallback( (e: { message: string }) => core.notifications.toasts.addDanger({ @@ -192,13 +251,6 @@ export function App({ const { TopNavMenu } = navigation.ui; - const confirmButton = addToDashboardMode ? ( - - ) : null; - return ( { + if (isSaveable && lastKnownDoc) { + runSave({ + newTitle: lastKnownDoc.title, + newCopyOnSave: false, + isTitleDuplicateConfirmed: false, + returnToOrigin: true, + }); + } + }, + testId: 'lnsApp_saveAndReturnButton', + disableButton: !isSaveable, + }, + ] + : []), { - label: i18n.translate('xpack.lens.app.save', { - defaultMessage: 'Save', - }), + label: + lastKnownDoc?.id && !!originatingApp + ? i18n.translate('xpack.lens.app.saveAs', { + defaultMessage: 'Save as', + }) + : i18n.translate('xpack.lens.app.save', { + defaultMessage: 'Save', + }), + emphasize: !originatingApp || !lastKnownDoc?.id, run: () => { if (isSaveable && lastKnownDoc) { setState(s => ({ ...s, isSaveModalVisible: true })); @@ -336,63 +417,18 @@ export function App({ )} {lastKnownDoc && state.isSaveModalVisible && ( - { - const [pinnedFilters, appFilters] = _.partition( - lastKnownDoc.state?.filters, - esFilters.isFilterPinned - ); - const lastDocWithoutPinned = pinnedFilters?.length - ? { - ...lastKnownDoc, - state: { - ...lastKnownDoc.state, - filters: appFilters, - }, - } - : lastKnownDoc; - - const doc = { - ...lastDocWithoutPinned, - id: props.newCopyOnSave ? undefined : lastKnownDoc.id, - title: props.newTitle, - }; - - docStorage - .save(doc) - .then(({ id }) => { - // Prevents unnecessary network request and disables save button - const newDoc = { ...doc, id }; - setState(s => ({ - ...s, - isSaveModalVisible: false, - persistedDoc: newDoc, - lastKnownDoc: newDoc, - })); - if (docId !== id) { - redirectTo(id); - } - }) - .catch(e => { - // eslint-disable-next-line no-console - console.dir(e); - trackUiEvent('save_failed'); - core.notifications.toasts.addDanger( - i18n.translate('xpack.lens.app.docSavingError', { - defaultMessage: 'Error saving document', - }) - ); - setState(s => ({ ...s, isSaveModalVisible: false })); - }); - }} + runSave(props)} onClose={() => setState(s => ({ ...s, isSaveModalVisible: false }))} - title={lastKnownDoc.title || ''} - showCopyOnSave={!!lastKnownDoc.id && !addToDashboardMode} + documentInfo={{ + id: lastKnownDoc.id, + title: lastKnownDoc.title || '', + }} objectType={i18n.translate('xpack.lens.app.saveModalType', { defaultMessage: 'Lens visualization', })} - showDescription={false} - confirmButtonLabel={confirmButton} + data-test-subj="lnsApp_saveModalOrigin" /> )} diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index f295f88a58e5f..74bc5821aa713 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -11,8 +11,8 @@ import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom import { render, unmountComponentAtNode } from 'react-dom'; import rison from 'rison-node'; -import { DashboardConstants } from '../../../../../src/plugins/dashboard/public'; -import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { parse } from 'query-string'; +import { Storage, removeQueryParam } from '../../../../../src/plugins/kibana_utils/public'; import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry'; @@ -52,33 +52,48 @@ export async function mountApp( }; const redirectTo = ( routeProps: RouteComponentProps<{ id?: string }>, - addToDashboardMode: boolean, - id?: string + originatingApp: string, + id?: string, + returnToOrigin?: boolean, + newlyCreated?: boolean ) => { + if (!!originatingApp && !returnToOrigin) { + removeQueryParam(routeProps.history, 'embeddableOriginatingApp'); + } + if (!id) { routeProps.history.push('/lens'); - } else if (!addToDashboardMode) { + } else if (!originatingApp) { routeProps.history.push(`/lens/edit/${id}`); - } else if (addToDashboardMode && id) { + } else if (!!originatingApp && id && returnToOrigin) { routeProps.history.push(`/lens/edit/${id}`); - const lastDashboardLink = coreStart.chrome.navLinks.get('kibana:dashboard'); - if (!lastDashboardLink || !lastDashboardLink.url) { - throw new Error('Cannot get last dashboard url'); + const originatingAppLink = coreStart.chrome.navLinks.get(originatingApp); + if (!originatingAppLink || !originatingAppLink.url) { + throw new Error('Cannot get originating app url'); + } + + // TODO: Remove this and use application.redirectTo after https://github.com/elastic/kibana/pull/63443 + if (originatingApp === 'kibana:dashboard') { + const addLensId = newlyCreated ? id : ''; + const urlVars = getUrlVars(originatingAppLink.url); + updateUrlTime(urlVars); // we need to pass in timerange in query params directly + const dashboardUrl = addEmbeddableToDashboardUrl( + originatingAppLink.url, + addLensId, + urlVars + ); + window.history.pushState({}, '', dashboardUrl); + } else { + window.location.href = originatingAppLink.url; } - const urlVars = getUrlVars(lastDashboardLink.url); - updateUrlTime(urlVars); // we need to pass in timerange in query params directly - const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardLink.url, id, urlVars); - window.history.pushState({}, '', dashboardUrl); } }; const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { trackUiEvent('loaded'); - const addToDashboardMode = - !!routeProps.location.search && - routeProps.location.search.includes( - DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM - ); + const urlParams = parse(routeProps.location.search) as Record; + const originatingApp = urlParams.embeddableOriginatingApp; + return ( redirectTo(routeProps, addToDashboardMode, id)} - addToDashboardMode={addToDashboardMode} + redirectTo={(id, returnToOrigin, newlyCreated) => + redirectTo(routeProps, originatingApp, id, returnToOrigin, newlyCreated) + } + originatingApp={originatingApp} /> ); }; diff --git a/x-pack/plugins/lens/public/helpers/url_helper.ts b/x-pack/plugins/lens/public/helpers/url_helper.ts index 0a97ba4b2edf7..2ffc381c4f62f 100644 --- a/x-pack/plugins/lens/public/helpers/url_helper.ts +++ b/x-pack/plugins/lens/public/helpers/url_helper.ts @@ -37,8 +37,10 @@ export function addEmbeddableToDashboardUrl(url: string, embeddableId: string, u keys.forEach(key => { dashboardParsedUrl.query[key] = urlVars[key]; }); - dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = 'lens'; - dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + if (embeddableId) { + dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = 'lens'; + dashboardParsedUrl.query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; + } const query = stringify(dashboardParsedUrl.query); return `${dashboardParsedUrl.url}?${query}`; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 85b4c7f7eb032..b5b6ae7c64d32 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3969,7 +3969,6 @@ "visualize.listing.table.titleColumnName": "タイトル", "visualize.listing.table.typeColumnName": "タイプ", "visualize.pageHeading": "{chartName} {chartType} ビジュアライゼーション", - "visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存してダッシュボードに追加", "visualize.topNavMenu.openInspectorButtonAriaLabel": "ビジュアライゼーションのインスペクターを開く", "visualize.topNavMenu.openInspectorDisabledButtonTooltip": "このビジュアライゼーションはインスペクターをサポートしていません。", "visualize.topNavMenu.saveVisualization.failureNotificationText": "「{visTitle}」の保存中にエラーが発生しました", @@ -8363,7 +8362,6 @@ "xpack.lens.app.docSavingError": "ドキュメントの保存中にエラーが発生", "xpack.lens.app.indexPatternLoadingError": "インデックスパターンの読み込み中にエラーが発生", "xpack.lens.app.save": "保存", - "xpack.lens.app.saveAddToDashboard": "保存してダッシュボードに追加", "xpack.lens.app.saveModalType": "レンズビジュアライゼーション", "xpack.lens.app404": "404 Not Found", "xpack.lens.breadcrumbsCreate": "作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2f2b2b7f37481..1370295d1412f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3970,7 +3970,6 @@ "visualize.listing.table.titleColumnName": "标题", "visualize.listing.table.typeColumnName": "类型", "visualize.pageHeading": "{chartName} {chartType}可视化", - "visualize.saveDialog.saveAndAddToDashboardButtonLabel": "保存并添加到仪表板", "visualize.topNavMenu.openInspectorButtonAriaLabel": "打开检查器查看可视化", "visualize.topNavMenu.openInspectorDisabledButtonTooltip": "此可视化不支持任何检查器。", "visualize.topNavMenu.saveVisualization.failureNotificationText": "保存 “{visTitle}” 时出错", @@ -8369,7 +8368,6 @@ "xpack.lens.app.docSavingError": "保存文档时出错", "xpack.lens.app.indexPatternLoadingError": "加载索引模式时出错", "xpack.lens.app.save": "保存", - "xpack.lens.app.saveAddToDashboard": "保存并添加到仪表板", "xpack.lens.app.saveModalType": "Lens 可视化", "xpack.lens.app404": "404 找不到", "xpack.lens.breadcrumbsCreate": "创建", diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js index 19eebb3ba501c..c8ff3117506c3 100644 --- a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect'; + export default function({ getPageObjects, getService }) { const log = getService('log'); const testSubjects = getService('testSubjects'); const esArchiver = getService('esArchiver'); const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardPanelActions = getService('dashboardPanelActions'); const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens']); describe('empty dashboard', function() { @@ -52,7 +55,7 @@ export default function({ getPageObjects, getService }) { operation: 'terms', field: 'ip', }); - await PageObjects.lens.save(title); + await PageObjects.lens.save(title, false, true); } it('adds Lens visualization to empty dashboard', async () => { @@ -64,5 +67,39 @@ export default function({ getPageObjects, getService }) { await PageObjects.dashboard.waitForRenderComplete(); await testSubjects.exists(`embeddablePanelHeading-${title}`); }); + + it('redirects via save and return button after edit', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + }); + + it('redirects via save as button after edit, renaming itself', async () => { + const newTitle = 'wowee, looks like I have a new title'; + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.save(newTitle, false, true); + await PageObjects.dashboard.waitForRenderComplete(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount); + const titles = await PageObjects.dashboard.getPanelTitles(); + expect(titles.indexOf(newTitle)).to.not.be(-1); + }); + + it('redirects via save as button after edit, adding a new panel', async () => { + const newTitle = 'wowee, my title just got cooler'; + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.save(newTitle, true, true); + await PageObjects.dashboard.waitForRenderComplete(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount + 1); + const titles = await PageObjects.dashboard.getPanelTitles(); + expect(titles.indexOf(newTitle)).to.not.be(-1); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 57b2847cc2e50..c4dcf63941cd5 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -133,9 +133,22 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont /** * Save the current Lens visualization. */ - async save(title: string) { + async save(title: string, saveAsNew?: boolean, redirectToOrigin?: boolean) { await testSubjects.click('lnsApp_saveButton'); await testSubjects.setValue('savedObjectTitle', title); + + const saveAsNewCheckboxExists = await testSubjects.exists('saveAsNewCheckbox'); + if (saveAsNewCheckboxExists) { + const state = saveAsNew ? 'check' : 'uncheck'; + await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } + + const redirectToOriginCheckboxExists = await testSubjects.exists('returnToOriginModeSwitch'); + if (redirectToOriginCheckboxExists) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); + } + await testSubjects.click('confirmSaveSavedObjectButton'); retry.waitForWithTimeout('Save modal to disappear', 1000, () => testSubjects @@ -145,6 +158,10 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont ); }, + async saveAndReturn() { + await testSubjects.click('lnsApp_saveAndReturnButton'); + }, + getTitle() { return testSubjects.getVisibleText('lns_ChartTitle'); },