[Core][Navigation] Chrome nav display application deepLinks (#100590) (#101615)

* chrome nav allows deepLinks

* docs updated

* use ChromeNavLink.url to call navigateToUrl

* to_nav_link test cases added for deepLink parameter

* snapshots updated

* deep nav links functional test added

* AppNavOptions type encapsulation

* docs updated

* docs for AppNavOptions

* implement navigateToApp deepLinkId option

* app searchable flag implementation

* code cleaning and test case added

* use explicit type

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2021-06-08 19:16:24 +02:00 committed by GitHub
parent c2c1ea0d31
commit ecc3a30556
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1216 additions and 145 deletions

View file

@ -8,7 +8,7 @@
<b>Signature:</b>
```typescript
export interface App<HistoryLocationState = unknown>
export interface App<HistoryLocationState = unknown> extends AppNavOptions
```
## Properties
@ -21,16 +21,13 @@ export interface App<HistoryLocationState = unknown>
| [chromeless](./kibana-plugin-core-public.app.chromeless.md) | <code>boolean</code> | Hide the UI chrome when the application is mounted. Defaults to <code>false</code>. Takes precedence over chrome service visibility settings. |
| [deepLinks](./kibana-plugin-core-public.app.deeplinks.md) | <code>AppDeepLink[]</code> | Input type for registering secondary in-app locations for an application.<!-- -->Deep links must include at least one of <code>path</code> or <code>deepLinks</code>. A deep link that does not have a <code>path</code> represents a topological level in the application's hierarchy, but does not have a destination URL that is user-accessible. |
| [defaultPath](./kibana-plugin-core-public.app.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.app.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. |
| [exactRoute](./kibana-plugin-core-public.app.exactroute.md) | <code>boolean</code> | If set to true, the application's route will only be checked against an exact match. Defaults to <code>false</code>. |
| [icon](./kibana-plugin-core-public.app.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.app.id.md) | <code>string</code> | The unique identifier of the application |
| [keywords](./kibana-plugin-core-public.app.keywords.md) | <code>string[]</code> | Optional keywords to match with in deep links search. Omit if this part of the hierarchy does not have a page URL. |
| [mount](./kibana-plugin-core-public.app.mount.md) | <code>AppMount&lt;HistoryLocationState&gt;</code> | A mount function called when the user navigates to this app's route. |
| [navLinkStatus](./kibana-plugin-core-public.app.navlinkstatus.md) | <code>AppNavLinkStatus</code> | The initial status of the application's navLink. Defaulting to <code>visible</code> if <code>status</code> is <code>accessible</code> and <code>hidden</code> if status is <code>inaccessible</code> See [AppNavLinkStatus](./kibana-plugin-core-public.appnavlinkstatus.md) |
| [order](./kibana-plugin-core-public.app.order.md) | <code>number</code> | An ordinal used to sort nav links relative to one another for display. |
| [searchable](./kibana-plugin-core-public.app.searchable.md) | <code>boolean</code> | The initial flag to determine if the application is searchable in the global search. Defaulting to <code>true</code> if <code>navLinkStatus</code> is <code>visible</code> or omitted. |
| [status](./kibana-plugin-core-public.app.status.md) | <code>AppStatus</code> | The initial status of the application. Defaulting to <code>accessible</code> |
| [title](./kibana-plugin-core-public.app.title.md) | <code>string</code> | The title of the application. |
| [tooltip](./kibana-plugin-core-public.app.tooltip.md) | <code>string</code> | A tooltip shown when hovering over app link. |
| [updater$](./kibana-plugin-core-public.app.updater_.md) | <code>Observable&lt;AppUpdater&gt;</code> | An [AppUpdater](./kibana-plugin-core-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-core-public.appupdatablefields.md) at runtime. |

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; [App](./kibana-plugin-core-public.app.md) &gt; [searchable](./kibana-plugin-core-public.app.searchable.md)
## App.searchable property
The initial flag to determine if the application is searchable in the global search. Defaulting to `true` if `navLinkStatus` is `visible` or omitted.
<b>Signature:</b>
```typescript
searchable?: boolean;
```

View file

@ -16,7 +16,8 @@ export declare type AppDeepLink = {
title: string;
keywords?: string[];
navLinkStatus?: AppNavLinkStatus;
} & ({
searchable?: boolean;
} & AppNavOptions & ({
path: string;
deepLinks?: AppDeepLink[];
} | {

View file

@ -1,8 +1,8 @@
<!-- 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; [App](./kibana-plugin-core-public.app.md) &gt; [euiIconType](./kibana-plugin-core-public.app.euiicontype.md)
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppNavOptions](./kibana-plugin-core-public.appnavoptions.md) &gt; [euiIconType](./kibana-plugin-core-public.appnavoptions.euiicontype.md)
## App.euiIconType property
## AppNavOptions.euiIconType property
A EUI iconType that will be used for the app's icon. This icon takes precendence over the `icon` property.

View file

@ -1,8 +1,8 @@
<!-- 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; [App](./kibana-plugin-core-public.app.md) &gt; [icon](./kibana-plugin-core-public.app.icon.md)
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppNavOptions](./kibana-plugin-core-public.appnavoptions.md) &gt; [icon](./kibana-plugin-core-public.appnavoptions.icon.md)
## App.icon property
## AppNavOptions.icon property
A URL to an image file used as an icon. Used as a fallback if `euiIconType` is not provided.

View file

@ -0,0 +1,23 @@
<!-- 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; [AppNavOptions](./kibana-plugin-core-public.appnavoptions.md)
## AppNavOptions interface
App navigation menu options
<b>Signature:</b>
```typescript
export interface AppNavOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [euiIconType](./kibana-plugin-core-public.appnavoptions.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.appnavoptions.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. |
| [order](./kibana-plugin-core-public.appnavoptions.order.md) | <code>number</code> | An ordinal used to sort nav links relative to one another for display. |
| [tooltip](./kibana-plugin-core-public.appnavoptions.tooltip.md) | <code>string</code> | A tooltip shown when hovering over app link. |

View file

@ -1,8 +1,8 @@
<!-- 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; [App](./kibana-plugin-core-public.app.md) &gt; [order](./kibana-plugin-core-public.app.order.md)
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppNavOptions](./kibana-plugin-core-public.appnavoptions.md) &gt; [order](./kibana-plugin-core-public.appnavoptions.order.md)
## App.order property
## AppNavOptions.order property
An ordinal used to sort nav links relative to one another for display.

View file

@ -1,8 +1,8 @@
<!-- 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; [App](./kibana-plugin-core-public.app.md) &gt; [tooltip](./kibana-plugin-core-public.app.tooltip.md)
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppNavOptions](./kibana-plugin-core-public.appnavoptions.md) &gt; [tooltip](./kibana-plugin-core-public.appnavoptions.tooltip.md)
## App.tooltip property
## AppNavOptions.tooltip property
A tooltip shown when hovering over app link.

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<App, 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath' | 'deepLinks'>;
export declare type AppUpdatableFields = Pick<App, 'status' | 'navLinkStatus' | 'searchable' | 'tooltip' | 'defaultPath' | 'deepLinks'>;
```

View file

@ -26,5 +26,5 @@ export interface ChromeNavLink
| [order](./kibana-plugin-core-public.chromenavlink.order.md) | <code>number</code> | An ordinal used to sort nav links relative to one another for display. |
| [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> | The route used to open the of an application. If unset, <code>baseUrl</code> will be used instead. |
| [url](./kibana-plugin-core-public.chromenavlink.url.md) | <code>string</code> | The route used to open the default path and the deep links of an application. |

View file

@ -4,10 +4,10 @@
## ChromeNavLink.url property
The route used to open the of an application. If unset, `baseUrl` will be used instead.
The route used to open the default path and the deep links of an application.
<b>Signature:</b>
```typescript
readonly url?: string;
readonly url: string;
```

View file

@ -38,6 +38,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | |
| [ApplicationStart](./kibana-plugin-core-public.applicationstart.md) | |
| [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) | |
| [AppNavOptions](./kibana-plugin-core-public.appnavoptions.md) | App navigation menu options |
| [AsyncPlugin](./kibana-plugin-core-public.asyncplugin.md) | A plugin with asynchronous lifecycle methods. |
| [Capabilities](./kibana-plugin-core-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. |
| [ChromeBadge](./kibana-plugin-core-public.chromebadge.md) | |

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; [NavigateToAppOptions](./kibana-plugin-core-public.navigatetoappoptions.md) &gt; [deepLinkId](./kibana-plugin-core-public.navigatetoappoptions.deeplinkid.md)
## NavigateToAppOptions.deepLinkId property
optional [deep link](./kibana-plugin-core-public.app.deeplinks.md) id inside the application to navigate to. If an additional [path](./kibana-plugin-core-public.navigatetoappoptions.path.md) is defined it will be appended to the deep link path.
<b>Signature:</b>
```typescript
deepLinkId?: string;
```

View file

@ -16,8 +16,9 @@ export interface NavigateToAppOptions
| Property | Type | Description |
| --- | --- | --- |
| [deepLinkId](./kibana-plugin-core-public.navigatetoappoptions.deeplinkid.md) | <code>string</code> | optional [deep link](./kibana-plugin-core-public.app.deeplinks.md) id inside the application to navigate to. If an additional [path](./kibana-plugin-core-public.navigatetoappoptions.path.md) is defined it will be appended to the deep link path. |
| [openInNewTab](./kibana-plugin-core-public.navigatetoappoptions.openinnewtab.md) | <code>boolean</code> | if true, will open the app in new tab, will share session information via window.open if base |
| [path](./kibana-plugin-core-public.navigatetoappoptions.path.md) | <code>string</code> | optional path inside application to deep link to. If undefined, will use [the app's default path](./kibana-plugin-core-public.app.defaultpath.md)<!-- -->\` as default. |
| [path](./kibana-plugin-core-public.navigatetoappoptions.path.md) | <code>string</code> | optional path inside application to deep link to. If undefined, will use [the app's default path](./kibana-plugin-core-public.app.defaultpath.md) as default. |
| [replace](./kibana-plugin-core-public.navigatetoappoptions.replace.md) | <code>boolean</code> | if true, will not create a new history entry when navigating (using <code>replace</code> instead of <code>push</code>) |
| [state](./kibana-plugin-core-public.navigatetoappoptions.state.md) | <code>unknown</code> | optional state to forward to the application |

View file

@ -4,7 +4,7 @@
## NavigateToAppOptions.path property
optional path inside application to deep link to. If undefined, will use [the app's default path](./kibana-plugin-core-public.app.defaultpath.md)<!-- -->\` as default.
optional path inside application to deep link to. If undefined, will use [the app's default path](./kibana-plugin-core-public.app.defaultpath.md) as default.
<b>Signature:</b>

View file

@ -9,9 +9,10 @@ Public information about a registered app's [deepLinks](./kibana-plugin-core-pub
<b>Signature:</b>
```typescript
export declare type PublicAppDeepLinkInfo = Omit<AppDeepLink, 'deepLinks' | 'keywords' | 'navLinkStatus'> & {
export declare type PublicAppDeepLinkInfo = Omit<AppDeepLink, 'deepLinks' | 'keywords' | 'navLinkStatus' | 'searchable'> & {
deepLinks: PublicAppDeepLinkInfo[];
keywords: string[];
navLinkStatus: AppNavLinkStatus;
searchable: boolean;
};
```

View file

@ -9,11 +9,12 @@ Public information about a registered [application](./kibana-plugin-core-public.
<b>Signature:</b>
```typescript
export declare type PublicAppInfo = Omit<App, 'mount' | 'updater$' | 'keywords' | 'deepLinks'> & {
export declare type PublicAppInfo = Omit<App, 'mount' | 'updater$' | 'keywords' | 'deepLinks' | 'searchable'> & {
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
keywords: string[];
deepLinks: PublicAppDeepLinkInfo[];
searchable: boolean;
};
```

View file

@ -107,6 +107,7 @@ describe('#setup()', () => {
status: AppStatus.inaccessible,
tooltip: 'App inaccessible due to reason',
defaultPath: 'foo/bar',
deepLinks: [{ id: 'subapp2', title: 'Subapp 2', path: '/subapp2' }],
}));
applications = await applications$.pipe(take(1)).toPromise();
@ -118,6 +119,9 @@ describe('#setup()', () => {
status: AppStatus.inaccessible,
defaultPath: 'foo/bar',
tooltip: 'App inaccessible due to reason',
deepLinks: [
expect.objectContaining({ id: 'subapp2', title: 'Subapp 2', path: '/subapp2' }),
],
})
);
expect(applications.get('app2')).toEqual(
@ -814,6 +818,128 @@ describe('#start()', () => {
expect(MockHistory.replace).not.toHaveBeenCalled();
});
});
describe('deepLinkId option', () => {
beforeEach(() => {
MockHistory.push.mockClear();
});
it('preserves trailing slash when path contains a hash', async () => {
const { register } = service.setup(setupDeps);
register(
Symbol(),
createApp({
id: 'app1',
appRoute: '/custom/app-path',
deepLinks: [{ id: 'dl1', title: 'deep link 1', path: '/deep-link' }],
})
);
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('app1', { deepLinkId: 'dl1', path: '#/' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/custom/app-path/deep-link#/',
undefined
);
await navigateToApp('app1', { deepLinkId: 'dl1', path: '#/foo/bar/' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/custom/app-path/deep-link#/foo/bar/',
undefined
);
await navigateToApp('app1', { deepLinkId: 'dl1', path: '/path#/' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/custom/app-path/deep-link/path#/',
undefined
);
await navigateToApp('app1', { deepLinkId: 'dl1', path: '/path#/hash/' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/custom/app-path/deep-link/path#/hash/',
undefined
);
await navigateToApp('app1', { deepLinkId: 'dl1', path: '/path/' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/custom/app-path/deep-link/path',
undefined
);
});
it('omits the defaultPath when the deepLinkId parameter is specified', async () => {
const { register } = service.setup(setupDeps);
register(
Symbol(),
createApp({
id: 'app1',
defaultPath: 'default/path',
deepLinks: [{ id: 'dl1', title: 'deep link 1', path: '/deep-link' }],
})
);
register(
Symbol(),
createApp({
id: 'app2',
appRoute: '/custom-app-path',
defaultPath: '/my-default',
deepLinks: [{ id: 'dl2', title: 'deep link 2', path: '/deep-link-2' }],
})
);
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('app1', {});
expect(MockHistory.push).toHaveBeenLastCalledWith('/app/app1/default/path', undefined);
await navigateToApp('app1', { deepLinkId: 'dl1' });
expect(MockHistory.push).toHaveBeenLastCalledWith('/app/app1/deep-link', undefined);
await navigateToApp('app1', { deepLinkId: 'dl1', path: 'some-other-path' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/app/app1/deep-link/some-other-path',
undefined
);
await navigateToApp('app2', {});
expect(MockHistory.push).toHaveBeenLastCalledWith('/custom-app-path/my-default', undefined);
await navigateToApp('app2', { deepLinkId: 'dl2' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/custom-app-path/deep-link-2',
undefined
);
await navigateToApp('app2', { deepLinkId: 'dl2', path: 'some-other-path' });
expect(MockHistory.push).toHaveBeenLastCalledWith(
'/custom-app-path/deep-link-2/some-other-path',
undefined
);
});
it('ignores the deepLinkId parameter if it is unknown', async () => {
const { register } = service.setup(setupDeps);
register(
Symbol(),
createApp({
id: 'app1',
defaultPath: 'default/path',
deepLinks: [{ id: 'dl1', title: 'deep link 1', path: '/deep-link' }],
})
);
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('app1', { deepLinkId: 'dl-unknown' });
expect(MockHistory.push).toHaveBeenLastCalledWith('/app/app1/default/path', undefined);
await navigateToApp('app1', { deepLinkId: 'dl-unknown', path: 'some-other-path' });
expect(MockHistory.push).toHaveBeenLastCalledWith('/app/app1/some-other-path', undefined);
});
});
});
describe('navigateToUrl', () => {

View file

@ -64,6 +64,10 @@ const getAppUrl = (mounters: Map<string, Mounter>, appId: string, path: string =
return appendAppPath(appBasePath, path);
};
const getAppDeepLinkPath = (mounters: Map<string, Mounter>, appId: string, deepLinkId: string) => {
return mounters.get(appId)?.deepLinkPaths[deepLinkId];
};
const allApplicationsFilter = '__ALL__';
interface AppUpdaterWrapper {
@ -175,6 +179,7 @@ export class ApplicationService {
this.mounters.set(app.id, {
appRoute: app.appRoute!,
appBasePath: basePath.prepend(app.appRoute!),
deepLinkPaths: toDeepLinkPaths(app.deepLinks),
exactRoute: app.exactRoute ?? false,
mount: wrapMount(plugin, app),
unmountBeforeMounting: false,
@ -226,7 +231,7 @@ export class ApplicationService {
const navigateToApp: InternalApplicationStart['navigateToApp'] = async (
appId,
{ path, state, replace = false, openInNewTab = false }: NavigateToAppOptions = {}
{ deepLinkId, path, state, replace = false, openInNewTab = false }: NavigateToAppOptions = {}
) => {
const currentAppId = this.currentAppId$.value;
const navigatingToSameApp = currentAppId === appId;
@ -235,6 +240,12 @@ export class ApplicationService {
: await this.shouldNavigate(overlays, appId);
if (shouldNavigate) {
if (deepLinkId) {
const deepLinkPath = getAppDeepLinkPath(availableMounters, appId, deepLinkId);
if (deepLinkPath) {
path = appendAppPath(deepLinkPath, path);
}
}
if (path === undefined) {
path = applications$.value.get(appId)?.defaultPath;
}
@ -384,8 +395,18 @@ const updateStatus = (app: App, statusUpdaters: AppUpdaterWrapper[]): App => {
...fields,
// status and navLinkStatus enums are ordered by reversed priority
// if multiple updaters wants to change these fields, we will always follow the priority order.
status: Math.max(changes.status ?? 0, fields.status ?? 0),
navLinkStatus: Math.max(changes.navLinkStatus ?? 0, fields.navLinkStatus ?? 0),
status: Math.max(
changes.status ?? AppStatus.accessible,
fields.status ?? AppStatus.accessible
),
navLinkStatus: Math.max(
changes.navLinkStatus ?? AppNavLinkStatus.default,
fields.navLinkStatus ?? AppNavLinkStatus.default
),
// deepLinks take the last defined update
deepLinks: fields.deepLinks
? populateDeepLinkDefaults(fields.deepLinks)
: changes.deepLinks,
};
}
});
@ -396,10 +417,22 @@ const updateStatus = (app: App, statusUpdaters: AppUpdaterWrapper[]): App => {
};
const populateDeepLinkDefaults = (deepLinks?: AppDeepLink[]): AppDeepLink[] => {
if (!deepLinks) return [];
if (!deepLinks) {
return [];
}
return deepLinks.map((deepLink) => ({
...deepLink,
navLinkStatus: deepLink.navLinkStatus ?? AppNavLinkStatus.default,
deepLinks: populateDeepLinkDefaults(deepLink.deepLinks),
}));
};
const toDeepLinkPaths = (deepLinks?: AppDeepLink[]): Mounter['deepLinkPaths'] => {
if (!deepLinks) {
return {};
}
return deepLinks.reduce((deepLinkPaths: Mounter['deepLinkPaths'], deepLink) => {
if (deepLink.path) deepLinkPaths[deepLink.id] = deepLink.path;
return { ...deepLinkPaths, ...toDeepLinkPaths(deepLink.deepLinks) };
}, {});
};

View file

@ -17,6 +17,7 @@ export type {
AppUnmount,
AppMountParameters,
AppUpdatableFields,
AppNavOptions,
AppUpdater,
AppDeepLink,
ApplicationSetup,

View file

@ -35,12 +35,14 @@ export const createAppMounter = ({
appId,
html = `<div>App ${appId}</div>`,
appRoute = `/app/${appId}`,
deepLinkPaths = {},
exactRoute = false,
extraMountHook,
}: {
appId: string;
html?: string;
appRoute?: string;
deepLinkPaths?: Record<string, string>;
exactRoute?: boolean;
extraMountHook?: (params: AppMountParameters) => void;
}): MockedMounterTuple => {
@ -51,6 +53,7 @@ export const createAppMounter = ({
mounter: {
appRoute,
appBasePath: appRoute,
deepLinkPaths,
exactRoute,
mount: jest.fn(async (params: AppMountParameters) => {
const { appBasePath: basename, element } = params;

View file

@ -63,9 +63,37 @@ export enum AppNavLinkStatus {
*/
export type AppUpdatableFields = Pick<
App,
'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath' | 'deepLinks'
'status' | 'navLinkStatus' | 'searchable' | 'tooltip' | 'defaultPath' | 'deepLinks'
>;
/**
* App navigation menu options
* @public
*/
export interface AppNavOptions {
/**
* An ordinal used to sort nav links relative to one another for display.
*/
order?: number;
/**
* A tooltip shown when hovering over app link.
*/
tooltip?: string;
/**
* A EUI iconType that will be used for the app's icon. This icon
* takes precendence over the `icon` property.
*/
euiIconType?: string;
/**
* A URL to an image file used as an icon. Used as a fallback
* if `euiIconType` is not provided.
*/
icon?: string;
}
/**
* Updater for applications.
* see {@link ApplicationSetup}
@ -76,7 +104,7 @@ export type AppUpdater = (app: App) => Partial<AppUpdatableFields> | undefined;
/**
* @public
*/
export interface App<HistoryLocationState = unknown> {
export interface App<HistoryLocationState = unknown> extends AppNavOptions {
/**
* The unique identifier of the application
*/
@ -107,6 +135,12 @@ export interface App<HistoryLocationState = unknown> {
*/
navLinkStatus?: AppNavLinkStatus;
/**
* The initial flag to determine if the application is searchable in the global search.
* Defaulting to `true` if `navLinkStatus` is `visible` or omitted.
*/
searchable?: boolean;
/**
* 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}`,
@ -148,28 +182,6 @@ export interface App<HistoryLocationState = unknown> {
*/
updater$?: Observable<AppUpdater>;
/**
* An ordinal used to sort nav links relative to one another for display.
*/
order?: number;
/**
* A tooltip shown when hovering over app link.
*/
tooltip?: string;
/**
* A EUI iconType that will be used for the app's icon. This icon
* takes precendence over the `icon` property.
*/
euiIconType?: string;
/**
* A URL to an image file used as an icon. Used as a fallback
* if `euiIconType` is not provided.
*/
icon?: string;
/**
* Custom capabilities defined by the app.
*/
@ -261,11 +273,12 @@ export interface App<HistoryLocationState = unknown> {
*/
export type PublicAppDeepLinkInfo = Omit<
AppDeepLink,
'deepLinks' | 'keywords' | 'navLinkStatus'
'deepLinks' | 'keywords' | 'navLinkStatus' | 'searchable'
> & {
deepLinks: PublicAppDeepLinkInfo[];
keywords: string[];
navLinkStatus: AppNavLinkStatus;
searchable: boolean;
};
/**
@ -285,33 +298,40 @@ export type AppDeepLink = {
keywords?: string[];
/** Optional status of the chrome navigation, defaults to `hidden` */
navLinkStatus?: AppNavLinkStatus;
} & (
| {
/** URL path to access this link, relative to the application's appRoute. */
path: string;
/** Optional array of links that are 'underneath' this section in the hierarchy */
deepLinks?: AppDeepLink[];
}
| {
/** Optional path to access this section. Omit if this part of the hierarchy does not have a page URL. */
path?: string;
/** Array links that are 'underneath' this section in this hierarchy. */
deepLinks: AppDeepLink[];
}
);
/** Optional flag to determine if the link is searchable in the global search. Defaulting to `true` if `navLinkStatus` is `visible` or omitted */
searchable?: boolean;
} & AppNavOptions &
(
| {
/** URL path to access this link, relative to the application's appRoute. */
path: string;
/** Optional array of links that are 'underneath' this section in the hierarchy */
deepLinks?: AppDeepLink[];
}
| {
/** Optional path to access this section. Omit if this part of the hierarchy does not have a page URL. */
path?: string;
/** Array links that are 'underneath' this section in this hierarchy. */
deepLinks: AppDeepLink[];
}
);
/**
* Public information about a registered {@link App | application}
*
* @public
*/
export type PublicAppInfo = Omit<App, 'mount' | 'updater$' | 'keywords' | 'deepLinks'> & {
export type PublicAppInfo = Omit<
App,
'mount' | 'updater$' | 'keywords' | 'deepLinks' | 'searchable'
> & {
// remove optional on fields populated with default values
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
keywords: string[];
deepLinks: PublicAppDeepLinkInfo[];
searchable: boolean;
};
/**
@ -592,6 +612,7 @@ export interface AppLeaveActionFactory {
export interface Mounter {
appRoute: string;
appBasePath: string;
deepLinkPaths: Record<string, string>;
mount: AppMount;
exactRoute: boolean;
unmountBeforeMounting?: boolean;
@ -657,11 +678,17 @@ export interface InternalApplicationSetup extends Pick<ApplicationSetup, 'regist
/**
* Options for the {@link ApplicationStart.navigateToApp | navigateToApp API}
* @public
*/
export interface NavigateToAppOptions {
/**
* optional {@link App.deepLinks | deep link} id inside the application to navigate to.
* If an additional {@link NavigateToAppOptions.path | path} is defined it will be appended to the deep link path.
*/
deepLinkId?: string;
/**
* optional path inside application to deep link to.
* If undefined, will use {@link App.defaultPath | the app's default path}` as default.
* If undefined, will use {@link App.defaultPath | the app's default path} as default.
*/
path?: string;
/**

View file

@ -45,6 +45,7 @@ describe('AppContainer', () => {
appRoute: '/some-route',
unmountBeforeMounting: false,
exactRoute: false,
deepLinkPaths: {},
mount: async ({ element }: AppMountParameters) => {
await promise;
const container = document.createElement('div');
@ -133,6 +134,7 @@ describe('AppContainer', () => {
const mounter = {
appBasePath: '/base-path/some-route',
appRoute: '/some-route',
deepLinkPaths: {},
unmountBeforeMounting: false,
exactRoute: false,
mount: async ({ element }: AppMountParameters) => {

View file

@ -7,7 +7,7 @@
*/
import { of } from 'rxjs';
import { App, AppNavLinkStatus, AppStatus } from '../types';
import { App, AppDeepLink, AppNavLinkStatus, AppStatus } from '../types';
import { getAppInfo } from './get_app_info';
describe('getAppInfo', () => {
@ -18,10 +18,22 @@ describe('getAppInfo', () => {
title: 'some-title',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.default,
searchable: true,
appRoute: `/app/some-id`,
...props,
});
const createDeepLink = (props: Partial<AppDeepLink> = {}): AppDeepLink => ({
id: 'some-deep-link-id',
title: 'my deep link',
path: '/my-deep-link',
navLinkStatus: AppNavLinkStatus.default,
searchable: true,
deepLinks: [],
keywords: [],
...props,
});
it('converts an application and remove sensitive properties', () => {
const app = createApp();
const info = getAppInfo(app);
@ -31,6 +43,7 @@ describe('getAppInfo', () => {
title: 'some-title',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
searchable: true,
appRoute: `/app/some-id`,
keywords: [],
deepLinks: [],
@ -54,12 +67,15 @@ describe('getAppInfo', () => {
title: 'some-title',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
searchable: true,
appRoute: `/app/some-id`,
keywords: [],
deepLinks: [
{
id: 'sub-id',
title: 'sub-title',
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
keywords: [],
deepLinks: [
{
@ -67,6 +83,8 @@ describe('getAppInfo', () => {
title: 'sub-sub-title',
path: '/sub-sub',
keywords: [],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
deepLinks: [], // default empty array added
},
],
@ -102,7 +120,70 @@ describe('getAppInfo', () => {
);
});
it('adds default meta fields to sublinks when needed', () => {
it('computes the searchable flag depending on the navLinkStatus when needed', () => {
expect(
getAppInfo(
createApp({
navLinkStatus: AppNavLinkStatus.default,
searchable: undefined,
})
)
).toEqual(
expect.objectContaining({
searchable: true,
})
);
expect(
getAppInfo(
createApp({
navLinkStatus: AppNavLinkStatus.visible,
searchable: undefined,
})
)
).toEqual(
expect.objectContaining({
searchable: true,
})
);
expect(
getAppInfo(
createApp({
navLinkStatus: AppNavLinkStatus.disabled,
searchable: undefined,
})
)
).toEqual(
expect.objectContaining({
searchable: false,
})
);
expect(
getAppInfo(
createApp({
navLinkStatus: AppNavLinkStatus.hidden,
searchable: undefined,
})
)
).toEqual(
expect.objectContaining({
searchable: false,
})
);
expect(
getAppInfo(
createApp({
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
})
)
).toEqual(
expect.objectContaining({
searchable: true,
})
);
});
it('adds default deepLinks when needed', () => {
const app = createApp({
deepLinks: [
{
@ -126,17 +207,22 @@ describe('getAppInfo', () => {
title: 'some-title',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
searchable: true,
appRoute: `/app/some-id`,
keywords: [],
deepLinks: [
{
id: 'sub-id',
title: 'sub-title',
keywords: [], // default empty array
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
keywords: [],
deepLinks: [
{
id: 'sub-sub-id',
title: 'sub-sub-title',
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
path: '/sub-sub',
keywords: ['sub sub'],
deepLinks: [],
@ -146,4 +232,127 @@ describe('getAppInfo', () => {
],
});
});
it('computes the deepLinks navLinkStatus when needed', () => {
expect(
getAppInfo(
createApp({
deepLinks: [
createDeepLink({
navLinkStatus: AppNavLinkStatus.visible,
}),
],
})
)
).toEqual(
expect.objectContaining({
deepLinks: [
expect.objectContaining({
navLinkStatus: AppNavLinkStatus.visible,
}),
],
})
);
expect(
getAppInfo(
createApp({
deepLinks: [
createDeepLink({
navLinkStatus: AppNavLinkStatus.default,
}),
],
})
)
).toEqual(
expect.objectContaining({
deepLinks: [
expect.objectContaining({
navLinkStatus: AppNavLinkStatus.hidden,
}),
],
})
);
expect(
getAppInfo(
createApp({
deepLinks: [
createDeepLink({
navLinkStatus: undefined,
}),
],
})
)
).toEqual(
expect.objectContaining({
deepLinks: [
expect.objectContaining({
navLinkStatus: AppNavLinkStatus.hidden,
}),
],
})
);
});
it('computes the deepLinks searchable depending on the navLinkStatus when needed', () => {
expect(
getAppInfo(
createApp({
deepLinks: [
createDeepLink({
navLinkStatus: AppNavLinkStatus.default,
searchable: undefined,
}),
],
})
)
).toEqual(
expect.objectContaining({
deepLinks: [
expect.objectContaining({
searchable: true,
}),
],
})
);
expect(
getAppInfo(
createApp({
deepLinks: [
createDeepLink({
navLinkStatus: AppNavLinkStatus.hidden,
searchable: undefined,
}),
],
})
)
).toEqual(
expect.objectContaining({
deepLinks: [
expect.objectContaining({
searchable: false,
}),
],
})
);
expect(
getAppInfo(
createApp({
deepLinks: [
createDeepLink({
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
}),
],
})
)
).toEqual(
expect.objectContaining({
deepLinks: [
expect.objectContaining({
searchable: true,
}),
],
})
);
});
});

View file

@ -16,17 +16,19 @@ import {
} from '../types';
export function getAppInfo(app: App): PublicAppInfo {
const navLinkStatus =
app.navLinkStatus === AppNavLinkStatus.default
? app.status === AppStatus.inaccessible
? AppNavLinkStatus.hidden
: AppNavLinkStatus.visible
: app.navLinkStatus!;
const { updater$, mount, ...infos } = app;
const { updater$, mount, navLinkStatus = AppNavLinkStatus.default, ...infos } = app;
return {
...infos,
status: app.status!,
navLinkStatus,
navLinkStatus:
navLinkStatus === AppNavLinkStatus.default
? app.status === AppStatus.inaccessible
? AppNavLinkStatus.hidden
: AppNavLinkStatus.visible
: navLinkStatus,
searchable:
app.searchable ??
(navLinkStatus === AppNavLinkStatus.default || navLinkStatus === AppNavLinkStatus.visible),
appRoute: app.appRoute!,
keywords: app.keywords ?? [],
deepLinks: getDeepLinkInfos(app.deepLinks),
@ -37,17 +39,18 @@ function getDeepLinkInfos(deepLinks?: AppDeepLink[]): PublicAppDeepLinkInfo[] {
if (!deepLinks) return [];
return deepLinks.map(
(rawDeepLink): PublicAppDeepLinkInfo => {
const navLinkStatus =
rawDeepLink.navLinkStatus === AppNavLinkStatus.default
? AppNavLinkStatus.hidden
: rawDeepLink.navLinkStatus!;
({ navLinkStatus = AppNavLinkStatus.default, ...rawDeepLink }): PublicAppDeepLinkInfo => {
return {
id: rawDeepLink.id,
title: rawDeepLink.title,
path: rawDeepLink.path,
keywords: rawDeepLink.keywords ?? [],
navLinkStatus,
navLinkStatus:
navLinkStatus === AppNavLinkStatus.default ? AppNavLinkStatus.hidden : navLinkStatus,
searchable:
rawDeepLink.searchable ??
(navLinkStatus === AppNavLinkStatus.default ||
navLinkStatus === AppNavLinkStatus.visible),
deepLinks: getDeepLinkInfos(rawDeepLink.deepLinks),
};
}

View file

@ -33,10 +33,9 @@ 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.
* The route used to open the default path and the deep links of an application.
*/
readonly url?: string;
readonly url: string;
/**
* An ordinal used to sort nav links relative to one another for display.

View file

@ -20,6 +20,22 @@ const availableApps = new Map([
order: -10,
title: 'App 2',
euiIconType: 'canvasApp',
deepLinks: [
{
id: 'deepApp1',
order: 50,
title: 'Deep App 1',
path: '/deepapp1',
deepLinks: [
{
id: 'deepApp2',
order: 40,
title: 'Deep App 2',
path: '/deepapp2',
},
],
},
],
},
],
['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }],
@ -66,7 +82,7 @@ describe('NavLinksService', () => {
map((links) => links.map((l) => l.id))
)
.toPromise()
).toEqual(['app2', 'app1']);
).toEqual(['app2', 'app1', 'app2:deepApp2', 'app2:deepApp1']);
});
it('emits multiple values', async () => {
@ -76,7 +92,7 @@ describe('NavLinksService', () => {
start.showOnly('app1');
service.stop();
expect(emittedLinks).toEqual([['app2', 'app1'], ['app1']]);
expect(emittedLinks).toEqual([['app2', 'app1', 'app2:deepApp2', 'app2:deepApp1'], ['app1']]);
});
it('completes when service is stopped', async () => {
@ -98,7 +114,12 @@ describe('NavLinksService', () => {
describe('#getAll()', () => {
it('returns a sorted array of navlinks', () => {
expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1']);
expect(start.getAll().map((l) => l.id)).toEqual([
'app2',
'app1',
'app2:deepApp2',
'app2:deepApp1',
]);
});
});
@ -123,7 +144,7 @@ describe('NavLinksService', () => {
map((links) => links.map((l) => l.id))
)
.toPromise()
).toEqual(['app2', 'app1']);
).toEqual(['app2', 'app1', 'app2:deepApp2', 'app2:deepApp1']);
});
it('does nothing on chromeless applications', async () => {
@ -136,7 +157,7 @@ describe('NavLinksService', () => {
map((links) => links.map((l) => l.id))
)
.toPromise()
).toEqual(['app2', 'app1']);
).toEqual(['app2', 'app1', 'app2:deepApp2', 'app2:deepApp1']);
});
it('removes all other links', async () => {
@ -152,6 +173,19 @@ describe('NavLinksService', () => {
).toEqual(['app2']);
});
it('show only deep link', async () => {
start.showOnly('app2:deepApp1');
expect(
await start
.getNavLinks$()
.pipe(
take(1),
map((links) => links.map((l) => l.id))
)
.toPromise()
).toEqual(['app2:deepApp1']);
});
it('still removes all other links when availableApps are re-emitted', async () => {
start.showOnly('app2');
mockAppService.applications$.next(mockAppService.applications$.value);

View file

@ -10,8 +10,8 @@ import { sortBy } from 'lodash';
import { BehaviorSubject, combineLatest, Observable, ReplaySubject } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { InternalApplicationStart } from '../../application';
import { HttpStart } from '../../http';
import { InternalApplicationStart, PublicAppDeepLinkInfo, PublicAppInfo } from '../../application';
import { HttpStart, IBasePath } from '../../http';
import { ChromeNavLink, NavLinkWrapper } from './nav_link';
import { toNavLink } from './to_nav_link';
@ -89,7 +89,13 @@ export class NavLinksService {
return new Map(
[...apps]
.filter(([, app]) => !app.chromeless)
.map(([appId, app]) => [appId, toNavLink(app, http.basePath)])
.reduce((navLinks: Array<[string, NavLinkWrapper]>, [appId, app]) => {
navLinks.push(
[appId, toNavLink(app, http.basePath)],
...toNavDeepLinks(app, app.deepLinks, http.basePath)
);
return navLinks;
}, [])
);
})
);
@ -163,3 +169,21 @@ function sortNavLinks(navLinks: ReadonlyMap<string, NavLinkWrapper>) {
'order'
);
}
function toNavDeepLinks(
app: PublicAppInfo,
deepLinks: PublicAppDeepLinkInfo[],
basePath: IBasePath
): Array<[string, NavLinkWrapper]> {
if (!deepLinks) {
return [];
}
return deepLinks.reduce((navDeepLinks: Array<[string, NavLinkWrapper]>, deepLink) => {
const id = `${app.id}:${deepLink.id}`;
if (deepLink.path) {
navDeepLinks.push([id, toNavLink(app, basePath, { ...deepLink, id })]);
}
navDeepLinks.push(...toNavDeepLinks(app, deepLink.deepLinks, basePath));
return navDeepLinks;
}, []);
}

View file

@ -6,7 +6,12 @@
* Side Public License, v 1.
*/
import { PublicAppInfo, AppNavLinkStatus, AppStatus } from '../../application';
import {
PublicAppInfo,
AppNavLinkStatus,
AppStatus,
PublicAppDeepLinkInfo,
} from '../../application';
import { toNavLink } from './to_nav_link';
import { httpServiceMock } from '../../mocks';
@ -16,12 +21,24 @@ const app = (props: Partial<PublicAppInfo> = {}): PublicAppInfo => ({
title: 'some-title',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.default,
searchable: true,
appRoute: `/app/some-id`,
keywords: [],
deepLinks: [],
...props,
});
const deepLink = (props: Partial<PublicAppDeepLinkInfo> = {}): PublicAppDeepLinkInfo => ({
id: 'some-deep-link-id',
title: 'my deep link',
path: '/my-deep-link',
navLinkStatus: AppNavLinkStatus.default,
searchable: true,
deepLinks: [],
keywords: [],
...props,
});
describe('toNavLink', () => {
const basePath = httpServiceMock.createSetupContract({ basePath: '/base-path' }).basePath;
@ -64,7 +81,7 @@ describe('toNavLink', () => {
}),
basePath
);
expect(link.properties.url).toEqual('http://localhost/base-path/my-route/my-path');
expect(link.properties.url).toEqual('/base-path/my-route/my-path');
link = toNavLink(
app({
@ -73,9 +90,7 @@ describe('toNavLink', () => {
}),
basePath
);
expect(link.properties.url).toEqual(
'http://localhost/base-path/my-route/my-path/some/default/path'
);
expect(link.properties.url).toEqual('/base-path/my-route/my-path/some/default/path');
});
it('uses the application status when the navLinkStatus is set to default', () => {
@ -153,4 +168,90 @@ describe('toNavLink', () => {
})
);
});
describe('deepLink parameter', () => {
it('should be hidden and not disabled by default', () => {
expect(toNavLink(app(), basePath, deepLink()).properties).toEqual(
expect.objectContaining({
disabled: false,
hidden: true,
})
);
});
it('should not be hidden when navLinkStatus is visible', () => {
expect(
toNavLink(
app(),
basePath,
deepLink({
navLinkStatus: AppNavLinkStatus.visible,
})
).properties
).toEqual(
expect.objectContaining({
disabled: false,
hidden: false,
})
);
});
it('should be disabled when navLinkStatus is disabled', () => {
expect(
toNavLink(
app(),
basePath,
deepLink({
navLinkStatus: AppNavLinkStatus.disabled,
})
).properties
).toEqual(
expect.objectContaining({
disabled: true,
hidden: false,
})
);
});
it('should have href, baseUrl and url containing the path', () => {
const testApp = app({
appRoute: '/app/app-id',
defaultPath: '/default-path',
});
expect(toNavLink(testApp, basePath).properties).toEqual(
expect.objectContaining({
baseUrl: 'http://localhost/base-path/app/app-id',
url: '/base-path/app/app-id/default-path',
href: 'http://localhost/base-path/app/app-id/default-path',
})
);
expect(
toNavLink(
testApp,
basePath,
deepLink({
id: 'deep-link-id',
path: '/my-deep-link',
})
).properties
).toEqual(
expect.objectContaining({
baseUrl: 'http://localhost/base-path/app/app-id',
url: '/base-path/app/app-id/my-deep-link',
href: 'http://localhost/base-path/app/app-id/my-deep-link',
})
);
});
it('should use the main app category', () => {
expect(toNavLink(app(), basePath, deepLink()).properties.category).toBeUndefined();
const category = { id: 'some-category', label: 'some category' };
expect(toNavLink(app({ category }), basePath, deepLink()).properties.category).toEqual(
category
);
});
});
});

View file

@ -6,29 +6,50 @@
* Side Public License, v 1.
*/
import { PublicAppInfo, AppNavLinkStatus, AppStatus } from '../../application';
import {
PublicAppInfo,
AppNavLinkStatus,
AppStatus,
PublicAppDeepLinkInfo,
} from '../../application';
import { IBasePath } from '../../http';
import { NavLinkWrapper } from './nav_link';
import { appendAppPath } from '../../application/utils';
export function toNavLink(app: PublicAppInfo, basePath: IBasePath): NavLinkWrapper {
const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default;
export function toNavLink(
app: PublicAppInfo,
basePath: IBasePath,
deepLink?: PublicAppDeepLinkInfo
): NavLinkWrapper {
const relativeBaseUrl = basePath.prepend(app.appRoute!);
const url = relativeToAbsolute(appendAppPath(relativeBaseUrl, app.defaultPath));
const url = appendAppPath(relativeBaseUrl, deepLink?.path || app.defaultPath);
const href = relativeToAbsolute(url);
const baseUrl = relativeToAbsolute(relativeBaseUrl);
return new NavLinkWrapper({
...app,
hidden: useAppStatus
? app.status === AppStatus.inaccessible
: app.navLinkStatus === AppNavLinkStatus.hidden,
disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled,
...(deepLink || app),
...(app.category ? { category: app.category } : {}), // deepLinks use the main app category
hidden: deepLink ? isDeepNavLinkHidden(deepLink) : isAppNavLinkHidden(app),
disabled: (deepLink?.navLinkStatus ?? app.navLinkStatus) === AppNavLinkStatus.disabled,
baseUrl,
href: url,
href,
url,
});
}
function isAppNavLinkHidden(app: PublicAppInfo) {
return app.navLinkStatus === AppNavLinkStatus.default
? app.status === AppStatus.inaccessible
: app.navLinkStatus === AppNavLinkStatus.hidden;
}
function isDeepNavLinkHidden(deepLink: PublicAppDeepLinkInfo) {
return (
deepLink.navLinkStatus === AppNavLinkStatus.default ||
deepLink.navLinkStatus === AppNavLinkStatus.hidden
);
}
/**
* @param {string} url - a relative or root relative url. If a relative path is given then the
* absolute url returned will depend on the current page where this function is called from. For example

View file

@ -73,6 +73,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "Custom link",
"isActive": true,
"title": "Custom link",
"url": "/",
},
"closed": false,
"hasError": false,
@ -140,6 +141,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "discover",
"isActive": true,
"title": "discover",
"url": "/",
},
Object {
"baseUrl": "/",
@ -154,6 +156,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "siem",
"isActive": true,
"title": "siem",
"url": "/",
},
Object {
"baseUrl": "/",
@ -168,6 +171,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "metrics",
"isActive": true,
"title": "metrics",
"url": "/",
},
Object {
"baseUrl": "/",
@ -182,6 +186,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "monitoring",
"isActive": true,
"title": "monitoring",
"url": "/",
},
Object {
"baseUrl": "/",
@ -196,6 +201,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "visualize",
"isActive": true,
"title": "visualize",
"url": "/",
},
Object {
"baseUrl": "/",
@ -210,6 +216,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "dashboard",
"isActive": true,
"title": "dashboard",
"url": "/",
},
Object {
"baseUrl": "/",
@ -219,6 +226,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "canvas",
"isActive": true,
"title": "canvas",
"url": "/",
},
Object {
"baseUrl": "/",
@ -233,6 +241,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"id": "logs",
"isActive": true,
"title": "logs",
"url": "/",
},
],
"closed": false,
@ -351,6 +360,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
"values": Array [],
}
}
url="/"
>
<EuiCollapsibleNav
aria-label="Primary"
@ -2274,6 +2284,7 @@ exports[`CollapsibleNav renders the default nav 1`] = `
"values": Array [],
}
}
url="/"
>
<EuiCollapsibleNav
aria-label="Primary"
@ -2511,6 +2522,7 @@ exports[`CollapsibleNav renders the default nav 2`] = `
"values": Array [],
}
}
url="/"
>
<EuiCollapsibleNav
aria-label="Primary"
@ -2748,6 +2760,7 @@ exports[`CollapsibleNav renders the default nav 3`] = `
"values": Array [],
}
}
url="/"
>
<EuiCollapsibleNav
aria-label="Primary"

View file

@ -357,6 +357,7 @@ exports[`Header renders 1`] = `
"href": "",
"id": "cloud-deployment-link",
"title": "Manage cloud deployment",
"url": "",
},
"closed": false,
"hasError": false,
@ -1607,6 +1608,7 @@ exports[`Header renders 1`] = `
"href": "",
"id": "kibana",
"title": "kibana",
"url": "",
},
],
"closed": false,
@ -1924,6 +1926,7 @@ exports[`Header renders 1`] = `
"href": "",
"id": "kibana",
"title": "kibana",
"url": "",
},
],
"closed": false,
@ -3027,6 +3030,7 @@ exports[`Header renders 1`] = `
"href": "",
"id": "kibana",
"title": "kibana",
"url": "",
},
],
"closed": false,
@ -4731,6 +4735,7 @@ exports[`Header renders 1`] = `
"href": "",
"id": "cloud-deployment-link",
"title": "Manage cloud deployment",
"url": "",
},
"closed": false,
"hasError": false,
@ -4790,6 +4795,7 @@ exports[`Header renders 1`] = `
"href": "",
"id": "kibana",
"title": "kibana",
"url": "",
},
],
"closed": false,

View file

@ -29,6 +29,7 @@ function mockLink({ title = 'discover', category }: Partial<ChromeNavLink>) {
id: title,
href: title,
baseUrl: '/',
url: '/',
isActive: true,
'data-test-subj': title,
};
@ -50,6 +51,7 @@ function mockProps() {
isLocked: false,
isNavOpen: false,
homeHref: '/',
url: '/',
navLinks$: new BehaviorSubject([]),
recentlyAccessed$: new BehaviorSubject([]),
storage: new StubBrowserStorage(),

View file

@ -107,7 +107,7 @@ export function CollapsibleNav({
link,
appId,
dataTestSubj: 'collapsibleNavAppLink',
navigateToApp,
navigateToUrl,
onClick: closeNav,
...(needsIcon && { basePath }),
});
@ -137,7 +137,7 @@ export function CollapsibleNav({
createEuiListItem({
link: customNavLink,
basePath,
navigateToApp,
navigateToUrl,
dataTestSubj: 'collapsibleNavCustomNavLink',
onClick: closeNav,
externalLink: true,

View file

@ -61,13 +61,14 @@ describe('Header', () => {
const breadcrumbs$ = new BehaviorSubject([{ text: 'test' }]);
const isLocked$ = new BehaviorSubject(false);
const navLinks$ = new BehaviorSubject([
{ id: 'kibana', title: 'kibana', baseUrl: '', href: '' },
{ id: 'kibana', title: 'kibana', baseUrl: '', href: '', url: '' },
]);
const headerBanner$ = new BehaviorSubject(undefined);
const customNavLink$ = new BehaviorSubject({
id: 'cloud-deployment-link',
title: 'Manage cloud deployment',
baseUrl: '',
url: '',
href: '',
});
const recentlyAccessed$ = new BehaviorSubject([

View file

@ -23,7 +23,7 @@ interface Props {
basePath?: HttpStart['basePath'];
dataTestSubj: string;
onClick?: Function;
navigateToApp: CoreStart['application']['navigateToApp'];
navigateToUrl: CoreStart['application']['navigateToUrl'];
externalLink?: boolean;
}
@ -36,11 +36,11 @@ export function createEuiListItem({
appId,
basePath,
onClick = () => {},
navigateToApp,
navigateToUrl,
dataTestSubj,
externalLink = false,
}: Props) {
const { href, id, title, disabled, euiIconType, icon, tooltip } = link;
const { href, id, title, disabled, euiIconType, icon, tooltip, url } = link;
return {
label: tooltip ?? title,
@ -57,7 +57,7 @@ export function createEuiListItem({
!isModifiedOrPrevented(event)
) {
event.preventDefault();
navigateToApp(id);
navigateToUrl(url);
}
},
isActive: appId === id,

View file

@ -91,6 +91,7 @@ export type {
AppLeaveConfirmAction,
AppUpdatableFields,
AppUpdater,
AppNavOptions,
AppDeepLink,
PublicAppInfo,
PublicAppDeepLinkInfo,

View file

@ -53,24 +53,21 @@ import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/type
export function __kbnBootstrap__(): Promise<void>;
// @public (undocumented)
export interface App<HistoryLocationState = unknown> {
export interface App<HistoryLocationState = unknown> extends AppNavOptions {
appRoute?: string;
capabilities?: Partial<Capabilities>;
category?: AppCategory;
chromeless?: boolean;
deepLinks?: AppDeepLink[];
defaultPath?: string;
euiIconType?: string;
exactRoute?: boolean;
icon?: string;
id: string;
keywords?: string[];
mount: AppMount<HistoryLocationState>;
navLinkStatus?: AppNavLinkStatus;
order?: number;
searchable?: boolean;
status?: AppStatus;
title: string;
tooltip?: string;
updater$?: Observable<AppUpdater>;
}
@ -92,7 +89,8 @@ export type AppDeepLink = {
title: string;
keywords?: string[];
navLinkStatus?: AppNavLinkStatus;
} & ({
searchable?: boolean;
} & AppNavOptions & ({
path: string;
deepLinks?: AppDeepLink[];
} | {
@ -179,6 +177,14 @@ export enum AppNavLinkStatus {
visible = 1
}
// @public
export interface AppNavOptions {
euiIconType?: string;
icon?: string;
order?: number;
tooltip?: string;
}
// @public
export enum AppStatus {
accessible = 0,
@ -189,7 +195,7 @@ export enum AppStatus {
export type AppUnmount = () => void;
// @public
export type AppUpdatableFields = Pick<App, 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath' | 'deepLinks'>;
export type AppUpdatableFields = Pick<App, 'status' | 'navLinkStatus' | 'searchable' | 'tooltip' | 'defaultPath' | 'deepLinks'>;
// @public
export type AppUpdater = (app: App) => Partial<AppUpdatableFields> | undefined;
@ -314,8 +320,7 @@ export interface ChromeNavLink {
readonly order?: number;
readonly title: string;
readonly tooltip?: string;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AppBase"
readonly url?: string;
readonly url: string;
}
// @public
@ -916,10 +921,9 @@ export interface IUiSettingsClient {
// @public
export type MountPoint<T extends HTMLElement = HTMLElement> = (element: T) => UnmountCallback;
// Warning: (ae-missing-release-tag) "NavigateToAppOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface NavigateToAppOptions {
deepLinkId?: string;
openInNewTab?: boolean;
path?: string;
replace?: boolean;
@ -1067,19 +1071,21 @@ export interface PluginInitializerContext<ConfigSchema extends object = object>
export type PluginOpaqueId = symbol;
// @public
export type PublicAppDeepLinkInfo = Omit<AppDeepLink, 'deepLinks' | 'keywords' | 'navLinkStatus'> & {
export type PublicAppDeepLinkInfo = Omit<AppDeepLink, 'deepLinks' | 'keywords' | 'navLinkStatus' | 'searchable'> & {
deepLinks: PublicAppDeepLinkInfo[];
keywords: string[];
navLinkStatus: AppNavLinkStatus;
searchable: boolean;
};
// @public
export type PublicAppInfo = Omit<App, 'mount' | 'updater$' | 'keywords' | 'deepLinks'> & {
export type PublicAppInfo = Omit<App, 'mount' | 'updater$' | 'keywords' | 'deepLinks' | 'searchable'> & {
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
keywords: string[];
deepLinks: PublicAppDeepLinkInfo[];
searchable: boolean;
};
// @public

View file

@ -0,0 +1,7 @@
{
"id": "corePluginDeepLinks",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["core_plugin_deep_links"],
"ui": true
}

View file

@ -0,0 +1,14 @@
{
"name": "core_plugin_deep_links",
"version": "1.0.0",
"main": "target/test/plugin_functional/plugins/core_plugin_deep_links",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "SSPL-1.0 OR Elastic License 2.0",
"scripts": {
"kbn": "node ../../../../scripts/kbn.js",
"build": "rm -rf './target' && ../../../../node_modules/.bin/tsc"
}
}

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { History } from 'history';
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, withRouter, RouteComponentProps, Redirect } from 'react-router-dom';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPageHeader,
EuiPageHeaderSection,
EuiPageSideBar,
EuiTitle,
EuiSideNav,
} from '@elastic/eui';
import { CoreStart, AppMountParameters } from 'kibana/public';
const Home = () => (
<EuiPageBody data-test-subj="dlAppHome">
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Welcome to DL!</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h2>DL home page section title</h2>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>Wow this is the content!</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);
const PageA = () => (
<EuiPageBody data-test-subj="dlAppPageA">
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>DL Page A</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h2>DL Page A section title</h2>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>DL Page A&apos;s content goes here</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);
const PageB = () => (
<EuiPageBody data-test-subj="dlAppPageB">
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>DL Page B</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h2>DL Page B section title</h2>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>DL Page B&apos;s content goes here</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);
type NavProps = RouteComponentProps & {
navigateToApp: CoreStart['application']['navigateToApp'];
};
const Nav = withRouter(({ history, navigateToApp }: NavProps) => (
<EuiSideNav
items={[
{
name: 'DeepLinks!',
id: 'deeplinks',
items: [
{
id: 'home',
name: 'DL Home',
onClick: () => history.push('/home'),
'data-test-subj': 'dlNavHome',
},
{
id: 'page-a',
name: 'DL page A',
onClick: () => history.push('/page-a'),
'data-test-subj': 'dlNavPageA',
},
{
id: 'navigateDeepByPath',
name: 'DL section 1 page B',
onClick: () => {
navigateToApp('deeplinks', { path: '/page-b' });
},
'data-test-subj': 'dlNavDeepPageB',
},
{
id: 'navigateDeepById',
name: 'DL page A deep link',
onClick: () => {
navigateToApp('deeplinks', { deepLinkId: 'pageA' });
},
'data-test-subj': 'dlNavDeepPageAById',
},
],
},
]}
/>
));
const DlApp = ({ history, coreStart }: { history: History; coreStart: CoreStart }) => (
<Router history={history}>
<EuiPage>
<EuiPageSideBar>
<Nav navigateToApp={coreStart.application.navigateToApp} />
</EuiPageSideBar>
<Route path="/" exact render={() => <Redirect to="/home" />} />
<Route path="/home" exact component={Home} />
<Route path="/page-a" component={PageA} />
<Route path="/page-b" component={PageB} />
</EuiPage>
</Router>
);
export const renderApp = (coreStart: CoreStart, { history, element }: AppMountParameters) => {
ReactDOM.render(<DlApp history={history} coreStart={coreStart} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginInitializer } from 'kibana/public';
import {
CorePluginDeepLinksPlugin,
CorePluginDeepLinksPluginSetup,
CorePluginDeepLinksPluginStart,
} from './plugin';
export const plugin: PluginInitializer<
CorePluginDeepLinksPluginSetup,
CorePluginDeepLinksPluginStart
> = () => new CorePluginDeepLinksPlugin();

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Plugin, CoreSetup } from 'kibana/public';
import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../../src/core/public';
export class CorePluginDeepLinksPlugin
implements Plugin<CorePluginDeepLinksPluginSetup, CorePluginDeepLinksPluginStart> {
public setup(core: CoreSetup, deps: {}) {
core.application.register({
id: 'deeplinks',
title: 'Deep Links',
appRoute: '/app/dl',
defaultPath: '/home',
category: DEFAULT_APP_CATEGORIES.security,
navLinkStatus: AppNavLinkStatus.hidden,
deepLinks: [
{
id: 'home',
title: 'DL Home',
path: '/home',
navLinkStatus: AppNavLinkStatus.visible,
},
{
id: 'pageA',
title: 'DL Page A',
path: '/page-a',
navLinkStatus: AppNavLinkStatus.visible,
},
{
id: 'sectionOne',
title: 'DL Section One',
deepLinks: [
{
id: 'pageB',
title: 'DL Page B',
path: '/page-b',
navLinkStatus: AppNavLinkStatus.visible,
},
],
},
{
id: 'pageC',
title: 'DL Page C',
path: '/page-c',
// navLinkStatus hidden by default
},
],
async mount(params) {
const { renderApp } = await import('./application');
const [coreStart] = await core.getStartServices();
return renderApp(coreStart, params);
},
});
return {
getGreeting() {
return 'Hello from Deep Link Plugin!';
},
};
}
public start() {}
public stop() {}
}
export type CorePluginDeepLinksPluginSetup = ReturnType<CorePluginDeepLinksPlugin['setup']>;
export type CorePluginDeepLinksPluginStart = ReturnType<CorePluginDeepLinksPlugin['start']>;

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"../../../../typings/**/*",
],
"exclude": [],
"references": [
{ "path": "../../../../src/core/tsconfig.json" }
]
}

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import url from 'url';
import expect from '@kbn/expect';
import { PluginFunctionalProviderContext } from '../../services';
export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) {
const PageObjects = getPageObjects(['common']);
const browser = getService('browser');
const appsMenu = getService('appsMenu');
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const esArchiver = getService('esArchiver');
const loadingScreenNotShown = async () =>
expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false);
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,
});
/** Use retry logic to make URL assertions less flaky */
const waitForUrlToBe = (pathname?: string, search?: string) => {
const expectedUrl = getKibanaUrl(pathname, search);
return retry.waitFor(`Url to be ${expectedUrl}`, async () => {
return (await browser.getCurrentUrl()) === expectedUrl;
});
};
describe('application deep links navigation', function describeDeepLinksTests() {
before(async () => {
await esArchiver.emptyKibanaIndex();
await PageObjects.common.navigateToApp('dl');
});
it('should start on home page', async () => {
await testSubjects.existOrFail('dlAppHome');
});
it('should navigate to page A when navlink is clicked', async () => {
await appsMenu.clickLink('DL Page A');
await waitForUrlToBe('/app/dl/page-a');
await loadingScreenNotShown();
await testSubjects.existOrFail('dlAppPageA');
});
it('should be able to use the back button to navigate back to previous deep link', async () => {
await browser.goBack();
await waitForUrlToBe('/app/dl/home');
await loadingScreenNotShown();
await testSubjects.existOrFail('dlAppHome');
});
it('should navigate to nested page B when navlink is clicked', async () => {
await appsMenu.clickLink('DL Page B');
await waitForUrlToBe('/app/dl/page-b');
await loadingScreenNotShown();
await testSubjects.existOrFail('dlAppPageB');
});
it('should navigate to Home when navlink is clicked inside the defined category group', async () => {
await appsMenu.clickLink('DL Home', { category: 'securitySolution' });
await waitForUrlToBe('/app/dl/home');
await loadingScreenNotShown();
await testSubjects.existOrFail('dlAppHome');
});
it('should navigate to nested page B using navigateToApp path', async () => {
await testSubjects.click('dlNavDeepPageB');
await waitForUrlToBe('/app/dl/page-b');
await loadingScreenNotShown();
await testSubjects.existOrFail('dlAppPageB');
});
it('should navigate to nested page A using navigateToApp deepLinkId', async () => {
await testSubjects.click('dlNavDeepPageAById');
await waitForUrlToBe('/app/dl/page-a');
await loadingScreenNotShown();
await testSubjects.existOrFail('dlAppPageA');
});
it('should not display hidden deep links', async () => {
expect(await appsMenu.linkExists('DL Section One')).to.be(false);
expect(await appsMenu.linkExists('DL Page C')).to.be(false);
});
});
}

View file

@ -18,6 +18,7 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
loadTestFile(require.resolve('./top_nav'));
loadTestFile(require.resolve('./application_leave_confirm'));
loadTestFile(require.resolve('./application_status'));
loadTestFile(require.resolve('./application_deep_links'));
loadTestFile(require.resolve('./rendering'));
loadTestFile(require.resolve('./chrome_help_menu_links'));
loadTestFile(require.resolve('./history_block'));

View file

@ -28,6 +28,7 @@ const createApp = (props: Partial<PublicAppInfo> = {}): PublicAppInfo => ({
appRoute: '/app/app1',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
searchable: true,
chromeless: false,
keywords: props.keywords || [],
deepLinks: [],
@ -163,7 +164,7 @@ describe('applicationResultProvider', () => {
expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]);
});
it('ignores apps with non-visible navlink', async () => {
it('does not ignore apps with non-visible navlink', async () => {
application.applications$ = of(
createAppMap([
createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }),
@ -178,7 +179,11 @@ describe('applicationResultProvider', () => {
const provider = createApplicationResultProvider(Promise.resolve(application));
await provider.find({ term: 'term' }, defaultOption).toPromise();
expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]);
expect(getAppResultsMock).toHaveBeenCalledWith('term', [
expectApp('app1'),
expectApp('disabled'),
expectApp('hidden'),
]);
});
it('ignores chromeless apps', async () => {

View file

@ -20,8 +20,8 @@ export const createApplicationResultProvider = (
mergeMap((application) => application.applications$),
map((apps) =>
[...apps.values()].filter(
// only include non-chromeless enabled apps with visible navLinks
(app) => app.status === 0 && app.navLinkStatus === 1 && app.chromeless !== true
// only include non-chromeless enabled apps
(app) => app.status === 0 && app.chromeless !== true
)
),
shareReplay(1)

View file

@ -25,6 +25,7 @@ const createApp = (props: Partial<PublicAppInfo> = {}): PublicAppInfo => ({
appRoute: '/app/app1',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
searchable: true,
chromeless: false,
keywords: [],
deepLinks: [],
@ -44,6 +45,11 @@ describe('getAppResults', () => {
const apps = [
createApp({ id: 'dashboard', title: 'dashboard' }),
createApp({ id: 'visualize', title: 'visualize' }),
createApp({
id: 'dashboard_not_searchable',
title: 'dashboard not searchable',
searchable: false,
}),
];
const results = getAppResults('dashboard', apps);
@ -63,6 +69,7 @@ describe('getAppResults', () => {
deepLinks: [],
keywords: [],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
},
{
id: 'sub2',
@ -76,10 +83,12 @@ describe('getAppResults', () => {
deepLinks: [],
keywords: [],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
},
],
keywords: [],
navLinkStatus: AppNavLinkStatus.hidden,
navLinkStatus: AppNavLinkStatus.visible,
searchable: false,
},
],
keywords: [],
@ -88,11 +97,9 @@ describe('getAppResults', () => {
const results = getAppResults('App 1', apps);
expect(results.length).toBe(4);
expect(results.map(({ title }) => title)).toEqual([
'App 1',
'App 1 / Sub1',
'App 1 / Sub2',
'App 1 / Sub2 / Sub2Sub1',
]);
});
@ -108,6 +115,7 @@ describe('getAppResults', () => {
deepLinks: [],
keywords: [],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
},
],
keywords: [],
@ -135,6 +143,7 @@ describe('getAppResults', () => {
deepLinks: [],
keywords: [],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
},
{
id: 'sub2',
@ -148,10 +157,12 @@ describe('getAppResults', () => {
deepLinks: [],
keywords: ['TwoOne'],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
},
],
keywords: ['two'],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
},
],
keywords: [],

View file

@ -118,18 +118,22 @@ export const appToResult = (appLink: AppLink, score: number): GlobalSearchProvid
const flattenDeepLinks = (app: PublicAppInfo, deepLink?: PublicAppDeepLinkInfo): AppLink[] => {
if (!deepLink) {
return [
{
id: app.id,
app,
path: app.appRoute,
subLinkTitles: [],
keywords: app?.keywords ?? [],
},
...(app.searchable
? [
{
id: app.id,
app,
path: app.appRoute,
subLinkTitles: [],
keywords: app?.keywords ?? [],
},
]
: []),
...app.deepLinks.flatMap((appDeepLink) => flattenDeepLinks(app, appDeepLink)),
];
}
return [
...(deepLink.path
...(deepLink.path && deepLink.searchable
? [
{
id: `${app.id}-${deepLink.id}`,