diff --git a/docs/development/core/public/kibana-plugin-core-public.app.exactroute.md b/docs/development/core/public/kibana-plugin-core-public.app.exactroute.md index d1e0be17a92b2..eb050b62c7d43 100644 --- a/docs/development/core/public/kibana-plugin-core-public.app.exactroute.md +++ b/docs/development/core/public/kibana-plugin-core-public.app.exactroute.md @@ -18,7 +18,7 @@ exactRoute?: boolean; ```ts core.application.register({ id: 'my_app', - title: 'My App' + title: 'My App', exactRoute: true, mount: () => { ... }, }) diff --git a/docs/development/core/public/kibana-plugin-core-public.app.md b/docs/development/core/public/kibana-plugin-core-public.app.md index 7bdee9dc4c53e..8e8bae5ad9c58 100644 --- a/docs/development/core/public/kibana-plugin-core-public.app.md +++ b/docs/development/core/public/kibana-plugin-core-public.app.md @@ -27,6 +27,7 @@ export interface App | [mount](./kibana-plugin-core-public.app.mount.md) | AppMount<HistoryLocationState> | AppMountDeprecated<HistoryLocationState> | 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) | AppNavLinkStatus | The initial status of the application's navLink. Defaulting to visible if status is accessible and hidden if status is inaccessible See [AppNavLinkStatus](./kibana-plugin-core-public.appnavlinkstatus.md) | | [order](./kibana-plugin-core-public.app.order.md) | number | An ordinal used to sort nav links relative to one another for display. | +| [searchDeepLinks](./kibana-plugin-core-public.app.searchdeeplinks.md) | AppSearchDeepLink[] | Array of links that represent secondary in-app locations for the app. | | [status](./kibana-plugin-core-public.app.status.md) | AppStatus | The initial status of the application. Defaulting to accessible | | [title](./kibana-plugin-core-public.app.title.md) | string | The title of the application. | | [tooltip](./kibana-plugin-core-public.app.tooltip.md) | string | A tooltip shown when hovering over app link. | diff --git a/docs/development/core/public/kibana-plugin-core-public.app.searchdeeplinks.md b/docs/development/core/public/kibana-plugin-core-public.app.searchdeeplinks.md new file mode 100644 index 0000000000000..667fddbc212a5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.app.searchdeeplinks.md @@ -0,0 +1,42 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [App](./kibana-plugin-core-public.app.md) > [searchDeepLinks](./kibana-plugin-core-public.app.searchdeeplinks.md) + +## App.searchDeepLinks property + +Array of links that represent secondary in-app locations for the app. + +Signature: + +```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` + diff --git a/docs/development/core/public/kibana-plugin-core-public.appsearchdeeplink.md b/docs/development/core/public/kibana-plugin-core-public.appsearchdeeplink.md new file mode 100644 index 0000000000000..7e5ccf7d06ed1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appsearchdeeplink.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [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. + +Signature: + +```typescript +export declare type AppSearchDeepLink = { + id: string; + title: string; +} & ({ + path: string; + searchDeepLinks?: AppSearchDeepLink[]; +} | { + path?: string; + searchDeepLinks: AppSearchDeepLink[]; +}); +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md b/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md index 1232b7f940255..b6f404c3d11aa 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md +++ b/docs/development/core/public/kibana-plugin-core-public.appupdatablefields.md @@ -9,5 +9,5 @@ Defines the list of fields that can be updated via an [AppUpdater](./kibana-plug Signature: ```typescript -export declare type AppUpdatableFields = Pick; +export declare type AppUpdatableFields = Pick; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 6a90fd49f1d66..5f656b9ca510d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -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 confirm to to prompt a message to the user before leaving the page, or default 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 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. | | [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 plugin export at the root of a plugin's public 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) | diff --git a/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md b/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md index 3717dc847db25..d56b0ac58cd9b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md +++ b/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md @@ -9,9 +9,10 @@ Public information about a registered [application](./kibana-plugin-core-public. Signature: ```typescript -export declare type PublicAppInfo = Omit & { +export declare type PublicAppInfo = Omit & { status: AppStatus; navLinkStatus: AppNavLinkStatus; appRoute: string; + searchDeepLinks: PublicAppSearchDeepLinkInfo[]; }; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.publicappsearchdeeplinkinfo.md b/docs/development/core/public/kibana-plugin-core-public.publicappsearchdeeplinkinfo.md new file mode 100644 index 0000000000000..9814f0408d047 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.publicappsearchdeeplinkinfo.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [PublicAppSearchDeepLinkInfo](./kibana-plugin-core-public.publicappsearchdeeplinkinfo.md) + +## PublicAppSearchDeepLinkInfo type + +Public information about a registered app's [searchDeepLinks](./kibana-plugin-core-public.appsearchdeeplink.md) + +Signature: + +```typescript +export declare type PublicAppSearchDeepLinkInfo = Omit & { + searchDeepLinks: PublicAppSearchDeepLinkInfo[]; +}; +``` diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index 4f3b113a29c9b..b39aa70c888fe 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -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, diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 02d2d3a52a01a..d9f326c7a59ab 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -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; +export type AppUpdatableFields = Pick< + App, + 'status' | 'navLinkStatus' | 'tooltip' | 'defaultPath' | 'searchDeepLinks' +>; /** * Updater for applications. @@ -222,7 +225,7 @@ export interface App { * ```ts * core.application.register({ * id: 'my_app', - * title: 'My App' + * title: 'My App', * exactRoute: true, * mount: () => { ... }, * }) @@ -232,18 +235,89 @@ export interface App { * ``` */ 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 & { + searchDeepLinks: PublicAppSearchDeepLinkInfo[]; +}; + /** * Public information about a registered {@link App | application} * * @public */ -export type PublicAppInfo = Omit & { +export type PublicAppInfo = Omit & { // remove optional on fields populated with default values status: AppStatus; navLinkStatus: AppNavLinkStatus; appRoute: string; + searchDeepLinks: PublicAppSearchDeepLinkInfo[]; }; /** diff --git a/src/core/public/application/utils/get_app_info.test.ts b/src/core/public/application/utils/get_app_info.test.ts index 055f7d1a5ada9..ee0bd4f1eadfa 100644 --- a/src/core/public/application/utils/get_app_info.test.ts +++ b/src/core/public/application/utils/get_app_info.test.ts @@ -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 + }, + ], + }, + ], }); }); diff --git a/src/core/public/application/utils/get_app_info.ts b/src/core/public/application/utils/get_app_info.ts index 71cd8a3e14929..7316080816da7 100644 --- a/src/core/public/application/utils/get_app_info.ts +++ b/src/core/public/application/utils/get_app_info.ts @@ -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): 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): 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), + }; + } + ); +} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts index 7e2c1fc1f89f8..606370c5afd0a 100644 --- a/src/core/public/chrome/nav_links/to_nav_link.test.ts +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -28,6 +28,7 @@ const app = (props: Partial = {}): PublicAppInfo => ({ status: AppStatus.accessible, navLinkStatus: AppNavLinkStatus.default, appRoute: `/app/some-id`, + searchDeepLinks: [], ...props, }); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 564bbd712c535..557529fc94dc4 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -95,7 +95,6 @@ export { ApplicationSetup, ApplicationStart, App, - PublicAppInfo, AppMount, AppMountDeprecated, AppUnmount, @@ -110,6 +109,9 @@ export { AppNavLinkStatus, AppUpdatableFields, AppUpdater, + AppSearchDeepLink, + PublicAppInfo, + PublicAppSearchDeepLinkInfo, ScopedHistory, NavigateToAppOptions, } from './application'; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 37e57a9ee606e..aaea8f2f7c3fd 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -59,6 +59,8 @@ export interface App { mount: AppMount | AppMountDeprecated; 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; +export type AppUpdatableFields = Pick; // @public export type AppUpdater = (app: App) => Partial | undefined; @@ -967,10 +981,16 @@ export interface PluginInitializerContext export type PluginOpaqueId = symbol; // @public -export type PublicAppInfo = Omit & { +export type PublicAppInfo = Omit & { status: AppStatus; navLinkStatus: AppNavLinkStatus; appRoute: string; + searchDeepLinks: PublicAppSearchDeepLinkInfo[]; +}; + +// @public +export type PublicAppSearchDeepLinkInfo = Omit & { + searchDeepLinks: PublicAppSearchDeepLinkInfo[]; }; // @public diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index 122e73796753c..bf03c649fa6b4 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -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 { private readonly managementSections = new ManagementSectionsService(); - private readonly appUpdater = new BehaviorSubject(() => ({})); + private readonly appUpdater = new BehaviorSubject(() => { + 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; diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index 3746e636066a9..ecd1c92bfcee6 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -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) }]; } diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts index 2831550da00d9..7beed42de4c4f 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.test.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -28,6 +28,7 @@ const createApp = (props: Partial = {}): PublicAppInfo => ({ status: AppStatus.accessible, navLinkStatus: AppNavLinkStatus.visible, chromeless: false, + searchDeepLinks: [], ...props, }); diff --git a/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts b/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts index 5ef15a8cf2ea4..33fd358f61aca 100644 --- a/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts @@ -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 => ({ id: 'app1', @@ -19,9 +19,17 @@ const createApp = (props: Partial = {}): PublicAppInfo => ({ status: AppStatus.accessible, navLinkStatus: AppNavLinkStatus.visible, chromeless: false, + searchDeepLinks: [], ...props, }); +const createAppLink = (props: Partial = {}): 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'); + }); }); diff --git a/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts b/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts index c4e1a9532d144..01e6e87f30c94 100644 --- a/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts @@ -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], + })), + ]; +}; diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts index 16dc7b379214a..170548811def5 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts @@ -14,7 +14,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const browser = getService('browser'); const esArchiver = getService('esArchiver'); - const findResultsWithAPI = async (t: string): Promise => { + const findResultsWithApi = async (t: string): Promise => { 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'); + await pageObjects.common.navigateToApp('globalSearchTestApp'); }); - after(async () => { - await esArchiver.unload('global_search/basic'); - }); + describe('SavedObject provider', function () { + before(async () => { + await esArchiver.load('global_search/basic'); + }); - beforeEach(async () => { - await pageObjects.common.navigateToApp('globalSearchTestApp'); - }); + after(async () => { + await esArchiver.unload('global_search/basic'); + }); - 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); - }); + 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('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 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('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 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('Amazing'); - 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 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('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('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 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'); + 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 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'); + }); }); }); }