From 95e02f9ff9799c72fc212f8b4858dd8e23593840 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 25 May 2020 18:07:25 +0200 Subject: [PATCH] Add `application.navigateToUrl` core API (#67110) (#67272) * implement `navigateToUrl` core API * fix lint * review comments --- ...re-public.applicationstart.geturlforapp.md | 2 +- ...ana-plugin-core-public.applicationstart.md | 3 +- ...e-public.applicationstart.navigatetourl.md | 45 ++ .../application/application_service.mock.ts | 2 + .../application_service.test.mocks.ts | 6 + .../application/application_service.test.ts | 37 +- .../application/application_service.tsx | 46 +- src/core/public/application/types.ts | 34 +- src/core/public/application/utils.test.ts | 392 +++++++++++++++++- src/core/public/application/utils.ts | 67 +++ src/core/public/legacy/legacy_service.ts | 1 + src/core/public/plugins/plugin_context.ts | 1 + src/core/public/public.api.md | 1 + 13 files changed, 606 insertions(+), 31 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md index f36351c8b8f06..055ad9f37e654 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.geturlforapp.md @@ -6,7 +6,7 @@ Returns an URL to a given app, including the global base path. By default, the URL is relative (/basePath/app/my-app). Use the `absolute` option to generate an absolute url (http://host:port/basePath/app/my-app) -Note that when generating absolute urls, the protocol, host and port are determined from the browser location. +Note that when generating absolute urls, the origin (protocol, host and port) are determined from the browser's location. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md index a93bc61bac527..6f45bab3ebd2d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md @@ -22,7 +22,8 @@ export interface ApplicationStart | Method | Description | | --- | --- | -| [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns an URL to a given app, including the global base path. By default, the URL is relative (/basePath/app/my-app). Use the absolute option to generate an absolute url (http://host:port/basePath/app/my-app)Note that when generating absolute urls, the protocol, host and port are determined from the browser location. | +| [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns an URL to a given app, including the global base path. By default, the URL is relative (/basePath/app/my-app). Use the absolute option to generate an absolute url (http://host:port/basePath/app/my-app)Note that when generating absolute urls, the origin (protocol, host and port) are determined from the browser's location. | | [navigateToApp(appId, options)](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | Navigate to a given app | +| [navigateToUrl(url)](./kibana-plugin-core-public.applicationstart.navigatetourl.md) | Navigate to given url, which can either be an absolute url or a relative path, in a SPA friendly way when possible.If all these criteria are true for the given url: - (only for absolute URLs) The origin of the URL matches the origin of the browser's current location - The pathname of the URL starts with the current basePath (eg. /mybasepath/s/my-space) - The pathname segment after the basePath matches any known application route (eg. /app// or any application's appRoute configuration)Then a SPA navigation will be performed using navigateToApp using the corresponding application and path. Otherwise, fallback to a full page reload to navigate to the url using window.location.assign | | [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md new file mode 100644 index 0000000000000..86b86776b0b12 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.navigatetourl.md @@ -0,0 +1,45 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ApplicationStart](./kibana-plugin-core-public.applicationstart.md) > [navigateToUrl](./kibana-plugin-core-public.applicationstart.navigatetourl.md) + +## ApplicationStart.navigateToUrl() method + +Navigate to given url, which can either be an absolute url or a relative path, in a SPA friendly way when possible. + +If all these criteria are true for the given url: - (only for absolute URLs) The origin of the URL matches the origin of the browser's current location - The pathname of the URL starts with the current basePath (eg. /mybasepath/s/my-space) - The pathname segment after the basePath matches any known application route (eg. /app// or any application's `appRoute` configuration) + +Then a SPA navigation will be performed using `navigateToApp` using the corresponding application and path. Otherwise, fallback to a full page reload to navigate to the url using `window.location.assign` + +Signature: + +```typescript +navigateToUrl(url: string): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| url | string | an absolute url, or a relative path, to navigate to. | + +Returns: + +`Promise` + +## Example + + +```ts +// current url: `https://kibana:8080/base-path/s/my-space/app/dashboard` + +// will call `application.navigateToApp('discover', { path: '/some-path?foo=bar'})` +application.navigateToUrl('https://kibana:8080/base-path/s/my-space/app/discover/some-path?foo=bar') +application.navigateToUrl('/base-path/s/my-space/app/discover/some-path?foo=bar') + +// will perform a full page reload using `window.location.assign` +application.navigateToUrl('https://elsewhere:8080/base-path/s/my-space/app/discover/some-path') // origin does not match +application.navigateToUrl('/app/discover/some-path') // does not include the current basePath +application.navigateToUrl('/base-path/s/my-space/app/unknown-app/some-path') // unknown application + +``` + diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index e8d9e101bfa5a..24c0e66359afa 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -50,6 +50,7 @@ const createStartContractMock = (): jest.Mocked => { currentAppId$: currentAppId$.asObservable(), capabilities: capabilitiesServiceMock.createStartContract().capabilities, navigateToApp: jest.fn(), + navigateToUrl: jest.fn(), getUrlForApp: jest.fn(), registerMountContext: jest.fn(), }; @@ -65,6 +66,7 @@ const createInternalStartContractMock = (): jest.Mocked currentAppId$.next(appId)), + navigateToUrl: jest.fn(), registerMountContext: jest.fn(), }; }; diff --git a/src/core/public/application/application_service.test.mocks.ts b/src/core/public/application/application_service.test.mocks.ts index d829cf18e56be..a096f05209708 100644 --- a/src/core/public/application/application_service.test.mocks.ts +++ b/src/core/public/application/application_service.test.mocks.ts @@ -34,3 +34,9 @@ export const createBrowserHistoryMock = jest.fn().mockReturnValue(MockHistory); jest.doMock('history', () => ({ createBrowserHistory: createBrowserHistoryMock, })); + +export const parseAppUrlMock = jest.fn(); +jest.doMock('./utils', () => ({ + ...jest.requireActual('./utils'), + parseAppUrl: parseAppUrlMock, +})); diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 8ee1186760400..b65a8581e5b58 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -17,6 +17,12 @@ * under the License. */ +import { + MockCapabilitiesService, + MockHistory, + parseAppUrlMock, +} from './application_service.test.mocks'; + import { createElement } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; @@ -26,7 +32,6 @@ import { injectedMetadataServiceMock } from '../injected_metadata/injected_metad import { contextServiceMock } from '../context/context_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; -import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; @@ -61,6 +66,7 @@ describe('#setup()', () => { http, context: contextServiceMock.createSetupContract(), injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + redirectTo: jest.fn(), }; setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); startDeps = { http, overlays: overlayServiceMock.createStartContract() }; @@ -466,12 +472,14 @@ describe('#setup()', () => { describe('#start()', () => { beforeEach(() => { MockHistory.push.mockReset(); + parseAppUrlMock.mockReset(); const http = httpServiceMock.createSetupContract({ basePath: '/base-path' }); setupDeps = { http, context: contextServiceMock.createSetupContract(), injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + redirectTo: jest.fn(), }; setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); startDeps = { http, overlays: overlayServiceMock.createStartContract() }; @@ -779,7 +787,6 @@ describe('#start()', () => { }); it('redirects when in legacyMode', async () => { - setupDeps.redirectTo = jest.fn(); setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); service.setup(setupDeps); @@ -885,7 +892,6 @@ describe('#start()', () => { 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); @@ -897,7 +903,6 @@ describe('#start()', () => { 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); @@ -909,6 +914,30 @@ describe('#start()', () => { expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/baseApp'); }); }); + + describe('navigateToUrl', () => { + it('calls `redirectTo` when the url is not parseable', async () => { + parseAppUrlMock.mockReturnValue(undefined); + service.setup(setupDeps); + const { navigateToUrl } = await service.start(startDeps); + + await navigateToUrl('/not-an-app-path'); + + expect(MockHistory.push).not.toHaveBeenCalled(); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/not-an-app-path'); + }); + + it('calls `navigateToApp` when the url is an internal app link', async () => { + parseAppUrlMock.mockReturnValue({ app: 'foo', path: '/some-path' }); + service.setup(setupDeps); + const { navigateToUrl } = await service.start(startDeps); + + await navigateToUrl('/an-app-path'); + + expect(MockHistory.push).toHaveBeenCalledWith('/app/foo/some-path', undefined); + expect(setupDeps.redirectTo).not.toHaveBeenCalled(); + }); + }); }); describe('#stop()', () => { diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index dc5410d26c9b9..b52b4984fb5e1 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -46,17 +46,14 @@ import { Mounter, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; -import { appendAppPath } from './utils'; +import { appendAppPath, parseAppUrl, relativeToAbsolute } from './utils'; interface SetupDeps { context: ContextSetup; http: HttpSetup; injectedMetadata: InjectedMetadataSetup; history?: History; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ + /** Used to redirect to external urls (and legacy apps) */ redirectTo?: (path: string) => void; } @@ -109,6 +106,7 @@ export class ApplicationService { private history?: History; private mountContext?: IContextContainer; private navigate?: (url: string, state: any) => void; + private redirectTo?: (url: string) => void; public setup({ context, @@ -131,7 +129,7 @@ export class ApplicationService { 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.redirectTo = redirectTo; this.mountContext = context.createContextContainer(); const registerStatusUpdater = (application: string, updater$: Observable) => { @@ -278,6 +276,20 @@ export class ApplicationService { shareReplay(1) ); + const navigateToApp: InternalApplicationStart['navigateToApp'] = async ( + appId, + { path, state }: { path?: string; state?: any } = {} + ) => { + if (await this.shouldNavigate(overlays)) { + if (path === undefined) { + path = applications$.value.get(appId)?.defaultPath; + } + this.appLeaveHandlers.delete(this.currentAppId$.value!); + this.navigate!(getAppUrl(availableMounters, appId, path), state); + this.currentAppId$.next(appId); + } + }; + return { applications$, capabilities, @@ -294,14 +306,13 @@ export class ApplicationService { const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path)); return absolute ? relativeToAbsolute(relUrl) : relUrl; }, - navigateToApp: async (appId, { path, state }: { path?: string; state?: any } = {}) => { - if (await this.shouldNavigate(overlays)) { - if (path === undefined) { - path = applications$.value.get(appId)?.defaultPath; - } - this.appLeaveHandlers.delete(this.currentAppId$.value!); - this.navigate!(getAppUrl(availableMounters, appId, path), state); - this.currentAppId$.next(appId); + navigateToApp, + navigateToUrl: async (url) => { + const appInfo = parseAppUrl(url, http.basePath, this.apps); + if (appInfo) { + return navigateToApp(appInfo.app, { path: appInfo.path }); + } else { + return this.redirectTo!(url); } }, getComponent: () => { @@ -388,10 +399,3 @@ const updateStatus = (app: T, statusUpdaters: AppUpdaterWrapp ...changes, }; }; - -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 786d11a5ced7f..c07d929fc5cea 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -659,12 +659,41 @@ export interface ApplicationStart { */ navigateToApp(appId: string, options?: { path?: string; state?: any }): Promise; + /** + * Navigate to given url, which can either be an absolute url or a relative path, in a SPA friendly way when possible. + * + * If all these criteria are true for the given url: + * - (only for absolute URLs) The origin of the URL matches the origin of the browser's current location + * - The pathname of the URL starts with the current basePath (eg. /mybasepath/s/my-space) + * - The pathname segment after the basePath matches any known application route (eg. /app// or any application's `appRoute` configuration) + * + * Then a SPA navigation will be performed using `navigateToApp` using the corresponding application and path. + * Otherwise, fallback to a full page reload to navigate to the url using `window.location.assign` + * + * @example + * ```ts + * // current url: `https://kibana:8080/base-path/s/my-space/app/dashboard` + * + * // will call `application.navigateToApp('discover', { path: '/some-path?foo=bar'})` + * application.navigateToUrl('https://kibana:8080/base-path/s/my-space/app/discover/some-path?foo=bar') + * application.navigateToUrl('/base-path/s/my-space/app/discover/some-path?foo=bar') + * + * // will perform a full page reload using `window.location.assign` + * application.navigateToUrl('https://elsewhere:8080/base-path/s/my-space/app/discover/some-path') // origin does not match + * application.navigateToUrl('/app/discover/some-path') // does not include the current basePath + * application.navigateToUrl('/base-path/s/my-space/app/unknown-app/some-path') // unknown application + * ``` + * + * @param url - an absolute url, or a relative path, to navigate to. + */ + navigateToUrl(url: string): Promise; + /** * Returns an URL to a given app, including the global base path. * By default, the URL is relative (/basePath/app/my-app). * Use the `absolute` option to generate an absolute url (http://host:port/basePath/app/my-app) * - * Note that when generating absolute urls, the protocol, host and port are determined from the browser location. + * Note that when generating absolute urls, the origin (protocol, host and port) are determined from the browser's location. * * @param appId * @param options.path - optional path inside application to deep link to @@ -677,7 +706,6 @@ export interface ApplicationStart { * plugin that registered this context. Deprecated, use {@link CoreSetup.getStartServices}. * * @deprecated - * @param pluginOpaqueId - The opaque ID of the plugin that is registering the context. * @param contextName - The key of {@link AppMountContext} this provider's return value should be attached to. * @param provider - A {@link IContextProvider} function */ @@ -696,7 +724,7 @@ export interface ApplicationStart { export interface InternalApplicationStart extends Pick< ApplicationStart, - 'capabilities' | 'navigateToApp' | 'getUrlForApp' | 'currentAppId$' + 'capabilities' | 'navigateToApp' | 'navigateToUrl' | 'getUrlForApp' | 'currentAppId$' > { /** * Apps available based on the current capabilities. diff --git a/src/core/public/application/utils.test.ts b/src/core/public/application/utils.test.ts index 7ed0919f88c61..a86a1206fc983 100644 --- a/src/core/public/application/utils.test.ts +++ b/src/core/public/application/utils.test.ts @@ -17,7 +17,15 @@ * under the License. */ -import { removeSlashes, appendAppPath } from './utils'; +import { LegacyApp, App } from './types'; +import { BasePath } from '../http/base_path'; +import { + removeSlashes, + appendAppPath, + isLegacyApp, + relativeToAbsolute, + parseAppUrl, +} from './utils'; describe('removeSlashes', () => { it('only removes duplicates by default', () => { @@ -69,3 +77,385 @@ describe('appendAppPath', () => { expect(appendAppPath('/app/my-app', '/some-path#/hash')).toEqual('/app/my-app/some-path#/hash'); }); }); + +describe('isLegacyApp', () => { + it('returns true for legacy apps', () => { + expect( + isLegacyApp({ + id: 'legacy', + title: 'Legacy App', + appUrl: '/some-url', + legacy: true, + }) + ).toEqual(true); + }); + it('returns false for non-legacy apps', () => { + expect( + isLegacyApp({ + id: 'legacy', + title: 'Legacy App', + mount: () => () => undefined, + legacy: false, + }) + ).toEqual(false); + }); +}); + +describe('relativeToAbsolute', () => { + it('converts a relative path to an absolute url', () => { + const origin = window.location.origin; + expect(relativeToAbsolute('path')).toEqual(`${origin}/path`); + expect(relativeToAbsolute('/path#hash')).toEqual(`${origin}/path#hash`); + expect(relativeToAbsolute('/path?query=foo')).toEqual(`${origin}/path?query=foo`); + }); +}); + +describe('parseAppUrl', () => { + let apps: Map | LegacyApp>; + let basePath: BasePath; + + const getOrigin = () => 'https://kibana.local:8080'; + + const createApp = (props: Partial): App => { + const app: App = { + id: 'some-id', + title: 'some-title', + mount: () => () => undefined, + ...props, + legacy: false, + }; + apps.set(app.id, app); + return app; + }; + + const createLegacyApp = (props: Partial): LegacyApp => { + const app: LegacyApp = { + id: 'some-id', + title: 'some-title', + appUrl: '/my-url', + ...props, + legacy: true, + }; + apps.set(app.id, app); + return app; + }; + + beforeEach(() => { + apps = new Map(); + basePath = new BasePath('/base-path'); + + createApp({ + id: 'foo', + }); + createApp({ + id: 'bar', + appRoute: '/custom-bar', + }); + createLegacyApp({ + id: 'legacy', + appUrl: '/app/legacy', + }); + }); + + describe('with relative paths', () => { + it('parses the app id', () => { + expect(parseAppUrl('/base-path/app/foo', basePath, apps, getOrigin)).toEqual({ + app: 'foo', + path: undefined, + }); + expect(parseAppUrl('/base-path/custom-bar', basePath, apps, getOrigin)).toEqual({ + app: 'bar', + path: undefined, + }); + }); + it('parses the path', () => { + expect(parseAppUrl('/base-path/app/foo/some/path', basePath, apps, getOrigin)).toEqual({ + app: 'foo', + path: '/some/path', + }); + expect(parseAppUrl('/base-path/custom-bar/another/path/', basePath, apps, getOrigin)).toEqual( + { + app: 'bar', + path: '/another/path/', + } + ); + }); + it('includes query and hash in the path for default app route', () => { + expect(parseAppUrl('/base-path/app/foo#hash/bang', basePath, apps, getOrigin)).toEqual({ + app: 'foo', + path: '#hash/bang', + }); + expect(parseAppUrl('/base-path/app/foo?hello=dolly', basePath, apps, getOrigin)).toEqual({ + app: 'foo', + path: '?hello=dolly', + }); + expect(parseAppUrl('/base-path/app/foo/path?hello=dolly', basePath, apps, getOrigin)).toEqual( + { + app: 'foo', + path: '/path?hello=dolly', + } + ); + expect(parseAppUrl('/base-path/app/foo/path#hash/bang', basePath, apps, getOrigin)).toEqual({ + app: 'foo', + path: '/path#hash/bang', + }); + expect( + parseAppUrl('/base-path/app/foo/path#hash/bang?hello=dolly', basePath, apps, getOrigin) + ).toEqual({ + app: 'foo', + path: '/path#hash/bang?hello=dolly', + }); + }); + it('includes query and hash in the path for custom app route', () => { + expect(parseAppUrl('/base-path/custom-bar#hash/bang', basePath, apps, getOrigin)).toEqual({ + app: 'bar', + path: '#hash/bang', + }); + expect(parseAppUrl('/base-path/custom-bar?hello=dolly', basePath, apps, getOrigin)).toEqual({ + app: 'bar', + path: '?hello=dolly', + }); + expect( + parseAppUrl('/base-path/custom-bar/path?hello=dolly', basePath, apps, getOrigin) + ).toEqual({ + app: 'bar', + path: '/path?hello=dolly', + }); + expect( + parseAppUrl('/base-path/custom-bar/path#hash/bang', basePath, apps, getOrigin) + ).toEqual({ + app: 'bar', + path: '/path#hash/bang', + }); + expect( + parseAppUrl('/base-path/custom-bar/path#hash/bang?hello=dolly', basePath, apps, getOrigin) + ).toEqual({ + app: 'bar', + path: '/path#hash/bang?hello=dolly', + }); + }); + it('works with legacy apps', () => { + expect(parseAppUrl('/base-path/app/legacy', basePath, apps, getOrigin)).toEqual({ + app: 'legacy', + path: undefined, + }); + expect( + parseAppUrl('/base-path/app/legacy/path#hash?query=bar', basePath, apps, getOrigin) + ).toEqual({ + app: 'legacy', + path: '/path#hash?query=bar', + }); + }); + it('returns undefined when the app is not known', () => { + expect(parseAppUrl('/base-path/app/non-registered', basePath, apps, getOrigin)).toEqual( + undefined + ); + expect(parseAppUrl('/base-path/unknown-path', basePath, apps, getOrigin)).toEqual(undefined); + }); + }); + + describe('with absolute urls', () => { + it('parses the app id', () => { + expect( + parseAppUrl('https://kibana.local:8080/base-path/app/foo', basePath, apps, getOrigin) + ).toEqual({ + app: 'foo', + path: undefined, + }); + expect( + parseAppUrl('https://kibana.local:8080/base-path/custom-bar', basePath, apps, getOrigin) + ).toEqual({ + app: 'bar', + path: undefined, + }); + }); + it('parses the path', () => { + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/foo/some/path', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'foo', + path: '/some/path', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/custom-bar/another/path/', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'bar', + path: '/another/path/', + }); + }); + it('includes query and hash in the path for default app routes', () => { + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/foo#hash/bang', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'foo', + path: '#hash/bang', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/foo?hello=dolly', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'foo', + path: '?hello=dolly', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/foo/path?hello=dolly', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'foo', + path: '/path?hello=dolly', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/foo/path#hash/bang', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'foo', + path: '/path#hash/bang', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/foo/path#hash/bang?hello=dolly', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'foo', + path: '/path#hash/bang?hello=dolly', + }); + }); + it('includes query and hash in the path for custom app route', () => { + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/custom-bar#hash/bang', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'bar', + path: '#hash/bang', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/custom-bar?hello=dolly', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'bar', + path: '?hello=dolly', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/custom-bar/path?hello=dolly', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'bar', + path: '/path?hello=dolly', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/custom-bar/path#hash/bang', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'bar', + path: '/path#hash/bang', + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/custom-bar/path#hash/bang?hello=dolly', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'bar', + path: '/path#hash/bang?hello=dolly', + }); + }); + it('works with legacy apps', () => { + expect( + parseAppUrl('https://kibana.local:8080/base-path/app/legacy', basePath, apps, getOrigin) + ).toEqual({ + app: 'legacy', + path: undefined, + }); + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/legacy/path#hash?query=bar', + basePath, + apps, + getOrigin + ) + ).toEqual({ + app: 'legacy', + path: '/path#hash?query=bar', + }); + }); + it('returns undefined when the app is not known', () => { + expect( + parseAppUrl( + 'https://kibana.local:8080/base-path/app/non-registered', + basePath, + apps, + getOrigin + ) + ).toEqual(undefined); + expect( + parseAppUrl('https://kibana.local:8080/base-path/unknown-path', basePath, apps, getOrigin) + ).toEqual(undefined); + }); + it('returns undefined when origin does not match', () => { + expect( + parseAppUrl( + 'https://other-kibana.external:8080/base-path/app/foo', + basePath, + apps, + getOrigin + ) + ).toEqual(undefined); + expect( + parseAppUrl( + 'https://other-kibana.external:8080/base-path/custom-bar', + basePath, + apps, + getOrigin + ) + ).toEqual(undefined); + }); + }); +}); diff --git a/src/core/public/application/utils.ts b/src/core/public/application/utils.ts index 048f195fe1223..8987a9402f2db 100644 --- a/src/core/public/application/utils.ts +++ b/src/core/public/application/utils.ts @@ -17,6 +17,14 @@ * under the License. */ +import { IBasePath } from '../http'; +import { App, LegacyApp } from './types'; + +export interface AppUrlInfo { + app: string; + path?: string; +} + /** * Utility to remove trailing, leading or duplicate slashes. * By default will only remove duplicates. @@ -52,3 +60,62 @@ export const appendAppPath = (appBasePath: string, path: string = '') => { leading: false, }); }; + +export function isLegacyApp(app: App | LegacyApp): app is LegacyApp { + return app.legacy === true; +} + +/** + * Converts a relative path to an absolute url. + * Implementation is based on a specified behavior of the browser to automatically convert + * a relative url to an absolute one when setting the `href` attribute of a `` html element. + * + * @example + * ```ts + * // current url: `https://kibana:8000/base-path/app/my-app` + * relativeToAbsolute('/base-path/app/another-app') => `https://kibana:8000/base-path/app/another-app` + * ``` + */ +export const relativeToAbsolute = (url: string): string => { + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +}; + +/** + * Parse given url and return the associated app id and path if any app matches. + * Input can either be: + * - a path containing the basePath, ie `/base-path/app/my-app/some-path` + * - an absolute url matching the `origin` of the kibana instance (as seen by the browser), + * i.e `https://kibana:8080/base-path/app/my-app/some-path` + */ +export const parseAppUrl = ( + url: string, + basePath: IBasePath, + apps: Map | LegacyApp>, + getOrigin: () => string = () => window.location.origin +): AppUrlInfo | undefined => { + url = removeBasePath(url, basePath, getOrigin()); + if (!url.startsWith('/')) { + return undefined; + } + + for (const app of apps.values()) { + const appPath = isLegacyApp(app) ? app.appUrl : app.appRoute || `/app/${app.id}`; + + if (url.startsWith(appPath)) { + const path = url.substr(appPath.length); + return { + app: app.id, + path: path.length ? path : undefined, + }; + } + } +}; + +const removeBasePath = (url: string, basePath: IBasePath, origin: string): string => { + if (url.startsWith(origin)) { + url = url.substring(origin.length); + } + return basePath.remove(url); +}; diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index 6213e5e641406..810416cdbfe16 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -135,6 +135,7 @@ export class LegacyPlatformService { capabilities: core.application.capabilities, getUrlForApp: core.application.getUrlForApp, navigateToApp: core.application.navigateToApp, + navigateToUrl: core.application.navigateToUrl, registerMountContext: notSupported(`core.application.registerMountContext()`), }, }; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 0ac63199498f0..c688373630a07 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -138,6 +138,7 @@ export function createPluginStartContext< currentAppId$: deps.application.currentAppId$, capabilities: deps.application.capabilities, navigateToApp: deps.application.navigateToApp, + navigateToUrl: deps.application.navigateToUrl, getUrlForApp: deps.application.getUrlForApp, registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index fbd8f474151fa..4ccded9b9afec 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -116,6 +116,7 @@ export interface ApplicationStart { path?: string; state?: any; }): Promise; + navigateToUrl(url: string): Promise; // @deprecated registerMountContext(contextName: T, provider: IContextProvider): void; }