Allow chromeless applications to render via non-/app routes (#51527)

* Allow chromeless applications to render via non-/app routes

* Address review nits

* Fix chrome:application integration tests

* Move extraneous application integration tests to unit tests
This commit is contained in:
Eli Perelman 2019-12-29 15:10:14 -06:00 committed by GitHub
parent 054ec7036d
commit fdd87e024f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 927 additions and 632 deletions

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; [App](./kibana-plugin-public.app.md) &gt; [appRoute](./kibana-plugin-public.app.approute.md)
## App.appRoute property
Override the application's routing path from `/app/${id}`<!-- -->. Must be unique across registered applications. Should not include the base path from HTTP.
<b>Signature:</b>
```typescript
appRoute?: string;
```

View file

@ -16,6 +16,7 @@ export interface App extends AppBase
| Property | Type | Description |
| --- | --- | --- |
| [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)<!-- -->. |

View file

@ -4,7 +4,7 @@
## AppMountParameters.appBasePath property
The base path for configuring the application's router.
The route path for configuring navigation to the application. This string should not include the base path from HTTP.
<b>Signature:</b>
@ -22,6 +22,7 @@ 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);

View file

@ -15,6 +15,6 @@ export interface AppMountParameters
| Property | Type | Description |
| --- | --- | --- |
| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | <code>string</code> | The base path for configuring the application's router. |
| [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. |

View file

@ -20,15 +20,13 @@
import { Subject } from 'rxjs';
import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock';
import { ApplicationService } from './application_service';
import {
ApplicationSetup,
InternalApplicationStart,
ApplicationStart,
InternalApplicationSetup,
} from './types';
type ApplicationServiceContract = PublicMethodsOf<ApplicationService>;
import { ApplicationServiceContract } from './test_types';
const createSetupContractMock = (): jest.Mocked<ApplicationSetup> => ({
register: jest.fn(),
@ -41,23 +39,27 @@ const createInternalSetupContractMock = (): jest.Mocked<InternalApplicationSetup
registerMountContext: jest.fn(),
});
const createStartContractMock = (legacyMode = false): jest.Mocked<ApplicationStart> => ({
const createStartContractMock = (): jest.Mocked<ApplicationStart> => ({
capabilities: capabilitiesServiceMock.createStartContract().capabilities,
navigateToApp: jest.fn(),
getUrlForApp: jest.fn(),
registerMountContext: jest.fn(),
});
const createInternalStartContractMock = (): jest.Mocked<InternalApplicationStart> => ({
availableApps: new Map(),
availableLegacyApps: new Map(),
capabilities: capabilitiesServiceMock.createStartContract().capabilities,
navigateToApp: jest.fn(),
getUrlForApp: jest.fn(),
registerMountContext: jest.fn(),
currentAppId$: new Subject<string | undefined>(),
getComponent: jest.fn(),
});
const createInternalStartContractMock = (): jest.Mocked<InternalApplicationStart> => {
const currentAppId$ = new Subject<string | undefined>();
return {
availableApps: new Map(),
availableLegacyApps: new Map(),
capabilities: capabilitiesServiceMock.createStartContract().capabilities,
currentAppId$: currentAppId$.asObservable(),
getComponent: jest.fn(),
getUrlForApp: jest.fn(),
navigateToApp: jest.fn().mockImplementation(appId => currentAppId$.next(appId)),
registerMountContext: jest.fn(),
};
};
const createMock = (): jest.Mocked<ApplicationServiceContract> => ({
setup: jest.fn().mockReturnValue(createInternalSetupContractMock()),
@ -69,7 +71,6 @@ export const applicationServiceMock = {
create: createMock,
createSetupContract: createSetupContractMock,
createStartContract: createStartContractMock,
createInternalSetupContract: createInternalSetupContractMock,
createInternalStartContract: createInternalStartContractMock,
};

View file

@ -0,0 +1,441 @@
/*
* 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 { createElement } from 'react';
import { Subject } from 'rxjs';
import { bufferCount, skip, takeUntil } from 'rxjs/operators';
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 { 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;
beforeEach(() => {
const http = httpServiceMock.createSetupContract({ basePath: '/test' });
setupDeps = {
http,
context: contextServiceMock.createSetupContract(),
injectedMetadata: injectedMetadataServiceMock.createSetupContract(),
};
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false);
startDeps = { http, injectedMetadata: setupDeps.injectedMetadata };
service = new ApplicationService();
});
describe('register', () => {
it('throws an error if two apps with the same id are registered', () => {
const { register } = service.setup(setupDeps);
register(Symbol(), { id: 'app1', mount } as any);
expect(() =>
register(Symbol(), { id: 'app1', mount } as any)
).toThrowErrorMatchingInlineSnapshot(
`"An application is already registered with the id \\"app1\\""`
);
});
it('throws error if additional apps are registered after setup', async () => {
const { register } = service.setup(setupDeps);
await service.start(startDeps);
expect(() =>
register(Symbol(), { id: 'app1', mount } as any)
).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`);
});
it('throws an error if an App with the same appRoute is registered', () => {
const { register, registerLegacyApp } = service.setup(setupDeps);
register(Symbol(), { id: 'app1', mount } as any);
expect(() =>
register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any)
).toThrowErrorMatchingInlineSnapshot(
`"An application is already registered with the appRoute \\"/app/app1\\""`
);
expect(() => registerLegacyApp({ id: 'app1' } as any)).not.toThrow();
register(Symbol(), { id: 'app-next', mount, appRoute: '/app/app3' } as any);
expect(() =>
register(Symbol(), { id: 'app2', mount, appRoute: '/app/app3' } as any)
).toThrowErrorMatchingInlineSnapshot(
`"An application is already registered with the appRoute \\"/app/app3\\""`
);
expect(() => registerLegacyApp({ id: 'app3' } as any)).not.toThrow();
});
it('throws an error if an App starts with the HTTP base path', () => {
const { register } = service.setup(setupDeps);
expect(() =>
register(Symbol(), { id: 'app2', mount, appRoute: '/test/app2' } as any)
).toThrowErrorMatchingInlineSnapshot(
`"Cannot register an application route that includes HTTP base path"`
);
});
});
describe('registerLegacyApp', () => {
it('throws an error if two apps with the same id are registered', () => {
const { registerLegacyApp } = service.setup(setupDeps);
registerLegacyApp({ id: 'app2' } as any);
expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot(
`"A legacy application is already registered with the id \\"app2\\""`
);
});
it('throws error if additional apps are registered after setup', async () => {
const { registerLegacyApp } = service.setup(setupDeps);
await service.start(startDeps);
expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot(
`"Applications cannot be registered after \\"setup\\""`
);
});
it('throws an error if a LegacyApp with the same appRoute is registered', () => {
const { register, registerLegacyApp } = service.setup(setupDeps);
registerLegacyApp({ id: 'app1' } as any);
expect(() =>
register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any)
).toThrowErrorMatchingInlineSnapshot(
`"An application is already registered with the appRoute \\"/app/app1\\""`
);
expect(() => registerLegacyApp({ id: 'app1:other' } as any)).not.toThrow();
});
});
it("`registerMountContext` calls context container's registerContext", () => {
const { registerMountContext } = service.setup(setupDeps);
const container = setupDeps.context.createContextContainer.mock.results[0].value;
const pluginId = Symbol();
registerMountContext(pluginId, 'test' as any, mount as any);
expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount);
});
});
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,
context: contextServiceMock.createSetupContract(),
injectedMetadata: injectedMetadataServiceMock.createSetupContract(),
};
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false);
startDeps = { http, injectedMetadata: setupDeps.injectedMetadata };
service = new ApplicationService();
});
it('rejects if called prior to #setup()', async () => {
await expect(service.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
`"ApplicationService#setup() must be invoked before start."`
);
});
it('exposes available apps', async () => {
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true);
const { register, registerLegacyApp } = service.setup(setupDeps);
register(Symbol(), { id: 'app1', mount } as any);
registerLegacyApp({ id: 'app2' } as any);
const { availableApps, availableLegacyApps } = await service.start(startDeps);
expect(availableApps).toMatchInlineSnapshot(`
Map {
"app1" => Object {
"appRoute": "/app/app1",
"id": "app1",
"mount": [Function],
},
}
`);
expect(availableLegacyApps).toMatchInlineSnapshot(`
Map {
"app2" => Object {
"id": "app2",
},
}
`);
});
it('passes appIds to capabilities', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), { id: 'app1', mount } as any);
register(Symbol(), { id: 'app2', mount } as any);
register(Symbol(), { id: 'app3', mount } as any);
await service.start(startDeps);
expect(MockCapabilitiesService.start).toHaveBeenCalledWith({
appIds: ['app1', 'app2', 'app3'],
http: setupDeps.http,
});
});
it('filters available applications based on capabilities', async () => {
MockCapabilitiesService.start.mockResolvedValueOnce({
capabilities: {
navLinks: {
app1: true,
app2: false,
legacyApp1: true,
legacyApp2: false,
},
},
} as any);
const { register, registerLegacyApp } = service.setup(setupDeps);
register(Symbol(), { id: 'app1', mount } as any);
registerLegacyApp({ id: 'legacyApp1' } as any);
register(Symbol(), { id: 'app2', mount } as any);
registerLegacyApp({ id: 'legacyApp2' } as any);
const { availableApps, availableLegacyApps } = await service.start(startDeps);
expect(availableApps).toMatchInlineSnapshot(`
Map {
"app1" => Object {
"appRoute": "/app/app1",
"id": "app1",
"mount": [Function],
},
}
`);
expect(availableLegacyApps).toMatchInlineSnapshot(`
Map {
"legacyApp1" => Object {
"id": "legacyApp1",
},
}
`);
});
describe('getComponent', () => {
it('returns renderable JSX tree', async () => {
service.setup(setupDeps);
const { getComponent } = await service.start(startDeps);
expect(() => shallow(createElement(getComponent))).not.toThrow();
expect(getComponent()).toMatchInlineSnapshot(`
<AppRouter
history={
Object {
"push": [MockFunction],
}
}
mounters={Map {}}
/>
`);
});
it('renders null when in legacy mode', async () => {
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true);
service.setup(setupDeps);
const { getComponent } = await service.start(startDeps);
expect(() => shallow(createElement(getComponent))).not.toThrow();
expect(getComponent()).toBe(null);
});
});
describe('getUrlForApp', () => {
it('creates URL for unregistered appId', async () => {
service.setup(setupDeps);
const { getUrlForApp } = await service.start(startDeps);
expect(getUrlForApp('app1')).toBe('/app/app1');
});
it('creates URL for registered appId', async () => {
const { register, registerLegacyApp } = service.setup(setupDeps);
register(Symbol(), { id: 'app1', mount } as any);
registerLegacyApp({ id: 'legacyApp1' } as any);
register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any);
const { getUrlForApp } = await service.start(startDeps);
expect(getUrlForApp('app1')).toBe('/app/app1');
expect(getUrlForApp('legacyApp1')).toBe('/app/legacyApp1');
expect(getUrlForApp('app2')).toBe('/custom/path');
});
it('creates URLs with path parameter', async () => {
service.setup(setupDeps);
const { getUrlForApp } = await service.start(startDeps);
expect(getUrlForApp('app1', { path: 'deep/link' })).toBe('/app/app1/deep/link');
expect(getUrlForApp('app1', { path: '/deep//link/' })).toBe('/app/app1/deep/link');
expect(getUrlForApp('app1', { path: '//deep/link//' })).toBe('/app/app1/deep/link');
expect(getUrlForApp('app1', { path: 'deep/link///' })).toBe('/app/app1/deep/link');
});
});
describe('navigateToApp', () => {
it('changes the browser history to /app/:appId', async () => {
service.setup(setupDeps);
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined);
navigateToApp('myOtherApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined);
});
it('changes the browser history for custom appRoutes', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any);
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined);
navigateToApp('app2');
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', undefined);
});
it('appends a path if specified', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any);
const { navigateToApp } = await service.start(startDeps);
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' });
expect(MockHistory.push).toHaveBeenCalledWith(
'/custom/path/deep/link/to/location/2',
undefined
);
});
it('includes state if specified', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any);
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp', { state: 'my-state' });
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state');
navigateToApp('app2', { state: 'my-state' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', 'my-state');
});
it('redirects when in legacyMode', async () => {
setupDeps.redirectTo = jest.fn();
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true);
service.setup(setupDeps);
const { navigateToApp } = await service.start(startDeps);
navigateToApp('myTestApp');
expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/myTestApp');
});
it('updates currentApp$ after mounting', async () => {
service.setup(setupDeps);
const { currentAppId$, navigateToApp } = await service.start(startDeps);
const stop$ = new Subject();
const promise = currentAppId$.pipe(skip(1), bufferCount(4), takeUntil(stop$)).toPromise();
await navigateToApp('alpha');
await navigateToApp('beta');
await navigateToApp('gamma');
await navigateToApp('delta');
stop$.next();
const appIds = await promise;
expect(appIds).toMatchInlineSnapshot(`
Array [
"alpha",
"beta",
"gamma",
"delta",
]
`);
});
it('sets window.location.href when navigating to legacy apps', async () => {
setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' });
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true);
setupDeps.redirectTo = jest.fn();
service.setup(setupDeps);
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('alpha');
expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/alpha');
});
it('handles legacy apps with subapps', async () => {
setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' });
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true);
setupDeps.redirectTo = jest.fn();
const { registerLegacyApp } = service.setup(setupDeps);
registerLegacyApp({ id: 'baseApp:legacyApp1' } as any);
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('baseApp:legacyApp1');
expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/baseApp');
});
});
});

View file

@ -1,249 +0,0 @@
/*
* 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 { shallow } from 'enzyme';
import React from 'react';
import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock';
import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks';
import { ApplicationService } from './application_service';
import { contextServiceMock } from '../context/context_service.mock';
import { httpServiceMock } from '../http/http_service.mock';
describe('#setup()', () => {
describe('register', () => {
it('throws an error if two apps with the same id are registered', () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any);
expect(() =>
setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any)
).toThrowErrorMatchingInlineSnapshot(
`"An application is already registered with the id \\"app1\\""`
);
});
it('throws error if additional apps are registered after setup', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
await service.start({ http, injectedMetadata });
expect(() =>
setup.register(Symbol(), { id: 'app1' } as any)
).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`);
});
it('logs a warning when registering a deprecated app mount', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn');
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
setup.register(Symbol(), { id: 'app1', mount: (ctx: any, params: any) => {} } as any);
expect(consoleWarnSpy).toHaveBeenCalledWith(
`App [app1] is using deprecated mount context. Use core.getStartServices() instead.`
);
consoleWarnSpy.mockRestore();
});
});
describe('registerLegacyApp', () => {
it('throws an error if two apps with the same id are registered', () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
setup.registerLegacyApp({ id: 'app2' } as any);
expect(() =>
setup.registerLegacyApp({ id: 'app2' } as any)
).toThrowErrorMatchingInlineSnapshot(
`"A legacy application is already registered with the id \\"app2\\""`
);
});
it('throws error if additional apps are registered after setup', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
await service.start({ http, injectedMetadata });
expect(() =>
setup.registerLegacyApp({ id: 'app2' } as any)
).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`);
});
});
it("`registerMountContext` calls context container's registerContext", () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
const container = context.createContextContainer.mock.results[0].value;
const pluginId = Symbol();
const noop = () => {};
setup.registerMountContext(pluginId, 'test' as any, noop as any);
expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', noop);
});
});
describe('#start()', () => {
beforeEach(() => {
MockHistory.push.mockReset();
});
it('exposes available apps from capabilities', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any);
setup.registerLegacyApp({ id: 'app2' } as any);
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
const startContract = await service.start({ http, injectedMetadata });
expect(startContract.availableApps).toMatchInlineSnapshot(`
Map {
"app1" => Object {
"id": "app1",
"mount": [MockFunction],
},
}
`);
expect(startContract.availableLegacyApps).toMatchInlineSnapshot(`
Map {
"app2" => Object {
"id": "app2",
},
}
`);
});
it('passes registered applications to capabilities', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
const app1 = { id: 'app1', mount: jest.fn() };
setup.register(Symbol(), app1 as any);
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
await service.start({ http, injectedMetadata });
expect(MockCapabilitiesService.start).toHaveBeenCalledWith({
apps: new Map([['app1', app1]]),
legacyApps: new Map(),
http,
});
});
it('passes registered legacy applications to capabilities', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
const setup = service.setup({ context });
setup.registerLegacyApp({ id: 'legacyApp1' } as any);
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
await service.start({ http, injectedMetadata });
expect(MockCapabilitiesService.start).toHaveBeenCalledWith({
apps: new Map(),
legacyApps: new Map([['legacyApp1', { id: 'legacyApp1' }]]),
http,
});
});
it('returns renderable JSX tree', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
service.setup({ context });
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
injectedMetadata.getLegacyMode.mockReturnValue(false);
const start = await service.start({ http, injectedMetadata });
expect(() => shallow(React.createElement(() => start.getComponent()))).not.toThrow();
});
describe('navigateToApp', () => {
it('changes the browser history to /app/:appId', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
service.setup({ context });
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
injectedMetadata.getLegacyMode.mockReturnValue(false);
const start = await service.start({ http, injectedMetadata });
start.navigateToApp('myTestApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined);
start.navigateToApp('myOtherApp');
expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined);
});
it('appends a path if specified', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
service.setup({ context });
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
injectedMetadata.getLegacyMode.mockReturnValue(false);
const start = await service.start({ http, injectedMetadata });
start.navigateToApp('myTestApp', { path: 'deep/link/to/location/2' });
expect(MockHistory.push).toHaveBeenCalledWith(
'/app/myTestApp/deep/link/to/location/2',
undefined
);
});
it('includes state if specified', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
service.setup({ context });
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
injectedMetadata.getLegacyMode.mockReturnValue(false);
const start = await service.start({ http, injectedMetadata });
start.navigateToApp('myTestApp', { state: 'my-state' });
expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state');
});
it('redirects when in legacyMode', async () => {
const service = new ApplicationService();
const context = contextServiceMock.createSetupContract();
service.setup({ context });
const http = httpServiceMock.createStartContract();
const injectedMetadata = injectedMetadataServiceMock.createStartContract();
injectedMetadata.getLegacyMode.mockReturnValue(true);
const redirectTo = jest.fn();
const start = await service.start({ http, injectedMetadata, redirectTo });
start.navigateToApp('myTestApp');
expect(redirectTo).toHaveBeenCalledWith('/app/myTestApp');
});
});
});

View file

@ -17,31 +17,32 @@
* under the License.
*/
import { createBrowserHistory } from 'history';
import { BehaviorSubject } from 'rxjs';
import React from 'react';
import { BehaviorSubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { createBrowserHistory, History } from 'history';
import { InjectedMetadataStart } from '../injected_metadata';
import { CapabilitiesService } from './capabilities';
import { AppRouter } from './ui';
import { HttpStart } from '../http';
import { InjectedMetadataSetup, InjectedMetadataStart } from '../injected_metadata';
import { HttpSetup, HttpStart } from '../http';
import { ContextSetup, IContextContainer } from '../context';
import { AppRouter } from './ui';
import { CapabilitiesService, Capabilities } from './capabilities';
import {
App,
LegacyApp,
AppMount,
AppMountDeprecated,
AppMounter,
LegacyAppMounter,
Mounter,
InternalApplicationSetup,
InternalApplicationStart,
} from './types';
interface SetupDeps {
context: ContextSetup;
}
interface StartDeps {
http: HttpStart;
injectedMetadata: InjectedMetadataStart;
http: HttpSetup;
injectedMetadata: InjectedMetadataSetup;
/**
* Only necessary for redirecting to legacy apps
* @deprecated
@ -49,144 +50,158 @@ interface StartDeps {
redirectTo?: (path: string) => void;
}
interface AppBox {
app: App;
mount: AppMount;
interface StartDeps {
injectedMetadata: InjectedMetadataStart;
http: HttpStart;
}
// Mount functions with two arguments are assumed to expect deprecated `context` object.
const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated =>
mount.length === 2;
const filterAvailable = (map: Map<string, any>, capabilities: Capabilities) =>
new Map(
[...map].filter(
([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true
)
);
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}`
.replace(/\/{2,}/g, '/') // Remove duplicate slashes
.replace(/\/$/, ''); // Remove trailing slash
/**
* Service that is responsible for registering new applications.
* @internal
*/
export class ApplicationService {
private readonly apps$ = new BehaviorSubject<ReadonlyMap<string, AppBox>>(new Map());
private readonly legacyApps$ = new BehaviorSubject<ReadonlyMap<string, LegacyApp>>(new Map());
private readonly apps = new Map<string, App>();
private readonly legacyApps = new Map<string, LegacyApp>();
private readonly mounters = new Map<string, Mounter>();
private readonly capabilities = new CapabilitiesService();
private currentAppId$ = new BehaviorSubject<string | undefined>(undefined);
private stop$ = new Subject();
private registrationClosed = false;
private history?: History<any>;
private mountContext?: IContextContainer<AppMountDeprecated>;
private navigate?: (url: string, state: any) => void;
public setup({ context }: SetupDeps): InternalApplicationSetup {
public setup({
context,
http: { basePath },
injectedMetadata,
redirectTo = (path: string) => (window.location.href = path),
}: SetupDeps): InternalApplicationSetup {
const basename = basePath.get();
// Only setup history if we're not in legacy mode
if (!injectedMetadata.getLegacyMode()) {
this.history = createBrowserHistory({ basename });
}
// If we do not have history available, use redirectTo to do a full page refresh.
this.navigate = (url, state) =>
// basePath not needed here because `history` is configured with basename
this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url));
this.mountContext = context.createContextContainer();
return {
register: (plugin: symbol, app: App) => {
if (this.apps$.value.has(app.id)) {
throw new Error(`An application is already registered with the id "${app.id}"`);
}
if (this.apps$.isStopped) {
registerMountContext: this.mountContext!.registerContext,
register: (plugin, app) => {
app = { appRoute: `/app/${app.id}`, ...app };
if (this.registrationClosed) {
throw new Error(`Applications cannot be registered after "setup"`);
} else if (this.apps.has(app.id)) {
throw new Error(`An application is already registered with the id "${app.id}"`);
} else if (findMounter(this.mounters, app.appRoute)) {
throw new Error(
`An application is already registered with the appRoute "${app.appRoute}"`
);
} else if (basename && app.appRoute!.startsWith(basename)) {
throw new Error('Cannot register an application route that includes HTTP base path');
}
let appBox: AppBox;
let handler: AppMount;
if (isAppMountDeprecated(app.mount)) {
handler = this.mountContext!.createHandler(plugin, app.mount);
// eslint-disable-next-line no-console
console.warn(
`App [${app.id}] is using deprecated mount context. Use core.getStartServices() instead.`
);
appBox = {
app,
mount: this.mountContext!.createHandler(plugin, app.mount),
};
} else {
appBox = { app, mount: app.mount };
handler = app.mount;
}
this.apps$.next(new Map([...this.apps$.value.entries(), [app.id, appBox]]));
const mount: AppMounter = async params => {
const unmount = await handler(params);
this.currentAppId$.next(app.id);
return unmount;
};
this.apps.set(app.id, app);
this.mounters.set(app.id, {
appRoute: app.appRoute!,
appBasePath: basePath.prepend(app.appRoute!),
mount,
unmountBeforeMounting: false,
});
},
registerLegacyApp: (app: LegacyApp) => {
if (this.legacyApps$.value.has(app.id)) {
registerLegacyApp: app => {
const appRoute = `/app/${app.id.split(':')[0]}`;
if (this.registrationClosed) {
throw new Error('Applications cannot be registered after "setup"');
} else if (this.legacyApps.has(app.id)) {
throw new Error(`A legacy application is already registered with the id "${app.id}"`);
}
if (this.legacyApps$.isStopped) {
throw new Error(`Applications cannot be registered after "setup"`);
} else if (basename && appRoute!.startsWith(basename)) {
throw new Error('Cannot register an application route that includes HTTP base path');
}
this.legacyApps$.next(new Map([...this.legacyApps$.value.entries(), [app.id, app]]));
const appBasePath = basePath.prepend(appRoute);
const mount: LegacyAppMounter = () => redirectTo(appBasePath);
this.legacyApps.set(app.id, app);
this.mounters.set(app.id, {
appRoute,
appBasePath,
mount,
unmountBeforeMounting: true,
});
},
registerMountContext: this.mountContext!.registerContext,
};
}
public async start({
http,
injectedMetadata,
redirectTo = (path: string) => (window.location.href = path),
}: StartDeps): Promise<InternalApplicationStart> {
public async start({ injectedMetadata, http }: StartDeps): Promise<InternalApplicationStart> {
if (!this.mountContext) {
throw new Error(`ApplicationService#setup() must be invoked before start.`);
throw new Error('ApplicationService#setup() must be invoked before start.');
}
// Disable registration of new applications
this.apps$.complete();
this.legacyApps$.complete();
const legacyMode = injectedMetadata.getLegacyMode();
const currentAppId$ = new BehaviorSubject<string | undefined>(undefined);
const { availableApps, availableLegacyApps, capabilities } = await this.capabilities.start({
this.registrationClosed = true;
const { capabilities } = await this.capabilities.start({
appIds: [...this.mounters.keys()],
http,
apps: new Map([...this.apps$.value].map(([id, { app }]) => [id, app])),
legacyApps: this.legacyApps$.value,
});
// Only setup history if we're not in legacy mode
const history = legacyMode ? null : createBrowserHistory({ basename: http.basePath.get() });
const availableMounters = filterAvailable(this.mounters, capabilities);
return {
availableApps,
availableLegacyApps,
availableApps: filterAvailable(this.apps, capabilities),
availableLegacyApps: filterAvailable(this.legacyApps, capabilities),
capabilities,
currentAppId$: this.currentAppId$.pipe(takeUntil(this.stop$)),
registerMountContext: this.mountContext.registerContext,
currentAppId$,
getUrlForApp: (appId, options: { path?: string } = {}) => {
return http.basePath.prepend(appPath(appId, options));
},
getUrlForApp: (appId, { path }: { path?: string } = {}) =>
getAppUrl(availableMounters, appId, path),
navigateToApp: (appId, { path, state }: { path?: string; state?: any } = {}) => {
if (legacyMode) {
// If we're in legacy mode, do a full page refresh to load the NP app.
redirectTo(http.basePath.prepend(appPath(appId, { path })));
} else {
// basePath not needed here because `history` is configured with basename
history!.push(appPath(appId, { path }), state);
}
},
getComponent: () => {
if (legacyMode) {
return null;
}
// Filter only available apps and map to just the mount function.
const appMounts = new Map<string, AppMount>(
[...this.apps$.value]
.filter(([id]) => availableApps.has(id))
.map(([id, { mount }]) => [id, mount])
);
return (
<AppRouter
apps={appMounts}
legacyApps={availableLegacyApps}
basePath={http.basePath}
currentAppId$={currentAppId$}
history={history!}
redirectTo={redirectTo}
/>
);
this.navigate!(getAppUrl(availableMounters, appId, path), state);
this.currentAppId$.next(appId);
},
getComponent: () =>
this.history ? <AppRouter history={this.history} mounters={availableMounters} /> : null,
};
}
public stop() {}
}
const appPath = (appId: string, { path }: { path?: string } = {}): string =>
path
? `/app/${appId}/${path.replace(/^\//, '')}` // Remove preceding slash from path if present
: `/app/${appId}`;
function isAppMountDeprecated(mount: (...args: any[]) => any): mount is AppMountDeprecated {
// Mount functions with two arguments are assumed to expect deprecated `context` object.
return mount.length === 2;
public stop() {
this.stop$.next();
this.currentAppId$.complete();
}
}

View file

@ -17,15 +17,9 @@
* under the License.
*/
import { CapabilitiesService, CapabilitiesStart } from './capabilities_service';
import { deepFreeze } from '../../../utils/';
import { App, LegacyApp } from '../types';
import { deepFreeze } from '../../../utils';
const createStartContractMock = (
apps: ReadonlyMap<string, App> = new Map(),
legacyApps: ReadonlyMap<string, LegacyApp> = new Map()
): jest.Mocked<CapabilitiesStart> => ({
availableApps: apps,
availableLegacyApps: legacyApps,
const createStartContractMock = (): jest.Mocked<CapabilitiesStart> => ({
capabilities: deepFreeze({
catalogue: {},
management: {},
@ -33,11 +27,8 @@ const createStartContractMock = (
}),
});
type CapabilitiesServiceContract = PublicMethodsOf<CapabilitiesService>;
const createMock = (): jest.Mocked<CapabilitiesServiceContract> => ({
start: jest
.fn()
.mockImplementation(({ apps, legacyApps }) => createStartContractMock(apps, legacyApps)),
const createMock = (): jest.Mocked<PublicMethodsOf<CapabilitiesService>> => ({
start: jest.fn().mockImplementation(createStartContractMock),
});
export const capabilitiesServiceMock = {

View file

@ -19,7 +19,6 @@
import { httpServiceMock, HttpSetupMock } from '../../http/http_service.mock';
import { CapabilitiesService } from './capabilities_service';
import { LegacyApp, App } from '../types';
const mockedCapabilities = {
catalogue: {},
@ -42,36 +41,22 @@ describe('#start', () => {
http.post.mockReturnValue(Promise.resolve(mockedCapabilities));
});
const apps = new Map([
['app1', { id: 'app1' }],
['app2', { id: 'app2', capabilities: { app2: { feature: true } } }],
['appMissingInCapabilities', { id: 'appMissingInCapabilities' }],
] as Array<[string, App]>);
const legacyApps = new Map([
['legacyApp1', { id: 'legacyApp1' }],
['legacyApp2', { id: 'legacyApp2', capabilities: { app2: { feature: true } } }],
] as Array<[string, LegacyApp]>);
it('filters available apps based on returned navLinks', async () => {
it('only returns capabilities for given appIds', async () => {
const service = new CapabilitiesService();
const startContract = await service.start({ apps, legacyApps, http });
expect(startContract.availableApps).toEqual(
new Map([
['app1', { id: 'app1' }],
['appMissingInCapabilities', { id: 'appMissingInCapabilities' }],
])
);
expect(startContract.availableLegacyApps).toEqual(
new Map([['legacyApp1', { id: 'legacyApp1' }]])
);
const { capabilities } = await service.start({
http,
appIds: ['app1', 'app2', 'legacyApp1', 'legacyApp2'],
});
// @ts-ignore TypeScript knows this shouldn't be possible
expect(() => (capabilities.foo = 'foo')).toThrowError();
});
it('does not allow Capabilities to be modified', async () => {
const service = new CapabilitiesService();
const { capabilities } = await service.start({
apps,
legacyApps,
http,
appIds: ['app1', 'app2', 'legacyApp1', 'legacyApp2'],
});
// @ts-ignore TypeScript knows this shouldn't be possible

View file

@ -19,22 +19,16 @@
import { Capabilities } from '../../../types/capabilities';
import { deepFreeze, RecursiveReadonly } from '../../../utils';
import { LegacyApp, App } from '../types';
import { HttpStart } from '../../http';
interface StartDeps {
apps: ReadonlyMap<string, App>;
legacyApps: ReadonlyMap<string, LegacyApp>;
appIds: string[];
http: HttpStart;
}
export { Capabilities };
/** @internal */
export interface CapabilitiesStart {
capabilities: RecursiveReadonly<Capabilities>;
availableApps: ReadonlyMap<string, App>;
availableLegacyApps: ReadonlyMap<string, LegacyApp>;
}
/**
@ -42,41 +36,14 @@ export interface CapabilitiesStart {
* @internal
*/
export class CapabilitiesService {
public async start({ apps, legacyApps, http }: StartDeps): Promise<CapabilitiesStart> {
const capabilities = await this.fetchCapabilities(http, [...apps.keys(), ...legacyApps.keys()]);
const availableApps = new Map(
[...apps].filter(
([appId]) =>
capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true
)
);
const availableLegacyApps = new Map(
[...legacyApps].filter(
([appId]) =>
capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true
)
);
public async start({ appIds, http }: StartDeps): Promise<CapabilitiesStart> {
const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : '';
const capabilities = await http.post<Capabilities>(`/api/core/capabilities${route}`, {
body: JSON.stringify({ applications: appIds }),
});
return {
availableApps,
availableLegacyApps,
capabilities,
capabilities: deepFreeze(capabilities),
};
}
private async fetchCapabilities(http: HttpStart, appIds: string[]): Promise<Capabilities> {
const payload = JSON.stringify({
applications: appIds,
});
const url = http.anonymousPaths.isAnonymous(window.location.pathname)
? '/api/core/capabilities/defaults'
: '/api/core/capabilities';
const capabilities = await http.post<Capabilities>(url, {
body: payload,
});
return deepFreeze(capabilities);
}
}

View file

@ -17,4 +17,5 @@
* under the License.
*/
export { Capabilities, CapabilitiesService } from './capabilities_service';
export { Capabilities } from '../../../types/capabilities';
export { CapabilitiesService } from './capabilities_service';

View file

@ -18,107 +18,105 @@
*/
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { createMemoryHistory, History } from 'history';
import { BehaviorSubject } from 'rxjs';
import { I18nProvider } from '@kbn/i18n/react';
import { AppMount, LegacyApp, AppMountParameters } from '../types';
import { httpServiceMock } from '../../http/http_service.mock';
import { AppRouter, AppNotFound } from '../ui';
const createMountHandler = (htmlString: string) =>
jest.fn(async ({ appBasePath: basename, element: el }: AppMountParameters) => {
el.innerHTML = `<div>\nbasename: ${basename}\nhtml: ${htmlString}\n</div>`;
return jest.fn(() => (el.innerHTML = ''));
});
import { EitherApp, MockedMounterMap, MockedMounterTuple } from '../test_types';
import { createRenderer, createAppMounter, createLegacyAppMounter } from './utils';
describe('AppContainer', () => {
let apps: Map<string, jest.Mock<ReturnType<AppMount>, Parameters<AppMount>>>;
let legacyApps: Map<string, LegacyApp>;
let mounters: MockedMounterMap<EitherApp>;
let history: History;
let router: ReactWrapper;
let redirectTo: jest.Mock<void, [string]>;
let currentAppId$: BehaviorSubject<string | undefined>;
const navigate = async (path: string) => {
history.push(path);
router.update();
// flushes any pending promises
return new Promise(resolve => setImmediate(resolve));
};
let navigate: ReturnType<typeof createRenderer>;
beforeEach(() => {
redirectTo = jest.fn();
apps = new Map([
['app1', createMountHandler('<span>App 1</span>')],
['app2', createMountHandler('<div>App 2</div>')],
]);
legacyApps = new Map([
['legacyApp1', { id: 'legacyApp1' }],
['baseApp:legacyApp2', { id: 'baseApp:legacyApp2' }],
]) as Map<string, LegacyApp>;
mounters = new Map([
createAppMounter('app1', '<span>App 1</span>'),
createLegacyAppMounter('legacyApp1', jest.fn()),
createAppMounter('app2', '<div>App 2</div>'),
createLegacyAppMounter('baseApp:legacyApp2', jest.fn()),
createAppMounter('app3', '<div>App 3</div>', '/custom/path'),
] as Array<MockedMounterTuple<EitherApp>>);
history = createMemoryHistory();
currentAppId$ = new BehaviorSubject<string | undefined>(undefined);
// Use 'asdf' as the basepath
const http = httpServiceMock.createStartContract({ basePath: '/asdf' });
router = mount(
<I18nProvider>
<AppRouter
redirectTo={redirectTo}
history={history}
apps={apps}
legacyApps={legacyApps}
basePath={http.basePath}
currentAppId$={currentAppId$}
/>
</I18nProvider>
);
navigate = createRenderer(<AppRouter history={history} mounters={mounters} />, history.push);
});
it('calls mountHandler and returned unmount function when navigating between apps', async () => {
await navigate('/app/app1');
expect(apps.get('app1')!).toHaveBeenCalled();
expect(router.html()).toMatchInlineSnapshot(`
it('calls mount handler and returned unmount function when navigating between apps', async () => {
const dom1 = await navigate('/app/app1');
const app1 = mounters.get('app1')!;
expect(app1.mount).toHaveBeenCalled();
expect(dom1?.html()).toMatchInlineSnapshot(`
"<div><div>
basename: /asdf/app/app1
basename: /app/app1
html: <span>App 1</span>
</div></div>"
`);
const app1Unmount = await apps.get('app1')!.mock.results[0].value;
await navigate('/app/app2');
expect(app1Unmount).toHaveBeenCalled();
const app1Unmount = await app1.mount.mock.results[0].value;
const dom2 = await navigate('/app/app2');
expect(apps.get('app2')!).toHaveBeenCalled();
expect(router.html()).toMatchInlineSnapshot(`
expect(app1Unmount).toHaveBeenCalled();
expect(mounters.get('app2')!.mount).toHaveBeenCalled();
expect(dom2?.html()).toMatchInlineSnapshot(`
"<div><div>
basename: /asdf/app/app2
basename: /app/app2
html: <div>App 2</div>
</div></div>"
`);
});
it('updates currentApp$ after mounting', async () => {
await navigate('/app/app1');
expect(currentAppId$.value).toEqual('app1');
await navigate('/app/app2');
expect(currentAppId$.value).toEqual('app2');
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();
navigate = createRenderer(<AppRouter history={history} mounters={mounters} />, history.push);
await navigate('/fake-login');
expect(mounters.get('spaces')!.mount).not.toHaveBeenCalled();
expect(mounters.get('login')!.mount).toHaveBeenCalled();
});
it('sets window.location.href when navigating to legacy apps', async () => {
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();
navigate = createRenderer(<AppRouter history={history} mounters={mounters} />, history.push);
await navigate('/spaces/fake-login');
expect(mounters.get('spaces')!.mount).toHaveBeenCalled();
expect(mounters.get('login')!.mount).not.toHaveBeenCalled();
});
it('calls legacy mount handler', async () => {
await navigate('/app/legacyApp1');
expect(redirectTo).toHaveBeenCalledWith('/asdf/app/legacyApp1');
expect(mounters.get('legacyApp1')!.mount.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"appBasePath": "/app/legacyApp1",
"element": <div />,
},
]
`);
});
it('handles legacy apps with subapps', async () => {
await navigate('/app/baseApp');
expect(redirectTo).toHaveBeenCalledWith('/asdf/app/baseApp');
expect(mounters.get('baseApp:legacyApp2')!.mount.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"appBasePath": "/app/baseApp",
"element": <div />,
},
]
`);
});
it('displays error page if no app is found', async () => {
await navigate('/app/unknown');
expect(router.exists(AppNotFound)).toBe(true);
const dom = await navigate('/app/unknown');
expect(dom?.exists(AppNotFound)).toBe(true);
});
});

View file

@ -0,0 +1,78 @@
/*
* 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, { ReactElement } from 'react';
import { mount } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
import { App, LegacyApp, AppMountParameters } from '../types';
import { MockedMounter, MockedMounterTuple } from '../test_types';
type Dom = ReturnType<typeof mount> | null;
type Renderer = (item: string) => Dom | Promise<Dom>;
export const createRenderer = (
element: ReactElement | null,
callback?: (item: string) => void | Promise<void>
): Renderer => {
const dom: Dom = element && mount(<I18nProvider>{element}</I18nProvider>);
return item =>
new Promise(async resolve => {
if (callback) {
await callback(item);
}
if (dom) {
dom.update();
}
setImmediate(() => resolve(dom)); // flushes any pending promises
});
};
export const createAppMounter = (
appId: string,
html: string,
appRoute = `/app/${appId}`
): MockedMounterTuple<App> => [
appId,
{
appRoute,
appBasePath: appRoute,
mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => {
Object.assign(element, {
innerHTML: `<div>\nbasename: ${basename}\nhtml: ${html}\n</div>`,
});
return jest.fn(() => Object.assign(element, { innerHTML: '' }));
}),
},
];
export const createLegacyAppMounter = (
appId: string,
legacyMount: MockedMounter<LegacyApp>['mount']
): MockedMounterTuple<LegacyApp> => [
appId,
{
appRoute: `/app/${appId.split(':')[0]}`,
appBasePath: `/app/${appId.split(':')[0]}`,
unmountBeforeMounting: true,
mount: legacyMount,
},
];

View file

@ -0,0 +1,37 @@
/*
* 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 { App, LegacyApp, Mounter } from './types';
import { ApplicationService } from './application_service';
/** @internal */
export type ApplicationServiceContract = PublicMethodsOf<ApplicationService>;
/** @internal */
export type EitherApp = App | LegacyApp;
/** @internal */
export type MockedMounter<T extends EitherApp> = jest.Mocked<Mounter<jest.Mocked<T>>>;
/** @internal */
export type MockedMounterTuple<T extends EitherApp> = [string, MockedMounter<T>];
/** @internal */
export type MockedMounterMap<T extends EitherApp> = Map<string, MockedMounter<T>>;
/** @internal */
export type MockLifecycle<
T extends keyof ApplicationService,
U = Parameters<ApplicationService[T]>[0]
> = { [P in keyof U]: jest.Mocked<U[P]> };

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Observable, Subject } from 'rxjs';
import { Observable } from 'rxjs';
import { Capabilities } from './capabilities';
import { ChromeStart } from '../chrome';
@ -89,6 +89,13 @@ export interface App extends AppBase {
* Takes precedence over chrome service visibility settings.
*/
chromeless?: boolean;
/**
* Override the application's routing path from `/app/${id}`.
* Must be unique across registered applications. Should not include the
* base path from HTTP.
*/
appRoute?: string;
}
/** @internal */
@ -177,7 +184,8 @@ export interface AppMountParameters {
element: HTMLElement;
/**
* The base path for configuring the application's router.
* The route path for configuring navigation to the application.
* This string should not include the base path from HTTP.
*
* @example
*
@ -189,6 +197,7 @@ export interface AppMountParameters {
* setup({ application }) {
* application.register({
* id: 'my-app',
* appRoute: '/my-app',
* async mount(params) {
* const { renderApp } = await import('./application');
* return renderApp(params);
@ -229,6 +238,23 @@ export interface AppMountParameters {
*/
export type AppUnmount = () => void;
/** @internal */
export type AppMounter = (params: AppMountParameters) => Promise<AppUnmount>;
/** @internal */
export type LegacyAppMounter = (params: AppMountParameters) => void;
/** @internal */
export type Mounter<T = App | LegacyApp> = SelectivePartial<
{
appRoute: string;
appBasePath: string;
mount: T extends LegacyApp ? LegacyAppMounter : AppMounter;
unmountBeforeMounting: T extends LegacyApp ? true : boolean;
},
T extends LegacyApp ? never : 'unmountBeforeMounting'
>;
/** @public */
export interface ApplicationSetup {
/**
@ -352,6 +378,12 @@ export interface InternalApplicationStart
): void;
// Internal APIs
currentAppId$: Subject<string | undefined>;
currentAppId$: Observable<string | undefined>;
getComponent(): JSX.Element | null;
}
/** @internal */
type SelectivePartial<T, K extends keyof T> = Partial<Pick<T, K>> &
Required<Pick<T, Exclude<keyof T, K>>> extends infer U
? { [P in keyof U]: U[P] }
: never;

View file

@ -17,95 +17,60 @@
* under the License.
*/
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Subject } from 'rxjs';
import React, {
Fragment,
FunctionComponent,
useLayoutEffect,
useRef,
useState,
MutableRefObject,
} from 'react';
import { LegacyApp, AppMount, AppUnmount } from '../types';
import { HttpStart } from '../../http';
import { AppUnmount, Mounter } from '../types';
import { AppNotFound } from './app_not_found_screen';
interface Props extends RouteComponentProps<{ appId: string }> {
apps: ReadonlyMap<string, AppMount>;
legacyApps: ReadonlyMap<string, LegacyApp>;
basePath: HttpStart['basePath'];
currentAppId$: Subject<string | undefined>;
/**
* Only necessary for redirecting to legacy apps
* @deprecated
*/
redirectTo: (path: string) => void;
interface Props {
appId: string;
mounter?: Mounter;
}
interface State {
appNotFound: boolean;
}
export const AppContainer: FunctionComponent<Props> = ({ mounter, appId }: Props) => {
const [appNotFound, setAppNotFound] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
const unmountRef: MutableRefObject<AppUnmount | null> = useRef<AppUnmount>(null);
export class AppContainer extends React.Component<Props, State> {
private readonly containerDiv = React.createRef<HTMLDivElement>();
private unmountFunc?: AppUnmount;
useLayoutEffect(() => {
const unmount = () => {
if (unmountRef.current) {
unmountRef.current();
unmountRef.current = null;
}
};
const mount = async () => {
if (!mounter) {
return setAppNotFound(true);
}
state: State = { appNotFound: false };
if (mounter.unmountBeforeMounting) {
unmount();
}
componentDidMount() {
this.mountApp();
}
unmountRef.current =
(await mounter.mount({
appBasePath: mounter.appBasePath,
element: elementRef.current!,
})) || null;
setAppNotFound(false);
};
componentWillUnmount() {
this.unmountApp();
}
mount();
return unmount;
});
componentDidUpdate(prevProps: Props) {
if (prevProps.match.params.appId !== this.props.match.params.appId) {
this.unmountApp();
this.mountApp();
}
}
async mountApp() {
const { apps, legacyApps, match, basePath, currentAppId$, redirectTo } = this.props;
const { appId } = match.params;
const mount = apps.get(appId);
if (mount) {
this.unmountFunc = await mount({
appBasePath: basePath.prepend(`/app/${appId}`),
element: this.containerDiv.current!,
});
currentAppId$.next(appId);
this.setState({ appNotFound: false });
return;
}
const legacyApp = findLegacyApp(appId, legacyApps);
if (legacyApp) {
this.unmountApp();
redirectTo(basePath.prepend(`/app/${appId}`));
this.setState({ appNotFound: false });
return;
}
this.setState({ appNotFound: true });
}
async unmountApp() {
if (this.unmountFunc) {
this.unmountFunc();
this.unmountFunc = undefined;
}
}
render() {
return (
<React.Fragment>
{this.state.appNotFound && <AppNotFound />}
<div key={this.props.match.params.appId} ref={this.containerDiv} />
</React.Fragment>
);
}
}
function findLegacyApp(appId: string, apps: ReadonlyMap<string, LegacyApp>) {
const matchingApps = [...apps.entries()].filter(([id]) => id.split(':')[0] === appId);
return matchingApps.length ? matchingApps[0][1] : null;
}
return (
<Fragment>
{appNotFound && <AppNotFound />}
<div key={appId} ref={elementRef} />
</Fragment>
);
};

View file

@ -17,37 +17,53 @@
* under the License.
*/
import React, { FunctionComponent } from 'react';
import { History } from 'history';
import React from 'react';
import { Router, Route } from 'react-router-dom';
import { Subject } from 'rxjs';
import { Router, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { LegacyApp, AppMount } from '../types';
import { Mounter } from '../types';
import { AppContainer } from './app_container';
import { HttpStart } from '../../http';
interface Props {
apps: ReadonlyMap<string, AppMount>;
legacyApps: ReadonlyMap<string, LegacyApp>;
basePath: HttpStart['basePath'];
currentAppId$: Subject<string | undefined>;
mounters: Map<string, Mounter>;
history: History;
/**
* Only necessary for redirecting to legacy apps
* @deprecated
*/
redirectTo?: (path: string) => void;
}
export const AppRouter: React.FunctionComponent<Props> = ({
history,
redirectTo = (path: string) => (window.location.href = path),
...otherProps
}) => (
interface Params {
appId: string;
}
export const AppRouter: FunctionComponent<Props> = ({ history, mounters }) => (
<Router history={history}>
<Route
path="/app/:appId"
render={props => <AppContainer redirectTo={redirectTo} {...otherProps} {...props} />}
/>
<Switch>
{[...mounters].flatMap(([appId, mounter]) =>
// Remove /app paths from the routes as they will be handled by the
// "named" route parameter `:appId` below
mounter.appBasePath.startsWith('/app')
? []
: [
<Route
key={mounter.appRoute}
path={mounter.appRoute}
render={() => <AppContainer mounter={mounter} appId={appId} />}
/>,
]
)}
<Route
path="/app/:appId"
render={({
match: {
params: { appId },
},
}: RouteComponentProps<Params>) => {
// Find the mounter including legacy mounters with subapps:
const [id, mounter] = mounters.has(appId)
? [appId, mounters.get(appId)]
: [...mounters].filter(([key]) => key.split(':')[0] === appId)[0] ?? [];
return <AppContainer mounter={mounter} appId={id} />;
}}
/>
</Switch>
</Router>
);

View file

@ -211,14 +211,14 @@ describe('start', () => {
new FakeApp('beta', true),
new FakeApp('gamma', false),
]);
const { availableApps, currentAppId$ } = startDeps.application;
const { availableApps, navigateToApp } = startDeps.application;
const { chrome, service } = await start({ startDeps });
const promise = chrome
.getIsVisible$()
.pipe(toArray())
.toPromise();
[...availableApps.keys()].forEach(appId => currentAppId$.next(appId));
[...availableApps.keys()].forEach(appId => navigateToApp(appId));
service.stop();
await expect(promise).resolves.toMatchInlineSnapshot(`
@ -233,14 +233,14 @@ describe('start', () => {
it('changing visibility has no effect on chrome-hiding application', async () => {
const startDeps = defaultStartDeps([new FakeApp('alpha', true)]);
const { currentAppId$ } = startDeps.application;
const { navigateToApp } = startDeps.application;
const { chrome, service } = await start({ startDeps });
const promise = chrome
.getIsVisible$()
.pipe(toArray())
.toPromise();
currentAppId$.next('alpha');
navigateToApp('alpha');
chrome.setIsVisible(true);
service.stop();

View file

@ -127,7 +127,7 @@ export class ChromeService {
)
);
this.isVisible$ = combineLatest(this.appHidden$, this.toggleHidden$).pipe(
map(([appHidden, chromeHidden]) => !(appHidden || chromeHidden)),
map(([appHidden, toggleHidden]) => !(appHidden || toggleHidden)),
takeUntil(this.stop$)
);
}

View file

@ -174,7 +174,7 @@ export class CoreSystem {
[this.legacy.legacyId, [...pluginDependencies.keys()]],
]),
});
const application = this.application.setup({ context });
const application = this.application.setup({ context, http, injectedMetadata });
const core: InternalCoreSetup = {
application,
@ -307,6 +307,7 @@ export class CoreSystem {
this.uiSettings.stop();
this.chrome.stop();
this.i18n.stop();
this.application.stop();
this.rootDomElement.textContent = '';
}
}

View file

@ -18,6 +18,7 @@ import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/type
// @public
export interface App extends AppBase {
appRoute?: string;
chromeless?: boolean;
mount: AppMount | AppMountDeprecated;
}