Remove App communication from URL (#67064) (#69726)

Removed all inter-app communication via url in favour of a new service in the embeddable start contract called the state transfer service.
This commit is contained in:
Devon Thomson 2020-06-23 16:06:11 -04:00 committed by GitHub
parent c3a5536944
commit 8d26b5ce61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 600 additions and 170 deletions

View file

@ -75,7 +75,7 @@ import { getDashboardTitle } from './dashboard_strings';
import { DashboardAppScope } from './dashboard_app';
import { convertSavedDashboardPanelToPanelState } from './lib/embeddable_saved_object_converters';
import { RenderDeps } from './application';
import { IKbnUrlStateStorage, removeQueryParam, unhashUrl } from '../../../kibana_utils/public';
import { IKbnUrlStateStorage, unhashUrl } from '../../../kibana_utils/public';
import {
addFatalError,
AngularHttpError,
@ -132,6 +132,7 @@ export class DashboardAppController {
embeddable,
share,
dashboardCapabilities,
scopedHistory,
embeddableCapabilities: { visualizeCapabilities, mapsCapabilities },
data: { query: queryService },
core: {
@ -425,15 +426,13 @@ export class DashboardAppController {
refreshDashboardContainer();
});
// This code needs to be replaced with a better mechanism for adding new embeddables of
// any type from the add panel. Likely this will happen via creating a visualization "inline",
// without navigating away from the UX.
if ($routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]) {
const type = $routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE];
const id = $routeParams[DashboardConstants.ADD_EMBEDDABLE_ID];
container.addNewEmbeddable<SavedObjectEmbeddableInput>(type, { savedObjectId: id });
removeQueryParam(history, DashboardConstants.ADD_EMBEDDABLE_TYPE);
removeQueryParam(history, DashboardConstants.ADD_EMBEDDABLE_ID);
const incomingState = embeddable
.getStateTransfer(scopedHistory())
.getIncomingEmbeddablePackage();
if (incomingState) {
container.addNewEmbeddable<SavedObjectEmbeddableInput>(incomingState.type, {
savedObjectId: incomingState.id,
});
}
}

View file

@ -46,6 +46,7 @@ import {
} from '../../../../kibana_react/public';
import { PLACEHOLDER_EMBEDDABLE } from './placeholder';
import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement';
import { EmbeddableStateTransfer } from '../../../../embeddable/public';
export interface DashboardContainerInput extends ContainerInput {
viewMode: ViewMode;
@ -98,9 +99,12 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
public renderEmpty?: undefined | (() => React.ReactNode);
private embeddablePanel: EmbeddableStart['EmbeddablePanel'];
constructor(
initialInput: DashboardContainerInput,
private readonly options: DashboardContainerOptions,
stateTransfer?: EmbeddableStateTransfer,
parent?: Container
) {
super(
@ -111,6 +115,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
options.embeddable.getEmbeddableFactory,
parent
);
this.embeddablePanel = options.embeddable.getEmbeddablePanel(stateTransfer);
}
protected createNewPanelState<
@ -186,7 +191,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
<DashboardViewport
renderEmpty={this.renderEmpty}
container={this}
PanelComponent={this.options.embeddable.EmbeddablePanel}
PanelComponent={this.embeddablePanel}
/>
</KibanaContextProvider>
</I18nProvider>,

View file

@ -19,7 +19,7 @@
import { i18n } from '@kbn/i18n';
import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { CoreStart } from 'src/core/public';
import { CoreStart, ScopedHistory } from 'src/core/public';
import { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import { EmbeddableFactory, EmbeddableStart } from '../../../../embeddable/public';
import {
@ -54,7 +54,10 @@ export class DashboardContainerFactoryDefinition
public readonly isContainerType = true;
public readonly type = DASHBOARD_CONTAINER_TYPE;
constructor(private readonly getStartServices: () => Promise<StartServices>) {}
constructor(
private readonly getStartServices: () => Promise<StartServices>,
private getHistory: () => ScopedHistory
) {}
public isEditable = async () => {
const { capabilities } = await this.getStartServices();
@ -81,6 +84,7 @@ export class DashboardContainerFactoryDefinition
parent?: Container
): Promise<DashboardContainer | ErrorEmbeddable> => {
const services = await this.getStartServices();
return new DashboardContainer(initialInput, services, parent);
const stateTransfer = services.embeddable.getStateTransfer(this.getHistory());
return new DashboardContainer(initialInput, services, stateTransfer, parent);
};
}

View file

@ -64,6 +64,7 @@ function prepare(props?: Partial<DashboardGridProps>) {
embeddable: {
getTriggerCompatibleActions: (() => []) as any,
getEmbeddableFactories: start.getEmbeddableFactories,
getEmbeddablePanel: jest.fn(),
getEmbeddableFactory,
} as any,
notifications: {} as any,

View file

@ -54,6 +54,7 @@ function getProps(
application: applicationServiceMock.createStartContract(),
embeddable: {
getTriggerCompatibleActions: (() => []) as any,
getEmbeddablePanel: jest.fn(),
getEmbeddableFactories: start.getEmbeddableFactories,
getEmbeddableFactory: start.getEmbeddableFactory,
} as any,

View file

@ -26,8 +26,8 @@ import { context } from '../../../../../kibana_react/public';
export interface DashboardViewportProps {
container: DashboardContainer;
renderEmpty?: () => React.ReactNode;
PanelComponent: EmbeddableStart['EmbeddablePanel'];
renderEmpty?: () => React.ReactNode;
}
interface State {

View file

@ -206,7 +206,10 @@ export class DashboardPlugin
};
};
const factory = new DashboardContainerFactoryDefinition(getStartServices);
const factory = new DashboardContainerFactoryDefinition(
getStartServices,
() => this.currentHistory!
);
embeddable.registerEmbeddableFactory(factory.type, factory);
const placeholderFactory = new PlaceholderEmbeddableFactory();

View file

@ -22,7 +22,6 @@ 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,
@ -69,6 +68,9 @@ export {
isSavedObjectEmbeddableInput,
isRangeSelectTriggerContext,
isValueClickTriggerContext,
EmbeddableStateTransfer,
EmbeddableOriginatingAppState,
EmbeddablePackageState,
EmbeddableRenderer,
EmbeddableRendererProps,
} from './lib';
@ -82,4 +84,5 @@ export {
EmbeddableStart,
EmbeddableSetupDependencies,
EmbeddableStartDependencies,
EmbeddablePanelHOC,
} from './plugin';

View file

@ -23,11 +23,13 @@ import { ViewMode } from '../types';
import { ContactCardEmbeddable } from '../test_samples';
import { embeddablePluginMock } from '../../mocks';
import { applicationServiceMock } from '../../../../../core/public/mocks';
import { of } from 'rxjs';
const { doStart } = embeddablePluginMock.createInstance();
const start = doStart();
const getFactory = start.getEmbeddableFactory;
const applicationMock = applicationServiceMock.createStartContract();
const stateTransferMock = embeddablePluginMock.createStartContract().getStateTransfer();
class EditableEmbeddable extends Embeddable {
public readonly type = 'EDITABLE_EMBEDDABLE';
@ -43,7 +45,7 @@ class EditableEmbeddable extends Embeddable {
}
test('is compatible when edit url is available, in edit mode and editable', async () => {
const action = new EditPanelAction(getFactory, applicationMock);
const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock);
expect(
await action.isCompatible({
embeddable: new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true),
@ -51,8 +53,20 @@ test('is compatible when edit url is available, in edit mode and editable', asyn
).toBe(true);
});
test('redirects to app using state transfer', async () => {
applicationMock.currentAppId$ = of('superCoolCurrentApp');
const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock);
const embeddable = new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true);
embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' }));
await action.execute({ embeddable });
expect(stateTransferMock.navigateToWithOriginatingApp).toHaveBeenCalledWith('ultraVisualize', {
path: '/123',
state: { originatingApp: 'superCoolCurrentApp' },
});
});
test('getHref returns the edit urls', async () => {
const action = new EditPanelAction(getFactory, applicationMock);
const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock);
expect(action.getHref).toBeDefined();
if (action.getHref) {
@ -66,7 +80,7 @@ test('getHref returns the edit urls', async () => {
});
test('is not compatible when edit url is not available', async () => {
const action = new EditPanelAction(getFactory, applicationMock);
const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock);
const embeddable = new ContactCardEmbeddable(
{
id: '123',
@ -85,7 +99,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, applicationMock);
const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock);
expect(
await action.isCompatible({
embeddable: new EditableEmbeddable(
@ -100,7 +114,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, applicationMock);
const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock);
expect(
await action.isCompatible({
embeddable: new EditableEmbeddable(

View file

@ -24,7 +24,7 @@ import { take } from 'rxjs/operators';
import { ViewMode } from '../types';
import { EmbeddableFactoryNotFoundError } from '../errors';
import { EmbeddableStart } from '../../plugin';
import { EMBEDDABLE_ORIGINATING_APP_PARAM, IEmbeddable } from '../..';
import { IEmbeddable, EmbeddableOriginatingAppState, EmbeddableStateTransfer } from '../..';
export const ACTION_EDIT_PANEL = 'editPanel';
@ -32,6 +32,12 @@ interface ActionContext {
embeddable: IEmbeddable;
}
interface NavigationContext {
app: string;
path: string;
state?: EmbeddableOriginatingAppState;
}
export class EditPanelAction implements Action<ActionContext> {
public readonly type = ACTION_EDIT_PANEL;
public readonly id = ACTION_EDIT_PANEL;
@ -40,7 +46,8 @@ export class EditPanelAction implements Action<ActionContext> {
constructor(
private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'],
private readonly application: ApplicationStart
private readonly application: ApplicationStart,
private readonly stateTransfer?: EmbeddableStateTransfer
) {
if (this.application?.currentAppId$) {
this.application.currentAppId$
@ -79,9 +86,15 @@ export class EditPanelAction implements Action<ActionContext> {
public async execute(context: ActionContext) {
const appTarget = this.getAppTarget(context);
if (appTarget) {
await this.application.navigateToApp(appTarget.app, { path: appTarget.path });
if (this.stateTransfer && appTarget.state) {
await this.stateTransfer.navigateToWithOriginatingApp(appTarget.app, {
path: appTarget.path,
state: appTarget.state,
});
} else {
await this.application.navigateToApp(appTarget.app, { path: appTarget.path });
}
return;
}
@ -92,22 +105,17 @@ export class EditPanelAction implements Action<ActionContext> {
}
}
public getAppTarget({ embeddable }: ActionContext): { app: string; path: string } | undefined {
public getAppTarget({ embeddable }: ActionContext): NavigationContext | undefined {
const app = embeddable ? embeddable.getOutput().editApp : undefined;
let path = embeddable ? embeddable.getOutput().editPath : undefined;
const path = embeddable ? embeddable.getOutput().editPath : undefined;
if (app && path) {
if (this.currentAppId) {
path += `?${EMBEDDABLE_ORIGINATING_APP_PARAM}=${this.currentAppId}`;
}
return { app, path };
const state = this.currentAppId ? { originatingApp: this.currentAppId } : undefined;
return { app, path, state };
}
}
public async getHref({ embeddable }: ActionContext): Promise<string> {
let editUrl = embeddable ? embeddable.getOutput().editUrl : undefined;
if (editUrl && this.currentAppId) {
editUrl += `?${EMBEDDABLE_ORIGINATING_APP_PARAM}=${this.currentAppId}`;
}
const editUrl = embeddable ? embeddable.getOutput().editUrl : undefined;
return editUrl ? editUrl : '';
}
}

View file

@ -24,3 +24,4 @@ export * from './actions';
export * from './triggers';
export * from './containers';
export * from './panel';
export * from './state_transfer';

View file

@ -43,6 +43,7 @@ import { EditPanelAction } from '../actions';
import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
import { EmbeddableStart } from '../../plugin';
import { EmbeddableErrorLabel } from './embeddable_error_label';
import { EmbeddableStateTransfer } from '..';
const sortByOrderField = (
{ order: orderA }: { order?: number },
@ -62,6 +63,7 @@ interface Props {
application: CoreStart['application'];
inspector: InspectorStartContract;
SavedObjectFinder: React.ComponentType<any>;
stateTransfer?: EmbeddableStateTransfer;
hideHeader?: boolean;
}
@ -299,7 +301,11 @@ export class EmbeddablePanel extends React.Component<Props, State> {
),
new InspectPanelAction(this.props.inspector),
new RemovePanelAction(),
new EditPanelAction(this.props.getEmbeddableFactory, this.props.application),
new EditPanelAction(
this.props.getEmbeddableFactory,
this.props.application,
this.props.stateTransfer
),
];
const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField);

View file

@ -0,0 +1,167 @@
/*
* 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 { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks';
import { EmbeddableStateTransfer } from '.';
import { ApplicationStart, ScopedHistory } from '../../../../../core/public';
function mockHistoryState(state: unknown) {
return scopedHistoryMock.create({ state });
}
describe('embeddable state transfer', () => {
let application: jest.Mocked<ApplicationStart>;
let stateTransfer: EmbeddableStateTransfer;
const destinationApp = 'superUltraVisualize';
const originatingApp = 'superUltraTestDashboard';
beforeEach(() => {
const core = coreMock.createStart();
application = core.application;
stateTransfer = new EmbeddableStateTransfer(application.navigateToApp);
});
it('can send an outgoing originating app state', async () => {
await stateTransfer.navigateToWithOriginatingApp(destinationApp, { state: { originatingApp } });
expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', {
state: { originatingApp: 'superUltraTestDashboard' },
});
});
it('can send an outgoing originating app state in append mode', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
await stateTransfer.navigateToWithOriginatingApp(destinationApp, {
state: { originatingApp },
appendToExistingState: true,
});
expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', {
path: undefined,
state: {
kibanaIsNowForSports: 'extremeSportsKibana',
originatingApp: 'superUltraTestDashboard',
},
});
});
it('can send an outgoing embeddable package state', async () => {
await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, {
state: { type: 'coolestType', id: '150' },
});
expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', {
state: { type: 'coolestType', id: '150' },
});
});
it('can send an outgoing embeddable package state in append mode', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, {
state: { type: 'coolestType', id: '150' },
appendToExistingState: true,
});
expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', {
path: undefined,
state: { kibanaIsNowForSports: 'extremeSportsKibana', type: 'coolestType', id: '150' },
});
});
it('can fetch an incoming originating app state', async () => {
const historyMock = mockHistoryState({ originatingApp: 'extremeSportsKibana' });
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
const fetchedState = stateTransfer.getIncomingOriginatingApp();
expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' });
});
it('returns undefined with originating app state is not in the right shape', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
const fetchedState = stateTransfer.getIncomingOriginatingApp();
expect(fetchedState).toBeUndefined();
});
it('can fetch an incoming embeddable package state', async () => {
const historyMock = mockHistoryState({ type: 'skisEmbeddable', id: '123' });
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
const fetchedState = stateTransfer.getIncomingEmbeddablePackage();
expect(fetchedState).toEqual({ type: 'skisEmbeddable', id: '123' });
});
it('returns undefined when embeddable package is not in the right shape', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
const fetchedState = stateTransfer.getIncomingEmbeddablePackage();
expect(fetchedState).toBeUndefined();
});
it('removes all keys in the keysToRemoveAfterFetch array', async () => {
const historyMock = mockHistoryState({
type: 'skisEmbeddable',
id: '123',
test1: 'test1',
test2: 'test2',
});
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
stateTransfer.getIncomingEmbeddablePackage({ keysToRemoveAfterFetch: ['type', 'id'] });
expect(historyMock.replace).toHaveBeenCalledWith(
expect.objectContaining({ state: { test1: 'test1', test2: 'test2' } })
);
});
it('leaves state as is when no keysToRemove are supplied', async () => {
const historyMock = mockHistoryState({
type: 'skisEmbeddable',
id: '123',
test1: 'test1',
test2: 'test2',
});
stateTransfer = new EmbeddableStateTransfer(
application.navigateToApp,
(historyMock as unknown) as ScopedHistory
);
stateTransfer.getIncomingEmbeddablePackage();
expect(historyMock.location.state).toEqual({
type: 'skisEmbeddable',
id: '123',
test1: 'test1',
test2: 'test2',
});
});
});

View file

@ -0,0 +1,132 @@
/*
* 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 { cloneDeep } from 'lodash';
import { ScopedHistory, ApplicationStart } from '../../../../../core/public';
import {
EmbeddableOriginatingAppState,
isEmbeddableOriginatingAppState,
EmbeddablePackageState,
isEmbeddablePackageState,
} from './types';
/**
* A wrapper around the state object in {@link ScopedHistory | core scoped history} which provides
* strongly typed helper methods for common incoming and outgoing states used by the embeddable infrastructure.
*
* @public
*/
export class EmbeddableStateTransfer {
constructor(
private navigateToApp: ApplicationStart['navigateToApp'],
private scopedHistory?: ScopedHistory
) {}
/**
* Fetches an {@link EmbeddableOriginatingAppState | originating app} argument from the scoped
* history's location state.
*
* @param history - the scoped history to fetch from
* @param options.keysToRemoveAfterFetch - an array of keys to be removed from the state after they are retrieved
*/
public getIncomingOriginatingApp(options?: {
keysToRemoveAfterFetch?: string[];
}): EmbeddableOriginatingAppState | undefined {
return this.getIncomingState<EmbeddableOriginatingAppState>(isEmbeddableOriginatingAppState, {
keysToRemoveAfterFetch: options?.keysToRemoveAfterFetch,
});
}
/**
* Fetches an {@link EmbeddablePackageState | embeddable package} argument from the scoped
* history's location state.
*
* @param history - the scoped history to fetch from
* @param options.keysToRemoveAfterFetch - an array of keys to be removed from the state after they are retrieved
*/
public getIncomingEmbeddablePackage(options?: {
keysToRemoveAfterFetch?: string[];
}): EmbeddablePackageState | undefined {
return this.getIncomingState<EmbeddablePackageState>(isEmbeddablePackageState, {
keysToRemoveAfterFetch: options?.keysToRemoveAfterFetch,
});
}
/**
* A wrapper around the {@link ApplicationStart.navigateToApp} method which navigates to the specified appId
* with {@link EmbeddableOriginatingAppState | originating app state}
*/
public async navigateToWithOriginatingApp(
appId: string,
options?: {
path?: string;
state: EmbeddableOriginatingAppState;
appendToExistingState?: boolean;
}
): Promise<void> {
await this.navigateToWithState<EmbeddableOriginatingAppState>(appId, options);
}
/**
* A wrapper around the {@link ApplicationStart.navigateToApp} method which navigates to the specified appId
* with {@link EmbeddablePackageState | embeddable package state}
*/
public async navigateToWithEmbeddablePackage(
appId: string,
options?: { path?: string; state: EmbeddablePackageState; appendToExistingState?: boolean }
): Promise<void> {
await this.navigateToWithState<EmbeddablePackageState>(appId, options);
}
private getIncomingState<IncomingStateType>(
guard: (state: unknown) => state is IncomingStateType,
options?: {
keysToRemoveAfterFetch?: string[];
}
): IncomingStateType | undefined {
if (!this.scopedHistory) {
throw new TypeError('ScopedHistory is required to fetch incoming state');
}
const incomingState = this.scopedHistory.location?.state;
const castState =
!guard || guard(incomingState) ? (cloneDeep(incomingState) as IncomingStateType) : undefined;
if (castState && options?.keysToRemoveAfterFetch) {
const stateReplace = { ...(this.scopedHistory.location.state as { [key: string]: unknown }) };
options.keysToRemoveAfterFetch.forEach((key: string) => {
delete stateReplace[key];
});
this.scopedHistory.replace({ ...this.scopedHistory.location, state: stateReplace });
}
return castState;
}
private async navigateToWithState<OutgoingStateType = unknown>(
appId: string,
options?: { path?: string; state?: OutgoingStateType; appendToExistingState?: boolean }
): Promise<void> {
const stateObject =
options?.appendToExistingState && this.scopedHistory
? {
...(this.scopedHistory?.location.state as { [key: string]: unknown }),
...options.state,
}
: options?.state;
await this.navigateToApp(appId, { path: options?.path, state: stateObject });
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.
*/
export { EmbeddableStateTransfer } from './embeddable_state_transfer';
export { EmbeddableOriginatingAppState, EmbeddablePackageState } from './types';

View file

@ -0,0 +1,56 @@
/*
* 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.
*/
/**
* Represents a state package that contains the last active app id.
* @public
*/
export interface EmbeddableOriginatingAppState {
originatingApp: string;
}
export function isEmbeddableOriginatingAppState(
state: unknown
): state is EmbeddableOriginatingAppState {
return ensureFieldOfTypeExists('originatingApp', state, 'string');
}
/**
* Represents a state package that contains all fields necessary to create an embeddable in a container.
* @public
*/
export interface EmbeddablePackageState {
type: string;
id: string;
}
export function isEmbeddablePackageState(state: unknown): state is EmbeddablePackageState {
return (
ensureFieldOfTypeExists('type', state, 'string') &&
ensureFieldOfTypeExists('id', state, 'string')
);
}
function ensureFieldOfTypeExists(key: string, obj: unknown, type?: string): boolean {
return (
obj &&
key in (obj as { [key: string]: unknown }) &&
(!type || typeof (obj as { [key: string]: unknown })[key] === type)
);
}

View file

@ -22,6 +22,7 @@ import {
EmbeddableSetup,
EmbeddableSetupDependencies,
EmbeddableStartDependencies,
EmbeddableStateTransfer,
IEmbeddable,
EmbeddablePanel,
} from '.';
@ -75,6 +76,15 @@ export const createEmbeddablePanelMock = ({
);
};
export const createEmbeddableStateTransferMock = (): Partial<EmbeddableStateTransfer> => {
return {
getIncomingOriginatingApp: jest.fn(),
getIncomingEmbeddablePackage: jest.fn(),
navigateToWithOriginatingApp: jest.fn(),
navigateToWithEmbeddablePackage: jest.fn(),
};
};
const createSetupContract = (): Setup => {
const setupContract: Setup = {
registerEmbeddableFactory: jest.fn(),
@ -88,6 +98,8 @@ const createStartContract = (): Start => {
getEmbeddableFactories: jest.fn(),
getEmbeddableFactory: jest.fn(),
EmbeddablePanel: jest.fn(),
getEmbeddablePanel: jest.fn(),
getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer),
};
return startContract;
};

View file

@ -20,7 +20,13 @@ import React from 'react';
import { getSavedObjectFinder } from '../../saved_objects/public';
import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public';
import { Start as InspectorStart } from '../../inspector/public';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public';
import {
PluginInitializerContext,
CoreSetup,
CoreStart,
Plugin,
ScopedHistory,
} from '../../../core/public';
import { EmbeddableFactoryRegistry, EmbeddableFactoryProvider } from './types';
import { bootstrap } from './bootstrap';
import {
@ -32,6 +38,7 @@ import {
EmbeddablePanel,
} from './lib';
import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition';
import { EmbeddableStateTransfer } from './lib/state_transfer';
export interface EmbeddableSetupDependencies {
uiActions: UiActionsSetup;
@ -63,9 +70,13 @@ export interface EmbeddableStart {
embeddableFactoryId: string
) => EmbeddableFactory<I, O, E> | undefined;
getEmbeddableFactories: () => IterableIterator<EmbeddableFactory>;
EmbeddablePanel: React.FC<{ embeddable: IEmbeddable; hideHeader?: boolean }>;
EmbeddablePanel: EmbeddablePanelHOC;
getEmbeddablePanel: (stateTransfer?: EmbeddableStateTransfer) => EmbeddablePanelHOC;
getStateTransfer: (history?: ScopedHistory) => EmbeddableStateTransfer;
}
export type EmbeddablePanelHOC = React.FC<{ embeddable: IEmbeddable; hideHeader?: boolean }>;
export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, EmbeddableStart> {
private readonly embeddableFactoryDefinitions: Map<
string,
@ -73,6 +84,7 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
> = new Map();
private readonly embeddableFactories: EmbeddableFactoryRegistry = new Map();
private customEmbeddableFactoryProvider?: EmbeddableFactoryProvider;
private outgoingOnlyStateTransfer: EmbeddableStateTransfer = {} as EmbeddableStateTransfer;
private isRegistryReady = false;
constructor(initializerContext: PluginInitializerContext) {}
@ -105,31 +117,42 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
: defaultEmbeddableFactoryProvider(def)
);
});
this.outgoingOnlyStateTransfer = new EmbeddableStateTransfer(core.application.navigateToApp);
this.isRegistryReady = true;
const getEmbeddablePanelHoc = (stateTransfer?: EmbeddableStateTransfer) => ({
embeddable,
hideHeader,
}: {
embeddable: IEmbeddable;
hideHeader?: boolean;
}) => (
<EmbeddablePanel
hideHeader={hideHeader}
embeddable={embeddable}
stateTransfer={stateTransfer ? stateTransfer : this.outgoingOnlyStateTransfer}
getActions={uiActions.getTriggerCompatibleActions}
getEmbeddableFactory={this.getEmbeddableFactory}
getAllEmbeddableFactories={this.getEmbeddableFactories}
overlays={core.overlays}
notifications={core.notifications}
application={core.application}
inspector={inspector}
SavedObjectFinder={getSavedObjectFinder(core.savedObjects, core.uiSettings)}
/>
);
return {
getEmbeddableFactory: this.getEmbeddableFactory,
getEmbeddableFactories: this.getEmbeddableFactories,
EmbeddablePanel: ({
embeddable,
hideHeader,
}: {
embeddable: IEmbeddable;
hideHeader?: boolean;
}) => (
<EmbeddablePanel
hideHeader={hideHeader}
embeddable={embeddable}
getActions={uiActions.getTriggerCompatibleActions}
getEmbeddableFactory={this.getEmbeddableFactory}
getAllEmbeddableFactories={this.getEmbeddableFactories}
overlays={core.overlays}
notifications={core.notifications}
application={core.application}
inspector={inspector}
SavedObjectFinder={getSavedObjectFinder(core.savedObjects, core.uiSettings)}
/>
),
getStateTransfer: (history?: ScopedHistory) => {
return history
? new EmbeddableStateTransfer(core.application.navigateToApp, history)
: this.outgoingOnlyStateTransfer;
},
EmbeddablePanel: getEmbeddablePanelHoc(),
getEmbeddablePanel: getEmbeddablePanelHoc,
};
}

View file

@ -82,6 +82,7 @@ async function creatHelloWorldContainerAndEmbeddable(
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
});
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,

View file

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

View file

@ -26,7 +26,6 @@ import {
EmbeddableOutput,
ErrorEmbeddable,
IContainer,
EMBEDDABLE_ORIGINATING_APP_PARAM,
} from '../../../embeddable/public';
import { DisabledLabEmbeddable } from './disabled_lab_embeddable';
import { VisualizeEmbeddable, VisualizeInput, VisualizeOutput } from './visualize_embeddable';
@ -50,7 +49,7 @@ interface VisualizationAttributes extends SavedObjectAttributes {
}
export interface VisualizeEmbeddableFactoryDeps {
start: StartServicesGetter<Pick<VisualizationsStartDeps, 'inspector'>>;
start: StartServicesGetter<Pick<VisualizationsStartDeps, 'inspector' | 'embeddable'>>;
}
export class VisualizeEmbeddableFactory
@ -103,15 +102,7 @@ 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;
return await this.deps.start().core.application.currentAppId$.pipe(first()).toPromise();
}
public async createFromSavedObject(
@ -136,9 +127,8 @@ 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: [`${EMBEDDABLE_ORIGINATING_APP_PARAM}=${originatingAppParam}`],
originatingApp: await this.getCurrentAppId(),
outsideVisualizeApp: true,
});
return undefined;

View file

@ -22,6 +22,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TypesStart, VisType } from '../vis_types';
import { NewVisModal } from './new_vis_modal';
import { ApplicationStart, SavedObjectsStart } from '../../../../core/public';
import { embeddablePluginMock } from '../../../embeddable/public/mocks';
describe('NewVisModal', () => {
const defaultVisTypeParams = {
@ -144,30 +145,34 @@ describe('NewVisModal', () => {
);
});
it('closes and redirects properly if visualization with aliasPath and addToDashboard in editorParams', () => {
it('closes and redirects properly if visualization with aliasPath and originatingApp in props', () => {
const onClose = jest.fn();
const navigateToApp = jest.fn();
const stateTransfer = embeddablePluginMock.createStartContract().getStateTransfer();
const wrapper = mountWithIntl(
<NewVisModal
isOpen={true}
onClose={onClose}
visTypesRegistry={visTypes}
editorParams={['foo=true', 'bar=42', 'embeddableOriginatingApp=notAnApp']}
editorParams={['foo=true', 'bar=42']}
originatingApp={'coolJestTestApp'}
addBasePath={addBasePath}
uiSettings={uiSettings}
application={({ navigateToApp } as unknown) as ApplicationStart}
stateTransfer={stateTransfer}
savedObjects={{} as SavedObjectsStart}
/>
);
const visButton = wrapper.find('button[data-test-subj="visType-visWithAliasUrl"]');
visButton.simulate('click');
expect(navigateToApp).toBeCalledWith('otherApp', {
path: '#/aliasUrl?embeddableOriginatingApp=notAnApp',
expect(stateTransfer.navigateToWithOriginatingApp).toBeCalledWith('otherApp', {
path: '#/aliasUrl',
state: { originatingApp: 'coolJestTestApp' },
});
expect(onClose).toHaveBeenCalled();
});
it('closes and redirects properly if visualization with aliasApp and without addToDashboard in editorParams', () => {
it('closes and redirects properly if visualization with aliasApp and without originatingApp in props', () => {
const onClose = jest.fn();
const navigateToApp = jest.fn();
const wrapper = mountWithIntl(

View file

@ -28,7 +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';
import { EmbeddableStateTransfer } from '../../../embeddable/public';
import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants';
interface TypeSelectionProps {
@ -42,6 +42,8 @@ interface TypeSelectionProps {
usageCollection?: UsageCollectionSetup;
application: ApplicationStart;
outsideVisualizeApp?: boolean;
stateTransfer?: EmbeddableStateTransfer;
originatingApp?: string;
}
interface TypeSelectionState {
@ -148,14 +150,8 @@ class NewVisModal extends React.Component<TypeSelectionProps, TypeSelectionState
let params;
if ('aliasPath' in visType) {
params = visType.aliasPath;
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();
this.props.application.navigateToApp(visType.aliasApp, { path: params });
this.navigate(visType.aliasApp, visType.aliasPath);
return;
}
@ -168,13 +164,24 @@ class NewVisModal extends React.Component<TypeSelectionProps, TypeSelectionState
this.props.onClose();
if (this.props.outsideVisualizeApp) {
this.props.application.navigateToApp('visualize', {
path: `#${basePath}${params.join('&')}`,
});
this.navigate('visualize', `#${basePath}${params.join('&')}`);
} else {
location.assign(this.props.addBasePath(`${baseUrl}${params.join('&')}`));
}
}
private navigate(appId: string, params: string) {
if (this.props.stateTransfer && this.props.originatingApp) {
this.props.stateTransfer.navigateToWithOriginatingApp(appId, {
path: params,
state: { originatingApp: this.props.originatingApp },
});
} else {
this.props.application.navigateToApp(appId, {
path: params,
});
}
}
}
export { NewVisModal };

View file

@ -29,11 +29,13 @@ import {
getUISettings,
getUsageCollector,
getApplication,
getEmbeddable,
} from '../services';
export interface ShowNewVisModalParams {
editorParams?: string[];
onClose?: () => void;
originatingApp?: string;
outsideVisualizeApp?: boolean;
}
@ -45,6 +47,7 @@ export interface ShowNewVisModalParams {
export function showNewVisModal({
editorParams = [],
onClose,
originatingApp,
outsideVisualizeApp,
}: ShowNewVisModalParams = {}) {
const container = document.createElement('div');
@ -65,6 +68,8 @@ export function showNewVisModal({
<NewVisModal
isOpen={true}
onClose={handleClose}
originatingApp={originatingApp}
stateTransfer={getEmbeddable().getStateTransfer()}
outsideVisualizeApp={outsideVisualizeApp}
editorParams={editorParams}
visTypesRegistry={getTypes()}

View file

@ -9,7 +9,8 @@
"navigation",
"savedObjects",
"visualizations",
"dashboard"
"dashboard",
"embeddable"
],
"optionalPlugins": ["home", "share"]
}

View file

@ -29,10 +29,8 @@ import { makeStateful, useVisualizeAppState } 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 { unhashUrl } from '../../../../kibana_utils/public';
import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public';
import {
addFatalError,
@ -78,7 +76,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState
I18nContext,
setActiveUrl,
visualizations,
dashboard,
embeddable,
scopedHistory,
} = getServices();
const {
@ -118,8 +117,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState
uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE),
};
const originatingApp = $route.current.params[EMBEDDABLE_ORIGINATING_APP_PARAM];
removeQueryParam(history, EMBEDDABLE_ORIGINATING_APP_PARAM);
const { originatingApp } =
embeddable.getStateTransfer(scopedHistory()).getIncomingOriginatingApp() || {};
$scope.getOriginatingApp = () => originatingApp;
const visStateToEditorState = () => {
@ -647,7 +646,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);
const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave;
stateContainer.transitions.setVis({
title: savedVis.title,
type: savedVis.type || stateContainer.getState().vis.type,
@ -680,16 +679,14 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState
// Manually insert a new url so the back button will open the saved visualization.
history.replace(appPath);
setActiveUrl(appPath);
const lastAppType = $scope.getOriginatingApp();
if (lastAppType === 'dashboards') {
const savedVisId = firstSave || savedVis.copyOnSave ? savedVis.id : '';
dashboard.addEmbeddableToDashboard({
embeddableId: savedVisId,
embeddableType: VISUALIZE_EMBEDDABLE_TYPE,
});
if (newlyCreated && embeddable) {
embeddable
.getStateTransfer()
.navigateToWithEmbeddablePackage($scope.getOriginatingApp(), {
state: { id: savedVis.id, type: VISUALIZE_EMBEDDABLE_TYPE },
});
} else {
application.navigateToApp(lastAppType);
application.navigateToApp($scope.getOriginatingApp());
}
} else if (savedVis.id === $route.current.params.id) {
chrome.docTitle.change(savedVis.lastSavedTitle);

View file

@ -34,8 +34,8 @@ import { DataPublicPluginStart } from '../../data/public';
import { VisualizationsStart } from '../../visualizations/public';
import { SavedVisualizations } from './application/types';
import { KibanaLegacyStart } from '../../kibana_legacy/public';
import { DashboardStart } from '../../dashboard/public';
import { SavedObjectsStart } from '../../saved_objects/public';
import { EmbeddableStart } from '../../embeddable/public';
export interface VisualizeKibanaServices {
pluginInitializerContext: PluginInitializerContext;
@ -52,7 +52,7 @@ export interface VisualizeKibanaServices {
kibanaLegacy: KibanaLegacyStart;
visualizeCapabilities: any;
visualizations: VisualizationsStart;
dashboard: DashboardStart;
embeddable: EmbeddableStart;
I18nContext: I18nStart['Context'];
setActiveUrl: (newUrl: string) => void;
createVisEmbeddableFromObject: VisualizationsStart['__LEGACY']['createVisEmbeddableFromObject'];

View file

@ -40,16 +40,16 @@ import { VisualizationsStart } from '../../visualizations/public';
import { VisualizeConstants } from './application/visualize_constants';
import { setServices, VisualizeKibanaServices } from './kibana_services';
import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../home/public';
import { DashboardStart } from '../../dashboard/public';
import { DEFAULT_APP_CATEGORIES } from '../../../core/public';
import { SavedObjectsStart } from '../../saved_objects/public';
import { EmbeddableStart } from '../../embeddable/public';
export interface VisualizePluginStartDependencies {
data: DataPublicPluginStart;
navigation: NavigationStart;
share?: SharePluginStart;
visualizations: VisualizationsStart;
dashboard: DashboardStart;
embeddable: EmbeddableStart;
kibanaLegacy: KibanaLegacyStart;
savedObjects: SavedObjectsStart;
}
@ -129,11 +129,11 @@ export class VisualizePlugin
toastNotifications: coreStart.notifications.toasts,
visualizeCapabilities: coreStart.application.capabilities.visualize,
visualizations: pluginsStart.visualizations,
embeddable: pluginsStart.embeddable,
I18nContext: coreStart.i18n.Context,
setActiveUrl,
createVisEmbeddableFromObject:
pluginsStart.visualizations.__LEGACY.createVisEmbeddableFromObject,
dashboard: pluginsStart.dashboard,
scopedHistory: () => this.currentHistory!,
savedObjects: pluginsStart.savedObjects,
};

View file

@ -124,12 +124,7 @@ describe('Lens App', () => {
storage: Storage;
docId?: string;
docStorage: SavedObjectStore;
redirectTo: (
id?: string,
returnToOrigin?: boolean,
originatingApp?: string | undefined,
newlyCreated?: boolean
) => void;
redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void;
originatingApp: string | undefined;
onAppLeave: AppMountParameters['onAppLeave'];
history: History;
@ -168,14 +163,7 @@ describe('Lens App', () => {
load: jest.fn(),
save: jest.fn(),
},
redirectTo: jest.fn(
(
id?: string,
returnToOrigin?: boolean,
originatingApp?: string | undefined,
newlyCreated?: boolean
) => {}
),
redirectTo: jest.fn((id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => {}),
onAppLeave: jest.fn(),
history: createMemoryHistory(),
} as unknown) as jest.Mocked<{
@ -186,12 +174,7 @@ describe('Lens App', () => {
storage: Storage;
docId?: string;
docStorage: SavedObjectStore;
redirectTo: (
id?: string,
returnToOrigin?: boolean,
originatingApp?: string | undefined,
newlyCreated?: boolean
) => void;
redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void;
originatingApp: string | undefined;
onAppLeave: AppMountParameters['onAppLeave'];
history: History;
@ -533,7 +516,7 @@ describe('Lens App', () => {
expression: 'kibana 3',
});
expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, undefined, true);
expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true);
inst.setProps({ docId: 'aaa' });
@ -553,7 +536,7 @@ describe('Lens App', () => {
expression: 'kibana 3',
});
expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, undefined, true);
expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true);
inst.setProps({ docId: 'aaa' });
@ -621,7 +604,7 @@ describe('Lens App', () => {
title: 'hello there',
});
expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, undefined, true);
expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, true);
});
it('saves app filters and does not save pinned filters', async () => {

View file

@ -43,7 +43,6 @@ interface State {
isLoading: boolean;
isSaveModalVisible: boolean;
indexPatternsForTopNav: IndexPatternInstance[];
originatingApp: string | undefined;
persistedDoc?: Document;
lastKnownDoc?: Document;
@ -65,7 +64,7 @@ export function App({
docId,
docStorage,
redirectTo,
originatingAppFromUrl,
originatingApp,
navigation,
onAppLeave,
history,
@ -77,13 +76,8 @@ export function App({
storage: IStorageWrapper;
docId?: string;
docStorage: SavedObjectStore;
redirectTo: (
id?: string,
returnToOrigin?: boolean,
originatingApp?: string | undefined,
newlyCreated?: boolean
) => void;
originatingAppFromUrl?: string | undefined;
redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void;
originatingApp?: string | undefined;
onAppLeave: AppMountParameters['onAppLeave'];
history: History;
}) {
@ -98,7 +92,6 @@ export function App({
isSaveModalVisible: false,
indexPatternsForTopNav: [],
query: { query: '', language },
originatingApp: originatingAppFromUrl,
dateRange: {
fromDate: currentRange.from,
toDate: currentRange.to,
@ -316,7 +309,7 @@ export function App({
lastKnownDoc: newDoc,
}));
if (docId !== id || saveProps.returnToOrigin) {
redirectTo(id, saveProps.returnToOrigin, state.originatingApp, newlyCreated);
redirectTo(id, saveProps.returnToOrigin, newlyCreated);
}
})
.catch((e) => {
@ -356,7 +349,7 @@ export function App({
<div className="lnsApp__header">
<TopNavMenu
config={[
...(!!state.originatingApp && lastKnownDoc?.id
...(!!originatingApp && lastKnownDoc?.id
? [
{
label: i18n.translate('xpack.lens.app.saveAndReturn', {
@ -381,14 +374,14 @@ export function App({
: []),
{
label:
lastKnownDoc?.id && !!state.originatingApp
lastKnownDoc?.id && !!originatingApp
? i18n.translate('xpack.lens.app.saveAs', {
defaultMessage: 'Save as',
})
: i18n.translate('xpack.lens.app.save', {
defaultMessage: 'Save',
}),
emphasize: !state.originatingApp || !lastKnownDoc?.id,
emphasize: !originatingApp || !lastKnownDoc?.id,
run: () => {
if (isSaveable && lastKnownDoc) {
setState((s) => ({ ...s, isSaveModalVisible: true }));
@ -509,7 +502,7 @@ export function App({
</div>
{lastKnownDoc && state.isSaveModalVisible && (
<SavedObjectSaveModalOrigin
originatingApp={state.originatingApp}
originatingApp={originatingApp}
onSave={(props) => runSave(props)}
onClose={() => setState((s) => ({ ...s, isSaveModalVisible: false }))}
documentInfo={{

View file

@ -10,9 +10,8 @@ import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { render, unmountComponentAtNode } from 'react-dom';
import { i18n } from '@kbn/i18n';
import { parse } from 'query-string';
import { removeQueryParam, Storage } from '../../../../../src/plugins/kibana_utils/public';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry';
@ -29,7 +28,7 @@ export async function mountApp(
createEditorFrame: EditorFrameStart['createInstance']
) {
const [coreStart, startDependencies] = await core.getStartServices();
const { data: dataStart, navigation } = startDependencies;
const { data: dataStart, navigation, embeddable } = startDependencies;
const savedObjectsClient = coreStart.savedObjects.client;
addHelpMenuToAppChrome(coreStart.chrome, coreStart.docLinks);
@ -37,6 +36,10 @@ export async function mountApp(
i18n.translate('xpack.lens.pageTitle', { defaultMessage: 'Lens' })
);
const stateTransfer = embeddable?.getStateTransfer(params.history);
const { originatingApp } =
stateTransfer?.getIncomingOriginatingApp({ keysToRemoveAfterFetch: ['originatingApp'] }) || {};
const instance = await createEditorFrame();
setReportManager(
@ -49,7 +52,6 @@ export async function mountApp(
routeProps: RouteComponentProps<{ id?: string }>,
id?: string,
returnToOrigin?: boolean,
originatingApp?: string,
newlyCreated?: boolean
) => {
if (!id) {
@ -59,11 +61,9 @@ export async function mountApp(
} else if (!!originatingApp && id && returnToOrigin) {
routeProps.history.push(`/edit/${id}`);
if (originatingApp === 'dashboards') {
const addLensId = newlyCreated ? id : '';
startDependencies.dashboard.addEmbeddableToDashboard({
embeddableId: addLensId,
embeddableType: LENS_EMBEDDABLE_TYPE,
if (newlyCreated && stateTransfer) {
stateTransfer.navigateToWithEmbeddablePackage(originatingApp, {
state: { id, type: LENS_EMBEDDABLE_TYPE },
});
} else {
coreStart.application.navigateToApp(originatingApp);
@ -73,11 +73,6 @@ export async function mountApp(
const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => {
trackUiEvent('loaded');
const urlParams = parse(routeProps.location.search) as Record<string, string>;
const originatingAppFromUrl = urlParams.embeddableOriginatingApp;
if (urlParams.embeddableOriginatingApp) {
removeQueryParam(routeProps.history, 'embeddableOriginatingApp');
}
return (
<App
@ -88,10 +83,10 @@ export async function mountApp(
storage={new Storage(localStorage)}
docId={routeProps.match.params.id}
docStorage={new SavedObjectIndexStore(savedObjectsClient)}
redirectTo={(id, returnToOrigin, originatingApp, newlyCreated) =>
redirectTo(routeProps, id, returnToOrigin, originatingApp, newlyCreated)
redirectTo={(id, returnToOrigin, newlyCreated) =>
redirectTo(routeProps, id, returnToOrigin, newlyCreated)
}
originatingAppFromUrl={originatingAppFromUrl}
originatingApp={originatingApp}
onAppLeave={params.onAppLeave}
history={routeProps.history}
/>

View file

@ -6,7 +6,7 @@
import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public';
import { EmbeddableSetup } from 'src/plugins/embeddable/public';
import { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public';
import { ExpressionsSetup, ExpressionsStart } from 'src/plugins/expressions/public';
import { VisualizationsSetup } from 'src/plugins/visualizations/public';
import { NavigationPublicPluginStart } from 'src/plugins/navigation/public';
@ -26,7 +26,6 @@ import { EditorFrameStart } from './types';
import { getLensAliasConfig } from './vis_type_alias';
import './index.scss';
import { DashboardStart } from '../../../../src/plugins/dashboard/public';
export interface LensPluginSetupDependencies {
kibanaLegacy: KibanaLegacySetup;
@ -41,7 +40,7 @@ export interface LensPluginStartDependencies {
expressions: ExpressionsStart;
navigation: NavigationPublicPluginStart;
uiActions: UiActionsStart;
dashboard: DashboardStart;
embeddable: EmbeddableStart;
}
export class LensPlugin {