[Fleet] Add packages to global search results (#102227)

* added public-side implementation for package search

* added a test for the new search results provider and updated behaviour

* added comment about open issue regarding hash router in fleet

* fixed jest tests

* refactor to reduce size of if statement

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2021-06-17 15:45:28 +02:00 committed by GitHub
parent 0cfd04c87d
commit 7a08bd8b69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 304 additions and 8 deletions

View file

@ -7,8 +7,6 @@
A new Kibana plugin exposing an API on both public and server side, to allow consumers to search for various objects and
register result providers.
Note: whether this will be an oss or xpack plugin still depends on https://github.com/elastic/dev/issues/1404.
# Basic example
- registering a result provider:
@ -43,8 +41,7 @@ Kibana should do its best to assist users searching for and navigating to the va
We should expose an API to make it possible for plugins to search for the various objects present on a Kibana instance.
The first consumer of this API will be the global search bar [#57576](https://github.com/elastic/kibana/issues/57576). This API
should still be generic to answer similar needs from any other consumer, either client or server side.
The first consumer of this API will be the global search bar [#57576](https://github.com/elastic/kibana/issues/57576). This API should still be generic to answer similar needs from any other consumer, either client or server side.
# Detailed design
@ -84,7 +81,7 @@ interface GlobalSearchProviderFindOptions {
aborted$: Observable<void>;
/**
* The total maximum number of results (including all batches / emissions) that should be returned by the provider for a given `find` request.
* Any result emitted exceeding this quota will be ignored by the service and not emitted to the consumer.
* Any result emitted exceeding this quota will be ignored by the service and not emitted to the consumer.
*/
maxResults: number;
}
@ -462,8 +459,8 @@ search(
Notes:
- The example implementation is not streaming results from the server, meaning that all results from server-side
registered providers will all be fetched and emitted in a single batch. Ideally, we would leverage the `bfetch` plugin
- The example implementation is not streaming results from the server, meaning that all results from server-side
registered providers will all be fetched and emitted in a single batch. Ideally, we would leverage the `bfetch` plugin
to stream the results to the client instead.
### results sorting

View file

@ -5,7 +5,7 @@
"ui": true,
"configPath": ["xpack", "fleet"],
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation"],
"optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"],
"optionalPlugins": ["security", "features", "cloud", "usageCollection", "home", "globalSearch"],
"extraPublicDirs": ["common"],
"requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"]
}

View file

@ -36,6 +36,14 @@ export const useGetPackages = (query: GetPackagesRequest['query'] = {}) => {
});
};
export const sendGetPackages = (query: GetPackagesRequest['query'] = {}) => {
return sendRequest<GetPackagesResponse>({
path: epmRouteService.getListPath(),
method: 'get',
query: { experimental: true, ...query },
});
};
export const useGetLimitedPackages = () => {
return useRequest<GetLimitedPackagesResponse>({
path: epmRouteService.getListLimitedPath(),

View file

@ -26,6 +26,7 @@ import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import type { LicensingPluginSetup } from '../../licensing/public';
import type { CloudSetup } from '../../cloud/public';
import type { GlobalSearchPluginSetup } from '../../global_search/public';
import { PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, setupRouteService, appRoutesService } from '../common';
import type { CheckPermissionsResponse, PostIngestSetupResponse } from '../common';
@ -34,6 +35,7 @@ import type { FleetConfigType } from '../common/types';
import { FLEET_BASE_PATH } from './constants';
import { licenseService } from './hooks';
import { setHttpClient } from './hooks/use_request';
import { createPackageSearchProvider } from './search_provider';
import {
TutorialDirectoryNotice,
TutorialDirectoryHeaderLink,
@ -62,6 +64,7 @@ export interface FleetSetupDeps {
data: DataPublicPluginSetup;
home?: HomePublicPluginSetup;
cloud?: CloudSetup;
globalSearch?: GlobalSearchPluginSetup;
}
export interface FleetStartDeps {
@ -192,6 +195,10 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep
});
}
if (deps.globalSearch) {
deps.globalSearch.registerResultProvider(createPackageSearchProvider(core));
}
return {};
}

View file

@ -0,0 +1,189 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TestScheduler } from 'rxjs/testing';
import { NEVER } from 'rxjs';
import { coreMock } from 'src/core/public/mocks';
import { createPackageSearchProvider } from './search_provider';
import type { GetPackagesResponse } from './types';
jest.mock('./hooks/use_request/epm', () => {
return {
...jest.requireActual('./hooks/use_request/epm'),
sendGetPackages: jest.fn(),
};
});
import { sendGetPackages } from './hooks';
const mockSendGetPackages = sendGetPackages as jest.Mock;
const testResponse: GetPackagesResponse['response'] = [
{
description: 'test',
download: 'test',
id: 'test',
name: 'test',
path: 'test',
release: 'experimental',
savedObject: {} as any,
status: 'installed',
title: 'test',
version: 'test',
},
{
description: 'test1',
download: 'test1',
id: 'test1',
name: 'test1',
path: 'test1',
release: 'ga',
status: 'not_installed',
title: 'test1',
version: 'test1',
},
];
const getTestScheduler = () => {
return new TestScheduler((actual, expected) => {
return expect(actual).toEqual(expected);
});
};
describe('Package search provider', () => {
let setupMock: ReturnType<typeof coreMock.createSetup>;
beforeEach(() => {
setupMock = coreMock.createSetup();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('#find', () => {
test('returns formatted results', () => {
getTestScheduler().run(({ expectObservable, hot }) => {
mockSendGetPackages.mockReturnValue(
hot('--(a|)', { a: { data: { response: testResponse } } })
);
setupMock.getStartServices.mockReturnValue(
hot('--(a|)', { a: [coreMock.createStart()] }) as any
);
const packageSearchProvider = createPackageSearchProvider(setupMock);
expectObservable(
packageSearchProvider.find(
{ term: 'test' },
{ aborted$: NEVER, maxResults: 100, preference: '' }
)
).toBe('--(a|)', {
a: [
{
id: 'test-test',
score: 80,
title: 'test',
type: 'package',
url: {
path: 'undefined#/detail/test-test/overview',
prependBasePath: false,
},
},
{
id: 'test1-test1',
score: 80,
title: 'test1',
type: 'package',
url: {
path: 'undefined#/detail/test1-test1/overview',
prependBasePath: false,
},
},
],
});
});
expect(sendGetPackages).toHaveBeenCalledTimes(1);
});
test('calls EPR once only', () => {
getTestScheduler().run(({ hot }) => {
mockSendGetPackages.mockReturnValue(hot('--(a|)', { a: { data: { response: [] } } }));
setupMock.getStartServices.mockReturnValue(
hot('--(a|)', { a: [coreMock.createStart()] }) as any
);
const packageSearchProvider = createPackageSearchProvider(setupMock);
packageSearchProvider.find(
{ term: 'test' },
{ aborted$: NEVER, maxResults: 100, preference: '' }
);
packageSearchProvider.find(
{ term: 'test' },
{ aborted$: NEVER, maxResults: 100, preference: '' }
);
});
expect(sendGetPackages).toHaveBeenCalledTimes(1);
});
test('completes without returning results if aborted', () => {
getTestScheduler().run(({ expectObservable, hot }) => {
mockSendGetPackages.mockReturnValue(hot('--(a|)', { a: { data: { response: [] } } }));
setupMock.getStartServices.mockReturnValue(
hot('--(a|)', { a: [coreMock.createStart()] }) as any
);
const aborted$ = hot('-a', { a: undefined });
const packageSearchProvider = createPackageSearchProvider(setupMock);
expectObservable(
packageSearchProvider.find(
{ term: 'test' },
{ aborted$, maxResults: 100, preference: '' }
)
).toBe('-|');
});
expect(sendGetPackages).toHaveBeenCalledTimes(1);
});
test('respect maximum results', () => {
getTestScheduler().run(({ hot, expectObservable }) => {
mockSendGetPackages.mockReturnValue(
hot('--(a|)', { a: { data: { response: testResponse } } })
);
setupMock.getStartServices.mockReturnValue(
hot('--(a|)', { a: [coreMock.createStart()] }) as any
);
const packageSearchProvider = createPackageSearchProvider(setupMock);
expectObservable(
packageSearchProvider.find(
{ term: 'test' },
{ aborted$: NEVER, maxResults: 1, preference: '' }
)
).toBe('--(a|)', {
a: [
{
id: 'test-test',
score: 80,
title: 'test',
type: 'package',
url: {
path: 'undefined#/detail/test-test/overview',
prependBasePath: false,
},
},
],
});
});
expect(sendGetPackages).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreSetup, CoreStart } from 'src/core/public';
import type { Observable } from 'rxjs';
import { from, of, combineLatest } from 'rxjs';
import { map, shareReplay, takeUntil } from 'rxjs/operators';
import type {
GlobalSearchResultProvider,
GlobalSearchProviderResult,
} from '../../global_search/public';
import { INTEGRATIONS_PLUGIN_ID } from '../common';
import { sendGetPackages } from './hooks';
import type { GetPackagesResponse } from './types';
import { pagePathGetters } from './constants';
const packageType = 'package';
const createPackages$ = () =>
from(sendGetPackages()).pipe(
map(({ error, data }) => {
if (error) {
throw error;
}
return data?.response ?? [];
}),
shareReplay(1)
);
export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResultProvider => {
const coreStart$ = from(core.getStartServices()).pipe(
map(([coreStart]) => coreStart),
shareReplay(1)
);
let packages$: undefined | Observable<GetPackagesResponse['response']>;
const getPackages$ = () => {
if (!packages$) {
packages$ = createPackages$();
}
return packages$;
};
return {
id: 'packages',
getSearchableTypes: () => [packageType],
find: ({ term }, { maxResults, aborted$ }) => {
if (!term) {
return of([]);
}
term = term.toLowerCase();
const toSearchResults = (
coreStart: CoreStart,
packagesResponse: GetPackagesResponse['response']
): GlobalSearchProviderResult[] => {
const packages = packagesResponse.slice(0, maxResults);
return packages.flatMap((pkg) => {
if (!term || !pkg.title.toLowerCase().includes(term)) {
return [];
}
const pkgkey = `${pkg.name}-${pkg.version}`;
return {
id: pkgkey,
type: packageType,
title: pkg.title,
score: 80,
url: {
// TODO: See https://github.com/elastic/kibana/issues/96134 for details about why we use '#' here. Below should be updated
// as part of migrating to non-hash based router.
// prettier-ignore
path: `${coreStart.application.getUrlForApp(INTEGRATIONS_PLUGIN_ID)}#${pagePathGetters.integration_details_overview({ pkgkey })[1]}`,
prependBasePath: false,
},
};
});
};
return combineLatest([coreStart$, getPackages$()]).pipe(
takeUntil(aborted$),
map(([coreStart, data]) => (data ? toSearchResults(coreStart, data) : []))
);
},
};
};