[7.x] Add application deep links to global search (#83380) (#84591)

This commit is contained in:
Josh Dover 2020-12-01 08:00:43 -07:00 committed by GitHub
parent 3118c934a9
commit 6a76c1ccfe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 507 additions and 90 deletions

View file

@ -18,7 +18,7 @@ exactRoute?: boolean;
```ts
core.application.register({
id: 'my_app',
title: 'My App'
title: 'My App',
exactRoute: true,
mount: () => { ... },
})

View file

@ -27,6 +27,7 @@ export interface App<HistoryLocationState = unknown>
| [mount](./kibana-plugin-core-public.app.mount.md) | <code>AppMount&lt;HistoryLocationState&gt; &#124; AppMountDeprecated&lt;HistoryLocationState&gt;</code> | A mount function called when the user navigates to this app's route. May have signature of [AppMount](./kibana-plugin-core-public.appmount.md) or [AppMountDeprecated](./kibana-plugin-core-public.appmountdeprecated.md)<!-- -->. |
| [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. |
| [searchDeepLinks](./kibana-plugin-core-public.app.searchdeeplinks.md) | <code>AppSearchDeepLink[]</code> | Array of links that represent secondary in-app locations for the app. |
| [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. |

View file

@ -0,0 +1,42 @@
<!-- 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; [searchDeepLinks](./kibana-plugin-core-public.app.searchdeeplinks.md)
## App.searchDeepLinks property
Array of links that represent secondary in-app locations for the app.
<b>Signature:</b>
```typescript
searchDeepLinks?: AppSearchDeepLink[];
```
## Remarks
Used to populate navigational search results (where available). Can be updated using the [App.updater$](./kibana-plugin-core-public.app.updater_.md) observable. See for more details.
## Example
The `path` property on deep links should not include the application's `appRoute`<!-- -->:
```ts
core.application.register({
id: 'my_app',
title: 'My App',
searchDeepLinks: [
{ id: 'sub1', title: 'Sub1', path: '/sub1' },
{
id: 'sub2',
title: 'Sub2',
searchDeepLinks: [
{ id: 'subsub', title: 'SubSub', path: '/sub2/sub' }
]
}
],
mount: () => { ... },
})
```
Will produce deep links on these paths: - `/app/my_app/sub1` - `/app/my_app/sub2/sub`

View file

@ -0,0 +1,24 @@
<!-- 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; [AppSearchDeepLink](./kibana-plugin-core-public.appsearchdeeplink.md)
## AppSearchDeepLink type
Input type for registering secondary in-app locations for an application.
Deep links must include at least one of `path` or `searchDeepLinks`<!-- -->. A deep link that does not have a `path` represents a topological level in the application's hierarchy, but does not have a destination URL that is user-accessible.
<b>Signature:</b>
```typescript
export declare type AppSearchDeepLink = {
id: string;
title: string;
} & ({
path: string;
searchDeepLinks?: AppSearchDeepLink[];
} | {
path?: string;
searchDeepLinks: AppSearchDeepLink[];
});
```

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

View file

@ -138,6 +138,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [AppLeaveHandler](./kibana-plugin-core-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return <code>confirm</code> to to prompt a message to the user before leaving the page, or <code>default</code> to keep the default behavior (doing nothing).<!-- -->See [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) for detailed usage examples. |
| [AppMount](./kibana-plugin-core-public.appmount.md) | A mount function called when the user navigates to this app's route. |
| [AppMountDeprecated](./kibana-plugin-core-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. |
| [AppSearchDeepLink](./kibana-plugin-core-public.appsearchdeeplink.md) | Input type for registering secondary in-app locations for an application.<!-- -->Deep links must include at least one of <code>path</code> or <code>searchDeepLinks</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. |
| [AppUnmount](./kibana-plugin-core-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. |
| [AppUpdatableFields](./kibana-plugin-core-public.appupdatablefields.md) | Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-core-public.appupdater.md)<!-- -->. |
| [AppUpdater](./kibana-plugin-core-public.appupdater.md) | Updater for applications. see [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) |
@ -160,6 +161,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [PluginInitializer](./kibana-plugin-core-public.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>public</code> directory should conform to this interface. |
| [PluginOpaqueId](./kibana-plugin-core-public.pluginopaqueid.md) | |
| [PublicAppInfo](./kibana-plugin-core-public.publicappinfo.md) | Public information about a registered [application](./kibana-plugin-core-public.app.md) |
| [PublicAppSearchDeepLinkInfo](./kibana-plugin-core-public.publicappsearchdeeplinkinfo.md) | Public information about a registered app's [searchDeepLinks](./kibana-plugin-core-public.appsearchdeeplink.md) |
| [PublicUiSettingsParams](./kibana-plugin-core-public.publicuisettingsparams.md) | A sub-set of [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) exposed to the client-side. |
| [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value |
| [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) |

View file

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

View file

@ -0,0 +1,15 @@
<!-- 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; [PublicAppSearchDeepLinkInfo](./kibana-plugin-core-public.publicappsearchdeeplinkinfo.md)
## PublicAppSearchDeepLinkInfo type
Public information about a registered app's [searchDeepLinks](./kibana-plugin-core-public.appsearchdeeplink.md)
<b>Signature:</b>
```typescript
export declare type PublicAppSearchDeepLinkInfo = Omit<AppSearchDeepLink, 'searchDeepLinks'> & {
searchDeepLinks: PublicAppSearchDeepLinkInfo[];
};
```

View file

@ -31,6 +31,7 @@ export {
AppNavLinkStatus,
AppUpdatableFields,
AppUpdater,
AppSearchDeepLink,
ApplicationSetup,
ApplicationStart,
AppLeaveHandler,
@ -40,6 +41,7 @@ export {
AppLeaveConfirmAction,
NavigateToAppOptions,
PublicAppInfo,
PublicAppSearchDeepLinkInfo,
// Internal types
InternalApplicationSetup,
InternalApplicationStart,

View file

@ -81,7 +81,10 @@ export enum AppNavLinkStatus {
* Defines the list of fields that can be updated via an {@link AppUpdater}.
* @public
*/
export type AppUpdatableFields = Pick<App, 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath'>;
export type AppUpdatableFields = Pick<
App,
'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath' | 'searchDeepLinks'
>;
/**
* Updater for applications.
@ -222,7 +225,7 @@ export interface App<HistoryLocationState = unknown> {
* ```ts
* core.application.register({
* id: 'my_app',
* title: 'My App'
* title: 'My App',
* exactRoute: true,
* mount: () => { ... },
* })
@ -232,18 +235,89 @@ export interface App<HistoryLocationState = unknown> {
* ```
*/
exactRoute?: boolean;
/**
* Array of links that represent secondary in-app locations for the app.
*
* @remarks
* Used to populate navigational search results (where available).
* Can be updated using the {@link App.updater$} observable. See {@link AppSubLink} for more details.
*
* @example
* The `path` property on deep links should not include the application's `appRoute`:
* ```ts
* core.application.register({
* id: 'my_app',
* title: 'My App',
* searchDeepLinks: [
* { id: 'sub1', title: 'Sub1', path: '/sub1' },
* {
* id: 'sub2',
* title: 'Sub2',
* searchDeepLinks: [
* { id: 'subsub', title: 'SubSub', path: '/sub2/sub' }
* ]
* }
* ],
* mount: () => { ... },
* })
* ```
*
* Will produce deep links on these paths:
* - `/app/my_app/sub1`
* - `/app/my_app/sub2/sub`
*/
searchDeepLinks?: AppSearchDeepLink[];
}
/**
* Input type for registering secondary in-app locations for an application.
*
* Deep links must include at least one of `path` or `searchDeepLinks`. A deep link that does not have a `path`
* represents a topological level in the application's hierarchy, but does not have a destination URL that is
* user-accessible.
* @public
*/
export type AppSearchDeepLink = {
/** Identifier to represent this sublink, should be unique for this application */
id: string;
/** Title to label represent this deep link */
title: string;
} & (
| {
/** 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 */
searchDeepLinks?: AppSearchDeepLink[];
}
| {
/** 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. */
searchDeepLinks: AppSearchDeepLink[];
}
);
/**
* Public information about a registered app's {@link AppSearchDeepLink | searchDeepLinks}
*
* @public
*/
export type PublicAppSearchDeepLinkInfo = Omit<AppSearchDeepLink, 'searchDeepLinks'> & {
searchDeepLinks: PublicAppSearchDeepLinkInfo[];
};
/**
* Public information about a registered {@link App | application}
*
* @public
*/
export type PublicAppInfo = Omit<App, 'mount' | 'updater$'> & {
export type PublicAppInfo = Omit<App, 'mount' | 'updater$' | 'searchDeepLinks'> & {
// remove optional on fields populated with default values
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
searchDeepLinks: PublicAppSearchDeepLinkInfo[];
};
/**

View file

@ -43,6 +43,42 @@ describe('getAppInfo', () => {
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
appRoute: `/app/some-id`,
searchDeepLinks: [],
});
});
it('populates default values for nested searchDeepLinks', () => {
const app = createApp({
searchDeepLinks: [
{
id: 'sub-id',
title: 'sub-title',
searchDeepLinks: [{ id: 'sub-sub-id', title: 'sub-sub-title', path: '/sub-sub' }],
},
],
});
const info = getAppInfo(app);
expect(info).toEqual({
id: 'some-id',
title: 'some-title',
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
appRoute: `/app/some-id`,
searchDeepLinks: [
{
id: 'sub-id',
title: 'sub-title',
searchDeepLinks: [
{
id: 'sub-sub-id',
title: 'sub-sub-title',
path: '/sub-sub',
searchDeepLinks: [], // default empty array added
},
],
},
],
});
});

View file

@ -17,9 +17,16 @@
* under the License.
*/
import { App, AppNavLinkStatus, AppStatus, PublicAppInfo } from '../types';
import {
App,
AppNavLinkStatus,
AppStatus,
AppSearchDeepLink,
PublicAppInfo,
PublicAppSearchDeepLinkInfo,
} from '../types';
export function getAppInfo(app: App<unknown>): PublicAppInfo {
export function getAppInfo(app: App): PublicAppInfo {
const navLinkStatus =
app.navLinkStatus === AppNavLinkStatus.default
? app.status === AppStatus.inaccessible
@ -32,5 +39,26 @@ export function getAppInfo(app: App<unknown>): PublicAppInfo {
status: app.status!,
navLinkStatus,
appRoute: app.appRoute!,
searchDeepLinks: getSearchDeepLinkInfos(app, app.searchDeepLinks),
};
}
function getSearchDeepLinkInfos(
app: App,
searchDeepLinks?: AppSearchDeepLink[]
): PublicAppSearchDeepLinkInfo[] {
if (!searchDeepLinks) {
return [];
}
return searchDeepLinks.map(
(rawDeepLink): PublicAppSearchDeepLinkInfo => {
return {
id: rawDeepLink.id,
title: rawDeepLink.title,
path: rawDeepLink.path,
searchDeepLinks: getSearchDeepLinkInfos(app, rawDeepLink.searchDeepLinks),
};
}
);
}

View file

@ -28,6 +28,7 @@ const app = (props: Partial<PublicAppInfo> = {}): PublicAppInfo => ({
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.default,
appRoute: `/app/some-id`,
searchDeepLinks: [],
...props,
});

View file

@ -96,7 +96,6 @@ export {
ApplicationSetup,
ApplicationStart,
App,
PublicAppInfo,
AppMount,
AppMountDeprecated,
AppUnmount,
@ -111,6 +110,9 @@ export {
AppNavLinkStatus,
AppUpdatableFields,
AppUpdater,
AppSearchDeepLink,
PublicAppInfo,
PublicAppSearchDeepLinkInfo,
ScopedHistory,
NavigateToAppOptions,
} from './application';

View file

@ -59,6 +59,8 @@ export interface App<HistoryLocationState = unknown> {
mount: AppMount<HistoryLocationState> | AppMountDeprecated<HistoryLocationState>;
navLinkStatus?: AppNavLinkStatus;
order?: number;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AppSubLink"
searchDeepLinks?: AppSearchDeepLink[];
status?: AppStatus;
title: string;
tooltip?: string;
@ -175,6 +177,18 @@ export enum AppNavLinkStatus {
visible = 1
}
// @public
export type AppSearchDeepLink = {
id: string;
title: string;
} & ({
path: string;
searchDeepLinks?: AppSearchDeepLink[];
} | {
path?: string;
searchDeepLinks: AppSearchDeepLink[];
});
// @public
export enum AppStatus {
accessible = 0,
@ -185,7 +199,7 @@ export enum AppStatus {
export type AppUnmount = () => void;
// @public
export type AppUpdatableFields = Pick<App, 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath'>;
export type AppUpdatableFields = Pick<App, 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath' | 'searchDeepLinks'>;
// @public
export type AppUpdater = (app: App) => Partial<AppUpdatableFields> | undefined;
@ -967,10 +981,16 @@ export interface PluginInitializerContext<ConfigSchema extends object = object>
export type PluginOpaqueId = symbol;
// @public
export type PublicAppInfo = Omit<App, 'mount' | 'updater$'> & {
export type PublicAppInfo = Omit<App, 'mount' | 'updater$' | 'searchDeepLinks'> & {
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
searchDeepLinks: PublicAppSearchDeepLinkInfo[];
};
// @public
export type PublicAppSearchDeepLinkInfo = Omit<AppSearchDeepLink, 'searchDeepLinks'> & {
searchDeepLinks: PublicAppSearchDeepLinkInfo[];
};
// @public

View file

@ -31,6 +31,7 @@ import {
AppUpdater,
AppStatus,
AppNavLinkStatus,
AppSearchDeepLink,
} from '../../../core/public';
import { MANAGEMENT_APP_ID } from '../common/contants';
@ -38,6 +39,7 @@ import {
ManagementSectionsService,
getSectionsServiceStartPrivate,
} from './management_sections_service';
import { ManagementSection } from './utils';
interface ManagementSetupDependencies {
home?: HomePublicPluginSetup;
@ -46,7 +48,23 @@ interface ManagementSetupDependencies {
export class ManagementPlugin implements Plugin<ManagementSetup, ManagementStart> {
private readonly managementSections = new ManagementSectionsService();
private readonly appUpdater = new BehaviorSubject<AppUpdater>(() => ({}));
private readonly appUpdater = new BehaviorSubject<AppUpdater>(() => {
const deepLinks: AppSearchDeepLink[] = Object.values(
this.managementSections.definedSections
).map((section: ManagementSection) => ({
id: section.id,
title: section.title,
searchDeepLinks: section.getAppsEnabled().map((mgmtApp) => ({
id: mgmtApp.id,
title: mgmtApp.title,
path: mgmtApp.basePath,
})),
}));
return {
searchDeepLinks: deepLinks,
};
});
private hasAnyEnabledApps = true;

View file

@ -83,7 +83,7 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi
};
if (type === 'application') {
option.meta = [{ text: meta?.categoryLabel as string }];
option.meta = [{ text: (meta?.categoryLabel as string) ?? '' }];
} else {
option.meta = [{ text: cleanMeta(type) }];
}

View file

@ -28,6 +28,7 @@ const createApp = (props: Partial<PublicAppInfo> = {}): PublicAppInfo => ({
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
chromeless: false,
searchDeepLinks: [],
...props,
});

View file

@ -10,7 +10,7 @@ import {
PublicAppInfo,
DEFAULT_APP_CATEGORIES,
} from 'src/core/public';
import { appToResult, getAppResults, scoreApp } from './get_app_results';
import { AppLink, appToResult, getAppResults, scoreApp } from './get_app_results';
const createApp = (props: Partial<PublicAppInfo> = {}): PublicAppInfo => ({
id: 'app1',
@ -19,9 +19,17 @@ const createApp = (props: Partial<PublicAppInfo> = {}): PublicAppInfo => ({
status: AppStatus.accessible,
navLinkStatus: AppNavLinkStatus.visible,
chromeless: false,
searchDeepLinks: [],
...props,
});
const createAppLink = (props: Partial<PublicAppInfo> = {}): AppLink => ({
id: props.id ?? 'app1',
path: props.appRoute ?? '/app/app1',
subLinkTitles: [],
app: createApp(props),
});
describe('getAppResults', () => {
it('retrieves the matching results', () => {
const apps = [
@ -34,43 +42,82 @@ describe('getAppResults', () => {
expect(results.length).toBe(1);
expect(results[0]).toEqual(expect.objectContaining({ id: 'dashboard', score: 100 }));
});
it('creates multiple links for apps with searchDeepLinks', () => {
const apps = [
createApp({
searchDeepLinks: [
{ id: 'sub1', title: 'Sub1', path: '/sub1', searchDeepLinks: [] },
{
id: 'sub2',
title: 'Sub2',
path: '/sub2',
searchDeepLinks: [
{ id: 'sub2sub1', title: 'Sub2Sub1', path: '/sub2/sub1', searchDeepLinks: [] },
],
},
],
}),
];
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',
]);
});
it('only includes searchDeepLinks when search term is non-empty', () => {
const apps = [
createApp({
searchDeepLinks: [{ id: 'sub1', title: 'Sub1', path: '/sub1', searchDeepLinks: [] }],
}),
];
expect(getAppResults('', apps).length).toBe(1);
expect(getAppResults('App 1', apps).length).toBe(2);
});
});
describe('scoreApp', () => {
describe('when the term is included in the title', () => {
it('returns 100 if the app title is an exact match', () => {
expect(scoreApp('dashboard', createApp({ title: 'dashboard' }))).toBe(100);
expect(scoreApp('dashboard', createApp({ title: 'DASHBOARD' }))).toBe(100);
expect(scoreApp('DASHBOARD', createApp({ title: 'DASHBOARD' }))).toBe(100);
expect(scoreApp('dashBOARD', createApp({ title: 'DASHboard' }))).toBe(100);
expect(scoreApp('dashboard', createAppLink({ title: 'dashboard' }))).toBe(100);
expect(scoreApp('dashboard', createAppLink({ title: 'DASHBOARD' }))).toBe(100);
expect(scoreApp('DASHBOARD', createAppLink({ title: 'DASHBOARD' }))).toBe(100);
expect(scoreApp('dashBOARD', createAppLink({ title: 'DASHboard' }))).toBe(100);
});
it('returns 90 if the app title starts with the term', () => {
expect(scoreApp('dash', createApp({ title: 'dashboard' }))).toBe(90);
expect(scoreApp('DASH', createApp({ title: 'dashboard' }))).toBe(90);
expect(scoreApp('dash', createAppLink({ title: 'dashboard' }))).toBe(90);
expect(scoreApp('DASH', createAppLink({ title: 'dashboard' }))).toBe(90);
});
it('returns 75 if the term in included in the app title', () => {
expect(scoreApp('board', createApp({ title: 'dashboard' }))).toBe(75);
expect(scoreApp('shboa', createApp({ title: 'dashboard' }))).toBe(75);
expect(scoreApp('board', createAppLink({ title: 'dashboard' }))).toBe(75);
expect(scoreApp('shboa', createAppLink({ title: 'dashboard' }))).toBe(75);
});
});
describe('when the term is not included in the title', () => {
it('returns the levenshtein ratio if superior or equal to 60', () => {
expect(scoreApp('0123456789', createApp({ title: '012345' }))).toBe(60);
expect(scoreApp('--1234567-', createApp({ title: '123456789' }))).toBe(60);
expect(scoreApp('0123456789', createAppLink({ title: '012345' }))).toBe(60);
expect(scoreApp('--1234567-', createAppLink({ title: '123456789' }))).toBe(60);
});
it('returns 0 if the levenshtein ratio is inferior to 60', () => {
expect(scoreApp('0123456789', createApp({ title: '12345' }))).toBe(0);
expect(scoreApp('1-2-3-4-5', createApp({ title: '123456789' }))).toBe(0);
expect(scoreApp('0123456789', createAppLink({ title: '12345' }))).toBe(0);
expect(scoreApp('1-2-3-4-5', createAppLink({ title: '123456789' }))).toBe(0);
});
});
});
describe('appToResult', () => {
it('converts an app to a result', () => {
const app = createApp({
const app = createAppLink({
id: 'foo',
title: 'Foo',
euiIconType: 'fooIcon',
@ -92,7 +139,7 @@ describe('appToResult', () => {
});
it('converts an app without category to a result', () => {
const app = createApp({
const app = createAppLink({
id: 'foo',
title: 'Foo',
euiIconType: 'fooIcon',
@ -111,4 +158,28 @@ describe('appToResult', () => {
score: 42,
});
});
it('includes the app name in sub links', () => {
const app = createApp();
const appLink: AppLink = {
id: 'app1-sub',
app,
path: '/sub1',
subLinkTitles: ['Sub1'],
};
expect(appToResult(appLink, 42).title).toEqual('App 1 / Sub1');
});
it('does not include the app name in sub links for Stack Management', () => {
const app = createApp({ id: 'management' });
const appLink: AppLink = {
id: 'management-sub',
app,
path: '/sub1',
subLinkTitles: ['Sub1'],
};
expect(appToResult(appLink, 42).title).toEqual('Sub1');
});
});

View file

@ -5,22 +5,41 @@
*/
import levenshtein from 'js-levenshtein';
import { PublicAppInfo } from 'src/core/public';
import { PublicAppInfo, PublicAppSearchDeepLinkInfo } from 'src/core/public';
import { GlobalSearchProviderResult } from '../../../global_search/public';
/** Type used internally to represent an application unrolled into its separate searchDeepLinks */
export interface AppLink {
id: string;
app: PublicAppInfo;
subLinkTitles: string[];
path: string;
}
export const getAppResults = (
term: string,
apps: PublicAppInfo[]
): GlobalSearchProviderResult[] => {
return apps
.map((app) => ({ app, score: scoreApp(term, app) }))
.filter(({ score }) => score > 0)
.map(({ app, score }) => appToResult(app, score));
return (
apps
// Unroll all searchDeepLinks, only if there is a search term
.flatMap((app) =>
term.length > 0
? flattenDeepLinks(app)
: [{ id: app.id, app, path: app.appRoute, subLinkTitles: [] }]
)
.map((appLink) => ({
appLink,
score: scoreApp(term, appLink),
}))
.filter(({ score }) => score > 0)
.map(({ appLink, score }) => appToResult(appLink, score))
);
};
export const scoreApp = (term: string, { title }: PublicAppInfo): number => {
export const scoreApp = (term: string, appLink: AppLink): number => {
term = term.toLowerCase();
title = title.toLowerCase();
const title = [appLink.app.title, ...appLink.subLinkTitles].join(' ').toLowerCase();
// shortcuts to avoid calculating the distance when there is an exact match somewhere.
if (title === term) {
@ -43,17 +62,61 @@ export const scoreApp = (term: string, { title }: PublicAppInfo): number => {
return 0;
};
export const appToResult = (app: PublicAppInfo, score: number): GlobalSearchProviderResult => {
export const appToResult = (appLink: AppLink, score: number): GlobalSearchProviderResult => {
const titleParts =
// Stack Management app should not include the app title in the concatenated link label
appLink.app.id === 'management' && appLink.subLinkTitles.length > 0
? appLink.subLinkTitles
: [appLink.app.title, ...appLink.subLinkTitles];
return {
id: app.id,
title: app.title,
id: appLink.id,
// Concatenate title using slashes
title: titleParts.join(' / '),
type: 'application',
icon: app.euiIconType,
url: app.appRoute,
icon: appLink.app.euiIconType,
url: appLink.path,
meta: {
categoryId: app.category?.id ?? null,
categoryLabel: app.category?.label ?? null,
categoryId: appLink.app.category?.id ?? null,
categoryLabel: appLink.app.category?.label ?? null,
},
score,
};
};
const flattenDeepLinks = (
app: PublicAppInfo,
deepLink?: PublicAppSearchDeepLinkInfo
): AppLink[] => {
if (!deepLink) {
return [
{
id: app.id,
app,
path: app.appRoute,
subLinkTitles: [],
},
...app.searchDeepLinks.flatMap((appDeepLink) => flattenDeepLinks(app, appDeepLink)),
];
}
return [
...(deepLink.path
? [
{
id: `${app.id}-${deepLink.id}`,
app,
subLinkTitles: [deepLink.title],
path: `${app.appRoute}${deepLink.path}`,
},
]
: []),
...deepLink.searchDeepLinks
.flatMap((deepDeepLink) => flattenDeepLinks(app, deepDeepLink))
.map((deepAppLink) => ({
...deepAppLink,
// shift current sublink title into array of sub-sublink titles
subLinkTitles: [deepLink.title, ...deepAppLink.subLinkTitles],
})),
];
};

View file

@ -14,7 +14,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const browser = getService('browser');
const esArchiver = getService('esArchiver');
const findResultsWithAPI = async (t: string): Promise<GlobalSearchResult[]> => {
const findResultsWithApi = async (t: string): Promise<GlobalSearchResult[]> => {
return browser.executeAsync(async (term, cb) => {
const { start } = window._coreProvider;
const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest;
@ -22,60 +22,76 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
}, t);
};
describe('GlobalSearch - SavedObject provider', function () {
describe('GlobalSearch providers', function () {
before(async () => {
await esArchiver.load('global_search/basic');
});
after(async () => {
await esArchiver.unload('global_search/basic');
});
beforeEach(async () => {
await pageObjects.common.navigateToApp('globalSearchTestApp');
});
it('can search for index patterns', async () => {
const results = await findResultsWithAPI('logstash');
expect(results.length).to.be(1);
expect(results[0].type).to.be('index-pattern');
expect(results[0].title).to.be('logstash-*');
expect(results[0].score).to.be.greaterThan(0.9);
describe('SavedObject provider', function () {
before(async () => {
await esArchiver.load('global_search/basic');
});
after(async () => {
await esArchiver.unload('global_search/basic');
});
it('can search for index patterns', async () => {
const results = await findResultsWithApi('type:index-pattern logstash');
expect(results.length).to.be(1);
expect(results[0].type).to.be('index-pattern');
expect(results[0].title).to.be('logstash-*');
expect(results[0].score).to.be.greaterThan(0.9);
});
it('can search for visualizations', async () => {
const results = await findResultsWithApi('type:visualization pie');
expect(results.length).to.be(1);
expect(results[0].type).to.be('visualization');
expect(results[0].title).to.be('A Pie');
});
it('can search for maps', async () => {
const results = await findResultsWithApi('type:map just');
expect(results.length).to.be(1);
expect(results[0].type).to.be('map');
expect(results[0].title).to.be('just a map');
});
it('can search for dashboards', async () => {
const results = await findResultsWithApi('type:dashboard Amazing');
expect(results.length).to.be(1);
expect(results[0].type).to.be('dashboard');
expect(results[0].title).to.be('Amazing Dashboard');
});
it('returns all objects matching the search', async () => {
const results = await findResultsWithApi('type:dashboard dashboard');
expect(results.length).to.be(2);
expect(results.map((r) => r.title)).to.contain('dashboard with map');
expect(results.map((r) => r.title)).to.contain('Amazing Dashboard');
});
it('can search by prefix', async () => {
const results = await findResultsWithApi('type:dashboard Amaz');
expect(results.length).to.be(1);
expect(results[0].type).to.be('dashboard');
expect(results[0].title).to.be('Amazing Dashboard');
});
});
it('can search for visualizations', async () => {
const results = await findResultsWithAPI('pie');
expect(results.length).to.be(1);
expect(results[0].type).to.be('visualization');
expect(results[0].title).to.be('A Pie');
});
describe('Applications provider', function () {
it('can search for root-level applications', async () => {
const results = await findResultsWithApi('discover');
expect(results.length).to.be(1);
expect(results[0].title).to.be('Discover');
});
it('can search for maps', async () => {
const results = await findResultsWithAPI('just');
expect(results.length).to.be(1);
expect(results[0].type).to.be('map');
expect(results[0].title).to.be('just a map');
});
it('can search for dashboards', async () => {
const results = await findResultsWithAPI('Amazing');
expect(results.length).to.be(1);
expect(results[0].type).to.be('dashboard');
expect(results[0].title).to.be('Amazing Dashboard');
});
it('returns all objects matching the search', async () => {
const results = await findResultsWithAPI('dashboard');
expect(results.length).to.be.greaterThan(2);
expect(results.map((r) => r.title)).to.contain('dashboard with map');
expect(results.map((r) => r.title)).to.contain('Amazing Dashboard');
});
it('can search by prefix', async () => {
const results = await findResultsWithAPI('Amaz');
expect(results.length).to.be(1);
expect(results[0].type).to.be('dashboard');
expect(results[0].title).to.be('Amazing Dashboard');
it('can search for application deep links', async () => {
const results = await findResultsWithApi('saved objects');
expect(results.length).to.be(1);
expect(results[0].title).to.be('Kibana / Saved Objects');
});
});
});
}