diff --git a/docs/development/core/public/kibana-plugin-public.app.approute.md b/docs/development/core/public/kibana-plugin-public.app.approute.md new file mode 100644 index 000000000000..7f35f4346b6b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.app.approute.md @@ -0,0 +1,13 @@ + + +[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. + +Signature: + +```typescript +appRoute?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.app.md b/docs/development/core/public/kibana-plugin-public.app.md index edab4f88497f..acf07cbf62e9 100644 --- a/docs/development/core/public/kibana-plugin-public.app.md +++ b/docs/development/core/public/kibana-plugin-public.app.md @@ -16,6 +16,7 @@ export interface App extends AppBase | Property | Type | Description | | --- | --- | --- | +| [appRoute](./kibana-plugin-public.app.approute.md) | string | Override the application's routing path from /app/${id}. Must be unique across registered applications. Should not include the base path from HTTP. | | [chromeless](./kibana-plugin-public.app.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [mount](./kibana-plugin-public.app.mount.md) | AppMount | AppMountDeprecated | 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). | diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md index a1544373ee69..7cd709d61572 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.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. Signature: @@ -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); diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.md index 8733f9cd4915..aa5ca93ed8ff 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.md @@ -15,6 +15,6 @@ export interface AppMountParameters | Property | Type | Description | | --- | --- | --- | -| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | The base path for configuring the application's router. | +| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | 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) | HTMLElement | The container element to render the application into. | diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index a2db75522463..b2e2161c92cc 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -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; +import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), @@ -41,23 +39,27 @@ const createInternalSetupContractMock = (): jest.Mocked => ({ +const createStartContractMock = (): jest.Mocked => ({ capabilities: capabilitiesServiceMock.createStartContract().capabilities, navigateToApp: jest.fn(), getUrlForApp: jest.fn(), registerMountContext: jest.fn(), }); -const createInternalStartContractMock = (): jest.Mocked => ({ - availableApps: new Map(), - availableLegacyApps: new Map(), - capabilities: capabilitiesServiceMock.createStartContract().capabilities, - navigateToApp: jest.fn(), - getUrlForApp: jest.fn(), - registerMountContext: jest.fn(), - currentAppId$: new Subject(), - getComponent: jest.fn(), -}); +const createInternalStartContractMock = (): jest.Mocked => { + const currentAppId$ = new Subject(); + + 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 => ({ setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), @@ -69,7 +71,6 @@ export const applicationServiceMock = { create: createMock, createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, - createInternalSetupContract: createInternalSetupContractMock, createInternalStartContract: createInternalStartContractMock, }; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts new file mode 100644 index 000000000000..d064b17ace14 --- /dev/null +++ b/src/core/public/application/application_service.test.ts @@ -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(` + + `); + }); + + 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'); + }); + }); +}); diff --git a/src/core/public/application/application_service.test.tsx b/src/core/public/application/application_service.test.tsx deleted file mode 100644 index 32634572466a..000000000000 --- a/src/core/public/application/application_service.test.tsx +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index df00c84028e6..a96b9dea9b9c 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -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, capabilities: Capabilities) => + new Map( + [...map].filter( + ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true + ) + ); +const findMounter = (mounters: Map, appRoute?: string) => + [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); +const getAppUrl = (mounters: Map, 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>(new Map()); - private readonly legacyApps$ = new BehaviorSubject>(new Map()); + private readonly apps = new Map(); + private readonly legacyApps = new Map(); + private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); + private currentAppId$ = new BehaviorSubject(undefined); + private stop$ = new Subject(); + private registrationClosed = false; + private history?: History; private mountContext?: IContextContainer; + 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 { + public async start({ injectedMetadata, http }: StartDeps): Promise { 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(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( - [...this.apps$.value] - .filter(([id]) => availableApps.has(id)) - .map(([id, { mount }]) => [id, mount]) - ); - - return ( - - ); + this.navigate!(getAppUrl(availableMounters, appId, path), state); + this.currentAppId$.next(appId); }, + getComponent: () => + this.history ? : 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(); + } } diff --git a/src/core/public/application/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index 29c3275f0e3b..54aaa31e0885 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -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 = new Map(), - legacyApps: ReadonlyMap = new Map() -): jest.Mocked => ({ - availableApps: apps, - availableLegacyApps: legacyApps, +const createStartContractMock = (): jest.Mocked => ({ capabilities: deepFreeze({ catalogue: {}, management: {}, @@ -33,11 +27,8 @@ const createStartContractMock = ( }), }); -type CapabilitiesServiceContract = PublicMethodsOf; -const createMock = (): jest.Mocked => ({ - start: jest - .fn() - .mockImplementation(({ apps, legacyApps }) => createStartContractMock(apps, legacyApps)), +const createMock = (): jest.Mocked> => ({ + start: jest.fn().mockImplementation(createStartContractMock), }); export const capabilitiesServiceMock = { diff --git a/src/core/public/application/capabilities/capabilities_service.test.ts b/src/core/public/application/capabilities/capabilities_service.test.ts index 3245be8dd502..dfbb449b4d58 100644 --- a/src/core/public/application/capabilities/capabilities_service.test.ts +++ b/src/core/public/application/capabilities/capabilities_service.test.ts @@ -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 diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx index 24d9765953c4..05d718e1073d 100644 --- a/src/core/public/application/capabilities/capabilities_service.tsx +++ b/src/core/public/application/capabilities/capabilities_service.tsx @@ -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; - legacyApps: ReadonlyMap; + appIds: string[]; http: HttpStart; } -export { Capabilities }; - /** @internal */ export interface CapabilitiesStart { capabilities: RecursiveReadonly; - availableApps: ReadonlyMap; - availableLegacyApps: ReadonlyMap; } /** @@ -42,41 +36,14 @@ export interface CapabilitiesStart { * @internal */ export class CapabilitiesService { - public async start({ apps, legacyApps, http }: StartDeps): Promise { - 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 { + const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : ''; + const capabilities = await http.post(`/api/core/capabilities${route}`, { + body: JSON.stringify({ applications: appIds }), + }); return { - availableApps, - availableLegacyApps, - capabilities, + capabilities: deepFreeze(capabilities), }; } - - private async fetchCapabilities(http: HttpStart, appIds: string[]): Promise { - 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(url, { - body: payload, - }); - return deepFreeze(capabilities); - } } diff --git a/src/core/public/application/capabilities/index.ts b/src/core/public/application/capabilities/index.ts index 9d8bec955eb9..e4112a55ef6b 100644 --- a/src/core/public/application/capabilities/index.ts +++ b/src/core/public/application/capabilities/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { Capabilities, CapabilitiesService } from './capabilities_service'; +export { Capabilities } from '../../../types/capabilities'; +export { CapabilitiesService } from './capabilities_service'; diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 81aef5204c7e..ffc10820a9c3 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -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 = `
\nbasename: ${basename}\nhtml: ${htmlString}\n
`; - return jest.fn(() => (el.innerHTML = '')); - }); +import { EitherApp, MockedMounterMap, MockedMounterTuple } from '../test_types'; +import { createRenderer, createAppMounter, createLegacyAppMounter } from './utils'; describe('AppContainer', () => { - let apps: Map, Parameters>>; - let legacyApps: Map; + let mounters: MockedMounterMap; let history: History; - let router: ReactWrapper; - let redirectTo: jest.Mock; - let currentAppId$: BehaviorSubject; - - const navigate = async (path: string) => { - history.push(path); - router.update(); - // flushes any pending promises - return new Promise(resolve => setImmediate(resolve)); - }; + let navigate: ReturnType; beforeEach(() => { - redirectTo = jest.fn(); - apps = new Map([ - ['app1', createMountHandler('App 1')], - ['app2', createMountHandler('
App 2
')], - ]); - legacyApps = new Map([ - ['legacyApp1', { id: 'legacyApp1' }], - ['baseApp:legacyApp2', { id: 'baseApp:legacyApp2' }], - ]) as Map; + mounters = new Map([ + createAppMounter('app1', 'App 1'), + createLegacyAppMounter('legacyApp1', jest.fn()), + createAppMounter('app2', '
App 2
'), + createLegacyAppMounter('baseApp:legacyApp2', jest.fn()), + createAppMounter('app3', '
App 3
', '/custom/path'), + ] as Array>); history = createMemoryHistory(); - currentAppId$ = new BehaviorSubject(undefined); - // Use 'asdf' as the basepath - const http = httpServiceMock.createStartContract({ basePath: '/asdf' }); - router = mount( - - - - ); + navigate = createRenderer(, 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(` "
- basename: /asdf/app/app1 + basename: /app/app1 html: App 1
" `); - 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(` "
- basename: /asdf/app/app2 + basename: /app/app2 html:
App 2
" `); }); - 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', '
Custom Space
', '/spaces/fake-login')); + mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); + history = createMemoryHistory(); + navigate = createRenderer(, history.push); + + await navigate('/fake-login'); + + expect(mounters.get('spaces')!.mount).not.toHaveBeenCalled(); + expect(mounters.get('login')!.mount).toHaveBeenCalled(); + }); + + it('should not mount when partial route path has higher specificity', async () => { + mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); + mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); + history = createMemoryHistory(); + navigate = createRenderer(, history.push); + + await navigate('/spaces/fake-login'); + + expect(mounters.get('spaces')!.mount).toHaveBeenCalled(); + expect(mounters.get('login')!.mount).not.toHaveBeenCalled(); }); - it('sets window.location.href when navigating to legacy apps', async () => { + 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":
, + }, + ] + `); }); 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":
, + }, + ] + `); }); 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); }); }); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx new file mode 100644 index 000000000000..b8ade4d1d878 --- /dev/null +++ b/src/core/public/application/integration_tests/utils.tsx @@ -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 | null; +type Renderer = (item: string) => Dom | Promise; + +export const createRenderer = ( + element: ReactElement | null, + callback?: (item: string) => void | Promise +): Renderer => { + const dom: Dom = element && mount({element}); + + 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 => [ + appId, + { + appRoute, + appBasePath: appRoute, + mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => { + Object.assign(element, { + innerHTML: `
\nbasename: ${basename}\nhtml: ${html}\n
`, + }); + return jest.fn(() => Object.assign(element, { innerHTML: '' })); + }), + }, +]; + +export const createLegacyAppMounter = ( + appId: string, + legacyMount: MockedMounter['mount'] +): MockedMounterTuple => [ + appId, + { + appRoute: `/app/${appId.split(':')[0]}`, + appBasePath: `/app/${appId.split(':')[0]}`, + unmountBeforeMounting: true, + mount: legacyMount, + }, +]; diff --git a/src/core/public/application/test_types.ts b/src/core/public/application/test_types.ts new file mode 100644 index 000000000000..f5fb639eaa32 --- /dev/null +++ b/src/core/public/application/test_types.ts @@ -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; +/** @internal */ +export type EitherApp = App | LegacyApp; +/** @internal */ +export type MockedMounter = jest.Mocked>>; +/** @internal */ +export type MockedMounterTuple = [string, MockedMounter]; +/** @internal */ +export type MockedMounterMap = Map>; +/** @internal */ +export type MockLifecycle< + T extends keyof ApplicationService, + U = Parameters[0] +> = { [P in keyof U]: jest.Mocked }; diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index fd009066fc66..c026851af7eb 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -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; + +/** @internal */ +export type LegacyAppMounter = (params: AppMountParameters) => void; + +/** @internal */ +export type Mounter = 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; + currentAppId$: Observable; getComponent(): JSX.Element | null; } + +/** @internal */ +type SelectivePartial = Partial> & + Required>> extends infer U + ? { [P in keyof U]: U[P] } + : never; diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 9c2bb30e7950..96ee91c7c21f 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -17,95 +17,60 @@ * under the License. */ -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { Subject } from 'rxjs'; - -import { LegacyApp, AppMount, AppUnmount } from '../types'; -import { HttpStart } from '../../http'; +import React, { + Fragment, + FunctionComponent, + useLayoutEffect, + useRef, + useState, + MutableRefObject, +} from 'react'; + +import { AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; -interface Props extends RouteComponentProps<{ appId: string }> { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo: (path: string) => void; -} - -interface State { - appNotFound: boolean; -} - -export class AppContainer extends React.Component { - private readonly containerDiv = React.createRef(); - private unmountFunc?: AppUnmount; - - state: State = { appNotFound: false }; - - componentDidMount() { - this.mountApp(); - } - - componentWillUnmount() { - this.unmountApp(); - } - - 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 ( - - {this.state.appNotFound && } -
- - ); - } +interface Props { + appId: string; + mounter?: Mounter; } -function findLegacyApp(appId: string, apps: ReadonlyMap) { - const matchingApps = [...apps.entries()].filter(([id]) => id.split(':')[0] === appId); - return matchingApps.length ? matchingApps[0][1] : null; -} +export const AppContainer: FunctionComponent = ({ mounter, appId }: Props) => { + const [appNotFound, setAppNotFound] = useState(false); + const elementRef = useRef(null); + const unmountRef: MutableRefObject = useRef(null); + + useLayoutEffect(() => { + const unmount = () => { + if (unmountRef.current) { + unmountRef.current(); + unmountRef.current = null; + } + }; + const mount = async () => { + if (!mounter) { + return setAppNotFound(true); + } + + if (mounter.unmountBeforeMounting) { + unmount(); + } + + unmountRef.current = + (await mounter.mount({ + appBasePath: mounter.appBasePath, + element: elementRef.current!, + })) || null; + setAppNotFound(false); + }; + + mount(); + return unmount; + }); + + return ( + + {appNotFound && } +
+ + ); +}; diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 67701a33dabf..8db46f979427 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -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; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; + mounters: Map; history: History; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo?: (path: string) => void; } -export const AppRouter: React.FunctionComponent = ({ - history, - redirectTo = (path: string) => (window.location.href = path), - ...otherProps -}) => ( +interface Params { + appId: string; +} + +export const AppRouter: FunctionComponent = ({ history, mounters }) => ( - } - /> + + {[...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') + ? [] + : [ + } + />, + ] + )} + ) => { + // 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 ; + }} + /> + ); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 965673942168..d9c35b20db03 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -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(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 25c00836a4db..18c0c9870d72 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -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$) ); } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 2a9dca96062d..485c11aae650 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -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 = ''; } } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index dfbb6b4a6fbf..f61741571dc1 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -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; }