Return to dashboard after editing embeddable (#62865)

Changed the process for saving visualizations and lens visualizations when the user has been redirected there from another application.
This commit is contained in:
Devon Thomson 2020-05-05 16:58:36 -04:00 committed by GitHub
parent 932d6ad214
commit 5bad855e4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 649 additions and 197 deletions

View file

@ -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,

View file

@ -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}

View file

@ -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',

View file

@ -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,

View file

@ -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(

View file

@ -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<ActionContext> {
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<ActionContext> {
}
public async getHref({ embeddable }: ActionContext): Promise<string> {
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 : '';
}
}

View file

@ -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<string, Action>();
const triggerRegistry = new Map<string, Trigger>();
@ -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}

View file

@ -26,6 +26,8 @@ import {
EmbeddableFactoryDefinition,
} from './lib/embeddables';
export const EMBEDDABLE_ORIGINATING_APP_PARAM = 'embeddableOriginatingApp';
export type EmbeddableFactoryRegistry = Map<string, EmbeddableFactory>;
export type EmbeddableFactoryProvider = <

View file

@ -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,

View file

@ -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';

View file

@ -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<Props, State> {
export class SavedObjectSaveModal extends React.Component<Props, SaveModalState> {
private warning = React.createRef<HTMLDivElement>();
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<Props, State> {
{this.renderViewDescription()}
{this.props.options}
{typeof this.props.options === 'function'
? this.props.options(this.state)
: this.props.options}
</EuiForm>
</EuiModalBody>

View file

@ -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 (
<Fragment>
<EuiFormRow>
<EuiSwitch
data-test-subj="returnToOriginModeSwitch"
checked={returnToOriginMode}
onChange={event => {
setReturnToOriginMode(event.target.checked);
}}
label={
<FormattedMessage
id="savedObjects.saveModalOrigin.originAfterSavingSwitchLabel"
defaultMessage="{originVerb} to {origin} after saving"
values={{ originVerb, origin }}
/>
}
/>
</EuiFormRow>
</Fragment>
);
} 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 (
<SavedObjectSaveModal
onSave={onModalSave}
onClose={props.onClose}
title={documentInfo.title}
showCopyOnSave={documentInfo.id ? true : false}
initialCopyOnSave={Boolean(documentInfo.id) && returnToOriginMode}
confirmButtonLabel={confirmButtonLabel}
objectType={props.objectType}
options={getReturnToOriginSwitch}
description={documentInfo.description}
showDescription={true}
/>
);
}

View file

@ -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<VisualizationAttributes> = {
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<VisualizeInput> & { 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;
}

View file

@ -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 {

View file

@ -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);

View file

@ -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();
});

View file

@ -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<TypeSelectionProps, TypeSelectionState
let params;
if ('aliasUrl' in visType) {
params = this.props.addBasePath(visType.aliasUrl);
if (this.props.editorParams && this.props.editorParams.includes('addToDashboard')) {
params = `${params}?addToDashboard`;
if (this.props.editorParams) {
const originatingAppParam = this.props.editorParams?.find((param: string) =>
param.startsWith(EMBEDDABLE_ORIGINATING_APP_PARAM)
);
params = originatingAppParam ? `${params}?${originatingAppParam}` : params;
}
this.props.onClose();
window.location.assign(params);

View file

@ -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() ? (
<FormattedMessage
id="visualize.saveDialog.saveAndAddToDashboardButtonLabel"
defaultMessage="Save and add to dashboard"
/>
) : null;
const saveModal = (
<SavedObjectSaveModal
<SavedObjectSaveModalOrigin
documentInfo={savedVis}
onSave={onSave}
objectType={'visualization'}
onClose={() => {}}
title={savedVis.title}
showCopyOnSave={savedVis.id ? true : false}
objectType="visualization"
confirmButtonLabel={confirmButtonLabel}
description={savedVis.description}
showDescription={true}
originatingApp={$scope.getOriginatingApp()}
/>
);
showSaveModal(saveModal, I18nContext);
@ -398,12 +440,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;
};
@ -604,6 +640,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,
@ -631,15 +668,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 lastAppType = $scope.getOriginatingApp();
let href = chrome.navLinks.get(lastAppType).url;
const lastDashboardUrl = chrome.navLinks.get('kibana:dashboard').url;
const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardUrl, savedVis.id);
history.push(dashboardUrl);
// 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));

View file

@ -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)}`;
}

View file

@ -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 () => {

View file

@ -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);
});
});
}

View file

@ -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'));

View file

@ -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

View file

@ -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();

View file

@ -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,
});
}
})();
}

View file

@ -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);
}
}

View file

@ -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(() => {

View file

@ -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<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
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 ? (
<FormattedMessage
id="xpack.lens.app.saveAddToDashboard"
defaultMessage="Save and add to dashboard"
/>
) : null;
return (
<I18nProvider>
<KibanaContextProvider
@ -213,10 +265,39 @@ export function App({
<div className="lnsApp__header">
<TopNavMenu
config={[
...(!!originatingApp && lastKnownDoc?.id
? [
{
label: i18n.translate('xpack.lens.app.saveAndReturn', {
defaultMessage: 'Save and return',
}),
emphasize: true,
iconType: 'check',
run: () => {
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({
)}
</div>
{lastKnownDoc && state.isSaveModalVisible && (
<SavedObjectSaveModal
onSave={props => {
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 }));
});
}}
<SavedObjectSaveModalOrigin
originatingApp={originatingApp}
onSave={props => 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"
/>
)}
</KibanaContextProvider>

View file

@ -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<string, string>;
const originatingApp = urlParams.embeddableOriginatingApp;
return (
<App
core={coreStart}
@ -88,8 +103,10 @@ export async function mountApp(
storage={new Storage(localStorage)}
docId={routeProps.match.params.id}
docStorage={new SavedObjectIndexStore(savedObjectsClient)}
redirectTo={id => redirectTo(routeProps, addToDashboardMode, id)}
addToDashboardMode={addToDashboardMode}
redirectTo={(id, returnToOrigin, newlyCreated) =>
redirectTo(routeProps, originatingApp, id, returnToOrigin, newlyCreated)
}
originatingApp={originatingApp}
/>
);
};

View file

@ -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}`;

View file

@ -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": "作成",

View file

@ -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": "创建",

View file

@ -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);
});
});
}

View file

@ -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');
},