From b4b11720bb2f0bd54e62c400b6915a480ffa56b8 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 29 Jun 2020 15:17:00 +0200 Subject: [PATCH] [GS] add application result provider (#68488) * add application result provider * remove empty contracts & cache searchable apps * fix types --- ...kibana-plugin-core-public.publicappinfo.md | 3 + ...-plugin-core-public.publiclegacyappinfo.md | 2 + package.json | 1 + src/core/public/application/types.ts | 7 + src/core/public/application/utils.ts | 5 + src/core/public/public.api.md | 5 + .../global_search_providers/kibana.json | 10 + .../global_search_providers/public/index.ts | 11 + .../public/plugin.test.ts | 33 +++ .../global_search_providers/public/plugin.ts | 29 +++ .../providers/application.test.mocks.ts | 10 + .../public/providers/application.test.ts | 204 ++++++++++++++++++ .../public/providers/application.ts | 39 ++++ .../public/providers/get_app_results.test.ts | 119 ++++++++++ .../public/providers/get_app_results.ts | 58 +++++ .../public/providers/index.ts | 7 + x-pack/typings/js_levenshtein.d.ts | 10 + yarn.lock | 5 + 18 files changed, 558 insertions(+) create mode 100644 x-pack/plugins/global_search_providers/kibana.json create mode 100644 x-pack/plugins/global_search_providers/public/index.ts create mode 100644 x-pack/plugins/global_search_providers/public/plugin.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/plugin.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/get_app_results.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/index.ts create mode 100644 x-pack/typings/js_levenshtein.d.ts 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 c70f3a97a8882..4b3b103c92731 100644 --- a/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md +++ b/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md @@ -11,5 +11,8 @@ Public information about a registered [application](./kibana-plugin-core-public. ```typescript export declare type PublicAppInfo = Omit & { legacy: false; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md b/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md index cc3e9de3193cb..051638daabd12 100644 --- a/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md +++ b/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md @@ -11,5 +11,7 @@ Information about a registered [legacy application](./kibana-plugin-core-public. ```typescript export declare type PublicLegacyAppInfo = Omit & { legacy: true; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; ``` diff --git a/package.json b/package.json index 7d4ace2cbdfdc..1a8b6e8aca3d2 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,7 @@ "inline-style": "^2.0.0", "joi": "^13.5.2", "jquery": "^3.5.0", + "js-levenshtein": "^1.1.6", "js-yaml": "3.13.1", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 6926b6acf2411..cd2dd99c30c11 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -269,6 +269,10 @@ export interface LegacyApp extends AppBase { */ export type PublicAppInfo = Omit & { legacy: false; + // remove optional on fields populated with default values + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; /** @@ -278,6 +282,9 @@ export type PublicAppInfo = Omit & { */ export type PublicLegacyAppInfo = Omit & { legacy: true; + // remove optional on fields populated with default values + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; /** diff --git a/src/core/public/application/utils.ts b/src/core/public/application/utils.ts index 1dc9ec7059001..92d25fa468c4a 100644 --- a/src/core/public/application/utils.ts +++ b/src/core/public/application/utils.ts @@ -120,12 +120,17 @@ export function getAppInfo(app: App | LegacyApp): PublicAppInfo | Publi const { updater$, ...infos } = app; return { ...infos, + status: app.status!, + navLinkStatus: app.navLinkStatus!, legacy: true, }; } else { const { updater$, mount, ...infos } = app; return { ...infos, + status: app.status!, + navLinkStatus: app.navLinkStatus!, + appRoute: app.appRoute!, legacy: false, }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d10e351f4d13e..a65b9dd9d242a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1143,11 +1143,16 @@ export type PluginOpaqueId = symbol; // @public export type PublicAppInfo = Omit & { legacy: false; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; // @public export type PublicLegacyAppInfo = Omit & { legacy: true; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; // @public diff --git a/x-pack/plugins/global_search_providers/kibana.json b/x-pack/plugins/global_search_providers/kibana.json new file mode 100644 index 0000000000000..025ea2bceed2c --- /dev/null +++ b/x-pack/plugins/global_search_providers/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "globalSearchProviders", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["globalSearch"], + "optionalPlugins": [], + "configPath": ["xpack", "global_search_providers"] +} diff --git a/x-pack/plugins/global_search_providers/public/index.ts b/x-pack/plugins/global_search_providers/public/index.ts new file mode 100644 index 0000000000000..bc66994aa393a --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/public'; +import { GlobalSearchProvidersPlugin, GlobalSearchProvidersPluginSetupDeps } from './plugin'; + +export const plugin: PluginInitializer<{}, {}, GlobalSearchProvidersPluginSetupDeps, {}> = () => + new GlobalSearchProvidersPlugin(); diff --git a/x-pack/plugins/global_search_providers/public/plugin.test.ts b/x-pack/plugins/global_search_providers/public/plugin.test.ts new file mode 100644 index 0000000000000..a2880acae440b --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/plugin.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { coreMock } from '../../../../src/core/public/mocks'; +import { globalSearchPluginMock } from '../../global_search/public/mocks'; +import { GlobalSearchProvidersPlugin } from './plugin'; + +describe('GlobalSearchProvidersPlugin', () => { + let plugin: GlobalSearchProvidersPlugin; + let globalSearchSetup: ReturnType; + + beforeEach(() => { + globalSearchSetup = globalSearchPluginMock.createSetupContract(); + plugin = new GlobalSearchProvidersPlugin(); + }); + + describe('#setup', () => { + it('registers the `application` result provider', () => { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, { globalSearch: globalSearchSetup }); + + expect(globalSearchSetup.registerResultProvider).toHaveBeenCalledTimes(1); + expect(globalSearchSetup.registerResultProvider).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'application', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/plugin.ts b/x-pack/plugins/global_search_providers/public/plugin.ts new file mode 100644 index 0000000000000..9f18c06608b01 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/plugin.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, Plugin } from 'src/core/public'; +import { GlobalSearchPluginSetup } from '../../global_search/public'; +import { createApplicationResultProvider } from './providers'; + +export interface GlobalSearchProvidersPluginSetupDeps { + globalSearch: GlobalSearchPluginSetup; +} + +export class GlobalSearchProvidersPlugin + implements Plugin<{}, {}, GlobalSearchProvidersPluginSetupDeps, {}> { + setup( + { getStartServices }: CoreSetup<{}, {}>, + { globalSearch }: GlobalSearchProvidersPluginSetupDeps + ) { + const applicationPromise = getStartServices().then(([core]) => core.application); + globalSearch.registerResultProvider(createApplicationResultProvider(applicationPromise)); + return {}; + } + + start() { + return {}; + } +} diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts new file mode 100644 index 0000000000000..4fdf8a75a4bc2 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getAppResultsMock = jest.fn(); +jest.doMock('./get_app_results', () => ({ + getAppResults: getAppResultsMock, +})); 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 new file mode 100644 index 0000000000000..ca19bddb60297 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getAppResultsMock } from './application.test.mocks'; + +import { of, EMPTY } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { ApplicationStart, AppNavLinkStatus, AppStatus, PublicAppInfo } from 'src/core/public'; +import { + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, +} from '../../../global_search/public'; +import { applicationServiceMock } from 'src/core/public/mocks'; +import { createApplicationResultProvider } from './application'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + +const createApp = (props: Partial = {}): PublicAppInfo => ({ + id: 'app1', + title: 'App 1', + appRoute: '/app/app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + chromeless: false, + ...props, +}); + +const createResult = (props: Partial): GlobalSearchProviderResult => ({ + id: 'id', + title: 'title', + type: 'application', + url: '/app/id', + score: 100, + ...props, +}); + +const createAppMap = (apps: PublicAppInfo[]): Map => { + return new Map(apps.map((app) => [app.id, app])); +}; + +const expectApp = (id: string) => expect.objectContaining({ id }); +const expectResult = expectApp; + +describe('applicationResultProvider', () => { + let application: ReturnType; + + const defaultOption: GlobalSearchProviderFindOptions = { + preference: 'pref', + maxResults: 20, + aborted$: EMPTY, + }; + + beforeEach(() => { + application = applicationServiceMock.createStartContract(); + getAppResultsMock.mockReturnValue([]); + }); + + it('has the correct id', () => { + const provider = createApplicationResultProvider(Promise.resolve(application)); + expect(provider.id).toBe('application'); + }); + + it('calls `getAppResults` with the term and the list of apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + expectApp('app3'), + ]); + }); + + it('ignores inaccessible apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'disabled', title: 'disabled', status: AppStatus.inaccessible }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); + + it('ignores chromeless apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), + ]) + ); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); + + it('sorts the results returned by `getAppResults`', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + const results = await provider.find('term', defaultOption).toPromise(); + + expect(results).toEqual([ + expectResult('r100'), + expectResult('r75'), + expectResult('r60'), + expectResult('r50'), + ]); + }); + + it('only returns the highest `maxResults` results', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const options = { + ...defaultOption, + maxResults: 2, + }; + const results = await provider.find('term', options).toPromise(); + + expect(results).toEqual([expectResult('r100'), expectResult('r75')]); + }); + + it('only emits once, even if `application$` emits multiple times', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + + application.applications$ = hot('--a---b', { a: appMap, b: appMap }); + + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { a: application }) as unknown) as Promise< + ApplicationStart + >; + + const provider = createApplicationResultProvider(applicationPromise); + + const options = { + ...defaultOption, + aborted$: hot('|'), + }; + + const resultObs = provider.find('term', options); + + expectObservable(resultObs).toBe('--(a|)', { a: [] }); + }); + }); + + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + + application.applications$ = hot('---a', { a: appMap, b: appMap }); + + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { a: application }) as unknown) as Promise< + ApplicationStart + >; + + const provider = createApplicationResultProvider(applicationPromise); + + const options = { + ...defaultOption, + aborted$: hot('-(a|)', { a: undefined }), + }; + + const resultObs = provider.find('term', options); + + expectObservable(resultObs).toBe('-|'); + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts new file mode 100644 index 0000000000000..e40fcef17f73c --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { from } from 'rxjs'; +import { take, map, takeUntil, mergeMap, shareReplay } from 'rxjs/operators'; +import { ApplicationStart } from 'src/core/public'; +import { GlobalSearchResultProvider } from '../../../global_search/public'; +import { getAppResults } from './get_app_results'; + +export const createApplicationResultProvider = ( + applicationPromise: Promise +): GlobalSearchResultProvider => { + const searchableApps$ = from(applicationPromise).pipe( + mergeMap((application) => application.applications$), + map((apps) => + [...apps.values()].filter( + (app) => app.status === 0 && (app.legacy === true || app.chromeless !== true) + ) + ), + shareReplay(1) + ); + + return { + id: 'application', + find: (term, { aborted$, maxResults }) => { + return searchableApps$.pipe( + takeUntil(aborted$), + take(1), + map((apps) => { + const results = getAppResults(term, [...apps.values()]); + return results.sort((a, b) => b.score - a.score).slice(0, maxResults); + }) + ); + }, + }; +}; 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 new file mode 100644 index 0000000000000..1c5a446b8e564 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AppNavLinkStatus, AppStatus, PublicAppInfo, PublicLegacyAppInfo } from 'src/core/public'; +import { appToResult, getAppResults, scoreApp } from './get_app_results'; + +const createApp = (props: Partial = {}): PublicAppInfo => ({ + id: 'app1', + title: 'App 1', + appRoute: '/app/app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + chromeless: false, + ...props, +}); + +const createLegacyApp = (props: Partial = {}): PublicLegacyAppInfo => ({ + id: 'app1', + title: 'App 1', + appUrl: '/app/app1', + legacy: true, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + ...props, +}); + +describe('getAppResults', () => { + it('retrieves the matching results', () => { + const apps = [ + createApp({ id: 'dashboard', title: 'dashboard' }), + createApp({ id: 'visualize', title: 'visualize' }), + ]; + + const results = getAppResults('dashboard', apps); + + expect(results.length).toBe(1); + expect(results[0]).toEqual(expect.objectContaining({ id: 'dashboard', score: 100 })); + }); +}); + +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); + }); + + 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); + }); + + 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); + }); + }); + + 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); + }); + 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); + }); + }); + + it('works with legacy apps', () => { + expect(scoreApp('dashboard', createLegacyApp({ title: 'dashboard' }))).toBe(100); + expect(scoreApp('dash', createLegacyApp({ title: 'dashboard' }))).toBe(90); + expect(scoreApp('board', createLegacyApp({ title: 'dashboard' }))).toBe(75); + expect(scoreApp('0123456789', createLegacyApp({ title: '012345' }))).toBe(60); + expect(scoreApp('0123456789', createLegacyApp({ title: '12345' }))).toBe(0); + }); +}); + +describe('appToResult', () => { + it('converts an app to a result', () => { + const app = createApp({ + id: 'foo', + title: 'Foo', + euiIconType: 'fooIcon', + appRoute: '/app/foo', + }); + expect(appToResult(app, 42)).toEqual({ + id: 'foo', + title: 'Foo', + type: 'application', + icon: 'fooIcon', + url: '/app/foo', + score: 42, + }); + }); + + it('converts a legacy app to a result', () => { + const app = createLegacyApp({ + id: 'legacy', + title: 'Legacy', + euiIconType: 'legacyIcon', + appUrl: '/app/legacy', + }); + expect(appToResult(app, 69)).toEqual({ + id: 'legacy', + title: 'Legacy', + type: 'application', + icon: 'legacyIcon', + url: '/app/legacy', + score: 69, + }); + }); +}); 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 new file mode 100644 index 0000000000000..1a1939230105b --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import levenshtein from 'js-levenshtein'; +import { PublicAppInfo, PublicLegacyAppInfo } from 'src/core/public'; +import { GlobalSearchProviderResult } from '../../../global_search/public'; + +export const getAppResults = ( + term: string, + apps: Array +): GlobalSearchProviderResult[] => { + return apps + .map((app) => ({ app, score: scoreApp(term, app) })) + .filter(({ score }) => score > 0) + .map(({ app, score }) => appToResult(app, score)); +}; + +export const scoreApp = (term: string, { title }: PublicAppInfo | PublicLegacyAppInfo): number => { + term = term.toLowerCase(); + title = title.toLowerCase(); + + // shortcuts to avoid calculating the distance when there is an exact match somewhere. + if (title === term) { + return 100; + } + if (title.startsWith(term)) { + return 90; + } + if (title.includes(term)) { + return 75; + } + const length = Math.max(term.length, title.length); + const distance = levenshtein(term, title); + + // maximum lev distance is length, we compute the match ratio (lower distance is better) + const ratio = Math.floor((1 - distance / length) * 100); + if (ratio >= 60) { + return ratio; + } + return 0; +}; + +export const appToResult = ( + app: PublicAppInfo | PublicLegacyAppInfo, + score: number +): GlobalSearchProviderResult => { + return { + id: app.id, + title: app.title, + type: 'application', + icon: app.euiIconType, + url: app.legacy ? app.appUrl : app.appRoute, + score, + }; +}; diff --git a/x-pack/plugins/global_search_providers/public/providers/index.ts b/x-pack/plugins/global_search_providers/public/providers/index.ts new file mode 100644 index 0000000000000..d71c30d41d46a --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createApplicationResultProvider } from './application'; diff --git a/x-pack/typings/js_levenshtein.d.ts b/x-pack/typings/js_levenshtein.d.ts new file mode 100644 index 0000000000000..812bf24bf3dd7 --- /dev/null +++ b/x-pack/typings/js_levenshtein.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +declare module 'js-levenshtein' { + const levenshtein: (a: string, b: string) => number; + export = levenshtein; +} diff --git a/yarn.lock b/yarn.lock index 9caa949d23957..03ec583f6a4a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19606,6 +19606,11 @@ js-levenshtein@^1.1.3: resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.3.tgz#3ef627df48ec8cf24bacf05c0f184ff30ef413c5" integrity sha512-/812MXr9RBtMObviZ8gQBhHO8MOrGj8HlEE+4ccMTElNA/6I3u39u+bhny55Lk921yn44nSZFy9naNLElL5wgQ== +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + js-search@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/js-search/-/js-search-1.4.3.tgz#23a86d7e064ca53a473930edc48615b6b1c1954a"