Allow the mounted application to prompt a confirm message before leaving (#54221)

* add onAppLeave to AppMountParameters

* adapt legacy shims of app mount

* update generated doc

* returns properly typed AppLeaveAction from leave handler instead of raw strings

* add openConfirm to modal service and use it instead of window.confirm

* fix unit test

* update querystringinput snapshots

* add integration tests

* nits and review comments

* add functional tests
This commit is contained in:
Pierre Gayvallet 2020-01-10 12:17:21 +01:00 committed by GitHub
parent 4d659477ad
commit c0d6b932f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2535 additions and 1169 deletions

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveAction](./kibana-plugin-public.appleaveaction.md)
## AppLeaveAction type
Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)
See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md)
<b>Signature:</b>
```typescript
export declare type AppLeaveAction = AppLeaveDefaultAction | AppLeaveConfirmAction;
```

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md)
## AppLeaveActionType enum
Possible type of actions on application leave.
<b>Signature:</b>
```typescript
export declare enum AppLeaveActionType
```
## Enumeration Members
| Member | Value | Description |
| --- | --- | --- |
| confirm | <code>&quot;confirm&quot;</code> | |
| default | <code>&quot;default&quot;</code> | |

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md)
## AppLeaveConfirmAction interface
Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.
See
<b>Signature:</b>
```typescript
export interface AppLeaveConfirmAction
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [text](./kibana-plugin-public.appleaveconfirmaction.text.md) | <code>string</code> | |
| [title](./kibana-plugin-public.appleaveconfirmaction.title.md) | <code>string</code> | |
| [type](./kibana-plugin-public.appleaveconfirmaction.type.md) | <code>AppLeaveActionType.confirm</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) &gt; [text](./kibana-plugin-public.appleaveconfirmaction.text.md)
## AppLeaveConfirmAction.text property
<b>Signature:</b>
```typescript
text: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) &gt; [title](./kibana-plugin-public.appleaveconfirmaction.title.md)
## AppLeaveConfirmAction.title property
<b>Signature:</b>
```typescript
title?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) &gt; [type](./kibana-plugin-public.appleaveconfirmaction.type.md)
## AppLeaveConfirmAction.type property
<b>Signature:</b>
```typescript
type: AppLeaveActionType.confirm;
```

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md)
## AppLeaveDefaultAction interface
Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.
See
<b>Signature:</b>
```typescript
export interface AppLeaveDefaultAction
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [type](./kibana-plugin-public.appleavedefaultaction.type.md) | <code>AppLeaveActionType.default</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) &gt; [type](./kibana-plugin-public.appleavedefaultaction.type.md)
## AppLeaveDefaultAction.type property
<b>Signature:</b>
```typescript
type: AppLeaveActionType.default;
```

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)
## AppLeaveHandler type
A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return `confirm` to to prompt a message to the user before leaving the page, or `default` to keep the default behavior (doing nothing).
See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples.
<b>Signature:</b>
```typescript
export declare type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction;
```

View file

@ -22,6 +22,6 @@ export interface ApplicationStart
| Method | Description |
| --- | --- |
| [getUrlForApp(appId, options)](./kibana-plugin-public.applicationstart.geturlforapp.md) | Returns a relative URL to a given app, including the global base path. |
| [navigateToApp(appId, options)](./kibana-plugin-public.applicationstart.navigatetoapp.md) | Navigiate to a given app |
| [navigateToApp(appId, options)](./kibana-plugin-public.applicationstart.navigatetoapp.md) | Navigate to a given app |
| [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md)<!-- -->. |

View file

@ -4,7 +4,7 @@
## ApplicationStart.navigateToApp() method
Navigiate to a given app
Navigate to a given app
<b>Signature:</b>
@ -12,7 +12,7 @@ Navigiate to a given app
navigateToApp(appId: string, options?: {
path?: string;
state?: any;
}): void;
}): Promise<void>;
```
## Parameters
@ -24,5 +24,5 @@ navigateToApp(appId: string, options?: {
<b>Returns:</b>
`void`
`Promise<void>`

View file

@ -17,4 +17,5 @@ export interface AppMountParameters
| --- | --- | --- |
| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | <code>string</code> | The route path for configuring navigation to the application. This string should not include the base path from HTTP. |
| [element](./kibana-plugin-public.appmountparameters.element.md) | <code>HTMLElement</code> | The container element to render the application into. |
| [onAppLeave](./kibana-plugin-public.appmountparameters.onappleave.md) | <code>(handler: AppLeaveHandler) =&gt; void</code> | A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page.<!-- -->This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url. |

View file

@ -0,0 +1,41 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [AppMountParameters](./kibana-plugin-public.appmountparameters.md) &gt; [onAppLeave](./kibana-plugin-public.appmountparameters.onappleave.md)
## AppMountParameters.onAppLeave property
A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page.
This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url.
<b>Signature:</b>
```typescript
onAppLeave: (handler: AppLeaveHandler) => void;
```
## Example
```ts
// application.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';
import { CoreStart, AppMountParams } from 'src/core/public';
import { MyPluginDepsStart } from './plugin';
export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => {
const { renderApp, hasUnsavedChanges } = await import('./application');
onAppLeave(actions => {
if(hasUnsavedChanges()) {
return actions.confirm('Some changes were not saved. Are you sure you want to leave?');
}
return actions.default();
});
return renderApp(params);
}
```

View file

@ -24,7 +24,7 @@ export interface ChromeNavLink
| [id](./kibana-plugin-public.chromenavlink.id.md) | <code>string</code> | A unique identifier for looking up links. |
| [linkToLastSubUrl](./kibana-plugin-public.chromenavlink.linktolastsuburl.md) | <code>boolean</code> | Whether or not the subUrl feature should be enabled. |
| [order](./kibana-plugin-public.chromenavlink.order.md) | <code>number</code> | An ordinal used to sort nav links relative to one another for display. |
| [subUrlBase](./kibana-plugin-public.chromenavlink.suburlbase.md) | <code>string</code> | A url base that legacy apps can set to match deep URLs to an applcation. |
| [subUrlBase](./kibana-plugin-public.chromenavlink.suburlbase.md) | <code>string</code> | A url base that legacy apps can set to match deep URLs to an application. |
| [title](./kibana-plugin-public.chromenavlink.title.md) | <code>string</code> | The title of the application. |
| [tooltip](./kibana-plugin-public.chromenavlink.tooltip.md) | <code>string</code> | A tooltip shown when hovering over an app link. |
| [url](./kibana-plugin-public.chromenavlink.url.md) | <code>string</code> | A url that legacy apps can set to deep link into their applications. |

View file

@ -8,7 +8,7 @@
>
>
A url base that legacy apps can set to match deep URLs to an applcation.
A url base that legacy apps can set to match deep URLs to an application.
<b>Signature:</b>

View file

@ -18,12 +18,20 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md)<!-- -->.<!-- -->It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. |
| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. |
## Enumerations
| Enumeration | Description |
| --- | --- |
| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. |
## Interfaces
| Interface | Description |
| --- | --- |
| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. |
| [AppBase](./kibana-plugin-public.appbase.md) | |
| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.<!-- -->See |
| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.<!-- -->See |
| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | |
| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | |
| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md)<!-- -->. |
@ -105,6 +113,8 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| Type Alias | Description |
| --- | --- |
| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)<!-- -->See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) |
| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return <code>confirm</code> to to prompt a message to the user before leaving the page, or <code>default</code> to keep the default behavior (doing nothing).<!-- -->See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. |
| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. |
| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. |
| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. |

View file

@ -16,6 +16,7 @@ export interface OverlayStart
| Property | Type | Description |
| --- | --- | --- |
| [banners](./kibana-plugin-public.overlaystart.banners.md) | <code>OverlayBannersStart</code> | [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) |
| [openConfirm](./kibana-plugin-public.overlaystart.openconfirm.md) | <code>OverlayModalStart['openConfirm']</code> | |
| [openFlyout](./kibana-plugin-public.overlaystart.openflyout.md) | <code>OverlayFlyoutStart['open']</code> | |
| [openModal](./kibana-plugin-public.overlaystart.openmodal.md) | <code>OverlayModalStart['open']</code> | |

View file

@ -0,0 +1,12 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [OverlayStart](./kibana-plugin-public.overlaystart.md) &gt; [openConfirm](./kibana-plugin-public.overlaystart.openconfirm.md)
## OverlayStart.openConfirm property
<b>Signature:</b>
```typescript
openConfirm: OverlayModalStart['openConfirm'];
```

View file

@ -0,0 +1,49 @@
/*
* 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 { isConfirmAction, getLeaveAction } from './application_leave';
import { AppLeaveActionType } from './types';
describe('isConfirmAction', () => {
it('returns true if action is confirm', () => {
expect(isConfirmAction({ type: AppLeaveActionType.confirm, text: 'message' })).toEqual(true);
});
it('returns false if action is default', () => {
expect(isConfirmAction({ type: AppLeaveActionType.default })).toEqual(false);
});
});
describe('getLeaveAction', () => {
it('returns the default action provided by the handler', () => {
expect(getLeaveAction(actions => actions.default())).toEqual({
type: AppLeaveActionType.default,
});
});
it('returns the confirm action provided by the handler', () => {
expect(getLeaveAction(actions => actions.confirm('some message'))).toEqual({
type: AppLeaveActionType.confirm,
text: 'some message',
});
expect(getLeaveAction(actions => actions.confirm('another message', 'a title'))).toEqual({
type: AppLeaveActionType.confirm,
text: 'another message',
title: 'a title',
});
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 {
AppLeaveActionFactory,
AppLeaveActionType,
AppLeaveAction,
AppLeaveConfirmAction,
AppLeaveHandler,
} from './types';
const appLeaveActionFactory: AppLeaveActionFactory = {
confirm(text: string, title?: string) {
return { type: AppLeaveActionType.confirm, text, title };
},
default() {
return { type: AppLeaveActionType.default };
},
};
export function isConfirmAction(action: AppLeaveAction): action is AppLeaveConfirmAction {
return action.type === AppLeaveActionType.confirm;
}
export function getLeaveAction(handler?: AppLeaveHandler): AppLeaveAction {
if (!handler) {
return appLeaveActionFactory.default();
}
return handler(appLeaveActionFactory);
}

View file

@ -25,17 +25,18 @@ import { shallow } from 'enzyme';
import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock';
import { contextServiceMock } from '../context/context_service.mock';
import { httpServiceMock } from '../http/http_service.mock';
import { overlayServiceMock } from '../overlays/overlay_service.mock';
import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks';
import { MockLifecycle } from './test_types';
import { ApplicationService } from './application_service';
function mount() {}
describe('#setup()', () => {
let setupDeps: MockLifecycle<'setup'>;
let startDeps: MockLifecycle<'start'>;
let service: ApplicationService;
let setupDeps: MockLifecycle<'setup'>;
let startDeps: MockLifecycle<'start'>;
let service: ApplicationService;
describe('#setup()', () => {
beforeEach(() => {
const http = httpServiceMock.createSetupContract({ basePath: '/test' });
setupDeps = {
@ -44,7 +45,7 @@ describe('#setup()', () => {
injectedMetadata: injectedMetadataServiceMock.createSetupContract(),
};
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false);
startDeps = { http, injectedMetadata: setupDeps.injectedMetadata };
startDeps = { http, overlays: overlayServiceMock.createStartContract() };
service = new ApplicationService();
});
@ -146,12 +147,9 @@ describe('#setup()', () => {
});
describe('#start()', () => {
let setupDeps: MockLifecycle<'setup'>;
let startDeps: MockLifecycle<'start'>;
let service: ApplicationService;
beforeEach(() => {
MockHistory.push.mockReset();
const http = httpServiceMock.createSetupContract({ basePath: '/test' });
setupDeps = {
http,
@ -159,7 +157,7 @@ describe('#start()', () => {
injectedMetadata: injectedMetadataServiceMock.createSetupContract(),
};
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false);
startDeps = { http, injectedMetadata: setupDeps.injectedMetadata };
startDeps = { http, overlays: overlayServiceMock.createStartContract() };
service = new ApplicationService();
});
@ -264,6 +262,7 @@ describe('#start()', () => {
}
}
mounters={Map {}}
setAppLeaveHandler={[Function]}
/>
`);
});
@ -320,10 +319,10 @@ describe('#start()', () => {
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp');
await navigateToApp('myTestApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined);
navigateToApp('myOtherApp');
await navigateToApp('myOtherApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined);
});
@ -334,10 +333,10 @@ describe('#start()', () => {
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp');
await navigateToApp('myTestApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined);
navigateToApp('app2');
await navigateToApp('app2');
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', undefined);
});
@ -348,13 +347,13 @@ describe('#start()', () => {
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp', { path: 'deep/link/to/location/2' });
await navigateToApp('myTestApp', { path: 'deep/link/to/location/2' });
expect(MockHistory.push).toHaveBeenCalledWith(
'/app/myTestApp/deep/link/to/location/2',
undefined
);
navigateToApp('app2', { path: 'deep/link/to/location/2' });
await navigateToApp('app2', { path: 'deep/link/to/location/2' });
expect(MockHistory.push).toHaveBeenCalledWith(
'/custom/path/deep/link/to/location/2',
undefined
@ -368,10 +367,10 @@ describe('#start()', () => {
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp', { state: 'my-state' });
await navigateToApp('myTestApp', { state: 'my-state' });
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state');
navigateToApp('app2', { state: 'my-state' });
await navigateToApp('app2', { state: 'my-state' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', 'my-state');
});
@ -382,7 +381,7 @@ describe('#start()', () => {
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp');
await navigateToApp('myTestApp');
expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/myTestApp');
});
@ -439,3 +438,39 @@ describe('#start()', () => {
});
});
});
describe('#stop()', () => {
let addListenerSpy: jest.SpyInstance;
let removeListenerSpy: jest.SpyInstance;
beforeEach(() => {
addListenerSpy = jest.spyOn(window, 'addEventListener');
removeListenerSpy = jest.spyOn(window, 'removeEventListener');
MockHistory.push.mockReset();
const http = httpServiceMock.createSetupContract({ basePath: '/test' });
setupDeps = {
http,
context: contextServiceMock.createSetupContract(),
injectedMetadata: injectedMetadataServiceMock.createSetupContract(),
};
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false);
startDeps = { http, overlays: overlayServiceMock.createStartContract() };
service = new ApplicationService();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('removes the beforeunload listener', async () => {
service.setup(setupDeps);
await service.start(startDeps);
expect(addListenerSpy).toHaveBeenCalledTimes(1);
expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
const handler = addListenerSpy.mock.calls[0][1];
service.stop();
expect(removeListenerSpy).toHaveBeenCalledTimes(1);
expect(removeListenerSpy).toHaveBeenCalledWith('beforeunload', handler);
});
});

View file

@ -22,13 +22,15 @@ import { BehaviorSubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { createBrowserHistory, History } from 'history';
import { InjectedMetadataSetup, InjectedMetadataStart } from '../injected_metadata';
import { InjectedMetadataSetup } from '../injected_metadata';
import { HttpSetup, HttpStart } from '../http';
import { OverlayStart } from '../overlays';
import { ContextSetup, IContextContainer } from '../context';
import { AppRouter } from './ui';
import { CapabilitiesService, Capabilities } from './capabilities';
import {
App,
AppLeaveHandler,
LegacyApp,
AppMount,
AppMountDeprecated,
@ -38,11 +40,13 @@ import {
InternalApplicationSetup,
InternalApplicationStart,
} from './types';
import { getLeaveAction, isConfirmAction } from './application_leave';
interface SetupDeps {
context: ContextSetup;
http: HttpSetup;
injectedMetadata: InjectedMetadataSetup;
history?: History<any>;
/**
* Only necessary for redirecting to legacy apps
* @deprecated
@ -51,8 +55,8 @@ interface SetupDeps {
}
interface StartDeps {
injectedMetadata: InjectedMetadataStart;
http: HttpStart;
overlays: OverlayStart;
}
// Mount functions with two arguments are assumed to expect deprecated `context` object.
@ -80,6 +84,7 @@ export class ApplicationService {
private readonly legacyApps = new Map<string, LegacyApp>();
private readonly mounters = new Map<string, Mounter>();
private readonly capabilities = new CapabilitiesService();
private readonly appLeaveHandlers = new Map<string, AppLeaveHandler>();
private currentAppId$ = new BehaviorSubject<string | undefined>(undefined);
private stop$ = new Subject();
private registrationClosed = false;
@ -92,11 +97,12 @@ export class ApplicationService {
http: { basePath },
injectedMetadata,
redirectTo = (path: string) => (window.location.href = path),
history,
}: SetupDeps): InternalApplicationSetup {
const basename = basePath.get();
// Only setup history if we're not in legacy mode
if (!injectedMetadata.getLegacyMode()) {
this.history = createBrowserHistory({ basename });
this.history = history || createBrowserHistory({ basename });
}
// If we do not have history available, use redirectTo to do a full page refresh.
@ -171,12 +177,14 @@ export class ApplicationService {
};
}
public async start({ injectedMetadata, http }: StartDeps): Promise<InternalApplicationStart> {
public async start({ http, overlays }: StartDeps): Promise<InternalApplicationStart> {
if (!this.mountContext) {
throw new Error('ApplicationService#setup() must be invoked before start.');
}
this.registrationClosed = true;
window.addEventListener('beforeunload', this.onBeforeUnload);
const { capabilities } = await this.capabilities.start({
appIds: [...this.mounters.keys()],
http,
@ -191,17 +199,66 @@ export class ApplicationService {
registerMountContext: this.mountContext.registerContext,
getUrlForApp: (appId, { path }: { path?: string } = {}) =>
getAppUrl(availableMounters, appId, path),
navigateToApp: (appId, { path, state }: { path?: string; state?: any } = {}) => {
this.navigate!(getAppUrl(availableMounters, appId, path), state);
this.currentAppId$.next(appId);
navigateToApp: async (appId, { path, state }: { path?: string; state?: any } = {}) => {
if (await this.shouldNavigate(overlays)) {
this.appLeaveHandlers.delete(this.currentAppId$.value!);
this.navigate!(getAppUrl(availableMounters, appId, path), state);
this.currentAppId$.next(appId);
}
},
getComponent: () => {
if (!this.history) {
return null;
}
return (
<AppRouter
history={this.history}
mounters={availableMounters}
setAppLeaveHandler={this.setAppLeaveHandler}
/>
);
},
getComponent: () =>
this.history ? <AppRouter history={this.history} mounters={availableMounters} /> : null,
};
}
private setAppLeaveHandler = (appId: string, handler: AppLeaveHandler) => {
this.appLeaveHandlers.set(appId, handler);
};
private async shouldNavigate(overlays: OverlayStart): Promise<boolean> {
const currentAppId = this.currentAppId$.value;
if (currentAppId === undefined) {
return true;
}
const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId));
if (isConfirmAction(action)) {
const confirmed = await overlays.openConfirm(action.text, {
title: action.title,
'data-test-subj': 'appLeaveConfirmModal',
});
if (!confirmed) {
return false;
}
}
return true;
}
private onBeforeUnload = (event: Event) => {
const currentAppId = this.currentAppId$.value;
if (currentAppId === undefined) {
return;
}
const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId));
if (isConfirmAction(action)) {
event.preventDefault();
// some browsers accept a string return value being the message displayed
event.returnValue = action.text as any;
}
};
public stop() {
this.stop$.next();
this.currentAppId$.complete();
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
}

View file

@ -29,6 +29,11 @@ export {
AppMountParameters,
ApplicationSetup,
ApplicationStart,
AppLeaveHandler,
AppLeaveActionType,
AppLeaveAction,
AppLeaveDefaultAction,
AppLeaveConfirmAction,
// Internal types
InternalApplicationStart,
LegacyApp,

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 { createRenderer } from './utils';
import { createMemoryHistory, MemoryHistory } from 'history';
import { ApplicationService } from '../application_service';
import { httpServiceMock } from '../../http/http_service.mock';
import { contextServiceMock } from '../../context/context_service.mock';
import { injectedMetadataServiceMock } from '../../injected_metadata/injected_metadata_service.mock';
import { MockLifecycle } from '../test_types';
import { overlayServiceMock } from '../../overlays/overlay_service.mock';
import { AppMountParameters } from '../types';
describe('ApplicationService', () => {
let setupDeps: MockLifecycle<'setup'>;
let startDeps: MockLifecycle<'start'>;
let service: ApplicationService;
let history: MemoryHistory<any>;
let update: ReturnType<typeof createRenderer>;
const navigate = (path: string) => {
history.push(path);
return update();
};
beforeEach(() => {
history = createMemoryHistory();
const http = httpServiceMock.createSetupContract({ basePath: '/test' });
http.post.mockResolvedValue({ navLinks: {} });
setupDeps = {
http,
context: contextServiceMock.createSetupContract(),
injectedMetadata: injectedMetadataServiceMock.createSetupContract(),
history: history as any,
};
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false);
startDeps = { http, overlays: overlayServiceMock.createStartContract() };
service = new ApplicationService();
});
describe('leaving an application that registered an app leave handler', () => {
it('navigates to the new app if action is default', async () => {
startDeps.overlays.openConfirm.mockResolvedValue(true);
const { register } = service.setup(setupDeps);
register(Symbol(), {
id: 'app1',
title: 'App1',
mount: ({ onAppLeave }: AppMountParameters) => {
onAppLeave(actions => actions.default());
return () => undefined;
},
});
register(Symbol(), {
id: 'app2',
title: 'App2',
mount: ({}: AppMountParameters) => {
return () => undefined;
},
});
const { navigateToApp, getComponent } = await service.start(startDeps);
update = createRenderer(getComponent());
await navigate('/app/app1');
await navigateToApp('app2');
expect(startDeps.overlays.openConfirm).not.toHaveBeenCalled();
expect(history.entries.length).toEqual(3);
expect(history.entries[2].pathname).toEqual('/app/app2');
});
it('navigates to the new app if action is confirm and user accepted', async () => {
startDeps.overlays.openConfirm.mockResolvedValue(true);
const { register } = service.setup(setupDeps);
register(Symbol(), {
id: 'app1',
title: 'App1',
mount: ({ onAppLeave }: AppMountParameters) => {
onAppLeave(actions => actions.confirm('confirmation-message', 'confirmation-title'));
return () => undefined;
},
});
register(Symbol(), {
id: 'app2',
title: 'App2',
mount: ({}: AppMountParameters) => {
return () => undefined;
},
});
const { navigateToApp, getComponent } = await service.start(startDeps);
update = createRenderer(getComponent());
await navigate('/app/app1');
await navigateToApp('app2');
expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1);
expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith(
'confirmation-message',
expect.objectContaining({ title: 'confirmation-title' })
);
expect(history.entries.length).toEqual(3);
expect(history.entries[2].pathname).toEqual('/app/app2');
});
it('blocks navigation to the new app if action is confirm and user declined', async () => {
startDeps.overlays.openConfirm.mockResolvedValue(false);
const { register } = service.setup(setupDeps);
register(Symbol(), {
id: 'app1',
title: 'App1',
mount: ({ onAppLeave }: AppMountParameters) => {
onAppLeave(actions => actions.confirm('confirmation-message', 'confirmation-title'));
return () => undefined;
},
});
register(Symbol(), {
id: 'app2',
title: 'App2',
mount: ({}: AppMountParameters) => {
return () => undefined;
},
});
const { navigateToApp, getComponent } = await service.start(startDeps);
update = createRenderer(getComponent());
await navigate('/app/app1');
await navigateToApp('app2');
expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1);
expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith(
'confirmation-message',
expect.objectContaining({ title: 'confirmation-title' })
);
expect(history.entries.length).toEqual(2);
expect(history.entries[1].pathname).toEqual('/app/app1');
});
});
});

View file

@ -36,6 +36,7 @@ describe('AppContainer', () => {
const mockMountersToMounters = () =>
new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter]));
const setAppLeaveHandlerMock = () => undefined;
beforeEach(() => {
mounters = new Map([
@ -46,7 +47,13 @@ describe('AppContainer', () => {
createAppMounter('app3', '<div>App 3</div>', '/custom/path'),
] as Array<MockedMounterTuple<EitherApp>>);
history = createMemoryHistory();
update = createRenderer(<AppRouter history={history} mounters={mockMountersToMounters()} />);
update = createRenderer(
<AppRouter
history={history}
mounters={mockMountersToMounters()}
setAppLeaveHandler={setAppLeaveHandlerMock}
/>
);
});
it('calls mount handler and returned unmount function when navigating between apps', async () => {
@ -78,7 +85,13 @@ describe('AppContainer', () => {
mounters.set(...createAppMounter('spaces', '<div>Custom Space</div>', '/spaces/fake-login'));
mounters.set(...createAppMounter('login', '<div>Login Page</div>', '/fake-login'));
history = createMemoryHistory();
update = createRenderer(<AppRouter history={history} mounters={mockMountersToMounters()} />);
update = createRenderer(
<AppRouter
history={history}
mounters={mockMountersToMounters()}
setAppLeaveHandler={setAppLeaveHandlerMock}
/>
);
await navigate('/fake-login');
@ -90,7 +103,13 @@ describe('AppContainer', () => {
mounters.set(...createAppMounter('login', '<div>Login Page</div>', '/fake-login'));
mounters.set(...createAppMounter('spaces', '<div>Custom Space</div>', '/spaces/fake-login'));
history = createMemoryHistory();
update = createRenderer(<AppRouter history={history} mounters={mockMountersToMounters()} />);
update = createRenderer(
<AppRouter
history={history}
mounters={mockMountersToMounters()}
setAppLeaveHandler={setAppLeaveHandlerMock}
/>
);
await navigate('/spaces/fake-login');
@ -124,7 +143,13 @@ describe('AppContainer', () => {
it('should not remount when when changing pages within app using hash history', async () => {
history = createHashHistory();
update = createRenderer(<AppRouter history={history} mounters={mockMountersToMounters()} />);
update = createRenderer(
<AppRouter
history={history}
mounters={mockMountersToMounters()}
setAppLeaveHandler={setAppLeaveHandlerMock}
/>
);
const { mounter, unmount } = mounters.get('app1')!;
await navigate('/app/app1/page1');
@ -153,6 +178,7 @@ describe('AppContainer', () => {
Object {
"appBasePath": "/app/legacyApp1",
"element": <div />,
"onAppLeave": [Function],
},
]
`);
@ -165,6 +191,7 @@ describe('AppContainer', () => {
Object {
"appBasePath": "/app/baseApp",
"element": <div />,
"onAppLeave": [Function],
},
]
`);

View file

@ -230,6 +230,117 @@ export interface AppMountParameters {
* ```
*/
appBasePath: string;
/**
* A function that can be used to register a handler that will be called
* when the user is leaving the current application, allowing to
* prompt a confirmation message before actually changing the page.
*
* This will be called either when the user goes to another application, or when
* trying to close the tab or manually changing the url.
*
* @example
*
* ```ts
* // application.tsx
* import React from 'react';
* import ReactDOM from 'react-dom';
* import { BrowserRouter, Route } from 'react-router-dom';
*
* import { CoreStart, AppMountParams } from 'src/core/public';
* import { MyPluginDepsStart } from './plugin';
*
* export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => {
* const { renderApp, hasUnsavedChanges } = await import('./application');
* onAppLeave(actions => {
* if(hasUnsavedChanges()) {
* return actions.confirm('Some changes were not saved. Are you sure you want to leave?');
* }
* return actions.default();
* });
* return renderApp(params);
* }
* ```
*/
onAppLeave: (handler: AppLeaveHandler) => void;
}
/**
* A handler that will be executed before leaving the application, either when
* going to another application or when closing the browser tab or manually changing
* the url.
* Should return `confirm` to to prompt a message to the user before leaving the page, or `default`
* to keep the default behavior (doing nothing).
*
* See {@link AppMountParameters} for detailed usage examples.
*
* @public
*/
export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction;
/**
* Possible type of actions on application leave.
*
* @public
*/
export enum AppLeaveActionType {
confirm = 'confirm',
default = 'default',
}
/**
* Action to return from a {@link AppLeaveHandler} to execute the default
* behaviour when leaving the application.
*
* See {@link AppLeaveActionFactory}
*
* @public
*/
export interface AppLeaveDefaultAction {
type: AppLeaveActionType.default;
}
/**
* Action to return from a {@link AppLeaveHandler} to show a confirmation
* message when trying to leave an application.
*
* See {@link AppLeaveActionFactory}
*
* @public
*/
export interface AppLeaveConfirmAction {
type: AppLeaveActionType.confirm;
text: string;
title?: string;
}
/**
* Possible actions to return from a {@link AppLeaveHandler}
*
* See {@link AppLeaveConfirmAction} and {@link AppLeaveDefaultAction}
*
* @public
* */
export type AppLeaveAction = AppLeaveDefaultAction | AppLeaveConfirmAction;
/**
* Factory provided when invoking a {@link AppLeaveHandler} to retrieve the {@link AppLeaveAction} to execute.
*/
export interface AppLeaveActionFactory {
/**
* Returns a confirm action, resulting on prompting a message to the user before leaving the
* application, allowing him to choose if he wants to stay on the app or confirm that he
* wants to leave.
*
* @param text The text to display in the confirmation message
* @param title (optional) title to display in the confirmation message
*/
confirm(text: string, title?: string): AppLeaveConfirmAction;
/**
* Returns a default action, resulting on executing the default behavior when
* the user tries to leave an application
*/
default(): AppLeaveDefaultAction;
}
/**
@ -317,13 +428,13 @@ export interface ApplicationStart {
capabilities: RecursiveReadonly<Capabilities>;
/**
* Navigiate to a given app
* Navigate to a given app
*
* @param appId
* @param options.path - optional path inside application to deep link to
* @param options.state - optional state to forward to the application
*/
navigateToApp(appId: string, options?: { path?: string; state?: any }): void;
navigateToApp(appId: string, options?: { path?: string; state?: any }): Promise<void>;
/**
* Returns a relative URL to a given app, including the global base path.

View file

@ -26,15 +26,20 @@ import React, {
MutableRefObject,
} from 'react';
import { AppUnmount, Mounter } from '../types';
import { AppUnmount, Mounter, AppLeaveHandler } from '../types';
import { AppNotFound } from './app_not_found_screen';
interface Props {
appId: string;
mounter?: Mounter;
setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void;
}
export const AppContainer: FunctionComponent<Props> = ({ mounter, appId }: Props) => {
export const AppContainer: FunctionComponent<Props> = ({
mounter,
appId,
setAppLeaveHandler,
}: Props) => {
const [appNotFound, setAppNotFound] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
const unmountRef: MutableRefObject<AppUnmount | null> = useRef<AppUnmount>(null);
@ -59,13 +64,14 @@ export const AppContainer: FunctionComponent<Props> = ({ mounter, appId }: Props
(await mounter.mount({
appBasePath: mounter.appBasePath,
element: elementRef.current!,
onAppLeave: handler => setAppLeaveHandler(appId, handler),
})) || null;
setAppNotFound(false);
};
mount();
return unmount;
}, [mounter]);
}, [appId, mounter, setAppLeaveHandler]);
return (
<Fragment>

View file

@ -21,19 +21,20 @@ import React, { FunctionComponent } from 'react';
import { History } from 'history';
import { Router, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { Mounter } from '../types';
import { Mounter, AppLeaveHandler } from '../types';
import { AppContainer } from './app_container';
interface Props {
mounters: Map<string, Mounter>;
history: History;
setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void;
}
interface Params {
appId: string;
}
export const AppRouter: FunctionComponent<Props> = ({ history, mounters }) => (
export const AppRouter: FunctionComponent<Props> = ({ history, mounters, setAppLeaveHandler }) => (
<Router history={history}>
<Switch>
{[...mounters].flatMap(([appId, mounter]) =>
@ -45,7 +46,13 @@ export const AppRouter: FunctionComponent<Props> = ({ history, mounters }) => (
<Route
key={mounter.appRoute}
path={mounter.appRoute}
render={() => <AppContainer mounter={mounter} appId={appId} />}
render={() => (
<AppContainer
mounter={mounter}
appId={appId}
setAppLeaveHandler={setAppLeaveHandler}
/>
)}
/>,
]
)}
@ -61,7 +68,9 @@ export const AppRouter: FunctionComponent<Props> = ({ history, mounters }) => (
? [appId, mounters.get(appId)]
: [...mounters].filter(([key]) => key.split(':')[0] === appId)[0] ?? [];
return <AppContainer mounter={mounter} appId={id} />;
return (
<AppContainer mounter={mounter} appId={id} setAppLeaveHandler={setAppLeaveHandler} />
);
}}
/>
</Switch>

View file

@ -63,7 +63,7 @@ export interface ChromeNavLink {
/** LEGACY FIELDS */
/**
* A url base that legacy apps can set to match deep URLs to an applcation.
* A url base that legacy apps can set to match deep URLs to an application.
*
* @internalRemarks
* This should be removed once legacy apps are gone.

View file

@ -214,7 +214,6 @@ export class CoreSystem {
const http = await this.http.start({ injectedMetadata, fatalErrors: this.fatalErrorsSetup! });
const savedObjects = await this.savedObjects.start({ http });
const i18n = await this.i18n.start();
const application = await this.application.start({ http, injectedMetadata });
await this.integrations.start({ uiSettings });
const coreUiTargetDomElement = document.createElement('div');
@ -239,6 +238,7 @@ export class CoreSystem {
overlays,
targetDomElement: notificationsTargetDomElement,
});
const application = await this.application.start({ http, overlays });
const chrome = await this.chrome.start({
application,
docLinks,

View file

@ -89,6 +89,11 @@ export {
AppUnmount,
AppMountContext,
AppMountParameters,
AppLeaveHandler,
AppLeaveActionType,
AppLeaveAction,
AppLeaveDefaultAction,
AppLeaveConfirmAction,
} from './application';
export {

View file

@ -8,6 +8,129 @@ Array [
]
`;
exports[`ModalService openConfirm() renders a mountpoint confirm message 1`] = `
Array [
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiConfirmModal
buttonColor="primary"
cancelButtonText="Cancel"
confirmButtonText="Confirm"
onCancel={[Function]}
onConfirm={[Function]}
>
<MountWrapper
className="kbnOverlayMountWrapper"
mount={[Function]}
/>
</EuiConfirmModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
]
`;
exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = `"<div data-focus-guard=\\"true\\" tabindex=\\"0\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div><div data-focus-guard=\\"true\\" tabindex=\\"1\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div><div data-focus-lock-disabled=\\"false\\"><div class=\\"euiModal euiModal--maxWidth-default euiModal--confirmation\\" tabindex=\\"0\\"><button class=\\"euiButtonIcon euiButtonIcon--text euiModal__closeIcon\\" type=\\"button\\" aria-label=\\"Closes this modal window\\"><svg width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"euiIcon euiIcon--medium euiIcon-isLoading euiButtonIcon__icon\\" focusable=\\"false\\" role=\\"img\\" aria-hidden=\\"true\\"></svg></button><div class=\\"euiModal__flex\\"><div class=\\"euiModalBody\\"><div class=\\"euiModalBody__overflow\\"><div class=\\"euiText euiText--medium\\" data-test-subj=\\"confirmModalBodyText\\"><div class=\\"kbnOverlayMountWrapper\\"><span>Modal content</span></div></div></div></div><div class=\\"euiModalFooter\\"><button class=\\"euiButtonEmpty euiButtonEmpty--primary\\" type=\\"button\\" data-test-subj=\\"confirmModalCancelButton\\"><span class=\\"euiButtonEmpty__content\\"><span class=\\"euiButtonEmpty__text\\">Cancel</span></span></button><button class=\\"euiButton euiButton--primary euiButton--fill\\" type=\\"button\\" data-test-subj=\\"confirmModalConfirmButton\\"><span class=\\"euiButton__content\\"><span class=\\"euiButton__text\\">Confirm</span></span></button></div></div></div></div><div data-focus-guard=\\"true\\" tabindex=\\"0\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div>"`;
exports[`ModalService openConfirm() renders a string confirm message 1`] = `
Array [
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiConfirmModal
buttonColor="primary"
cancelButtonText="Cancel"
confirmButtonText="Confirm"
onCancel={[Function]}
onConfirm={[Function]}
>
Some message
</EuiConfirmModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
]
`;
exports[`ModalService openConfirm() renders a string confirm message 2`] = `"<div data-focus-guard=\\"true\\" tabindex=\\"0\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div><div data-focus-guard=\\"true\\" tabindex=\\"1\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div><div data-focus-lock-disabled=\\"false\\"><div class=\\"euiModal euiModal--maxWidth-default euiModal--confirmation\\" tabindex=\\"0\\"><button class=\\"euiButtonIcon euiButtonIcon--text euiModal__closeIcon\\" type=\\"button\\" aria-label=\\"Closes this modal window\\"><svg width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"euiIcon euiIcon--medium euiIcon-isLoading euiButtonIcon__icon\\" focusable=\\"false\\" role=\\"img\\" aria-hidden=\\"true\\"></svg></button><div class=\\"euiModal__flex\\"><div class=\\"euiModalBody\\"><div class=\\"euiModalBody__overflow\\"><div class=\\"euiText euiText--medium\\" data-test-subj=\\"confirmModalBodyText\\"><p>Some message</p></div></div></div><div class=\\"euiModalFooter\\"><button class=\\"euiButtonEmpty euiButtonEmpty--primary\\" type=\\"button\\" data-test-subj=\\"confirmModalCancelButton\\"><span class=\\"euiButtonEmpty__content\\"><span class=\\"euiButtonEmpty__text\\">Cancel</span></span></button><button class=\\"euiButton euiButton--primary euiButton--fill\\" type=\\"button\\" data-test-subj=\\"confirmModalConfirmButton\\"><span class=\\"euiButton__content\\"><span class=\\"euiButton__text\\">Confirm</span></span></button></div></div></div></div><div data-focus-guard=\\"true\\" tabindex=\\"0\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div>"`;
exports[`ModalService openConfirm() with a currently active confirm replaces the current confirm with the new one 1`] = `
Array [
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiConfirmModal
buttonColor="primary"
cancelButtonText="Cancel"
confirmButtonText="Confirm"
onCancel={[Function]}
onConfirm={[Function]}
>
confirm 1
</EuiConfirmModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiConfirmModal
buttonColor="primary"
cancelButtonText="Cancel"
confirmButtonText="Confirm"
onCancel={[Function]}
onConfirm={[Function]}
>
some confirm
</EuiConfirmModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
]
`;
exports[`ModalService openConfirm() with a currently active modal replaces the current modal with the new confirm 1`] = `
Array [
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiModal
maxWidth={true}
onClose={[Function]}
>
<MountWrapper
className="kbnOverlayMountWrapper"
mount={[Function]}
/>
</EuiModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiConfirmModal
buttonColor="primary"
cancelButtonText="Cancel"
confirmButtonText="Confirm"
onCancel={[Function]}
onConfirm={[Function]}
>
some confirm
</EuiConfirmModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
]
`;
exports[`ModalService openModal() renders a modal to the DOM 1`] = `
Array [
Array [
@ -31,6 +154,43 @@ Array [
exports[`ModalService openModal() renders a modal to the DOM 2`] = `"<div data-focus-guard=\\"true\\" tabindex=\\"0\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div><div data-focus-guard=\\"true\\" tabindex=\\"1\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div><div data-focus-lock-disabled=\\"false\\"><div class=\\"euiModal euiModal--maxWidth-default\\" tabindex=\\"0\\"><button class=\\"euiButtonIcon euiButtonIcon--text euiModal__closeIcon\\" type=\\"button\\" aria-label=\\"Closes this modal window\\"><svg width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"euiIcon euiIcon--medium euiIcon-isLoading euiButtonIcon__icon\\" focusable=\\"false\\" role=\\"img\\" aria-hidden=\\"true\\"></svg></button><div class=\\"euiModal__flex\\"><div class=\\"kbnOverlayMountWrapper\\"><span>Modal content</span></div></div></div></div><div data-focus-guard=\\"true\\" tabindex=\\"0\\" style=\\"width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;\\"></div>"`;
exports[`ModalService openModal() with a currently active confirm replaces the current confirm with the new one 1`] = `
Array [
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiConfirmModal
buttonColor="primary"
cancelButtonText="Cancel"
confirmButtonText="Confirm"
onCancel={[Function]}
onConfirm={[Function]}
>
confirm 1
</EuiConfirmModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
Array [
<EuiOverlayMask>
<mockConstructor>
<EuiConfirmModal
buttonColor="primary"
cancelButtonText="Cancel"
confirmButtonText="Confirm"
onCancel={[Function]}
onConfirm={[Function]}
>
some confirm
</EuiConfirmModal>
</mockConstructor>
</EuiOverlayMask>,
<div />,
],
]
`;
exports[`ModalService openModal() with a currently active modal replaces the current modal with a new one 1`] = `
Array [
Array [

View file

@ -25,6 +25,7 @@ const createStartContractMock = () => {
close: jest.fn(),
onClose: Promise.resolve(),
}),
openConfirm: jest.fn().mockResolvedValue(true),
};
return startContract;
};

View file

@ -80,6 +80,91 @@ describe('ModalService', () => {
expect(onCloseComplete).toBeCalledTimes(1);
});
});
describe('with a currently active confirm', () => {
let confirm1: Promise<boolean>;
beforeEach(() => {
confirm1 = modals.openConfirm('confirm 1');
});
it('replaces the current confirm with the new one', () => {
modals.openConfirm('some confirm');
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
});
it('resolves the previous confirm promise', async () => {
modals.open(mountReactNode(<span>Flyout content 2</span>));
expect(await confirm1).toEqual(false);
});
});
});
describe('openConfirm()', () => {
it('renders a mountpoint confirm message', () => {
expect(mockReactDomRender).not.toHaveBeenCalled();
modals.openConfirm(container => {
const content = document.createElement('span');
content.textContent = 'Modal content';
container.append(content);
return () => {};
});
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
const modalContent = mount(mockReactDomRender.mock.calls[0][0]);
expect(modalContent.html()).toMatchSnapshot();
});
it('renders a string confirm message', () => {
expect(mockReactDomRender).not.toHaveBeenCalled();
modals.openConfirm('Some message');
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
const modalContent = mount(mockReactDomRender.mock.calls[0][0]);
expect(modalContent.html()).toMatchSnapshot();
});
describe('with a currently active modal', () => {
let ref1: OverlayRef;
beforeEach(() => {
ref1 = modals.open(mountReactNode(<span>Modal content 1</span>));
});
it('replaces the current modal with the new confirm', () => {
modals.openConfirm('some confirm');
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
expect(() => ref1.close()).not.toThrowError();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
});
it('resolves onClose on the previous ref', async () => {
const onCloseComplete = jest.fn();
ref1.onClose.then(onCloseComplete);
modals.openConfirm('some confirm');
await ref1.onClose;
expect(onCloseComplete).toBeCalledTimes(1);
});
});
describe('with a currently active confirm', () => {
let confirm1: Promise<boolean>;
beforeEach(() => {
confirm1 = modals.openConfirm('confirm 1');
});
it('replaces the current confirm with the new one', () => {
modals.openConfirm('some confirm');
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
});
it('resolves the previous confirm promise', async () => {
modals.openConfirm('some confirm');
expect(await confirm1).toEqual(false);
});
});
});
describe('ModalRef#close()', () => {

View file

@ -19,7 +19,8 @@
/* eslint-disable max-classes-per-file */
import { EuiModal, EuiOverlayMask } from '@elastic/eui';
import { i18n as t } from '@kbn/i18n';
import { EuiModal, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subject } from 'rxjs';
@ -57,6 +58,18 @@ class ModalRef implements OverlayRef {
}
}
/**
* @public
*/
export interface OverlayModalConfirmOptions {
title?: string;
cancelButtonText?: string;
confirmButtonText?: string;
className?: string;
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}
/**
* APIs to open and manage modal dialogs.
*
@ -72,6 +85,14 @@ export interface OverlayModalStart {
* @return {@link OverlayRef} A reference to the opened modal.
*/
open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef;
/**
* Opens a confirmation modal with the given text or mountpoint as a message.
* Returns a Promise resolving to `true` if user confirmed or `false` otherwise.
*
* @param message {@link MountPoint} - string or mountpoint to be used a the confirm message body
* @param options {@link OverlayModalConfirmOptions} - options for the confirm modal
*/
openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise<boolean>;
}
/**
@ -98,7 +119,7 @@ export class ModalService {
return {
open: (mount: MountPoint, options: OverlayModalOpenOptions = {}): OverlayRef => {
// If there is an active flyout session close it before opening a new one.
// If there is an active modal, close it before opening a new one.
if (this.activeModal) {
this.activeModal.close();
this.cleanupDom();
@ -128,6 +149,65 @@ export class ModalService {
return modal;
},
openConfirm: (message: MountPoint | string, options?: OverlayModalConfirmOptions) => {
// If there is an active modal, close it before opening a new one.
if (this.activeModal) {
this.activeModal.close();
this.cleanupDom();
}
return new Promise((resolve, reject) => {
let resolved = false;
const closeModal = (confirmed: boolean) => {
resolved = true;
modal.close();
resolve(confirmed);
};
const modal = new ModalRef();
modal.onClose.then(() => {
if (this.activeModal === modal) {
this.cleanupDom();
}
// modal.close can be called when opening a new modal/confirm, so we need to resolve the promise in that case.
if (!resolved) {
closeModal(false);
}
});
this.activeModal = modal;
const props = {
...options,
children:
typeof message === 'string' ? (
message
) : (
<MountWrapper mount={message} className="kbnOverlayMountWrapper" />
),
onCancel: () => closeModal(false),
onConfirm: () => closeModal(true),
cancelButtonText:
options?.cancelButtonText ||
t.translate('core.overlays.confirm.cancelButton', {
defaultMessage: 'Cancel',
}),
confirmButtonText:
options?.confirmButtonText ||
t.translate('core.overlays.confirm.okButton', {
defaultMessage: 'Confirm',
}),
};
render(
<EuiOverlayMask>
<i18n.Context>
<EuiConfirmModal {...props} />
</i18n.Context>
</EuiOverlayMask>,
targetDomElement
);
});
},
};
}

View file

@ -22,9 +22,11 @@ import { overlayFlyoutServiceMock } from './flyout/flyout_service.mock';
import { overlayModalServiceMock } from './modal/modal_service.mock';
const createStartContractMock = () => {
const overlayStart = overlayModalServiceMock.createStartContract();
const startContract: DeeplyMockedKeys<OverlayStart> = {
openFlyout: overlayFlyoutServiceMock.createStartContract().open,
openModal: overlayModalServiceMock.createStartContract().open,
openModal: overlayStart.open,
openConfirm: overlayStart.openConfirm,
banners: overlayBannersServiceMock.createStartContract(),
};
return startContract;

View file

@ -50,6 +50,7 @@ export class OverlayService {
banners,
openFlyout: flyouts.open.bind(flyouts),
openModal: modals.open.bind(modals),
openConfirm: modals.openConfirm.bind(modals),
};
}
}
@ -62,4 +63,6 @@ export interface OverlayStart {
openFlyout: OverlayFlyoutStart['open'];
/** {@link OverlayModalStart#open} */
openModal: OverlayModalStart['open'];
/** {@link OverlayModalStart#openConfirm} */
openConfirm: OverlayModalStart['openConfirm'];
}

File diff suppressed because it is too large Load diff

View file

@ -68,7 +68,7 @@ export class LocalApplicationService {
isUnmounted = true;
});
(async () => {
const params = { element, appBasePath: '' };
const params = { element, appBasePath: '', onAppLeave: () => undefined };
unmountHandler = isAppMountDeprecated(app.mount)
? await app.mount({ core: npStart.core }, params)
: await app.mount(params);

View file

@ -62,6 +62,7 @@ describe('ui/new_platform', () => {
expect(mountMock).toHaveBeenCalledWith({
element: elementMock[0],
appBasePath: '/test/base/path/app/test',
onAppLeave: expect.any(Function),
});
});
@ -82,6 +83,7 @@ describe('ui/new_platform', () => {
expect(mountMock).toHaveBeenCalledWith(expect.any(Object), {
element: elementMock[0],
appBasePath: '/test/base/path/app/test',
onAppLeave: expect.any(Function),
});
});

View file

@ -124,7 +124,11 @@ export const legacyAppRegister = (app: App) => {
// Root controller cannot return a Promise so use an internal async function and call it immediately
(async () => {
const params = { element, appBasePath: npSetup.core.http.basePath.prepend(`/app/${app.id}`) };
const params = {
element,
appBasePath: npSetup.core.http.basePath.prepend(`/app/${app.id}`),
onAppLeave: () => undefined,
};
const unmount = isAppMountDeprecated(app.mount)
? await app.mount({ core: npStart.core }, params)
: await app.mount(params);

View file

@ -346,6 +346,7 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
"remove": [MockFunction],
"replace": [MockFunction],
},
"openConfirm": [MockFunction],
"openFlyout": [MockFunction],
"openModal": [MockFunction],
},
@ -965,6 +966,7 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
"remove": [MockFunction],
"replace": [MockFunction],
},
"openConfirm": [MockFunction],
"openFlyout": [MockFunction],
"openModal": [MockFunction],
},
@ -1572,6 +1574,7 @@ exports[`QueryStringInput Should pass the query language to the language switche
"remove": [MockFunction],
"replace": [MockFunction],
},
"openConfirm": [MockFunction],
"openFlyout": [MockFunction],
"openModal": [MockFunction],
},
@ -2188,6 +2191,7 @@ exports[`QueryStringInput Should pass the query language to the language switche
"remove": [MockFunction],
"replace": [MockFunction],
},
"openConfirm": [MockFunction],
"openFlyout": [MockFunction],
"openModal": [MockFunction],
},
@ -2795,6 +2799,7 @@ exports[`QueryStringInput Should render the given query 1`] = `
"remove": [MockFunction],
"replace": [MockFunction],
},
"openConfirm": [MockFunction],
"openFlyout": [MockFunction],
"openModal": [MockFunction],
},
@ -3411,6 +3416,7 @@ exports[`QueryStringInput Should render the given query 1`] = `
"remove": [MockFunction],
"replace": [MockFunction],
},
"openConfirm": [MockFunction],
"openFlyout": [MockFunction],
"openModal": [MockFunction],
},

View file

@ -91,7 +91,7 @@ function DevToolsWrapper({
if (mountedTool.current) {
mountedTool.current.unmountHandler();
}
const params = { element, appBasePath: '' };
const params = { element, appBasePath: '', onAppLeave: () => undefined };
const unmountHandler = isAppMountDeprecated(activeDevTool.mount)
? await activeDevTool.mount(appMountContext, params)
: await activeDevTool.mount(params);

View file

@ -0,0 +1,8 @@
{
"id": "core_plugin_appleave",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["core_plugin_appleave"],
"server": false,
"ui": true
}

View file

@ -0,0 +1,17 @@
{
"name": "core_plugin_appleave",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/core_plugin_appleave",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"scripts": {
"kbn": "node ../../../../scripts/kbn.js",
"build": "rm -rf './target' && tsc"
},
"devDependencies": {
"typescript": "3.5.3"
}
}

View file

@ -0,0 +1,63 @@
/*
* 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 from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
} from '@elastic/eui';
import { AppMountParameters } from 'kibana/public';
const App = ({ appName }: { appName: string }) => (
<EuiPage>
<EuiPageBody data-test-subj="chromelessAppHome">
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Welcome to {appName}!</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h2>{appName} home page section title</h2>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>{appName} page content</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
export const renderApp = (appName: string, { element }: AppMountParameters) => {
render(<App appName={appName} />, element);
return () => unmountComponentAtNode(element);
};

View file

@ -0,0 +1,24 @@
/*
* 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 { PluginInitializer } from 'kibana/public';
import { CoreAppLeavePlugin, CoreAppLeavePluginSetup, CoreAppLeavePluginStart } from './plugin';
export const plugin: PluginInitializer<CoreAppLeavePluginSetup, CoreAppLeavePluginStart> = () =>
new CoreAppLeavePlugin();

View file

@ -0,0 +1,52 @@
/*
* 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 { Plugin, CoreSetup } from 'kibana/public';
export class CoreAppLeavePlugin
implements Plugin<CoreAppLeavePluginSetup, CoreAppLeavePluginStart> {
public setup(core: CoreSetup, deps: {}) {
core.application.register({
id: 'appleave1',
title: 'AppLeave 1',
async mount(context, params) {
const { renderApp } = await import('./application');
params.onAppLeave(actions => actions.confirm('confirm-message', 'confirm-title'));
return renderApp('AppLeave 1', params);
},
});
core.application.register({
id: 'appleave2',
title: 'AppLeave 2',
async mount(context, params) {
const { renderApp } = await import('./application');
params.onAppLeave(actions => actions.default());
return renderApp('AppLeave 2', params);
},
});
return {};
}
public start() {}
public stop() {}
}
export type CoreAppLeavePluginSetup = ReturnType<CoreAppLeavePlugin['setup']>;
export type CoreAppLeavePluginStart = ReturnType<CoreAppLeavePlugin['start']>;

View file

@ -0,0 +1,14 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"../../../../typings/**/*",
],
"exclude": []
}

View file

@ -0,0 +1,80 @@
/*
* 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 url from 'url';
import expect from '@kbn/expect';
import { PluginFunctionalProviderContext } from '../../services';
const getKibanaUrl = (pathname?: string, search?: string) =>
url.format({
protocol: 'http:',
hostname: process.env.TEST_KIBANA_HOST || 'localhost',
port: process.env.TEST_KIBANA_PORT || '5620',
pathname,
search,
});
// eslint-disable-next-line import/no-default-export
export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) {
const PageObjects = getPageObjects(['common']);
const browser = getService('browser');
const appsMenu = getService('appsMenu');
const testSubjects = getService('testSubjects');
describe('application using leave confirmation', () => {
describe('when navigating to another app', () => {
it('prevents navigation if user click cancel on the confirmation dialog', async () => {
await PageObjects.common.navigateToApp('appleave1');
await appsMenu.clickLink('AppLeave 2');
await testSubjects.existOrFail('appLeaveConfirmModal');
await PageObjects.common.clickCancelOnModal(false);
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/appleave1'));
});
it('allows navigation if user click confirm on the confirmation dialog', async () => {
await PageObjects.common.navigateToApp('appleave1');
await appsMenu.clickLink('AppLeave 2');
await testSubjects.existOrFail('appLeaveConfirmModal');
await PageObjects.common.clickConfirmOnModal();
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/appleave2'));
});
});
describe('when navigating to a legacy app', () => {
it('prevents navigation if user click cancel on the alert dialog', async () => {
await PageObjects.common.navigateToApp('appleave1');
await appsMenu.clickLink('Core Legacy Compat');
const alert = await browser.getAlert();
expect(alert).not.to.eql(undefined);
alert!.dismiss();
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/appleave1'));
});
it('allows navigation if user click leave on the alert dialog', async () => {
await PageObjects.common.navigateToApp('appleave1');
await appsMenu.clickLink('Core Legacy Compat');
const alert = await browser.getAlert();
expect(alert).not.to.eql(undefined);
alert!.accept();
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/core_plugin_legacy'));
});
});
});
}

View file

@ -27,5 +27,6 @@ export default function({ loadTestFile }: PluginFunctionalProviderContext) {
loadTestFile(require.resolve('./ui_plugins'));
loadTestFile(require.resolve('./ui_settings'));
loadTestFile(require.resolve('./top_nav'));
loadTestFile(require.resolve('./application_leave_confirm'));
});
}

View file

@ -87,6 +87,7 @@ if (licenseManagementUiEnabled) {
const unmountApp = await app.mount({ ...npStart } as any, {
element,
appBasePath: '',
onAppLeave: () => undefined,
});
manageAngularLifecycle($scope, $route, unmountApp as any);
},

View file

@ -43,6 +43,7 @@ routes.when('/management/elasticsearch/watcher/:param1?/:param2?/:param3?/:param
app.mount(npStart as any, {
element: elem,
appBasePath: '/management/elasticsearch/watcher/',
onAppLeave: () => undefined,
});
},
},