Skip to content

Commit

Permalink
[GS] add application result provider (elastic#68488)
Browse files Browse the repository at this point in the history
* add application result provider

* remove empty contracts & cache searchable apps

* fix types
  • Loading branch information
pgayvallet committed Jun 29, 2020
1 parent c870b85 commit b4b1172
Show file tree
Hide file tree
Showing 18 changed files with 558 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@ Public information about a registered [application](./kibana-plugin-core-public.
```typescript
export declare type PublicAppInfo = Omit<App, 'mount' | 'updater$'> & {
legacy: false;
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
};
```
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ Information about a registered [legacy application](./kibana-plugin-core-public.
```typescript
export declare type PublicLegacyAppInfo = Omit<LegacyApp, 'updater$'> & {
legacy: true;
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
};
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/core/public/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ export interface LegacyApp extends AppBase {
*/
export type PublicAppInfo = Omit<App, 'mount' | 'updater$'> & {
legacy: false;
// remove optional on fields populated with default values
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
};

/**
Expand All @@ -278,6 +282,9 @@ export type PublicAppInfo = Omit<App, 'mount' | 'updater$'> & {
*/
export type PublicLegacyAppInfo = Omit<LegacyApp, 'updater$'> & {
legacy: true;
// remove optional on fields populated with default values
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
};

/**
Expand Down
5 changes: 5 additions & 0 deletions src/core/public/application/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,17 @@ export function getAppInfo(app: App<unknown> | 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,
};
}
Expand Down
5 changes: 5 additions & 0 deletions src/core/public/public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1143,11 +1143,16 @@ export type PluginOpaqueId = symbol;
// @public
export type PublicAppInfo = Omit<App, 'mount' | 'updater$'> & {
legacy: false;
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
appRoute: string;
};

// @public
export type PublicLegacyAppInfo = Omit<LegacyApp, 'updater$'> & {
legacy: true;
status: AppStatus;
navLinkStatus: AppNavLinkStatus;
};

// @public
Expand Down
10 changes: 10 additions & 0 deletions x-pack/plugins/global_search_providers/kibana.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "globalSearchProviders",
"version": "8.0.0",
"kibanaVersion": "kibana",
"server": false,
"ui": true,
"requiredPlugins": ["globalSearch"],
"optionalPlugins": [],
"configPath": ["xpack", "global_search_providers"]
}
11 changes: 11 additions & 0 deletions x-pack/plugins/global_search_providers/public/index.ts
Original file line number Diff line number Diff line change
@@ -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();
33 changes: 33 additions & 0 deletions x-pack/plugins/global_search_providers/public/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof globalSearchPluginMock.createSetupContract>;

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',
})
);
});
});
});
29 changes: 29 additions & 0 deletions x-pack/plugins/global_search_providers/public/plugin.ts
Original file line number Diff line number Diff line change
@@ -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 {};
}
}
Original file line number Diff line number Diff line change
@@ -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,
}));
Original file line number Diff line number Diff line change
@@ -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> = {}): 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>): GlobalSearchProviderResult => ({
id: 'id',
title: 'title',
type: 'application',
url: '/app/id',
score: 100,
...props,
});

const createAppMap = (apps: PublicAppInfo[]): Map<string, PublicAppInfo> => {
return new Map(apps.map((app) => [app.id, app]));
};

const expectApp = (id: string) => expect.objectContaining({ id });
const expectResult = expectApp;

describe('applicationResultProvider', () => {
let application: ReturnType<typeof applicationServiceMock.createStartContract>;

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<undefined>('|'),
};

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<undefined>('-(a|)', { a: undefined }),
};

const resultObs = provider.find('term', options);

expectObservable(resultObs).toBe('-|');
});
});
});
Loading

0 comments on commit b4b1172

Please sign in to comment.