Add ScopedHistory to AppMountParams (#56705)

This commit is contained in:
Josh Dover 2020-02-26 12:43:12 -07:00 committed by GitHub
parent 68624da3d5
commit 4cb9bc4472
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1479 additions and 119 deletions

View file

@ -9,7 +9,7 @@ Extension of [common app properties](./kibana-plugin-public.appbase.md) with the
<b>Signature:</b>
```typescript
export interface App extends AppBase
export interface App<HistoryLocationState = unknown> extends AppBase
```
## Properties
@ -18,5 +18,5 @@ export interface App extends AppBase
| --- | --- | --- |
| [appRoute](./kibana-plugin-public.app.approute.md) | <code>string</code> | Override the application's routing path from <code>/app/${id}</code>. Must be unique across registered applications. Should not include the base path from HTTP. |
| [chromeless](./kibana-plugin-public.app.chromeless.md) | <code>boolean</code> | Hide the UI chrome when the application is mounted. Defaults to <code>false</code>. Takes precedence over chrome service visibility settings. |
| [mount](./kibana-plugin-public.app.mount.md) | <code>AppMount &#124; AppMountDeprecated</code> | A mount function called when the user navigates to this app's route. May have signature of [AppMount](./kibana-plugin-public.appmount.md) or [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md)<!-- -->. |
| [mount](./kibana-plugin-public.app.mount.md) | <code>AppMount&lt;HistoryLocationState&gt; &#124; AppMountDeprecated&lt;HistoryLocationState&gt;</code> | A mount function called when the user navigates to this app's route. May have signature of [AppMount](./kibana-plugin-public.appmount.md) or [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md)<!-- -->. |

View file

@ -9,7 +9,7 @@ A mount function called when the user navigates to this app's route. May have si
<b>Signature:</b>
```typescript
mount: AppMount | AppMountDeprecated;
mount: AppMount<HistoryLocationState> | AppMountDeprecated<HistoryLocationState>;
```
## Remarks

View file

@ -9,14 +9,14 @@ Register an mountable application to the system.
<b>Signature:</b>
```typescript
register(app: App): void;
register<HistoryLocationState = unknown>(app: App<HistoryLocationState>): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| app | <code>App</code> | an [App](./kibana-plugin-public.app.md) |
| app | <code>App&lt;HistoryLocationState&gt;</code> | an [App](./kibana-plugin-public.app.md) |
<b>Returns:</b>

View file

@ -9,5 +9,5 @@ A mount function called when the user navigates to this app's route.
<b>Signature:</b>
```typescript
export declare type AppMount = (params: AppMountParameters) => AppUnmount | Promise<AppUnmount>;
export declare type AppMount<HistoryLocationState = unknown> = (params: AppMountParameters<HistoryLocationState>) => AppUnmount | Promise<AppUnmount>;
```

View file

@ -13,7 +13,7 @@ A mount function called when the user navigates to this app's route.
<b>Signature:</b>
```typescript
export declare type AppMountDeprecated = (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise<AppUnmount>;
export declare type AppMountDeprecated<HistoryLocationState = unknown> = (context: AppMountContext, params: AppMountParameters<HistoryLocationState>) => AppUnmount | Promise<AppUnmount>;
```
## Remarks

View file

@ -4,6 +4,11 @@
## AppMountParameters.appBasePath property
> Warning: This API is now obsolete.
>
> Use [AppMountParameters.history](./kibana-plugin-public.appmountparameters.history.md) instead.
>
The route path for configuring navigation to the application. This string should not include the base path from HTTP.
<b>Signature:</b>
@ -39,10 +44,10 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';
import { CoreStart, AppMountParams } from 'src/core/public';
import { CoreStart, AppMountParameters } from 'src/core/public';
import { MyPluginDepsStart } from './plugin';
export renderApp = ({ appBasePath, element }: AppMountParams) => {
export renderApp = ({ appBasePath, element }: AppMountParameters) => {
ReactDOM.render(
// pass `appBasePath` to `basename`
<BrowserRouter basename={appBasePath}>

View file

@ -0,0 +1,58 @@
<!-- 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; [history](./kibana-plugin-public.appmountparameters.history.md)
## AppMountParameters.history property
A scoped history instance for your application. Should be used to wire up your applications Router.
<b>Signature:</b>
```typescript
history: ScopedHistory<HistoryLocationState>;
```
## Example
How to configure react-router with a base path:
```ts
// inside your plugin's setup function
export class MyPlugin implements Plugin {
setup({ application }) {
application.register({
id: 'my-app',
appRoute: '/my-app',
async mount(params) {
const { renderApp } = await import('./application');
return renderApp(params);
},
});
}
}
```
```ts
// application.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route } from 'react-router-dom';
import { CoreStart, AppMountParameters } from 'src/core/public';
import { MyPluginDepsStart } from './plugin';
export renderApp = ({ element, history }: AppMountParameters) => {
ReactDOM.render(
// pass `appBasePath` to `basename`
<Router history={history}>
<Route path="/" exact component={HomePage} />
</Router>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
}
```

View file

@ -8,7 +8,7 @@
<b>Signature:</b>
```typescript
export interface AppMountParameters
export interface AppMountParameters<HistoryLocationState = unknown>
```
## Properties
@ -17,5 +17,6 @@ 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. |
| [history](./kibana-plugin-public.appmountparameters.history.md) | <code>ScopedHistory&lt;HistoryLocationState&gt;</code> | A scoped history instance for your application. Should be used to wire up your applications Router. |
| [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

@ -15,6 +15,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| Class | Description |
| --- | --- |
| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. |
| [ScopedHistory](./kibana-plugin-public.scopedhistory.md) | A wrapper around a <code>History</code> instance that is scoped to a particular base path of the history stack. Behaves similarly to the <code>basename</code> option except that this wrapper hides any history stack entries from outside the scope of this base path.<!-- -->This wrapper also allows Core and Plugins to share a single underlying global <code>History</code> instance without exposing the history of other applications.<!-- -->The [createSubHistory](./kibana-plugin-public.scopedhistory.createsubhistory.md) method is particularly useful for applications that contain any number of "sub-apps" which should not have access to the main application's history or basePath. |
| [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. |

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; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [(constructor)](./kibana-plugin-public.scopedhistory._constructor_.md)
## ScopedHistory.(constructor)
Constructs a new instance of the `ScopedHistory` class
<b>Signature:</b>
```typescript
constructor(parentHistory: History, basePath: string);
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| parentHistory | <code>History</code> | |
| basePath | <code>string</code> | |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [action](./kibana-plugin-public.scopedhistory.action.md)
## ScopedHistory.action property
The last action dispatched on the history stack.
<b>Signature:</b>
```typescript
get action(): Action;
```

View file

@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [block](./kibana-plugin-public.scopedhistory.block.md)
## ScopedHistory.block property
Not supported. Use [AppMountParameters.onAppLeave](./kibana-plugin-public.appmountparameters.onappleave.md)<!-- -->.
<b>Signature:</b>
```typescript
block: (prompt?: string | boolean | History.TransitionPromptHook<HistoryLocationState> | undefined) => UnregisterCallback;
```
## Remarks
We prefer that applications use the `onAppLeave` API because it supports a more graceful experience that prefers a modal when possible, falling back to a confirm dialog box in the beforeunload case.

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [createHref](./kibana-plugin-public.scopedhistory.createhref.md)
## ScopedHistory.createHref property
Creates an href (string) to the location.
<b>Signature:</b>
```typescript
createHref: (location: LocationDescriptorObject<HistoryLocationState>) => string;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [createSubHistory](./kibana-plugin-public.scopedhistory.createsubhistory.md)
## ScopedHistory.createSubHistory property
Creates a `ScopedHistory` for a subpath of this `ScopedHistory`<!-- -->. Useful for applications that may have sub-apps that do not need access to the containing application's history.
<b>Signature:</b>
```typescript
createSubHistory: <SubHistoryLocationState = unknown>(basePath: string) => ScopedHistory<SubHistoryLocationState>;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [go](./kibana-plugin-public.scopedhistory.go.md)
## ScopedHistory.go property
Send the user forward or backwards in the history stack.
<b>Signature:</b>
```typescript
go: (n: number) => void;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [goBack](./kibana-plugin-public.scopedhistory.goback.md)
## ScopedHistory.goBack property
Send the user one location back in the history stack. Equivalent to calling [ScopedHistory.go(-1)](./kibana-plugin-public.scopedhistory.go.md)<!-- -->. If no more entries are available backwards, this is a no-op.
<b>Signature:</b>
```typescript
goBack: () => void;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [goForward](./kibana-plugin-public.scopedhistory.goforward.md)
## ScopedHistory.goForward property
Send the user one location forward in the history stack. Equivalent to calling [ScopedHistory.go(1)](./kibana-plugin-public.scopedhistory.go.md)<!-- -->. If no more entries are available forwards, this is a no-op.
<b>Signature:</b>
```typescript
goForward: () => void;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [length](./kibana-plugin-public.scopedhistory.length.md)
## ScopedHistory.length property
The number of entries in the history stack, including all entries forwards and backwards from the current location.
<b>Signature:</b>
```typescript
get length(): number;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [listen](./kibana-plugin-public.scopedhistory.listen.md)
## ScopedHistory.listen property
Adds a listener for location updates.
<b>Signature:</b>
```typescript
listen: (listener: (location: Location<HistoryLocationState>, action: Action) => void) => UnregisterCallback;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [location](./kibana-plugin-public.scopedhistory.location.md)
## ScopedHistory.location property
The current location of the history stack.
<b>Signature:</b>
```typescript
get location(): Location<HistoryLocationState>;
```

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; [ScopedHistory](./kibana-plugin-public.scopedhistory.md)
## ScopedHistory class
A wrapper around a `History` instance that is scoped to a particular base path of the history stack. Behaves similarly to the `basename` option except that this wrapper hides any history stack entries from outside the scope of this base path.
This wrapper also allows Core and Plugins to share a single underlying global `History` instance without exposing the history of other applications.
The [createSubHistory](./kibana-plugin-public.scopedhistory.createsubhistory.md) method is particularly useful for applications that contain any number of "sub-apps" which should not have access to the main application's history or basePath.
<b>Signature:</b>
```typescript
export declare class ScopedHistory<HistoryLocationState = unknown> implements History<HistoryLocationState>
```
## Constructors
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(parentHistory, basePath)](./kibana-plugin-public.scopedhistory._constructor_.md) | | Constructs a new instance of the <code>ScopedHistory</code> class |
## Properties
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [action](./kibana-plugin-public.scopedhistory.action.md) | | <code>Action</code> | The last action dispatched on the history stack. |
| [block](./kibana-plugin-public.scopedhistory.block.md) | | <code>(prompt?: string &#124; boolean &#124; History.TransitionPromptHook&lt;HistoryLocationState&gt; &#124; undefined) =&gt; UnregisterCallback</code> | Not supported. Use [AppMountParameters.onAppLeave](./kibana-plugin-public.appmountparameters.onappleave.md)<!-- -->. |
| [createHref](./kibana-plugin-public.scopedhistory.createhref.md) | | <code>(location: LocationDescriptorObject&lt;HistoryLocationState&gt;) =&gt; string</code> | Creates an href (string) to the location. |
| [createSubHistory](./kibana-plugin-public.scopedhistory.createsubhistory.md) | | <code>&lt;SubHistoryLocationState = unknown&gt;(basePath: string) =&gt; ScopedHistory&lt;SubHistoryLocationState&gt;</code> | Creates a <code>ScopedHistory</code> for a subpath of this <code>ScopedHistory</code>. Useful for applications that may have sub-apps that do not need access to the containing application's history. |
| [go](./kibana-plugin-public.scopedhistory.go.md) | | <code>(n: number) =&gt; void</code> | Send the user forward or backwards in the history stack. |
| [goBack](./kibana-plugin-public.scopedhistory.goback.md) | | <code>() =&gt; void</code> | Send the user one location back in the history stack. Equivalent to calling [ScopedHistory.go(-1)](./kibana-plugin-public.scopedhistory.go.md)<!-- -->. If no more entries are available backwards, this is a no-op. |
| [goForward](./kibana-plugin-public.scopedhistory.goforward.md) | | <code>() =&gt; void</code> | Send the user one location forward in the history stack. Equivalent to calling [ScopedHistory.go(1)](./kibana-plugin-public.scopedhistory.go.md)<!-- -->. If no more entries are available forwards, this is a no-op. |
| [length](./kibana-plugin-public.scopedhistory.length.md) | | <code>number</code> | The number of entries in the history stack, including all entries forwards and backwards from the current location. |
| [listen](./kibana-plugin-public.scopedhistory.listen.md) | | <code>(listener: (location: Location&lt;HistoryLocationState&gt;, action: Action) =&gt; void) =&gt; UnregisterCallback</code> | Adds a listener for location updates. |
| [location](./kibana-plugin-public.scopedhistory.location.md) | | <code>Location&lt;HistoryLocationState&gt;</code> | The current location of the history stack. |
| [push](./kibana-plugin-public.scopedhistory.push.md) | | <code>(pathOrLocation: string &#124; LocationDescriptorObject&lt;HistoryLocationState&gt;, state?: HistoryLocationState &#124; undefined) =&gt; void</code> | Pushes a new location onto the history stack. If there are forward entries in the stack, they will be removed. |
| [replace](./kibana-plugin-public.scopedhistory.replace.md) | | <code>(pathOrLocation: string &#124; LocationDescriptorObject&lt;HistoryLocationState&gt;, state?: HistoryLocationState &#124; undefined) =&gt; void</code> | Replaces the current location in the history stack. Does not remove forward or backward entries. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [push](./kibana-plugin-public.scopedhistory.push.md)
## ScopedHistory.push property
Pushes a new location onto the history stack. If there are forward entries in the stack, they will be removed.
<b>Signature:</b>
```typescript
push: (pathOrLocation: string | LocationDescriptorObject<HistoryLocationState>, state?: HistoryLocationState | undefined) => void;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ScopedHistory](./kibana-plugin-public.scopedhistory.md) &gt; [replace](./kibana-plugin-public.scopedhistory.replace.md)
## ScopedHistory.replace property
Replaces the current location in the history stack. Does not remove forward or backward entries.
<b>Signature:</b>
```typescript
replace: (pathOrLocation: string | LocationDescriptorObject<HistoryLocationState>, state?: HistoryLocationState | undefined) => void;
```

View file

@ -588,6 +588,16 @@ describe('#start()', () => {
expect(getUrlForApp('app1', { path: 'deep/link///' })).toBe('/base-path/app/app1/deep/link');
});
it('does not append trailing slash if hash is provided in path parameter', async () => {
service.setup(setupDeps);
const { getUrlForApp } = await service.start(startDeps);
expect(getUrlForApp('app1', { path: '#basic-hash' })).toBe('/base-path/app/app1#basic-hash');
expect(getUrlForApp('app1', { path: '#/hash/router/path' })).toBe(
'/base-path/app/app1#/hash/router/path'
);
});
it('creates absolute URLs when `absolute` parameter is true', async () => {
service.setup(setupDeps);
const { getUrlForApp } = await service.start(startDeps);
@ -646,6 +656,26 @@ describe('#start()', () => {
);
});
it('appends a path if specified with hash', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' }));
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('myTestApp', { path: '#basic-hash' });
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp#basic-hash', undefined);
await navigateToApp('myTestApp', { path: '#/hash/router/path' });
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp#/hash/router/path', undefined);
await navigateToApp('app2', { path: '#basic-hash' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path#basic-hash', undefined);
await navigateToApp('app2', { path: '#/hash/router/path' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path#/hash/router/path', undefined);
});
it('includes state if specified', async () => {
const { register } = service.setup(setupDeps);

View file

@ -76,10 +76,19 @@ function filterAvailable<T>(m: Map<string, T>, capabilities: Capabilities) {
}
const findMounter = (mounters: Map<string, Mounter>, appRoute?: string) =>
[...mounters].find(([, mounter]) => mounter.appRoute === appRoute);
const getAppUrl = (mounters: Map<string, Mounter>, appId: string, path: string = '') =>
`/${mounters.get(appId)?.appRoute ?? `/app/${appId}`}/${path}`
const getAppUrl = (mounters: Map<string, Mounter>, appId: string, path: string = '') => {
const appBasePath = mounters.get(appId)?.appRoute
? `/${mounters.get(appId)!.appRoute}`
: `/app/${appId}`;
// Only preppend slash if not a hash or query path
path = path.startsWith('#') || path.startsWith('?') ? path : `/${path}`;
return `${appBasePath}${path}`
.replace(/\/{2,}/g, '/') // Remove duplicate slashes
.replace(/\/$/, ''); // Remove trailing slash
};
const allApplicationsFilter = '__ALL__';
@ -93,7 +102,7 @@ interface AppUpdaterWrapper {
* @internal
*/
export class ApplicationService {
private readonly apps = new Map<string, App | LegacyApp>();
private readonly apps = new Map<string, App<any> | LegacyApp>();
private readonly mounters = new Map<string, Mounter>();
private readonly capabilities = new CapabilitiesService();
private readonly appLeaveHandlers = new Map<string, AppLeaveHandler>();
@ -143,7 +152,7 @@ export class ApplicationService {
return {
registerMountContext: this.mountContext!.registerContext,
register: (plugin, app) => {
register: (plugin, app: App<any>) => {
app = { appRoute: `/app/${app.id}`, ...app };
if (this.registrationClosed) {

View file

@ -19,6 +19,7 @@
export { ApplicationService } from './application_service';
export { Capabilities } from './capabilities';
export { ScopedHistory } from './scoped_history';
export {
App,
AppBase,

View file

@ -25,15 +25,17 @@ import { AppRouter, AppNotFound } from '../ui';
import { EitherApp, MockedMounterMap, MockedMounterTuple } from '../test_types';
import { createRenderer, createAppMounter, createLegacyAppMounter, getUnmounter } from './utils';
import { AppStatus } from '../types';
import { ScopedHistory } from '../scoped_history';
describe('AppContainer', () => {
let mounters: MockedMounterMap<EitherApp>;
let history: History;
let globalHistory: History;
let appStatuses$: BehaviorSubject<Map<string, AppStatus>>;
let update: ReturnType<typeof createRenderer>;
let scopedAppHistory: History;
const navigate = (path: string) => {
history.push(path);
globalHistory.push(path);
return update();
};
const mockMountersToMounters = () =>
@ -53,20 +55,35 @@ describe('AppContainer', () => {
beforeEach(() => {
mounters = new Map([
createAppMounter('app1', '<span>App 1</span>'),
createAppMounter({ appId: 'app1', html: '<span>App 1</span>' }),
createLegacyAppMounter('legacyApp1', jest.fn()),
createAppMounter('app2', '<div>App 2</div>'),
createAppMounter({ appId: 'app2', html: '<div>App 2</div>' }),
createLegacyAppMounter('baseApp:legacyApp2', jest.fn()),
createAppMounter('app3', '<div>Chromeless A</div>', '/chromeless-a/path'),
createAppMounter('app4', '<div>Chromeless B</div>', '/chromeless-b/path'),
createAppMounter('disabledApp', '<div>Disabled app</div>'),
createAppMounter({
appId: 'app3',
html: '<div>Chromeless A</div>',
appRoute: '/chromeless-a/path',
}),
createAppMounter({
appId: 'app4',
html: '<div>Chromeless B</div>',
appRoute: '/chromeless-b/path',
}),
createAppMounter({ appId: 'disabledApp', html: '<div>Disabled app</div>' }),
createLegacyAppMounter('disabledLegacyApp', jest.fn()),
createAppMounter({
appId: 'scopedApp',
extraMountHook: ({ history }) => {
scopedAppHistory = history;
history.push('/subpath');
},
}),
] as Array<MockedMounterTuple<EitherApp>>);
history = createMemoryHistory();
globalHistory = createMemoryHistory();
appStatuses$ = mountersToAppStatus$();
update = createRenderer(
<AppRouter
history={history}
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={appStatuses$}
setAppLeaveHandler={setAppLeaveHandlerMock}
@ -177,12 +194,24 @@ describe('AppContainer', () => {
});
it('should not mount when partial route path matches', async () => {
mounters.set(...createAppMounter('spaces', '<div>Custom Space</div>', '/spaces/fake-login'));
mounters.set(...createAppMounter('login', '<div>Login Page</div>', '/fake-login'));
history = createMemoryHistory();
mounters.set(
...createAppMounter({
appId: 'spaces',
html: '<div>Custom Space</div>',
appRoute: '/spaces/fake-login',
})
);
mounters.set(
...createAppMounter({
appId: 'login',
html: '<div>Login Page</div>',
appRoute: '/fake-login',
})
);
globalHistory = createMemoryHistory();
update = createRenderer(
<AppRouter
history={history}
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}
setAppLeaveHandler={setAppLeaveHandlerMock}
@ -196,12 +225,24 @@ describe('AppContainer', () => {
});
it('should not mount when partial route path has higher specificity', async () => {
mounters.set(...createAppMounter('login', '<div>Login Page</div>', '/fake-login'));
mounters.set(...createAppMounter('spaces', '<div>Custom Space</div>', '/spaces/fake-login'));
history = createMemoryHistory();
mounters.set(
...createAppMounter({
appId: 'login',
html: '<div>Login Page</div>',
appRoute: '/fake-login',
})
);
mounters.set(
...createAppMounter({
appId: 'spaces',
html: '<div>Custom Space</div>',
appRoute: '/spaces/fake-login',
})
);
globalHistory = createMemoryHistory();
update = createRenderer(
<AppRouter
history={history}
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}
setAppLeaveHandler={setAppLeaveHandlerMock}
@ -232,17 +273,17 @@ describe('AppContainer', () => {
// Hitting back button within app does not trigger re-render
await navigate('/app/app1/page2');
history.goBack();
globalHistory.goBack();
await update();
expect(mounter.mount).toHaveBeenCalledTimes(1);
expect(unmount).not.toHaveBeenCalled();
});
it('should not remount when when changing pages within app using hash history', async () => {
history = createHashHistory();
globalHistory = createHashHistory();
update = createRenderer(
<AppRouter
history={history}
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}
setAppLeaveHandler={setAppLeaveHandlerMock}
@ -269,30 +310,49 @@ describe('AppContainer', () => {
expect(unmount).toHaveBeenCalledTimes(1);
});
it('pushes global history changes to inner scoped history', async () => {
const scopedApp = mounters.get('scopedApp');
await navigate('/app/scopedApp');
// Verify that internal app's redirect propagated
expect(scopedApp?.mounter.mount).toHaveBeenCalledTimes(1);
expect(scopedAppHistory.location.pathname).toEqual('/subpath');
expect(globalHistory.location.pathname).toEqual('/app/scopedApp/subpath');
// Simulate user clicking on navlink again to return to app root
globalHistory.push('/app/scopedApp');
// Should not call mount again
expect(scopedApp?.mounter.mount).toHaveBeenCalledTimes(1);
expect(scopedApp?.unmount).not.toHaveBeenCalled();
// Inner scoped history should be synced
expect(scopedAppHistory.location.pathname).toEqual('');
// Make sure going back to subpath works
globalHistory.goBack();
expect(scopedApp?.mounter.mount).toHaveBeenCalledTimes(1);
expect(scopedApp?.unmount).not.toHaveBeenCalled();
expect(scopedAppHistory.location.pathname).toEqual('/subpath');
expect(globalHistory.location.pathname).toEqual('/app/scopedApp/subpath');
});
it('calls legacy mount handler', async () => {
await navigate('/app/legacyApp1');
expect(mounters.get('legacyApp1')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"appBasePath": "/app/legacyApp1",
"element": <div />,
"onAppLeave": [Function],
},
]
`);
expect(mounters.get('legacyApp1')!.mounter.mount.mock.calls[0][0]).toMatchObject({
appBasePath: '/app/legacyApp1',
element: expect.any(HTMLDivElement),
onAppLeave: expect.any(Function),
history: expect.any(ScopedHistory),
});
});
it('handles legacy apps with subapps', async () => {
await navigate('/app/baseApp');
expect(mounters.get('baseApp:legacyApp2')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"appBasePath": "/app/baseApp",
"element": <div />,
"onAppLeave": [Function],
},
]
`);
expect(mounters.get('baseApp:legacyApp2')!.mounter.mount.mock.calls[0][0]).toMatchObject({
appBasePath: '/app/baseApp',
element: expect.any(HTMLDivElement),
onAppLeave: expect.any(Function),
history: expect.any(ScopedHistory),
});
});
it('displays error page if no app is found', async () => {

View file

@ -40,11 +40,17 @@ export const createRenderer = (element: ReactElement | null): Renderer => {
});
};
export const createAppMounter = (
appId: string,
html: string,
appRoute = `/app/${appId}`
): MockedMounterTuple<App> => {
export const createAppMounter = ({
appId,
html = `<div>App ${appId}</div>`,
appRoute = `/app/${appId}`,
extraMountHook,
}: {
appId: string;
html?: string;
appRoute?: string;
extraMountHook?: (params: AppMountParameters) => void;
}): MockedMounterTuple<App> => {
const unmount = jest.fn();
return [
appId,
@ -52,11 +58,15 @@ export const createAppMounter = (
mounter: {
appRoute,
appBasePath: appRoute,
mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => {
mount: jest.fn(async (params: AppMountParameters) => {
const { appBasePath: basename, element } = params;
Object.assign(element, {
innerHTML: `<div>\nbasename: ${basename}\nhtml: ${html}\n</div>`,
});
unmount.mockImplementation(() => Object.assign(element, { innerHTML: '' }));
if (extraMountHook) {
extraMountHook(params);
}
return unmount;
}),
},

View file

@ -0,0 +1,57 @@
/*
* 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 { Location } from 'history';
import { ScopedHistory } from './scoped_history';
type ScopedHistoryMock = jest.Mocked<Pick<ScopedHistory, keyof ScopedHistory>>;
const createMock = ({
pathname = '/',
search = '',
hash = '',
key,
state,
}: Partial<Location> = {}) => {
const mock: ScopedHistoryMock = {
block: jest.fn(),
createHref: jest.fn(),
createSubHistory: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
listen: jest.fn(),
push: jest.fn(),
replace: jest.fn(),
action: 'PUSH',
length: 1,
location: {
pathname,
search,
state,
hash,
key,
},
};
return mock;
};
export const scopedHistoryMock = {
create: createMock,
};

View file

@ -0,0 +1,329 @@
/*
* 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 { ScopedHistory } from './scoped_history';
import { createMemoryHistory } from 'history';
describe('ScopedHistory', () => {
describe('construction', () => {
it('succeeds if current location matches basePath', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
expect(() => new ScopedHistory(gh, '/app/wow')).not.toThrow();
gh.push('/app/wow/');
expect(() => new ScopedHistory(gh, '/app/wow')).not.toThrow();
gh.push('/app/wow/sub-page');
expect(() => new ScopedHistory(gh, '/app/wow')).not.toThrow();
});
it('fails if current location does not match basePath', () => {
const gh = createMemoryHistory();
gh.push('/app/other');
expect(() => new ScopedHistory(gh, '/app/wow')).toThrowErrorMatchingInlineSnapshot(
`"Browser location [/app/other] is not currently in expected basePath [/app/wow]"`
);
});
});
describe('navigation', () => {
it('supports push', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const pushSpy = jest.spyOn(gh, 'push');
const h = new ScopedHistory(gh, '/app/wow');
h.push('/new-page', { some: 'state' });
expect(pushSpy).toHaveBeenCalledWith('/app/wow/new-page', { some: 'state' });
expect(gh.length).toBe(3); // ['', '/app/wow', '/app/wow/new-page']
expect(h.length).toBe(2);
});
it('supports unbound push', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const pushSpy = jest.spyOn(gh, 'push');
const h = new ScopedHistory(gh, '/app/wow');
const { push } = h;
push('/new-page', { some: 'state' });
expect(pushSpy).toHaveBeenCalledWith('/app/wow/new-page', { some: 'state' });
expect(gh.length).toBe(3); // ['', '/app/wow', '/app/wow/new-page']
expect(h.length).toBe(2);
});
it('supports replace', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const replaceSpy = jest.spyOn(gh, 'replace');
const h = new ScopedHistory(gh, '/app/wow'); // ['']
h.push('/first-page'); // ['', '/first-page']
h.push('/second-page'); // ['', '/first-page', '/second-page']
h.goBack(); // ['', '/first-page', '/second-page']
h.replace('/first-page-replacement', { some: 'state' }); // ['', '/first-page-replacement', '/second-page']
expect(replaceSpy).toHaveBeenCalledWith('/app/wow/first-page-replacement', { some: 'state' });
expect(h.length).toBe(3);
expect(gh.length).toBe(4); // ['', '/app/wow', '/app/wow/first-page-replacement', '/app/wow/second-page']
});
it('hides previous stack', () => {
const gh = createMemoryHistory();
gh.push('/app/alpha');
gh.push('/app/beta');
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
expect(h.length).toBe(1);
expect(h.location.pathname).toEqual('');
});
it('cannot go back further than local stack', () => {
const gh = createMemoryHistory();
gh.push('/app/alpha');
gh.push('/app/beta');
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
h.push('/new-page');
expect(h.length).toBe(2);
expect(h.location.pathname).toEqual('/new-page');
// Test first back
h.goBack();
expect(h.length).toBe(2);
expect(h.location.pathname).toEqual('');
expect(gh.location.pathname).toEqual('/app/wow');
// Second back should be no-op
h.goBack();
expect(h.length).toBe(2);
expect(h.location.pathname).toEqual('');
expect(gh.location.pathname).toEqual('/app/wow');
});
it('cannot go forward further than local stack', () => {
const gh = createMemoryHistory();
gh.push('/app/alpha');
gh.push('/app/beta');
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
h.push('/new-page');
expect(h.length).toBe(2);
// Go back so we can go forward
h.goBack();
expect(h.length).toBe(2);
expect(h.location.pathname).toEqual('');
expect(gh.location.pathname).toEqual('/app/wow');
// Going forward should increase length and return to /new-page
h.goForward();
expect(h.length).toBe(2);
expect(h.location.pathname).toEqual('/new-page');
expect(gh.location.pathname).toEqual('/app/wow/new-page');
// Second forward should be no-op
h.goForward();
expect(h.length).toBe(2);
expect(h.location.pathname).toEqual('/new-page');
expect(gh.location.pathname).toEqual('/app/wow/new-page');
});
it('reacts to navigations from parent history', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
h.push('/page-1');
h.push('/page-2');
gh.goBack();
expect(h.location.pathname).toEqual('/page-1');
expect(h.length).toBe(3);
gh.goForward();
expect(h.location.pathname).toEqual('/page-2');
expect(h.length).toBe(3);
// Go back to /app/wow and push a new location
gh.goBack();
gh.goBack();
gh.push('/app/wow/page-3');
expect(h.location.pathname).toEqual('/page-3');
expect(h.length).toBe(2); // ['', '/page-3']
});
it('increments length on push and removes length when going back and then pushing', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
expect(gh.length).toBe(2);
const h = new ScopedHistory(gh, '/app/wow');
expect(h.length).toBe(1);
h.push('/page1');
expect(h.length).toBe(2);
h.push('/page2');
expect(h.length).toBe(3);
h.push('/page3');
expect(h.length).toBe(4);
h.push('/page4');
expect(h.length).toBe(5);
h.push('/page5');
expect(h.length).toBe(6);
h.goBack(); // back/forward should not reduce the length
expect(h.length).toBe(6);
h.goBack();
expect(h.length).toBe(6);
h.push('/page6'); // length should only change if a new location is pushed from a point further back in the history
expect(h.length).toBe(5);
h.goBack();
expect(h.length).toBe(5);
h.goBack();
expect(h.length).toBe(5);
h.push('/page7');
expect(h.length).toBe(4);
});
});
describe('teardown behavior', () => {
it('throws exceptions after falling out of scope', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
expect(gh.length).toBe(2);
const h = new ScopedHistory(gh, '/app/wow');
gh.push('/app/other');
expect(() => h.location).toThrowErrorMatchingInlineSnapshot(
`"ScopedHistory instance has fell out of navigation scope for basePath: /app/wow"`
);
expect(() => h.push('/new-page')).toThrow();
expect(() => h.replace('/new-page')).toThrow();
expect(() => h.goBack()).toThrow();
expect(() => h.goForward()).toThrow();
});
});
describe('listen', () => {
it('calls callback with scoped location', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
const listenPaths: string[] = [];
h.listen(l => listenPaths.push(l.pathname));
h.push('/first-page');
h.push('/second-page');
h.push('/third-page');
h.go(-2);
h.goForward();
expect(listenPaths).toEqual([
'/first-page',
'/second-page',
'/third-page',
'/first-page',
'/second-page',
]);
});
it('stops calling callback after unlisten is called', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
const listenPaths: string[] = [];
const unlisten = h.listen(l => listenPaths.push(l.pathname));
h.push('/first-page');
unlisten();
h.push('/second-page');
h.push('/third-page');
h.go(-2);
h.goForward();
expect(listenPaths).toEqual(['/first-page']);
});
it('stops calling callback after browser leaves scope', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
const listenPaths: string[] = [];
h.listen(l => listenPaths.push(l.pathname));
h.push('/first-page');
gh.push('/app/other');
gh.push('/second-page');
gh.push('/third-page');
gh.go(-2);
gh.goForward();
expect(listenPaths).toEqual(['/first-page']);
});
});
describe('createHref', () => {
it('creates scoped hrefs', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
expect(h.createHref({ pathname: '' })).toEqual(`/`);
expect(h.createHref({ pathname: '/new-page', search: '?alpha=true' })).toEqual(
`/new-page?alpha=true`
);
});
});
describe('action', () => {
it('provides last history action', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
gh.push('/alpha');
gh.goBack();
const h = new ScopedHistory(gh, '/app/wow');
expect(h.action).toBe('POP');
gh.push('/app/wow/page-1');
expect(h.action).toBe('PUSH');
h.replace('/page-2');
expect(h.action).toBe('REPLACE');
});
});
describe('createSubHistory', () => {
it('supports push', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const ghPushSpy = jest.spyOn(gh, 'push');
const h1 = new ScopedHistory(gh, '/app/wow');
h1.push('/new-page');
const h1PushSpy = jest.spyOn(h1, 'push');
const h2 = h1.createSubHistory('/new-page');
h2.push('/sub-page', { some: 'state' });
expect(h1PushSpy).toHaveBeenCalledWith('/new-page/sub-page', { some: 'state' });
expect(ghPushSpy).toHaveBeenCalledWith('/app/wow/new-page/sub-page', { some: 'state' });
expect(h2.length).toBe(2);
expect(h1.length).toBe(3);
expect(gh.length).toBe(4);
});
it('supports replace', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const ghReplaceSpy = jest.spyOn(gh, 'replace');
const h1 = new ScopedHistory(gh, '/app/wow');
h1.push('/new-page');
const h1ReplaceSpy = jest.spyOn(h1, 'replace');
const h2 = h1.createSubHistory('/new-page');
h2.push('/sub-page');
h2.replace('/other-sub-page', { some: 'state' });
expect(h1ReplaceSpy).toHaveBeenCalledWith('/new-page/other-sub-page', { some: 'state' });
expect(ghReplaceSpy).toHaveBeenCalledWith('/app/wow/new-page/other-sub-page', {
some: 'state',
});
expect(h2.length).toBe(2);
expect(h1.length).toBe(3);
expect(gh.length).toBe(4);
});
});
});

View file

@ -0,0 +1,318 @@
/*
* 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 {
History,
Path,
LocationDescriptorObject,
TransitionPromptHook,
UnregisterCallback,
LocationListener,
Location,
Href,
Action,
} from 'history';
/**
* A wrapper around a `History` instance that is scoped to a particular base path of the history stack. Behaves
* similarly to the `basename` option except that this wrapper hides any history stack entries from outside the scope
* of this base path.
*
* This wrapper also allows Core and Plugins to share a single underlying global `History` instance without exposing
* the history of other applications.
*
* The {@link ScopedHistory.createSubHistory | createSubHistory} method is particularly useful for applications that
* contain any number of "sub-apps" which should not have access to the main application's history or basePath.
*
* @public
*/
export class ScopedHistory<HistoryLocationState = unknown>
implements History<HistoryLocationState> {
/**
* Tracks whether or not the user has left this history's scope. All methods throw errors if called after scope has
* been left.
*/
private isActive = true;
/**
* All active listeners on this history instance.
*/
private listeners = new Set<LocationListener<HistoryLocationState>>();
/**
* Array of the local history stack. Only stores {@link Location.key} to use tracking an index of the current
* position of the window in the history stack.
*/
private locationKeys: Array<string | undefined> = [];
/**
* The key of the current position of the window in the history stack.
*/
private currentLocationKeyIndex: number = 0;
constructor(private readonly parentHistory: History, private readonly basePath: string) {
const parentPath = this.parentHistory.location.pathname;
if (!parentPath.startsWith(basePath)) {
throw new Error(
`Browser location [${parentPath}] is not currently in expected basePath [${basePath}]`
);
}
this.locationKeys.push(this.parentHistory.location.key);
this.setupHistoryListener();
}
/**
* Creates a `ScopedHistory` for a subpath of this `ScopedHistory`. Useful for applications that may have sub-apps
* that do not need access to the containing application's history.
*
* @param basePath the URL path scope for the sub history
*/
public createSubHistory = <SubHistoryLocationState = unknown>(
basePath: string
): ScopedHistory<SubHistoryLocationState> => {
return new ScopedHistory<SubHistoryLocationState>(this, basePath);
};
/**
* The number of entries in the history stack, including all entries forwards and backwards from the current location.
*/
public get length() {
this.verifyActive();
return this.locationKeys.length;
}
/**
* The current location of the history stack.
*/
public get location() {
this.verifyActive();
return this.stripBasePath(this.parentHistory.location);
}
/**
* The last action dispatched on the history stack.
*/
public get action() {
this.verifyActive();
return this.parentHistory.action;
}
/**
* Pushes a new location onto the history stack. If there are forward entries in the stack, they will be removed.
*
* @param pathOrLocation a string or location descriptor
* @param state
*/
public push = (
pathOrLocation: Path | LocationDescriptorObject<HistoryLocationState>,
state?: HistoryLocationState
): void => {
this.verifyActive();
if (typeof pathOrLocation === 'string') {
this.parentHistory.push(this.prependBasePath(pathOrLocation), state);
} else {
this.parentHistory.push(this.prependBasePath(pathOrLocation));
}
};
/**
* Replaces the current location in the history stack. Does not remove forward or backward entries.
*
* @param pathOrLocation a string or location descriptor
* @param state
*/
public replace = (
pathOrLocation: Path | LocationDescriptorObject<HistoryLocationState>,
state?: HistoryLocationState
): void => {
this.verifyActive();
if (typeof pathOrLocation === 'string') {
this.parentHistory.replace(this.prependBasePath(pathOrLocation), state);
} else {
this.parentHistory.replace(this.prependBasePath(pathOrLocation));
}
};
/**
* Send the user forward or backwards in the history stack.
*
* @param n number of positions in the stack to go. Negative numbers indicate number of entries backward, positive
* numbers for forwards. If passed 0, the current location will be reloaded. If `n` exceeds the number of
* entries available, this is a no-op.
*/
public go = (n: number): void => {
this.verifyActive();
if (n === 0) {
this.parentHistory.go(n);
} else if (n < 0) {
if (this.currentLocationKeyIndex + 1 + n >= 1) {
this.parentHistory.go(n);
}
} else if (n <= this.currentLocationKeyIndex + this.locationKeys.length - 1) {
this.parentHistory.go(n);
}
// no-op if no conditions above are met
};
/**
* Send the user one location back in the history stack. Equivalent to calling
* {@link ScopedHistory.go | ScopedHistory.go(-1)}. If no more entries are available backwards, this is a no-op.
*/
public goBack = () => {
this.verifyActive();
this.go(-1);
};
/**
* Send the user one location forward in the history stack. Equivalent to calling
* {@link ScopedHistory.go | ScopedHistory.go(1)}. If no more entries are available forwards, this is a no-op.
*/
public goForward = () => {
this.verifyActive();
this.go(1);
};
/**
* Not supported. Use {@link AppMountParameters.onAppLeave}.
*
* @remarks
* We prefer that applications use the `onAppLeave` API because it supports a more graceful experience that prefers
* a modal when possible, falling back to a confirm dialog box in the beforeunload case.
*/
public block = (
prompt?: boolean | string | TransitionPromptHook<HistoryLocationState>
): UnregisterCallback => {
throw new Error(
`history.block is not supported. Please use the AppMountParams.onAppLeave API.`
);
};
/**
* Adds a listener for location updates.
*
* @param listener a function that receives location updates.
* @returns an function to unsubscribe the listener.
*/
public listen = (
listener: (location: Location<HistoryLocationState>, action: Action) => void
): UnregisterCallback => {
this.verifyActive();
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
/**
* Creates an href (string) to the location.
*
* @param location
*/
public createHref = (location: LocationDescriptorObject<HistoryLocationState>): Href => {
this.verifyActive();
return this.parentHistory.createHref(location);
};
private prependBasePath(path: Path): Path;
private prependBasePath(
location: LocationDescriptorObject<HistoryLocationState>
): LocationDescriptorObject<HistoryLocationState>;
/**
* Prepends the scoped base path to the Path or Location
*/
private prependBasePath(
pathOrLocation: Path | LocationDescriptorObject<HistoryLocationState>
): Path | LocationDescriptorObject<HistoryLocationState> {
if (typeof pathOrLocation === 'string') {
return this.prependBasePathToString(pathOrLocation);
} else {
return {
...pathOrLocation,
pathname:
pathOrLocation.pathname !== undefined
? this.prependBasePathToString(pathOrLocation.pathname)
: undefined,
};
}
}
/**
* Prepends the base path to string.
*/
private prependBasePathToString(path: string): string {
path = path.startsWith('/') ? path.slice(1) : path;
return path.length ? `${this.basePath}/${path}` : this.basePath;
}
/**
* Removes the base path from a location.
*/
private stripBasePath(location: Location<HistoryLocationState>): Location<HistoryLocationState> {
return {
...location,
pathname: location.pathname.replace(new RegExp(`^${this.basePath}`), ''),
};
}
/** Called on each public method to ensure that we have not fallen out of scope yet. */
private verifyActive() {
if (!this.isActive) {
throw new Error(
`ScopedHistory instance has fell out of navigation scope for basePath: ${this.basePath}`
);
}
}
/**
* Sets up the listener on the parent history instance used to follow navigation updates and track our internal
* state. Also forwards events to child listeners with the base path stripped from the location.
*/
private setupHistoryListener() {
const unlisten = this.parentHistory.listen((location, action) => {
// If the user navigates outside the scope of this basePath, tear it down.
if (!location.pathname.startsWith(this.basePath)) {
unlisten();
this.isActive = false;
return;
}
/**
* Track location keys using the same algorithm the browser uses internally.
* - On PUSH, remove all items that came after the current location and append the new location.
* - On POP, set the current location, but do not change the entries.
* - On REPLACE, override the location for the current index with the new location.
*/
if (action === 'PUSH') {
this.locationKeys = [
...this.locationKeys.slice(0, this.currentLocationKeyIndex + 1),
location.key,
];
this.currentLocationKeyIndex = this.locationKeys.indexOf(location.key); // should always be the last index
} else if (action === 'POP') {
this.currentLocationKeyIndex = this.locationKeys.indexOf(location.key);
} else if (action === 'REPLACE') {
this.locationKeys[this.currentLocationKeyIndex] = location.key;
} else {
throw new Error(`Unrecognized history action: ${action}`);
}
[...this.listeners].forEach(listener => {
listener(this.stripBasePath(location), action);
});
});
}
}

View file

@ -32,6 +32,7 @@ import { IUiSettingsClient } from '../ui_settings';
import { RecursiveReadonly } from '../../utils';
import { SavedObjectsStart } from '../saved_objects';
import { AppCategory } from '../../types';
import { ScopedHistory } from './scoped_history';
/** @public */
export interface AppBase {
@ -199,7 +200,7 @@ export type AppUpdater = (app: AppBase) => Partial<AppUpdatableFields> | undefin
* Extension of {@link AppBase | common app properties} with the mount function.
* @public
*/
export interface App extends AppBase {
export interface App<HistoryLocationState = unknown> extends AppBase {
/**
* A mount function called when the user navigates to this app's route. May have signature of {@link AppMount} or
* {@link AppMountDeprecated}.
@ -208,7 +209,7 @@ export interface App extends AppBase {
* When function has two arguments, it will be called with a {@link AppMountContext | context} as the first argument.
* This behavior is **deprecated**, and consumers should instead use {@link CoreSetup.getStartServices}.
*/
mount: AppMount | AppMountDeprecated;
mount: AppMount<HistoryLocationState> | AppMountDeprecated<HistoryLocationState>;
/**
* Hide the UI chrome when the application is mounted. Defaults to `false`.
@ -240,7 +241,9 @@ export interface LegacyApp extends AppBase {
*
* @public
*/
export type AppMount = (params: AppMountParameters) => AppUnmount | Promise<AppUnmount>;
export type AppMount<HistoryLocationState = unknown> = (
params: AppMountParameters<HistoryLocationState>
) => AppUnmount | Promise<AppUnmount>;
/**
* A mount function called when the user navigates to this app's route.
@ -256,9 +259,9 @@ export type AppMount = (params: AppMountParameters) => AppUnmount | Promise<AppU
* @deprecated
* @public
*/
export type AppMountDeprecated = (
export type AppMountDeprecated<HistoryLocationState = unknown> = (
context: AppMountContext,
params: AppMountParameters
params: AppMountParameters<HistoryLocationState>
) => AppUnmount | Promise<AppUnmount>;
/**
@ -304,16 +307,65 @@ export interface AppMountContext {
}
/** @public */
export interface AppMountParameters {
export interface AppMountParameters<HistoryLocationState = unknown> {
/**
* The container element to render the application into.
*/
element: HTMLElement;
/**
* A scoped history instance for your application. Should be used to wire up
* your applications Router.
*
* @example
* How to configure react-router with a base path:
*
* ```ts
* // inside your plugin's setup function
* export class MyPlugin implements Plugin {
* setup({ application }) {
* application.register({
* id: 'my-app',
* appRoute: '/my-app',
* async mount(params) {
* const { renderApp } = await import('./application');
* return renderApp(params);
* },
* });
* }
* }
* ```
*
* ```ts
* // application.tsx
* import React from 'react';
* import ReactDOM from 'react-dom';
* import { Router, Route } from 'react-router-dom';
*
* import { CoreStart, AppMountParameters } from 'src/core/public';
* import { MyPluginDepsStart } from './plugin';
*
* export renderApp = ({ element, history }: AppMountParameters) => {
* ReactDOM.render(
* // pass `appBasePath` to `basename`
* <Router history={history}>
* <Route path="/" exact component={HomePage} />
* </Router>,
* element
* );
*
* return () => ReactDOM.unmountComponentAtNode(element);
* }
* ```
*/
history: ScopedHistory<HistoryLocationState>;
/**
* The route path for configuring navigation to the application.
* This string should not include the base path from HTTP.
*
* @deprecated Use {@link AppMountParameters.history} instead.
*
* @example
*
* How to configure react-router with a base path:
@ -340,10 +392,10 @@ export interface AppMountParameters {
* import ReactDOM from 'react-dom';
* import { BrowserRouter, Route } from 'react-router-dom';
*
* import { CoreStart, AppMountParams } from 'src/core/public';
* import { CoreStart, AppMountParameters } from 'src/core/public';
* import { MyPluginDepsStart } from './plugin';
*
* export renderApp = ({ appBasePath, element }: AppMountParams) => {
* export renderApp = ({ appBasePath, element }: AppMountParameters) => {
* ReactDOM.render(
* // pass `appBasePath` to `basename`
* <BrowserRouter basename={appBasePath}>
@ -498,8 +550,9 @@ export interface ApplicationSetup {
/**
* Register an mountable application to the system.
* @param app - an {@link App}
* @typeParam HistoryLocationState - shape of the `History` state on {@link AppMountParameters.history}, defaults to `unknown`.
*/
register(app: App): void;
register<HistoryLocationState = unknown>(app: App<HistoryLocationState>): void;
/**
* Register an application updater that can be used to change the {@link AppUpdatableFields} fields
@ -551,7 +604,10 @@ export interface InternalApplicationSetup extends Pick<ApplicationSetup, 'regist
* @param plugin - opaque ID of the plugin that registers this application
* @param app
*/
register(plugin: PluginOpaqueId, app: App): void;
register<HistoryLocationState = unknown>(
plugin: PluginOpaqueId,
app: App<HistoryLocationState>
): void;
/**
* Register metadata about legacy applications. Legacy apps will not be mounted when navigated to.

View file

@ -22,6 +22,8 @@ import { mount } from 'enzyme';
import { AppContainer } from './app_container';
import { Mounter, AppMountParameters, AppStatus } from '../types';
import { createMemoryHistory } from 'history';
import { ScopedHistory } from '../scoped_history';
describe('AppContainer', () => {
const appId = 'someApp';
@ -60,10 +62,15 @@ describe('AppContainer', () => {
const wrapper = mount(
<AppContainer
appPath={`/app/${appId}`}
appId={appId}
appStatus={AppStatus.inaccessible}
mounter={mounter}
setAppLeaveHandler={setAppLeaveHandler}
createScopedHistory={(appPath: string) =>
// Create a history using the appPath as the current location
new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath)
}
/>
);

View file

@ -28,18 +28,24 @@ import React, {
import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types';
import { AppNotFound } from './app_not_found_screen';
import { ScopedHistory } from '../scoped_history';
interface Props {
/** Path application is mounted on without the global basePath */
appPath: string;
appId: string;
mounter?: Mounter;
appStatus: AppStatus;
setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void;
createScopedHistory: (appUrl: string) => ScopedHistory;
}
export const AppContainer: FunctionComponent<Props> = ({
mounter,
appId,
appPath,
setAppLeaveHandler,
createScopedHistory,
appStatus,
}: Props) => {
const [appNotFound, setAppNotFound] = useState(false);
@ -67,6 +73,7 @@ export const AppContainer: FunctionComponent<Props> = ({
unmountRef.current =
(await mounter.mount({
appBasePath: mounter.appBasePath,
history: createScopedHistory(appPath),
element: elementRef.current!,
onAppLeave: handler => setAppLeaveHandler(appId, handler),
})) || null;
@ -75,7 +82,7 @@ export const AppContainer: FunctionComponent<Props> = ({
mount();
return unmount;
}, [appId, appStatus, mounter, setAppLeaveHandler]);
}, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath]);
return (
<Fragment>

View file

@ -17,7 +17,7 @@
* under the License.
*/
import React, { FunctionComponent } from 'react';
import React, { FunctionComponent, useMemo } from 'react';
import { Route, RouteComponentProps, Router, Switch } from 'react-router-dom';
import { History } from 'history';
import { Observable } from 'rxjs';
@ -25,6 +25,7 @@ import { useObservable } from 'react-use';
import { AppLeaveHandler, AppStatus, Mounter } from '../types';
import { AppContainer } from './app_container';
import { ScopedHistory } from '../scoped_history';
interface Props {
mounters: Map<string, Mounter>;
@ -44,6 +45,11 @@ export const AppRouter: FunctionComponent<Props> = ({
appStatuses$,
}) => {
const appStatuses = useObservable(appStatuses$, new Map());
const createScopedHistory = useMemo(
() => (appPath: string) => new ScopedHistory(history, appPath),
[history]
);
return (
<Router history={history}>
<Switch>
@ -56,12 +62,12 @@ export const AppRouter: FunctionComponent<Props> = ({
<Route
key={mounter.appRoute}
path={mounter.appRoute}
render={() => (
render={({ match: { url } }) => (
<AppContainer
mounter={mounter}
appId={appId}
appPath={url}
appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible}
setAppLeaveHandler={setAppLeaveHandler}
createScopedHistory={createScopedHistory}
{...{ appId, mounter, setAppLeaveHandler }}
/>
)}
/>,
@ -72,6 +78,7 @@ export const AppRouter: FunctionComponent<Props> = ({
render={({
match: {
params: { appId },
url,
},
}: RouteComponentProps<Params>) => {
// Find the mounter including legacy mounters with subapps:
@ -81,10 +88,11 @@ export const AppRouter: FunctionComponent<Props> = ({
return (
<AppContainer
mounter={mounter}
appPath={url}
appId={id}
appStatus={appStatuses.get(id) ?? AppStatus.inaccessible}
setAppLeaveHandler={setAppLeaveHandler}
createScopedHistory={createScopedHistory}
{...{ mounter, setAppLeaveHandler }}
/>
);
}}

View file

@ -108,6 +108,7 @@ export {
AppNavLinkStatus,
AppUpdatableFields,
AppUpdater,
ScopedHistory,
} from './application';
export {

View file

@ -41,6 +41,7 @@ export { notificationServiceMock } from './notifications/notifications_service.m
export { overlayServiceMock } from './overlays/overlay_service.mock';
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock';
export { scopedHistoryMock } from './application/scoped_history.mock';
function createCoreSetupMock({ basePath = '' } = {}) {
const mock = {

View file

@ -4,25 +4,30 @@
```ts
import { Action } from 'history';
import { Breadcrumb } from '@elastic/eui';
import { EuiButtonEmptyProps } from '@elastic/eui';
import { EuiConfirmModalProps } from '@elastic/eui';
import { EuiGlobalToastListToast } from '@elastic/eui';
import { ExclusiveUnion } from '@elastic/eui';
import { History } from 'history';
import { IconType } from '@elastic/eui';
import { Location } from 'history';
import { LocationDescriptorObject } from 'history';
import { MaybePromise } from '@kbn/utility-types';
import { Observable } from 'rxjs';
import React from 'react';
import * as Rx from 'rxjs';
import { ShallowPromise } from '@kbn/utility-types';
import { UiSettingsParams as UiSettingsParams_2 } from 'src/core/server/types';
import { UnregisterCallback } from 'history';
import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types';
// @public
export interface App extends AppBase {
export interface App<HistoryLocationState = unknown> extends AppBase {
appRoute?: string;
chromeless?: boolean;
mount: AppMount | AppMountDeprecated;
mount: AppMount<HistoryLocationState> | AppMountDeprecated<HistoryLocationState>;
}
// @public (undocumented)
@ -89,7 +94,7 @@ export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction
// @public (undocumented)
export interface ApplicationSetup {
register(app: App): void;
register<HistoryLocationState = unknown>(app: App<HistoryLocationState>): void;
registerAppUpdater(appUpdater$: Observable<AppUpdater>): void;
// @deprecated
registerMountContext<T extends keyof AppMountContext>(contextName: T, provider: IContextProvider<AppMountDeprecated, T>): void;
@ -112,7 +117,7 @@ export interface ApplicationStart {
}
// @public
export type AppMount = (params: AppMountParameters) => AppUnmount | Promise<AppUnmount>;
export type AppMount<HistoryLocationState = unknown> = (params: AppMountParameters<HistoryLocationState>) => AppUnmount | Promise<AppUnmount>;
// @public @deprecated
export interface AppMountContext {
@ -133,12 +138,14 @@ export interface AppMountContext {
}
// @public @deprecated
export type AppMountDeprecated = (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise<AppUnmount>;
export type AppMountDeprecated<HistoryLocationState = unknown> = (context: AppMountContext, params: AppMountParameters<HistoryLocationState>) => AppUnmount | Promise<AppUnmount>;
// @public (undocumented)
export interface AppMountParameters {
export interface AppMountParameters<HistoryLocationState = unknown> {
// @deprecated
appBasePath: string;
element: HTMLElement;
history: ScopedHistory<HistoryLocationState>;
onAppLeave: (handler: AppLeaveHandler) => void;
}
@ -1175,6 +1182,23 @@ export interface SavedObjectsUpdateOptions {
version?: string;
}
// @public
export class ScopedHistory<HistoryLocationState = unknown> implements History<HistoryLocationState> {
constructor(parentHistory: History, basePath: string);
get action(): Action;
block: (prompt?: string | boolean | History.TransitionPromptHook<HistoryLocationState> | undefined) => UnregisterCallback;
createHref: (location: LocationDescriptorObject<HistoryLocationState>) => string;
createSubHistory: <SubHistoryLocationState = unknown>(basePath: string) => ScopedHistory<SubHistoryLocationState>;
go: (n: number) => void;
goBack: () => void;
goForward: () => void;
get length(): number;
listen: (listener: (location: Location<HistoryLocationState>, action: Action) => void) => UnregisterCallback;
get location(): Location<HistoryLocationState>;
push: (pathOrLocation: string | LocationDescriptorObject<HistoryLocationState>, state?: HistoryLocationState | undefined) => void;
replace: (pathOrLocation: string | LocationDescriptorObject<HistoryLocationState>, state?: HistoryLocationState | undefined) => void;
}
// @public
export class SimpleSavedObject<T = unknown> {
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObject<T>);

View file

@ -68,7 +68,13 @@ export class LocalApplicationService {
isUnmounted = true;
});
(async () => {
const params = { element, appBasePath: '', onAppLeave: () => undefined };
const params = {
element,
appBasePath: '',
onAppLeave: () => undefined,
// TODO: adapt to use Core's ScopedHistory
history: {} as any,
};
unmountHandler = isAppMountDeprecated(app.mount)
? await app.mount({ core: npStart.core }, params)
: await app.mount(params);

View file

@ -17,8 +17,15 @@
* under the License.
*/
import { scopedHistoryMock } from '../../../../core/public/mocks';
export const setRootControllerMock = jest.fn();
jest.doMock('ui/chrome', () => ({
setRootController: setRootControllerMock,
}));
export const historyMock = scopedHistoryMock.create();
jest.doMock('../../../../core/public', () => ({
ScopedHistory: jest.fn(() => historyMock),
}));

View file

@ -17,7 +17,9 @@
* under the License.
*/
import { setRootControllerMock } from './new_platform.test.mocks';
jest.mock('history');
import { setRootControllerMock, historyMock } from './new_platform.test.mocks';
import { legacyAppRegister, __reset__, __setup__ } from './new_platform';
import { coreMock } from '../../../../core/public/mocks';
@ -63,6 +65,7 @@ describe('ui/new_platform', () => {
element: elementMock[0],
appBasePath: '/test/base/path/app/test',
onAppLeave: expect.any(Function),
history: historyMock,
});
});
@ -84,6 +87,7 @@ describe('ui/new_platform', () => {
element: elementMock[0],
appBasePath: '/test/base/path/app/test',
onAppLeave: expect.any(Function),
history: historyMock,
});
});

View file

@ -20,7 +20,14 @@ import { IScope } from 'angular';
import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public';
import { IEmbeddableStart, IEmbeddableSetup } from 'src/plugins/embeddable/public';
import { LegacyCoreSetup, LegacyCoreStart, App, AppMountDeprecated } from '../../../../core/public';
import { createBrowserHistory } from 'history';
import {
LegacyCoreSetup,
LegacyCoreStart,
App,
AppMountDeprecated,
ScopedHistory,
} from '../../../../core/public';
import { Plugin as DataPlugin } from '../../../../plugins/data/public';
import { Plugin as ExpressionsPlugin } from '../../../../plugins/expressions/public';
import {
@ -126,7 +133,7 @@ let legacyAppRegistered = false;
* Exported for testing only. Use `npSetup.core.application.register` in legacy apps.
* @internal
*/
export const legacyAppRegister = (app: App) => {
export const legacyAppRegister = (app: App<any>) => {
if (legacyAppRegistered) {
throw new Error(`core.application.register may only be called once for legacy plugins.`);
}
@ -137,9 +144,15 @@ export const legacyAppRegister = (app: App) => {
// Root controller cannot return a Promise so use an internal async function and call it immediately
(async () => {
const appRoute = app.appRoute || `/app/${app.id}`;
const appBasePath = npSetup.core.http.basePath.prepend(appRoute);
const params = {
element,
appBasePath: npSetup.core.http.basePath.prepend(`/app/${app.id}`),
appBasePath,
history: new ScopedHistory(
createBrowserHistory({ basename: npSetup.core.http.basePath.get() }),
appRoute
),
onAppLeave: () => undefined,
};
const unmount = isAppMountDeprecated(app.mount)

View file

@ -91,7 +91,13 @@ function DevToolsWrapper({
if (mountedTool.current) {
mountedTool.current.unmountHandler();
}
const params = { element, appBasePath: '', onAppLeave: () => undefined };
const params = {
element,
appBasePath: '',
onAppLeave: () => undefined,
// TODO: adapt to use Core's ScopedHistory
history: {} as any,
};
const unmountHandler = isAppMountDeprecated(activeDevTool.mount)
? await activeDevTool.mount(appMountContext, params)
: await activeDevTool.mount(params);

View file

@ -17,9 +17,10 @@
* under the License.
*/
import { History } from 'history';
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, withRouter, RouteComponentProps } from 'react-router-dom';
import { Router, Route, withRouter, RouteComponentProps } from 'react-router-dom';
import {
EuiPage,
@ -115,8 +116,8 @@ const Nav = withRouter(({ history, navigateToApp }: NavProps) => (
/>
));
const FooApp = ({ basename, context }: { basename: string; context: AppMountContext }) => (
<Router basename={basename}>
const FooApp = ({ history, context }: { history: History; context: AppMountContext }) => (
<Router history={history}>
<EuiPage>
<EuiPageSideBar>
<Nav navigateToApp={context.core.application.navigateToApp} />
@ -127,11 +128,8 @@ const FooApp = ({ basename, context }: { basename: string; context: AppMountCont
</Router>
);
export const renderApp = (
context: AppMountContext,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(<FooApp basename={appBasePath} context={context} />, element);
export const renderApp = (context: AppMountContext, { history, element }: AppMountParameters) => {
ReactDOM.render(<FooApp history={history} context={context} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -17,9 +17,10 @@
* under the License.
*/
import { History } from 'history';
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, withRouter, RouteComponentProps } from 'react-router-dom';
import { Router, Route, withRouter, RouteComponentProps } from 'react-router-dom';
import {
EuiPage,
@ -122,8 +123,8 @@ const Nav = withRouter(({ history, navigateToApp }: NavProps) => (
/>
));
const BarApp = ({ basename, context }: { basename: string; context: AppMountContext }) => (
<Router basename={basename}>
const BarApp = ({ history, context }: { history: History; context: AppMountContext }) => (
<Router history={history}>
<EuiPage>
<EuiPageSideBar>
<Nav navigateToApp={context.core.application.navigateToApp} />
@ -134,11 +135,8 @@ const BarApp = ({ basename, context }: { basename: string; context: AppMountCont
</Router>
);
export const renderApp = (
context: AppMountContext,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(<BarApp basename={appBasePath} context={context} />, element);
export const renderApp = (context: AppMountContext, { history, element }: AppMountParameters) => {
ReactDOM.render(<BarApp history={history} context={context} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -26,6 +26,7 @@ export default function(kibana: any) {
require: ['kibana'],
uiExports: {
app: {
id: 'core_legacy_compat',
title: 'Core Legacy Compat',
description: 'This is a sample plugin to test core to legacy compatibility',
main: 'plugins/core_plugin_legacy/index',

View file

@ -0,0 +1,34 @@
/*
* 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.
*/
// eslint-disable-next-line import/no-default-export
export default function(kibana: any) {
return new kibana.Plugin({
id: 'legacy_plugin',
require: ['kibana'],
uiExports: {
app: {
id: 'legacy_app',
title: 'Legacy App',
description: 'This is a sample plugin to test legacy apps',
main: 'plugins/legacy_plugin/index',
},
},
});
}

View file

@ -0,0 +1,17 @@
{
"name": "legacy_plugin",
"version": "1.0.0",
"main": "index.tsx",
"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.7.2"
}
}

View file

@ -0,0 +1,35 @@
/*
* 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 { IScope } from 'angular';
import React from 'react';
import ReactDOM from 'react-dom';
import chrome from 'ui/chrome';
const App = () => <h1 data-test-subj="legacyAppH1">legacy_app</h1>;
chrome.setRootController('legacy_app', ($scope: IScope, $element: JQLite) => {
const element = $element[0];
ReactDOM.render(<App />, element);
$scope.$on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element);
});
});

View file

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

View file

@ -73,7 +73,7 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
const alert = await browser.getAlert();
expect(alert).not.to.eql(undefined);
alert!.accept();
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/core_plugin_legacy'));
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/core_legacy_compat'));
});
});
});

View file

@ -28,6 +28,7 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
const appsMenu = getService('appsMenu');
const testSubjects = getService('testSubjects');
const find = getService('find');
const retry = getService('retry');
const loadingScreenNotShown = async () =>
expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false);
@ -48,6 +49,14 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
search,
});
/** Use retry logic to make URL assertions less flaky */
const waitForUrlToBe = (pathname?: string, search?: string) => {
const expectedUrl = getKibanaUrl(pathname, search);
return retry.waitFor(`Url to be ${expectedUrl}`, async () => {
return (await browser.getCurrentUrl()) === expectedUrl;
});
};
describe('ui applications', function describeIndexTests() {
before(async () => {
await PageObjects.common.navigateToApp('foo');
@ -60,29 +69,36 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
it('navigates to its own pages', async () => {
// Go to page A
await testSubjects.click('fooNavPageA');
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/foo/page-a'));
await waitForUrlToBe('/app/foo/page-a');
await loadingScreenNotShown();
await testSubjects.existOrFail('fooAppPageA');
// Go to home page
await testSubjects.click('fooNavHome');
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/foo/'));
await waitForUrlToBe('/app/foo');
await loadingScreenNotShown();
await testSubjects.existOrFail('fooAppHome');
});
it('can use the back button to navigate within an app', async () => {
await browser.goBack();
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/foo/page-a'));
await waitForUrlToBe('/app/foo/page-a');
await loadingScreenNotShown();
await testSubjects.existOrFail('fooAppPageA');
});
it('navigates to app root when navlink is clicked', async () => {
await appsMenu.clickLink('Foo');
await waitForUrlToBe('/app/foo');
await loadingScreenNotShown();
await testSubjects.existOrFail('fooAppHome');
});
it('navigates to other apps', async () => {
await testSubjects.click('fooNavBarPageB');
await loadingScreenNotShown();
await testSubjects.existOrFail('barAppPageB');
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/bar/page-b', 'query=here'));
await waitForUrlToBe('/app/bar/page-b', 'query=here');
});
it('preserves query parameters across apps', async () => {
@ -92,9 +108,9 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
it('can use the back button to navigate back to previous app', async () => {
await browser.goBack();
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/foo/page-a'));
await waitForUrlToBe('/app/foo');
await loadingScreenNotShown();
await testSubjects.existOrFail('fooAppPageA');
await testSubjects.existOrFail('fooAppHome');
});
it('chromeless applications are not visible in apps list', async () => {

View file

@ -45,7 +45,7 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
describe('application service compatibility layer', () => {
it('can render legacy apps', async () => {
await PageObjects.common.navigateToApp('core_plugin_legacy');
await PageObjects.common.navigateToApp('core_legacy_compat');
expect(await testSubjects.exists('coreLegacyCompatH1')).to.be(true);
});
});

View file

@ -106,7 +106,7 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
});
it('renders "legacy" application', async () => {
await navigateTo('/render/core_plugin_legacy');
await navigateTo('/render/legacy_app');
const [loadingMessage, legacyMode, userSettings] = await Promise.all([
findLoadingMessage(),
@ -119,12 +119,12 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
await find.waitForElementStale(loadingMessage);
expect(await exists('coreLegacyCompatH1')).to.be(true);
expect(await exists('legacyAppH1')).to.be(true);
expect(await exists('renderingHeader')).to.be(false);
});
it('renders "legacy" application without user settings', async () => {
await navigateTo('/render/core_plugin_legacy?includeUserSettings=false');
await navigateTo('/render/legacy_app?includeUserSettings=false');
const [loadingMessage, legacyMode, userSettings] = await Promise.all([
findLoadingMessage(),
@ -137,7 +137,7 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
await find.waitForElementStale(loadingMessage);
expect(await exists('coreLegacyCompatH1')).to.be(true);
expect(await exists('legacyAppH1')).to.be(true);
expect(await exists('renderingHeader')).to.be(false);
});

View file

@ -74,11 +74,13 @@ if (chrome.getInjected('ilmUiEnabled')) {
...core,
application: {
...core.application,
async register(app: App) {
async register(app: App<any>) {
const unmountApp = await app.mount({ ...npStart } as any, {
element,
appBasePath: '',
onAppLeave: () => undefined,
// TODO: adapt to use Core's ScopedHistory
history: {} as any,
});
manageAngularLifecycle($scope, $route, unmountApp as any);
},

View file

@ -63,11 +63,13 @@ if (licenseManagementUiEnabled) {
...npSetup.core,
application: {
...npSetup.core.application,
async register(app: App) {
async register(app: App<any>) {
const unmountApp = await app.mount({ ...npStart } as any, {
element,
appBasePath: '',
onAppLeave: () => undefined,
// TODO: adapt to use Core's ScopedHistory
history: {} as any,
});
manageAngularLifecycle($scope, $route, unmountApp as any);
},

View file

@ -19,6 +19,7 @@ export class MlPlugin implements Plugin<Setup, Start> {
element: params.element,
appBasePath: params.appBasePath,
onAppLeave: params.onAppLeave,
history: params.history,
data,
__LEGACY,
security,

View file

@ -46,7 +46,6 @@ export class Plugin {
title: PLUGIN.TITLE,
});
core.application.register({
appRoute: '/app/uptime#/',
id: PLUGIN.ID,
euiIconType: 'uptimeApp',
order: 8900,