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..a85c38ce2d39c 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, + subLinks: [], ...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..9dca61ec331f5 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, + subLinks: [], ...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,80 @@ describe('getAppResults', () => { expect(results.length).toBe(1); expect(results[0]).toEqual(expect.objectContaining({ id: 'dashboard', score: 100 })); }); + + it('creates multiple links for apps with sublinks', () => { + const apps = [ + createApp({ + subLinks: [ + { id: 'sub1', title: 'Sub1', path: '/sub1', subLinks: [] }, + { + id: 'sub2', + title: 'Sub2', + path: '/sub2', + subLinks: [{ id: 'sub2sub1', title: 'Sub2Sub1', path: '/sub2/sub1', subLinks: [] }], + }, + ], + }), + ]; + + 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 sublinks when search term is non-empty', () => { + const apps = [ + createApp({ + subLinks: [{ id: 'sub1', title: 'Sub1', path: '/sub1', subLinks: [] }], + }), + ]; + + 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 +137,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 +156,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..bf381e035e514 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,39 @@ */ import levenshtein from 'js-levenshtein'; -import { PublicAppInfo } from 'src/core/public'; +import { PublicAppInfo, PublicAppSubLinkInfo } from 'src/core/public'; import { GlobalSearchProviderResult } from '../../../global_search/public'; +/** Type used internally to represent an application unrolled into its separate sublinks */ +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 sublinks + .flatMap((app) => flattenSubLinks(app)) + // Only include sublinks if there is a search term + .filter((appLink) => term.length > 0 || appLink.subLinkTitles.length === 0) + .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 +60,56 @@ 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 flattenSubLinks = (app: PublicAppInfo, subLink?: PublicAppSubLinkInfo): AppLink[] => { + if (!subLink) { + return [ + { + id: app.id, + app, + path: app.appRoute, + subLinkTitles: [], + }, + ...app.subLinks.flatMap((appSubLink) => flattenSubLinks(app, appSubLink)), + ]; + } + + const appLink: AppLink = { + id: `${app.id}-${subLink.id}`, + app, + subLinkTitles: [subLink.title], + path: `${app.appRoute}${subLink.path}`, + }; + + return [ + ...(subLink.path ? [appLink] : []), + ...subLink.subLinks + .flatMap((subSubLink) => flattenSubLinks(app, subSubLink)) + .map((subAppLink) => ({ + ...subAppLink, + // shift current sublink title into array of sub-sublink titles + subLinkTitles: [subLink.title, ...subAppLink.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..29ee26866bd71 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 () { - before(async () => { - await esArchiver.load('global_search/basic'); - }); - - after(async () => { - await esArchiver.unload('global_search/basic'); - }); - + describe('GlobalSearch', function () { 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'); + }); - 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'); - }); + after(async () => { + await esArchiver.unload('global_search/basic'); + }); - 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 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 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 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 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('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 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 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 sublinks', async () => { + const results = await findResultsWithApi('saved objects'); + expect(results.length).to.be(1); + expect(results[0].title).to.be('Kibana / Saved Objects'); + }); }); }); }