Allow to define and update a defaultPath for applications (#64498)

* add defaultPath to `AppBase` and use it in `navigateToApp`

* add removeSlashes util

* adapt `toNavLink` to handle defaultPath

* update generated doc

* codestyle

* add FTR test

* address comments

* add tests
This commit is contained in:
Pierre Gayvallet 2020-04-30 08:05:16 +02:00 committed by GitHub
parent f85b3898f6
commit aa8cb620bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 320 additions and 36 deletions

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppBase](./kibana-plugin-core-public.appbase.md) &gt; [defaultPath](./kibana-plugin-core-public.appbase.defaultpath.md)
## AppBase.defaultPath property
Allow to define the default path a user should be directed to when navigating to the app. When defined, this value will be used as a default for the `path` option when calling [navigateToApp](./kibana-plugin-core-public.applicationstart.navigatetoapp.md)<!-- -->\`<!-- -->, and will also be appended to the [application navLink](./kibana-plugin-core-public.chromenavlink.md) in the navigation bar.
<b>Signature:</b>
```typescript
defaultPath?: string;
```

View file

@ -18,6 +18,7 @@ export interface AppBase
| [capabilities](./kibana-plugin-core-public.appbase.capabilities.md) | <code>Partial&lt;Capabilities&gt;</code> | Custom capabilities defined by the app. |
| [category](./kibana-plugin-core-public.appbase.category.md) | <code>AppCategory</code> | The category definition of the product See [AppCategory](./kibana-plugin-core-public.appcategory.md) See DEFAULT\_APP\_CATEGORIES for more reference |
| [chromeless](./kibana-plugin-core-public.appbase.chromeless.md) | <code>boolean</code> | Hide the UI chrome when the application is mounted. Defaults to <code>false</code>. Takes precedence over chrome service visibility settings. |
| [defaultPath](./kibana-plugin-core-public.appbase.defaultpath.md) | <code>string</code> | Allow to define the default path a user should be directed to when navigating to the app. When defined, this value will be used as a default for the <code>path</code> option when calling [navigateToApp](./kibana-plugin-core-public.applicationstart.navigatetoapp.md)<!-- -->\`<!-- -->, and will also be appended to the [application navLink](./kibana-plugin-core-public.chromenavlink.md) in the navigation bar. |
| [euiIconType](./kibana-plugin-core-public.appbase.euiicontype.md) | <code>string</code> | A EUI iconType that will be used for the app's icon. This icon takes precendence over the <code>icon</code> property. |
| [icon](./kibana-plugin-core-public.appbase.icon.md) | <code>string</code> | A URL to an image file used as an icon. Used as a fallback if <code>euiIconType</code> is not provided. |
| [id](./kibana-plugin-core-public.appbase.id.md) | <code>string</code> | The unique identifier of the application |

View file

@ -9,5 +9,5 @@ Defines the list of fields that can be updated via an [AppUpdater](./kibana-plug
<b>Signature:</b>
```typescript
export declare type AppUpdatableFields = Pick<AppBase, 'status' | 'navLinkStatus' | 'tooltip'>;
export declare type AppUpdatableFields = Pick<AppBase, 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath'>;
```

View file

@ -29,5 +29,5 @@ export interface ChromeNavLink
| [subUrlBase](./kibana-plugin-core-public.chromenavlink.suburlbase.md) | <code>string</code> | A url base that legacy apps can set to match deep URLs to an application. |
| [title](./kibana-plugin-core-public.chromenavlink.title.md) | <code>string</code> | The title of the application. |
| [tooltip](./kibana-plugin-core-public.chromenavlink.tooltip.md) | <code>string</code> | A tooltip shown when hovering over an app link. |
| [url](./kibana-plugin-core-public.chromenavlink.url.md) | <code>string</code> | A url that legacy apps can set to deep link into their applications. |
| [url](./kibana-plugin-core-public.chromenavlink.url.md) | <code>string</code> | The route used to open the [default path](./kibana-plugin-core-public.appbase.defaultpath.md) of an application. If unset, <code>baseUrl</code> will be used instead. |

View file

@ -4,11 +4,7 @@
## ChromeNavLink.url property
> Warning: This API is now obsolete.
>
>
A url that legacy apps can set to deep link into their applications.
The route used to open the [default path](./kibana-plugin-core-public.appbase.defaultpath.md) of an application. If unset, `baseUrl` will be used instead.
<b>Signature:</b>

View file

@ -87,7 +87,7 @@ describe('#setup()', () => {
).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`);
});
it('allows to register a statusUpdater for the application', async () => {
it('allows to register an AppUpdater for the application', async () => {
const setup = service.setup(setupDeps);
const pluginId = Symbol('plugin');
@ -118,6 +118,7 @@ describe('#setup()', () => {
updater$.next(app => ({
status: AppStatus.inaccessible,
tooltip: 'App inaccessible due to reason',
defaultPath: 'foo/bar',
}));
applications = await applications$.pipe(take(1)).toPromise();
@ -128,6 +129,7 @@ describe('#setup()', () => {
legacy: false,
navLinkStatus: AppNavLinkStatus.default,
status: AppStatus.inaccessible,
defaultPath: 'foo/bar',
tooltip: 'App inaccessible due to reason',
})
);
@ -209,7 +211,7 @@ describe('#setup()', () => {
});
});
describe('registerAppStatusUpdater', () => {
describe('registerAppUpdater', () => {
it('updates status fields', async () => {
const setup = service.setup(setupDeps);
@ -413,6 +415,36 @@ describe('#setup()', () => {
})
);
});
it('allows to update the basePath', async () => {
const setup = service.setup(setupDeps);
const pluginId = Symbol('plugin');
setup.register(pluginId, createApp({ id: 'app1' }));
const updater = new BehaviorSubject<AppUpdater>(app => ({}));
setup.registerAppUpdater(updater);
const start = await service.start(startDeps);
await start.navigateToApp('app1');
expect(MockHistory.push).toHaveBeenCalledWith('/app/app1', undefined);
MockHistory.push.mockClear();
updater.next(app => ({ defaultPath: 'default-path' }));
await start.navigateToApp('app1');
expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/default-path', undefined);
MockHistory.push.mockClear();
updater.next(app => ({ defaultPath: 'another-path' }));
await start.navigateToApp('app1');
expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/another-path', undefined);
MockHistory.push.mockClear();
updater.next(app => ({}));
await start.navigateToApp('app1');
expect(MockHistory.push).toHaveBeenCalledWith('/app/app1', undefined);
MockHistory.push.mockClear();
});
});
it("`registerMountContext` calls context container's registerContext", () => {
@ -676,6 +708,57 @@ describe('#start()', () => {
expect(MockHistory.push).toHaveBeenCalledWith('/custom/path#/hash/router/path', undefined);
});
it('preserves trailing slash when path contains a hash', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/app-path' }));
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('app2', { path: '#/' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path#/', undefined);
MockHistory.push.mockClear();
await navigateToApp('app2', { path: '#/foo/bar/' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path#/foo/bar/', undefined);
MockHistory.push.mockClear();
await navigateToApp('app2', { path: '/path#/' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path/path#/', undefined);
MockHistory.push.mockClear();
await navigateToApp('app2', { path: '/path#/hash/' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path/path#/hash/', undefined);
MockHistory.push.mockClear();
await navigateToApp('app2', { path: '/path/' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom/app-path/path', undefined);
MockHistory.push.mockClear();
});
it('appends the defaultPath when the path parameter is not specified', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), createApp({ id: 'app1', defaultPath: 'default/path' }));
register(
Symbol(),
createApp({ id: 'app2', appRoute: '/custom-app-path', defaultPath: '/my-base' })
);
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('app1', { path: 'defined-path' });
expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/defined-path', undefined);
await navigateToApp('app1', {});
expect(MockHistory.push).toHaveBeenCalledWith('/app/app1/default/path', undefined);
await navigateToApp('app2', { path: 'defined-path' });
expect(MockHistory.push).toHaveBeenCalledWith('/custom-app-path/defined-path', undefined);
await navigateToApp('app2', {});
expect(MockHistory.push).toHaveBeenCalledWith('/custom-app-path/my-base', undefined);
});
it('includes state if specified', async () => {
const { register } = service.setup(setupDeps);

View file

@ -46,6 +46,7 @@ import {
Mounter,
} from './types';
import { getLeaveAction, isConfirmAction } from './application_leave';
import { appendAppPath } from './utils';
interface SetupDeps {
context: ContextSetup;
@ -81,13 +82,7 @@ const getAppUrl = (mounters: Map<string, Mounter>, appId: string, path: string =
const appBasePath = mounters.get(appId)?.appRoute
? `/${mounters.get(appId)!.appRoute}`
: `/app/${appId}`;
// Only preppend slash if not a hash or query path
path = path.startsWith('#') || path.startsWith('?') ? path : `/${path}`;
return `${appBasePath}${path}`
.replace(/\/{2,}/g, '/') // Remove duplicate slashes
.replace(/\/$/, ''); // Remove trailing slash
return appendAppPath(appBasePath, path);
};
const allApplicationsFilter = '__ALL__';
@ -290,6 +285,9 @@ export class ApplicationService {
},
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);

View file

@ -66,6 +66,13 @@ export interface AppBase {
*/
navLinkStatus?: AppNavLinkStatus;
/**
* Allow to define the default path a user should be directed to when navigating to the app.
* When defined, this value will be used as a default for the `path` option when calling {@link ApplicationStart.navigateToApp | navigateToApp}`,
* and will also be appended to the {@link ChromeNavLink | application navLink} in the navigation bar.
*/
defaultPath?: string;
/**
* An {@link AppUpdater} observable that can be used to update the application {@link AppUpdatableFields} at runtime.
*
@ -187,7 +194,10 @@ export enum AppNavLinkStatus {
* Defines the list of fields that can be updated via an {@link AppUpdater}.
* @public
*/
export type AppUpdatableFields = Pick<AppBase, 'status' | 'navLinkStatus' | 'tooltip'>;
export type AppUpdatableFields = Pick<
AppBase,
'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath'
>;
/**
* Updater for applications.
@ -642,7 +652,8 @@ export interface ApplicationStart {
* Navigate to a given app
*
* @param appId
* @param options.path - optional path inside application to deep link to
* @param options.path - optional path inside application to deep link to.
* If undefined, will use {@link AppBase.defaultPath | the app's default path}` as default.
* @param options.state - optional state to forward to the application
*/
navigateToApp(appId: string, options?: { path?: string; state?: any }): Promise<void>;

View file

@ -0,0 +1,71 @@
/*
* 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 { removeSlashes, appendAppPath } from './utils';
describe('removeSlashes', () => {
it('only removes duplicates by default', () => {
expect(removeSlashes('/some//url//to//')).toEqual('/some/url/to/');
expect(removeSlashes('some/////other//url')).toEqual('some/other/url');
});
it('remove trailing slash when `trailing` is true', () => {
expect(removeSlashes('/some//url//to//', { trailing: true })).toEqual('/some/url/to');
});
it('remove leading slash when `leading` is true', () => {
expect(removeSlashes('/some//url//to//', { leading: true })).toEqual('some/url/to/');
});
it('does not removes duplicates when `duplicates` is false', () => {
expect(removeSlashes('/some//url//to/', { leading: true, duplicates: false })).toEqual(
'some//url//to/'
);
expect(removeSlashes('/some//url//to/', { trailing: true, duplicates: false })).toEqual(
'/some//url//to'
);
});
it('accept mixed options', () => {
expect(
removeSlashes('/some//url//to/', { leading: true, duplicates: false, trailing: true })
).toEqual('some//url//to');
expect(
removeSlashes('/some//url//to/', { leading: true, duplicates: true, trailing: true })
).toEqual('some/url/to');
});
});
describe('appendAppPath', () => {
it('appends the appBasePath with given path', () => {
expect(appendAppPath('/app/my-app', '/some-path')).toEqual('/app/my-app/some-path');
expect(appendAppPath('/app/my-app/', 'some-path')).toEqual('/app/my-app/some-path');
expect(appendAppPath('/app/my-app', 'some-path')).toEqual('/app/my-app/some-path');
expect(appendAppPath('/app/my-app', '')).toEqual('/app/my-app');
});
it('preserves the trailing slash only if included in the hash', () => {
expect(appendAppPath('/app/my-app', '/some-path/')).toEqual('/app/my-app/some-path');
expect(appendAppPath('/app/my-app', '/some-path#/')).toEqual('/app/my-app/some-path#/');
expect(appendAppPath('/app/my-app', '/some-path#/hash/')).toEqual(
'/app/my-app/some-path#/hash/'
);
expect(appendAppPath('/app/my-app', '/some-path#/hash')).toEqual('/app/my-app/some-path#/hash');
});
});

View file

@ -0,0 +1,54 @@
/*
* 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.
*/
/**
* Utility to remove trailing, leading or duplicate slashes.
* By default will only remove duplicates.
*/
export const removeSlashes = (
url: string,
{
trailing = false,
leading = false,
duplicates = true,
}: { trailing?: boolean; leading?: boolean; duplicates?: boolean } = {}
): string => {
if (duplicates) {
url = url.replace(/\/{2,}/g, '/');
}
if (trailing) {
url = url.replace(/\/$/, '');
}
if (leading) {
url = url.replace(/^\//, '');
}
return url;
};
export const appendAppPath = (appBasePath: string, path: string = '') => {
// Only prepend slash if not a hash or query path
path = path === '' || path.startsWith('#') || path.startsWith('?') ? path : `/${path}`;
// Do not remove trailing slash when in hashbang
const removeTrailing = path.indexOf('#') === -1;
return removeSlashes(`${appBasePath}${path}`, {
trailing: removeTrailing,
duplicates: true,
leading: false,
});
};

View file

@ -44,6 +44,12 @@ export interface ChromeNavLink {
*/
readonly baseUrl: string;
/**
* The route used to open the {@link AppBase.defaultPath | default path } of an application.
* If unset, `baseUrl` will be used instead.
*/
readonly url?: string;
/**
* An ordinal used to sort nav links relative to one another for display.
*/
@ -99,18 +105,6 @@ export interface ChromeNavLink {
*/
readonly linkToLastSubUrl?: boolean;
/**
* A url that legacy apps can set to deep link into their applications.
*
* @internalRemarks
* Currently used by the "lastSubUrl" feature legacy/ui/chrome. This should
* be removed once the ApplicationService is implemented and mounting apps. At that
* time, each app can handle opening to the previous location when they are mounted.
*
* @deprecated
*/
readonly url?: string;
/**
* Indicates whether or not this app is currently on the screen.
*

View file

@ -85,6 +85,38 @@ describe('toNavLink', () => {
expect(link.properties.baseUrl).toEqual('http://localhost/base-path/my-route/my-path');
});
it('generates the `url` property', () => {
let link = toNavLink(
app({
appRoute: '/my-route/my-path',
}),
basePath
);
expect(link.properties.url).toEqual('http://localhost/base-path/my-route/my-path');
link = toNavLink(
app({
appRoute: '/my-route/my-path',
defaultPath: 'some/default/path',
}),
basePath
);
expect(link.properties.url).toEqual(
'http://localhost/base-path/my-route/my-path/some/default/path'
);
});
it('does not generate `url` for legacy app', () => {
const link = toNavLink(
legacyApp({
appUrl: '/my-legacy-app/#foo',
defaultPath: '/some/default/path',
}),
basePath
);
expect(link.properties.url).toBeUndefined();
});
it('uses appUrl when converting legacy applications', () => {
expect(
toNavLink(

View file

@ -20,9 +20,11 @@
import { App, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application';
import { IBasePath } from '../../http';
import { NavLinkWrapper } from './nav_link';
import { appendAppPath } from '../../application/utils';
export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper {
const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default;
const baseUrl = isLegacyApp(app) ? basePath.prepend(app.appUrl) : basePath.prepend(app.appRoute!);
return new NavLinkWrapper({
...app,
hidden: useAppStatus
@ -30,9 +32,12 @@ export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWra
: app.navLinkStatus === AppNavLinkStatus.hidden,
disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled,
legacy: isLegacyApp(app),
baseUrl: isLegacyApp(app)
? relativeToAbsolute(basePath.prepend(app.appUrl))
: relativeToAbsolute(basePath.prepend(app.appRoute!)),
baseUrl: relativeToAbsolute(baseUrl),
...(isLegacyApp(app)
? {}
: {
url: relativeToAbsolute(appendAppPath(baseUrl, app.defaultPath)),
}),
});
}

View file

@ -53,7 +53,7 @@ export function euiNavLink(
order,
tooltip,
} = navLink;
let href = navLink.baseUrl;
let href = navLink.url ?? navLink.baseUrl;
if (legacy) {
href = url && !active ? url : baseUrl;

View file

@ -36,6 +36,7 @@ export interface AppBase {
capabilities?: Partial<Capabilities>;
category?: AppCategory;
chromeless?: boolean;
defaultPath?: string;
euiIconType?: string;
icon?: string;
id: string;
@ -168,7 +169,7 @@ export enum AppStatus {
export type AppUnmount = () => void;
// @public
export type AppUpdatableFields = Pick<AppBase, 'status' | 'navLinkStatus' | 'tooltip'>;
export type AppUpdatableFields = Pick<AppBase, 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath'>;
// @public
export type AppUpdater = (app: AppBase) => Partial<AppUpdatableFields> | undefined;
@ -290,7 +291,6 @@ export interface ChromeNavLink {
readonly subUrlBase?: string;
readonly title: string;
readonly tooltip?: string;
// @deprecated
readonly url?: string;
}

View file

@ -17,6 +17,7 @@
* under the License.
*/
import url from 'url';
import expect from '@kbn/expect';
import {
AppNavLinkStatus,
@ -26,6 +27,15 @@ import {
import { PluginFunctionalProviderContext } from '../../services';
import '../../plugins/core_app_status/public/types';
const getKibanaUrl = (pathname?: string, search?: string) =>
url.format({
protocol: 'http:',
hostname: process.env.TEST_KIBANA_HOST || 'localhost',
port: process.env.TEST_KIBANA_PORT || '5620',
pathname,
search,
});
// eslint-disable-next-line import/no-default-export
export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) {
const PageObjects = getPageObjects(['common']);
@ -97,6 +107,22 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider
expect(await testSubjects.exists('appStatusApp')).to.eql(true);
});
it('allows to change the defaultPath of an application', async () => {
let link = await appsMenu.getLink('App Status');
expect(link!.href).to.eql(getKibanaUrl('/app/app_status'));
await setAppStatus({
defaultPath: '/arbitrary/path',
});
link = await appsMenu.getLink('App Status');
expect(link!.href).to.eql(getKibanaUrl('/app/app_status/arbitrary/path'));
await navigateToApp('app_status');
expect(await testSubjects.exists('appStatusApp')).to.eql(true);
expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/app_status/arbitrary/path'));
});
it('can change the state of the currently mounted app', async () => {
await setAppStatus({
status: AppStatus.accessible,