mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
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:
parent
054ec7036d
commit
fdd87e024f
22 changed files with 927 additions and 632 deletions
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [App](./kibana-plugin-public.app.md) > [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;
|
||||
```
|
|
@ -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 | 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)<!-- -->. |
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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. |
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
441
src/core/public/application/application_service.test.ts
Normal file
441
src/core/public/application/application_service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,4 +17,5 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export { Capabilities, CapabilitiesService } from './capabilities_service';
|
||||
export { Capabilities } from '../../../types/capabilities';
|
||||
export { CapabilitiesService } from './capabilities_service';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
78
src/core/public/application/integration_tests/utils.tsx
Normal file
78
src/core/public/application/integration_tests/utils.tsx
Normal 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,
|
||||
},
|
||||
];
|
37
src/core/public/application/test_types.ts
Normal file
37
src/core/public/application/test_types.ts
Normal 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]> };
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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$)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue