From d63f769636b3f418c0e278dbed062780fda97e5c Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Wed, 9 Dec 2020 11:00:50 +0100 Subject: [PATCH 01/18] [ML] API integration tests - skip GetAnomaliesTableData --- .../apis/ml/results/get_anomalies_table_data.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts b/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts index b485dfb15d64e..0e5cf4488b065 100644 --- a/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts +++ b/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts @@ -55,7 +55,9 @@ export default ({ getService }: FtrProviderContext) => { await ml.api.cleanMlIndices(); }); - it('should fetch anomalies table data', async () => { + // Failing ES snapshot promotion after a ml-cpp change + // See https://github.com/elastic/kibana/issues/85363 + it.skip('should fetch anomalies table data', async () => { const requestBody = { jobIds: [JOB_CONFIG.job_id], criteriaFields: [{ fieldName: 'detector_index', fieldValue: 0 }], From 73fbf2a703e2ac1754bad920ea2fc0059d4400eb Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 9 Dec 2020 11:05:59 +0100 Subject: [PATCH 02/18] [GS] add tag and dashboard suggestion results (#85144) * initial draft * polish * fix mocks * add tests * tests on suggestions * add comment * add FTR tests * factorize getSearchableTypes * move to bottom --- .../public/api.mock.ts | 28 +- .../saved_objects_tagging_oss/public/api.ts | 29 +- .../saved_objects_tagging_oss/public/index.ts | 1 + x-pack/plugins/global_search/public/mocks.ts | 1 + x-pack/plugins/global_search/public/plugin.ts | 3 +- .../fetch_server_searchable_types.test.ts | 36 ++ .../services/fetch_server_searchable_types.ts | 18 + .../public/services/search_service.mock.ts | 12 +- .../services/search_service.test.mocks.ts | 5 + .../public/services/search_service.test.ts | 159 ++++++-- .../public/services/search_service.ts | 24 ++ x-pack/plugins/global_search/public/types.ts | 8 +- x-pack/plugins/global_search/server/mocks.ts | 2 + x-pack/plugins/global_search/server/plugin.ts | 2 + .../server/routes/get_searchable_types.ts | 24 ++ .../global_search/server/routes/index.test.ts | 10 +- .../global_search/server/routes/index.ts | 2 + .../get_searchable_types.test.ts | 78 ++++ .../server/services/search_service.mock.ts | 12 +- .../server/services/search_service.test.ts | 127 ++++-- .../server/services/search_service.ts | 17 + x-pack/plugins/global_search/server/types.ts | 12 +- .../public/components/search_bar.test.tsx | 8 +- .../public/components/search_bar.tsx | 82 +++- .../global_search_bar/public/plugin.tsx | 2 +- .../suggestions/get_suggestions.test.ts | 170 +++++++++ .../public/suggestions/get_suggestions.ts | 83 ++++ .../public/suggestions/index.ts | 7 + .../public/providers/application.test.ts | 361 ++++++++++-------- .../public/providers/application.ts | 5 +- .../providers/saved_objects/provider.test.ts | 204 +++++----- .../providers/saved_objects/provider.ts | 19 +- x-pack/plugins/lens/public/search_provider.ts | 1 + .../saved_objects_tagging/public/plugin.ts | 1 + .../public/services/tags/tags_cache.ts | 6 +- .../page_objects/navigational_search.ts | 5 + .../global_search/global_search_bar.ts | 41 ++ 37 files changed, 1226 insertions(+), 379 deletions(-) create mode 100644 x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts create mode 100644 x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts create mode 100644 x-pack/plugins/global_search/server/routes/get_searchable_types.ts create mode 100644 x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts create mode 100644 x-pack/plugins/global_search_bar/public/suggestions/index.ts diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index 87a3fd8f5b499..1e66a9baa812e 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -18,10 +18,10 @@ */ import { ITagsClient } from '../common'; -import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent } from './api'; +import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent, ITagsCache } from './api'; -const createClientMock = (): jest.Mocked => { - const mock = { +const createClientMock = () => { + const mock: jest.Mocked = { create: jest.fn(), get: jest.fn(), getAll: jest.fn(), @@ -32,14 +32,25 @@ const createClientMock = (): jest.Mocked => { return mock; }; +const createCacheMock = () => { + const mock: jest.Mocked = { + getState: jest.fn(), + getState$: jest.fn(), + }; + + return mock; +}; + interface SavedObjectsTaggingApiMock { client: jest.Mocked; + cache: jest.Mocked; ui: SavedObjectsTaggingApiUiMock; } const createApiMock = (): SavedObjectsTaggingApiMock => { - const mock = { + const mock: SavedObjectsTaggingApiMock = { client: createClientMock(), + cache: createCacheMock(), ui: createApiUiMock(), }; @@ -50,8 +61,8 @@ type SavedObjectsTaggingApiUiMock = Omit, components: SavedObjectsTaggingApiUiComponentMock; }; -const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { - const mock = { +const createApiUiMock = () => { + const mock: SavedObjectsTaggingApiUiMock = { components: createApiUiComponentsMock(), // TS is very picky with type guards hasTagDecoration: jest.fn() as any, @@ -69,8 +80,8 @@ const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { type SavedObjectsTaggingApiUiComponentMock = jest.Mocked; -const createApiUiComponentsMock = (): SavedObjectsTaggingApiUiComponentMock => { - const mock = { +const createApiUiComponentsMock = () => { + const mock: SavedObjectsTaggingApiUiComponentMock = { TagList: jest.fn(), TagSelector: jest.fn(), SavedObjectSaveModalTagSelector: jest.fn(), @@ -82,6 +93,7 @@ const createApiUiComponentsMock = (): SavedObjectsTaggingApiUiComponentMock => { export const taggingApiMock = { create: createApiMock, createClient: createClientMock, + createCache: createCacheMock, createUi: createApiUiMock, createComponents: createApiUiComponentsMock, }; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 81f7cc9326a77..987930af1e3e4 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -17,22 +17,49 @@ * under the License. */ +import { Observable } from 'rxjs'; import { SearchFilterConfig, EuiTableFieldDataColumnType } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import { SavedObject, SavedObjectReference } from '../../../core/types'; import { SavedObjectsFindOptionsReference } from '../../../core/public'; import { SavedObject as SavedObjectClass } from '../../saved_objects/public'; import { TagDecoratedSavedObject } from './decorator'; -import { ITagsClient } from '../common'; +import { ITagsClient, Tag } from '../common'; /** * @public */ export interface SavedObjectsTaggingApi { + /** + * The client to perform tag-related operations on the server-side + */ client: ITagsClient; + /** + * A client-side auto-refreshing cache of the existing tags. Can be used + * to synchronously access the list of tags. + */ + cache: ITagsCache; + /** + * UI API to use to add tagging capabilities to an application + */ ui: SavedObjectsTaggingApiUi; } +/** + * @public + */ +export interface ITagsCache { + /** + * Return the current state of the cache + */ + getState(): Tag[]; + + /** + * Return an observable that will emit everytime the cache's state mutates. + */ + getState$(): Observable; +} + /** * @public */ diff --git a/src/plugins/saved_objects_tagging_oss/public/index.ts b/src/plugins/saved_objects_tagging_oss/public/index.ts index bc824621830d2..ef3087f944add 100644 --- a/src/plugins/saved_objects_tagging_oss/public/index.ts +++ b/src/plugins/saved_objects_tagging_oss/public/index.ts @@ -26,6 +26,7 @@ export { SavedObjectsTaggingApi, SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent, + ITagsCache, TagListComponentProps, TagSelectorComponentProps, GetSearchBarFilterOptions, diff --git a/x-pack/plugins/global_search/public/mocks.ts b/x-pack/plugins/global_search/public/mocks.ts index 97dc01e92dbfe..8b0bfec66f61d 100644 --- a/x-pack/plugins/global_search/public/mocks.ts +++ b/x-pack/plugins/global_search/public/mocks.ts @@ -20,6 +20,7 @@ const createStartMock = (): jest.Mocked => { return { find: searchMock.find, + getSearchableTypes: searchMock.getSearchableTypes, }; }; diff --git a/x-pack/plugins/global_search/public/plugin.ts b/x-pack/plugins/global_search/public/plugin.ts index 6af8ec32a581d..a861911d935b4 100644 --- a/x-pack/plugins/global_search/public/plugin.ts +++ b/x-pack/plugins/global_search/public/plugin.ts @@ -45,13 +45,14 @@ export class GlobalSearchPlugin start({ http }: CoreStart, { licensing }: GlobalSearchPluginStartDeps) { this.licenseChecker = new LicenseChecker(licensing.license$); - const { find } = this.searchService.start({ + const { find, getSearchableTypes } = this.searchService.start({ http, licenseChecker: this.licenseChecker, }); return { find, + getSearchableTypes, }; } diff --git a/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts new file mode 100644 index 0000000000000..002ea0cff20d8 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { fetchServerSearchableTypes } from './fetch_server_searchable_types'; + +describe('fetchServerSearchableTypes', () => { + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + }); + + it('perform a GET request to the endpoint with valid options', () => { + http.get.mockResolvedValue({ results: [] }); + + fetchServerSearchableTypes(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith('/internal/global_search/searchable_types'); + }); + + it('returns the results from the server', async () => { + const types = ['typeA', 'typeB']; + + http.get.mockResolvedValue({ types }); + + const results = await fetchServerSearchableTypes(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(results).toEqual(types); + }); +}); diff --git a/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts new file mode 100644 index 0000000000000..c4a0724991870 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts @@ -0,0 +1,18 @@ +/* + * 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 { HttpStart } from 'src/core/public'; + +interface ServerSearchableTypesResponse { + types: string[]; +} + +export const fetchServerSearchableTypes = async (http: HttpStart) => { + const { types } = await http.get( + '/internal/global_search/searchable_types' + ); + return types; +}; diff --git a/x-pack/plugins/global_search/public/services/search_service.mock.ts b/x-pack/plugins/global_search/public/services/search_service.mock.ts index eca69148288b9..0aa65e39f026c 100644 --- a/x-pack/plugins/global_search/public/services/search_service.mock.ts +++ b/x-pack/plugins/global_search/public/services/search_service.mock.ts @@ -7,17 +7,21 @@ import { SearchServiceSetup, SearchServiceStart } from './search_service'; import { of } from 'rxjs'; -const createSetupMock = (): jest.Mocked => { - return { +const createSetupMock = () => { + const mock: jest.Mocked = { registerResultProvider: jest.fn(), }; + + return mock; }; -const createStartMock = (): jest.Mocked => { - const mock = { +const createStartMock = () => { + const mock: jest.Mocked = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; mock.find.mockReturnValue(of({ results: [] })); + mock.getSearchableTypes.mockResolvedValue([]); return mock; }; diff --git a/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts index 1caabd6a1681c..bbc513c78759e 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts @@ -9,6 +9,11 @@ jest.doMock('./fetch_server_results', () => ({ fetchServerResults: fetchServerResultsMock, })); +export const fetchServerSearchableTypesMock = jest.fn(); +jest.doMock('./fetch_server_searchable_types', () => ({ + fetchServerSearchableTypes: fetchServerSearchableTypesMock, +})); + export const getDefaultPreferenceMock = jest.fn(); jest.doMock('./utils', () => { const original = jest.requireActual('./utils'); diff --git a/x-pack/plugins/global_search/public/services/search_service.test.ts b/x-pack/plugins/global_search/public/services/search_service.test.ts index 419ad847d6c29..297a27e3c837c 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchServerResultsMock, getDefaultPreferenceMock } from './search_service.test.mocks'; +import { + fetchServerResultsMock, + getDefaultPreferenceMock, + fetchServerSearchableTypesMock, +} from './search_service.test.mocks'; import { Observable, of } from 'rxjs'; import { take } from 'rxjs/operators'; @@ -41,10 +45,17 @@ describe('SearchService', () => { const createProvider = ( id: string, - source: Observable = of([]) + { + source = of([]), + types = [], + }: { + source?: Observable; + types?: string[] | Promise; + } = {} ): jest.Mocked => ({ id, find: jest.fn().mockImplementation((term, options, context) => source), + getSearchableTypes: jest.fn().mockReturnValue(types), }); const expectedResult = (id: string) => expect.objectContaining({ id }); @@ -85,6 +96,9 @@ describe('SearchService', () => { fetchServerResultsMock.mockClear(); fetchServerResultsMock.mockReturnValue(of()); + fetchServerSearchableTypesMock.mockClear(); + fetchServerSearchableTypesMock.mockResolvedValue([]); + getDefaultPreferenceMock.mockClear(); getDefaultPreferenceMock.mockReturnValue('default_pref'); }); @@ -189,7 +203,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -229,22 +243,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [providerResult('A1'), providerResult('A2')], d: [providerResult('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [providerResult('B1')], c: [providerResult('B2'), providerResult('B3')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -272,13 +284,12 @@ describe('SearchService', () => { ); registerResultProvider( - createProvider( - 'A', - hot('a-b-|', { + createProvider('A', { + source: hot('a-b-|', { a: [providerResult('P1')], b: [providerResult('P2')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -302,7 +313,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const aborted$ = hot('----a--|', { a: undefined }); @@ -326,7 +337,7 @@ describe('SearchService', () => { b: [providerResult('2')], c: [providerResult('3')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -346,22 +357,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [providerResult('A1'), providerResult('A2')], d: [providerResult('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [providerResult('B1')], c: [providerResult('B2'), providerResult('B3')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -394,7 +403,7 @@ describe('SearchService', () => { url: { path: '/foo', prependBasePath: false }, }); - const provider = createProvider('A', of([resultA, resultB])); + const provider = createProvider('A', { source: of([resultA, resultB]) }); registerResultProvider(provider); const { find } = service.start(startDeps()); @@ -423,7 +432,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -438,5 +447,91 @@ describe('SearchService', () => { }); }); }); + + describe('#getSearchableTypes()', () => { + it('returns the types registered by the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('returns the types registered by the server', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'server-b']); + + service.setup({ + config: createConfig(), + }); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types).toEqual(['server-a', 'server-b']); + }); + + it('merges the types registered by the providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-c', 'type-d'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['type-a', 'type-b', 'type-c', 'type-d']); + }); + + it('merges the types registered by the providers and the server', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'server-b']); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['server-a', 'server-b', 'type-a', 'type-b']); + }); + + it('removes duplicates', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'dupe-1']); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'dupe-1', 'dupe-2'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-b', 'dupe-2'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['dupe-1', 'dupe-2', 'server-a', 'type-a', 'type-b']); + }); + }); }); }); diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 64bd2fd6c930f..015143d34886f 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -6,6 +6,7 @@ import { merge, Observable, timer, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { uniq } from 'lodash'; import { duration } from 'moment'; import { i18n } from '@kbn/i18n'; import { HttpStart } from 'src/core/public'; @@ -24,6 +25,7 @@ import { GlobalSearchClientConfigType } from '../config'; import { GlobalSearchFindOptions } from './types'; import { getDefaultPreference } from './utils'; import { fetchServerResults } from './fetch_server_results'; +import { fetchServerSearchableTypes } from './fetch_server_searchable_types'; /** @public */ export interface SearchServiceSetup { @@ -75,6 +77,11 @@ export interface SearchServiceStart { params: GlobalSearchFindParams, options: GlobalSearchFindOptions ): Observable; + + /** + * Returns all the searchable types registered by the underlying result providers. + */ + getSearchableTypes(): Promise; } interface SetupDeps { @@ -96,6 +103,7 @@ export class SearchService { private http?: HttpStart; private maxProviderResults = defaultMaxProviderResults; private licenseChecker?: ILicenseChecker; + private serverTypes?: string[]; setup({ config, maxProviderResults = defaultMaxProviderResults }: SetupDeps): SearchServiceSetup { this.config = config; @@ -118,9 +126,25 @@ export class SearchService { return { find: (params, options) => this.performFind(params, options), + getSearchableTypes: () => this.getSearchableTypes(), }; } + private async getSearchableTypes() { + const providerTypes = ( + await Promise.all( + [...this.providers.values()].map((provider) => provider.getSearchableTypes()) + ) + ).flat(); + + // only need to fetch from server once + if (!this.serverTypes) { + this.serverTypes = await fetchServerSearchableTypes(this.http!); + } + + return uniq([...providerTypes, ...this.serverTypes]); + } + private performFind(params: GlobalSearchFindParams, options: GlobalSearchFindOptions) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { diff --git a/x-pack/plugins/global_search/public/types.ts b/x-pack/plugins/global_search/public/types.ts index 2707a2fded222..7235347d4aa38 100644 --- a/x-pack/plugins/global_search/public/types.ts +++ b/x-pack/plugins/global_search/public/types.ts @@ -13,7 +13,7 @@ import { import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; -export type GlobalSearchPluginStart = Pick; +export type GlobalSearchPluginStart = Pick; /** * GlobalSearch result provider, to be registered using the {@link GlobalSearchPluginSetup | global search API} @@ -44,4 +44,10 @@ export interface GlobalSearchResultProvider { search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions ): Observable; + + /** + * Method that should return all the possible {@link GlobalSearchProviderResult.type | type} of results that + * this provider can return. + */ + getSearchableTypes: () => string[] | Promise; } diff --git a/x-pack/plugins/global_search/server/mocks.ts b/x-pack/plugins/global_search/server/mocks.ts index e7c133edf95c8..88be7f6e861a1 100644 --- a/x-pack/plugins/global_search/server/mocks.ts +++ b/x-pack/plugins/global_search/server/mocks.ts @@ -26,12 +26,14 @@ const createStartMock = (): jest.Mocked => { return { find: searchMock.find, + getSearchableTypes: searchMock.getSearchableTypes, }; }; const createRouteHandlerContextMock = (): jest.Mocked => { const handlerContextMock = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; handlerContextMock.find.mockReturnValue(of([])); diff --git a/x-pack/plugins/global_search/server/plugin.ts b/x-pack/plugins/global_search/server/plugin.ts index 87e7f96b34c0c..9d6844dde50f0 100644 --- a/x-pack/plugins/global_search/server/plugin.ts +++ b/x-pack/plugins/global_search/server/plugin.ts @@ -59,6 +59,7 @@ export class GlobalSearchPlugin core.http.registerRouteHandlerContext('globalSearch', (_, req) => { return { find: (term, options) => this.searchServiceStart!.find(term, options, req), + getSearchableTypes: () => this.searchServiceStart!.getSearchableTypes(req), }; }); @@ -75,6 +76,7 @@ export class GlobalSearchPlugin }); return { find: this.searchServiceStart.find, + getSearchableTypes: this.searchServiceStart.getSearchableTypes, }; } diff --git a/x-pack/plugins/global_search/server/routes/get_searchable_types.ts b/x-pack/plugins/global_search/server/routes/get_searchable_types.ts new file mode 100644 index 0000000000000..f9cc69e4a28ae --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/get_searchable_types.ts @@ -0,0 +1,24 @@ +/* + * 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 { IRouter } from 'src/core/server'; + +export const registerInternalSearchableTypesRoute = (router: IRouter) => { + router.get( + { + path: '/internal/global_search/searchable_types', + validate: false, + }, + async (ctx, req, res) => { + const types = await ctx.globalSearch!.getSearchableTypes(); + return res.ok({ + body: { + types, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/global_search/server/routes/index.test.ts b/x-pack/plugins/global_search/server/routes/index.test.ts index 64675bc13cb1c..1111f01d13055 100644 --- a/x-pack/plugins/global_search/server/routes/index.test.ts +++ b/x-pack/plugins/global_search/server/routes/index.test.ts @@ -14,7 +14,6 @@ describe('registerRoutes', () => { registerRoutes(router); expect(router.post).toHaveBeenCalledTimes(1); - expect(router.post).toHaveBeenCalledWith( expect.objectContaining({ path: '/internal/global_search/find', @@ -22,7 +21,14 @@ describe('registerRoutes', () => { expect.any(Function) ); - expect(router.get).toHaveBeenCalledTimes(0); + expect(router.get).toHaveBeenCalledTimes(1); + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/internal/global_search/searchable_types', + }), + expect.any(Function) + ); + expect(router.delete).toHaveBeenCalledTimes(0); expect(router.put).toHaveBeenCalledTimes(0); }); diff --git a/x-pack/plugins/global_search/server/routes/index.ts b/x-pack/plugins/global_search/server/routes/index.ts index 7840b95614993..0eeb443b72b53 100644 --- a/x-pack/plugins/global_search/server/routes/index.ts +++ b/x-pack/plugins/global_search/server/routes/index.ts @@ -6,7 +6,9 @@ import { IRouter } from 'src/core/server'; import { registerInternalFindRoute } from './find'; +import { registerInternalSearchableTypesRoute } from './get_searchable_types'; export const registerRoutes = (router: IRouter) => { registerInternalFindRoute(router); + registerInternalSearchableTypesRoute(router); }; diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts new file mode 100644 index 0000000000000..b3b6862599d6d --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts @@ -0,0 +1,78 @@ +/* + * 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 supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { setupServer } from '../../../../../../src/core/server/test_utils'; +import { globalSearchPluginMock } from '../../mocks'; +import { registerInternalSearchableTypesRoute } from '../get_searchable_types'; + +type SetupServerReturn = UnwrapPromise>; +const pluginId = Symbol('globalSearch'); + +describe('GET /internal/global_search/searchable_types', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let globalSearchHandlerContext: ReturnType< + typeof globalSearchPluginMock.createRouteHandlerContext + >; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(pluginId)); + + globalSearchHandlerContext = globalSearchPluginMock.createRouteHandlerContext(); + httpSetup.registerRouteHandlerContext( + pluginId, + 'globalSearch', + () => globalSearchHandlerContext + ); + + const router = httpSetup.createRouter('/'); + + registerInternalSearchableTypesRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('calls the handler context with correct parameters', async () => { + await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(globalSearchHandlerContext.getSearchableTypes).toHaveBeenCalledTimes(1); + }); + + it('returns the types returned from the service', async () => { + globalSearchHandlerContext.getSearchableTypes.mockResolvedValue(['type-a', 'type-b']); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(response.body).toEqual({ + types: ['type-a', 'type-b'], + }); + }); + + it('returns the default error when the observable throws any other error', async () => { + globalSearchHandlerContext.getSearchableTypes.mockRejectedValue(new Error()); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(response.body).toEqual( + expect.objectContaining({ + message: 'An internal server error occurred.', + statusCode: 500, + }) + ); + }); +}); diff --git a/x-pack/plugins/global_search/server/services/search_service.mock.ts b/x-pack/plugins/global_search/server/services/search_service.mock.ts index eca69148288b9..0aa65e39f026c 100644 --- a/x-pack/plugins/global_search/server/services/search_service.mock.ts +++ b/x-pack/plugins/global_search/server/services/search_service.mock.ts @@ -7,17 +7,21 @@ import { SearchServiceSetup, SearchServiceStart } from './search_service'; import { of } from 'rxjs'; -const createSetupMock = (): jest.Mocked => { - return { +const createSetupMock = () => { + const mock: jest.Mocked = { registerResultProvider: jest.fn(), }; + + return mock; }; -const createStartMock = (): jest.Mocked => { - const mock = { +const createStartMock = () => { + const mock: jest.Mocked = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; mock.find.mockReturnValue(of({ results: [] })); + mock.getSearchableTypes.mockResolvedValue([]); return mock; }; diff --git a/x-pack/plugins/global_search/server/services/search_service.test.ts b/x-pack/plugins/global_search/server/services/search_service.test.ts index c8d656a524e94..b3e4981b35392 100644 --- a/x-pack/plugins/global_search/server/services/search_service.test.ts +++ b/x-pack/plugins/global_search/server/services/search_service.test.ts @@ -36,10 +36,17 @@ describe('SearchService', () => { const createProvider = ( id: string, - source: Observable = of([]) + { + source = of([]), + types = [], + }: { + source?: Observable; + types?: string[] | Promise; + } = {} ): jest.Mocked => ({ id, find: jest.fn().mockImplementation((term, options, context) => source), + getSearchableTypes: jest.fn().mockReturnValue(types), }); const expectedResult = (id: string) => expect.objectContaining({ id }); @@ -122,7 +129,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -142,22 +149,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [result('A1'), result('A2')], d: [result('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [result('B1')], c: [result('B2'), result('B3')], - }) - ) + }), + }) ); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -183,7 +188,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const aborted$ = hot('----a--|', { a: undefined }); @@ -208,7 +213,7 @@ describe('SearchService', () => { b: [result('2')], c: [result('3')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -229,22 +234,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [result('A1'), result('A2')], d: [result('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [result('B1')], c: [result('B2'), result('B3')], - }) - ) + }), + }) ); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -278,7 +281,7 @@ describe('SearchService', () => { url: { path: '/foo', prependBasePath: false }, }); - const provider = createProvider('A', of([resultA, resultB])); + const provider = createProvider('A', { source: of([resultA, resultB]) }); registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -308,7 +311,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -323,5 +326,77 @@ describe('SearchService', () => { }); }); }); + + describe('#getSearchableTypes()', () => { + it('returns the types registered by the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('supports promises', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A', { types: Promise.resolve(['type-a', 'type-b']) }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('merges the types registered by the providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-c', 'type-d'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types.sort()).toEqual(['type-a', 'type-b', 'type-c', 'type-d']); + }); + + it('removes duplicates', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider1 = createProvider('A', { types: ['type-a', 'dupe'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-b', 'dupe'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types.sort()).toEqual(['dupe', 'type-a', 'type-b']); + }); + }); }); }); diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index 9ea62abac704c..88250820861a6 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -6,6 +6,7 @@ import { Observable, timer, merge, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { uniq } from 'lodash'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; import { @@ -71,6 +72,11 @@ export interface SearchServiceStart { options: GlobalSearchFindOptions, request: KibanaRequest ): Observable; + + /** + * Returns all the searchable types registered by the underlying result providers. + */ + getSearchableTypes(request: KibanaRequest): Promise; } interface SetupDeps { @@ -119,9 +125,20 @@ export class SearchService { this.contextFactory = getContextFactory(core); return { find: (params, options, request) => this.performFind(params, options, request), + getSearchableTypes: (request) => this.getSearchableTypes(request), }; } + private async getSearchableTypes(request: KibanaRequest) { + const context = this.contextFactory!(request); + const allTypes = ( + await Promise.all( + [...this.providers.values()].map((provider) => provider.getSearchableTypes(context)) + ) + ).flat(); + return uniq(allTypes); + } + private performFind( params: GlobalSearchFindParams, options: GlobalSearchFindOptions, diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts index 0878a965ea8c3..48c40fdb66e13 100644 --- a/x-pack/plugins/global_search/server/types.ts +++ b/x-pack/plugins/global_search/server/types.ts @@ -22,7 +22,7 @@ import { import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; -export type GlobalSearchPluginStart = Pick; +export type GlobalSearchPluginStart = Pick; /** * globalSearch route handler context. @@ -37,6 +37,10 @@ export interface RouteHandlerGlobalSearchContext { params: GlobalSearchFindParams, options: GlobalSearchFindOptions ): Observable; + /** + * See {@link SearchServiceStart.getSearchableTypes | the getSearchableTypes API} + */ + getSearchableTypes: () => Promise; } /** @@ -114,4 +118,10 @@ export interface GlobalSearchResultProvider { options: GlobalSearchProviderFindOptions, context: GlobalSearchProviderContext ): Observable; + + /** + * Method that should return all the possible {@link GlobalSearchProviderResult.type | type} of results that + * this provider can return. + */ + getSearchableTypes: (context: GlobalSearchProviderContext) => string[] | Promise; } diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 5ba00c293d213..1ed011d3cc3b1 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -11,8 +11,8 @@ import { of, BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { mountWithIntl } from '@kbn/test/jest'; import { applicationServiceMock } from '../../../../../src/core/public/mocks'; -import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { globalSearchPluginMock } from '../../../global_search/public/mocks'; +import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { SearchBar } from './search_bar'; type Result = { id: string; type: string } | string; @@ -86,7 +86,7 @@ describe('SearchBar', () => { component = mountWithIntl( { it('supports keyboard shortcuts', () => { mountWithIntl( { component = mountWithIntl( void; taggingApi?: SavedObjectTaggingPluginStart; @@ -43,16 +45,19 @@ interface Props { darkMode: boolean; } -const clearField = (field: HTMLInputElement) => { +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + +const setFieldValue = (field: HTMLInputElement, value: string) => { const nativeInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); const nativeInputValueSetter = nativeInputValue ? nativeInputValue.set : undefined; if (nativeInputValueSetter) { - nativeInputValueSetter.call(field, ''); + nativeInputValueSetter.call(field, value); } - field.dispatchEvent(new Event('change')); }; +const clearField = (field: HTMLInputElement) => setFieldValue(field, ''); + const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' '); const blurEvent = new FocusEvent('blur'); @@ -92,6 +97,19 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi return option; }; +const suggestionToOption = (suggestion: SearchSuggestion): EuiSelectableTemplateSitewideOption => { + const { key, label, description, icon, suggestedSearch } = suggestion; + return { + key, + label, + type: '__suggestion__', + icon: { type: icon }, + suggestion: suggestedSearch, + meta: [{ text: description }], + 'data-test-subj': `nav-search-option`, + }; +}; + export function SearchBar({ globalSearch, taggingApi, @@ -105,16 +123,34 @@ export function SearchBar({ const [searchRef, setSearchRef] = useState(null); const [buttonRef, setButtonRef] = useState(null); const searchSubscription = useRef(null); - const [options, _setOptions] = useState([] as EuiSelectableTemplateSitewideOption[]); - const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + const [options, _setOptions] = useState([]); + const [searchableTypes, setSearchableTypes] = useState([]); + + useEffect(() => { + const fetch = async () => { + const types = await globalSearch.getSearchableTypes(); + setSearchableTypes(types); + }; + fetch(); + }, [globalSearch]); + + const loadSuggestions = useCallback( + (searchTerm: string) => { + return getSuggestions({ + searchTerm, + searchableTypes, + tagCache: taggingApi?.cache, + }); + }, + [taggingApi, searchableTypes] + ); const setOptions = useCallback( - (_options: GlobalSearchResult[]) => { + (_options: GlobalSearchResult[], suggestions: SearchSuggestion[]) => { if (!isMounted()) { return; } - - _setOptions(_options.map(resultToOption)); + _setOptions([...suggestions.map(suggestionToOption), ..._options.map(resultToOption)]); }, [isMounted, _setOptions] ); @@ -127,7 +163,9 @@ export function SearchBar({ searchSubscription.current = null; } - let arr: GlobalSearchResult[] = []; + const suggestions = loadSuggestions(searchValue); + + let aggregatedResults: GlobalSearchResult[] = []; if (searchValue.length !== 0) { trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); } @@ -145,20 +183,20 @@ export function SearchBar({ tags: tagIds, }; - searchSubscription.current = globalSearch(searchParams, {}).subscribe({ + searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { - arr = [...results, ...arr].sort(sortByScore); - setOptions(arr); + aggregatedResults = [...results, ...aggregatedResults].sort(sortByScore); + setOptions(aggregatedResults, suggestions); return; } // if searchbar is empty, filter to only applications and sort alphabetically results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); - arr = [...results, ...arr].sort(sortByTitle); + aggregatedResults = [...results, ...aggregatedResults].sort(sortByTitle); - setOptions(arr); + setOptions(aggregatedResults, suggestions); }, error: () => { // Not doing anything on error right now because it'll either just show the previous @@ -169,7 +207,7 @@ export function SearchBar({ }); }, 350, - [searchValue] + [searchValue, loadSuggestions] ); const onKeyDown = (event: KeyboardEvent) => { @@ -191,7 +229,15 @@ export function SearchBar({ } // @ts-ignore - ts error is "union type is too complex to express" - const { url, type } = selected; + const { url, type, suggestion } = selected; + + // if the type is a suggestion, we change the query on the input and trigger a new search + // by setting the searchValue (only setting the field value does not trigger a search) + if (type === '__suggestion__') { + setFieldValue(searchRef!, suggestion); + setSearchValue(suggestion); + return; + } // errors in tracking should not prevent selection behavior try { diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx index 0d17bf4612737..80111e7746a75 100644 --- a/x-pack/plugins/global_search_bar/public/plugin.tsx +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -70,7 +70,7 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { ReactDOM.render( = {}): Tag => ({ + id: 'tag-id', + name: 'some-tag', + description: 'Some tag', + color: '#FF00CC', + ...parts, +}); + +describe('getSuggestions', () => { + let tagCache: ReturnType; + const searchableTypes = ['application', 'dashboard', 'maps']; + + beforeEach(() => { + tagCache = taggingApiMock.createCache(); + + tagCache.getState.mockReturnValue([ + createTag({ + id: 'basic', + name: 'normal', + }), + createTag({ + id: 'caps', + name: 'BAR', + }), + createTag({ + id: 'whitespace', + name: 'white space', + }), + ]); + }); + + describe('tag suggestion', () => { + it('returns a suggestion when matching the name of a tag', () => { + const suggestions = getSuggestions({ + searchTerm: 'normal', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: normal', + suggestedSearch: 'tag:normal', + }) + ); + }); + it('ignores leading or trailing spaces a suggestion when matching the name of a tag', () => { + const suggestions = getSuggestions({ + searchTerm: ' normal ', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: normal', + suggestedSearch: 'tag:normal', + }) + ); + }); + it('does not return suggestions when partially matching', () => { + const suggestions = getSuggestions({ + searchTerm: 'norm', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(0); + }); + it('ignores the case when matching the tag', () => { + const suggestions = getSuggestions({ + searchTerm: 'baR', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: BAR', + suggestedSearch: 'tag:BAR', + }) + ); + }); + it('escapes the name in the query when containing whitespaces', () => { + const suggestions = getSuggestions({ + searchTerm: 'white space', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: white space', + suggestedSearch: 'tag:"white space"', + }) + ); + }); + }); + + describe('type suggestion', () => { + it('returns a suggestion when matching a searchable type', () => { + const suggestions = getSuggestions({ + searchTerm: 'application', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: application', + suggestedSearch: 'type:application', + }) + ); + }); + it('ignores leading or trailing spaces in the search term', () => { + const suggestions = getSuggestions({ + searchTerm: ' application ', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: application', + suggestedSearch: 'type:application', + }) + ); + }); + it('does not return suggestions when partially matching', () => { + const suggestions = getSuggestions({ + searchTerm: 'appl', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(0); + }); + it('ignores the case when matching the type', () => { + const suggestions = getSuggestions({ + searchTerm: 'DASHboard', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: dashboard', + suggestedSearch: 'type:dashboard', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts b/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts new file mode 100644 index 0000000000000..c097e365045af --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts @@ -0,0 +1,83 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ITagsCache } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; + +interface GetSuggestionOptions { + searchTerm: string; + searchableTypes: string[]; + tagCache?: ITagsCache; +} + +export interface SearchSuggestion { + key: string; + label: string; + description: string; + icon: string; + suggestedSearch: string; +} + +export const getSuggestions = ({ + searchTerm, + searchableTypes, + tagCache, +}: GetSuggestionOptions): SearchSuggestion[] => { + const results: SearchSuggestion[] = []; + const suggestionTerm = searchTerm.trim(); + + const matchingType = findIgnoreCase(searchableTypes, suggestionTerm); + if (matchingType) { + const suggestedSearch = escapeIfWhiteSpaces(matchingType); + results.push({ + key: '__type__suggestion__', + label: `type: ${matchingType}`, + icon: 'filter', + description: i18n.translate('xpack.globalSearchBar.suggestions.filterByTypeLabel', { + defaultMessage: 'Filter by type', + }), + suggestedSearch: `type:${suggestedSearch}`, + }); + } + + if (tagCache && searchTerm) { + const matchingTag = tagCache + .getState() + .find((tag) => equalsIgnoreCase(tag.name, suggestionTerm)); + if (matchingTag) { + const suggestedSearch = escapeIfWhiteSpaces(matchingTag.name); + results.push({ + key: '__tag__suggestion__', + label: `tag: ${matchingTag.name}`, + icon: 'tag', + description: i18n.translate('xpack.globalSearchBar.suggestions.filterByTagLabel', { + defaultMessage: 'Filter by tag name', + }), + suggestedSearch: `tag:${suggestedSearch}`, + }); + } + } + + return results; +}; + +const findIgnoreCase = (array: string[], target: string) => { + for (const item of array) { + if (equalsIgnoreCase(item, target)) { + return item; + } + } + return undefined; +}; + +const equalsIgnoreCase = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +const escapeIfWhiteSpaces = (term: string) => { + if (/\s/g.test(term)) { + return `"${term}"`; + } + return term; +}; diff --git a/x-pack/plugins/global_search_bar/public/suggestions/index.ts b/x-pack/plugins/global_search_bar/public/suggestions/index.ts new file mode 100644 index 0000000000000..aa1402a93692b --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/suggestions/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 { getSuggestions, SearchSuggestion } from './get_suggestions'; 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 7beed42de4c4f..dadcf626ace4a 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 @@ -71,205 +71,228 @@ describe('applicationResultProvider', () => { 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: 'term' }, defaultOption).toPromise(); - - expect(getAppResultsMock).toHaveBeenCalledTimes(1); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [ - expectApp('app1'), - expectApp('app2'), - expectApp('app3'), - ]); - }); - - it('calls `getAppResults` when filtering by type with `application` included', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - - await provider - .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) - .toPromise(); + describe('#find', () => { + 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: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + expectApp('app3'), + ]); + }); - expect(getAppResultsMock).toHaveBeenCalledTimes(1); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1'), expectApp('app2')]); - }); + it('calls `getAppResults` when filtering by type with `application` included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider + .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + ]); + }); - it('does not call `getAppResults` and return no results when filtering by type with `application` not included', 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)); + it('does not call `getAppResults` and return no results when filtering by type with `application` not included', 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)); + + const results = await provider + .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); - const results = await provider - .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) - .toPromise(); + it('does not call `getAppResults` and returns no results when filtering by tag', 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)); + + const results = await provider + .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); - expect(getAppResultsMock).not.toHaveBeenCalled(); - expect(results).toEqual([]); - }); + 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: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - it('does not call `getAppResults` and returns no results when filtering by tag', 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)); + it('ignores apps with non-visible navlink', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }), + createApp({ + id: 'disabled', + title: 'disabled', + navLinkStatus: AppNavLinkStatus.disabled, + }), + createApp({ id: 'hidden', title: 'hidden', navLinkStatus: AppNavLinkStatus.hidden }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - const results = await provider - .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) - .toPromise(); + it('ignores chromeless apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), + ]) + ); - expect(getAppResultsMock).not.toHaveBeenCalled(); - expect(results).toEqual([]); - }); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); - 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: 'term' }, defaultOption).toPromise(); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - 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: 'term' }, defaultOption).toPromise(); + + expect(results).toEqual([ + expectResult('r100'), + expectResult('r75'), + expectResult('r60'), + expectResult('r50'), + ]); + }); - it('ignores apps with non-visible navlink', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }), - createApp({ id: 'disabled', title: 'disabled', navLinkStatus: AppNavLinkStatus.disabled }), - createApp({ id: 'hidden', title: 'hidden', navLinkStatus: AppNavLinkStatus.hidden }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + 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 }), + ]); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + const provider = createApplicationResultProvider(Promise.resolve(application)); - it('ignores chromeless apps', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), - ]) - ); + const options = { + ...defaultOption, + maxResults: 2, + }; + const results = await provider.find({ term: 'term' }, options).toPromise(); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + expect(results).toEqual([expectResult('r100'), expectResult('r75')]); + }); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + it('only emits once, even if `application$` emits multiple times', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - 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 }), - ]); + application.applications$ = hot('--a---b', { a: appMap, b: appMap }); - const provider = createApplicationResultProvider(Promise.resolve(application)); - const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); - - expect(results).toEqual([ - expectResult('r100'), - expectResult('r75'), - expectResult('r60'), - expectResult('r50'), - ]); - }); + // 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; - 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(applicationPromise); - const provider = createApplicationResultProvider(Promise.resolve(application)); + const options = { + ...defaultOption, + aborted$: hot('|'), + }; - const options = { - ...defaultOption, - maxResults: 2, - }; - const results = await provider.find({ term: 'term' }, options).toPromise(); + const resultObs = provider.find({ term: 'term' }, options); - expect(results).toEqual([expectResult('r100'), expectResult('r75')]); - }); + expectObservable(resultObs).toBe('--(a|)', { a: [] }); + }); + }); - it('only emits once, even if `application$` emits multiple times', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - application.applications$ = hot('--a---b', { a: appMap, b: appMap }); + 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; + // 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; - const provider = createApplicationResultProvider(applicationPromise); + const provider = createApplicationResultProvider(applicationPromise); - const options = { - ...defaultOption, - aborted$: hot('|'), - }; + const options = { + ...defaultOption, + aborted$: hot('-(a|)', { a: undefined }), + }; - const resultObs = provider.find({ term: 'term' }, options); + const resultObs = provider.find({ term: 'term' }, options); - expectObservable(resultObs).toBe('--(a|)', { a: [] }); + expectObservable(resultObs).toBe('-|'); + }); }); }); - 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; - - const provider = createApplicationResultProvider(applicationPromise); - - const options = { - ...defaultOption, - aborted$: hot('-(a|)', { a: undefined }), - }; - - const resultObs = provider.find({ term: 'term' }, options); - - expectObservable(resultObs).toBe('-|'); + describe('#getSearchableTypes', () => { + it('returns only the `application` type', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + expect(await provider.getSearchableTypes()).toEqual(['application']); }); }); }); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts index fd6eb0dc1878b..5b4c58161c0ae 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -10,6 +10,8 @@ import { ApplicationStart } from 'src/core/public'; import { GlobalSearchResultProvider } from '../../../global_search/public'; import { getAppResults } from './get_app_results'; +const applicationType = 'application'; + export const createApplicationResultProvider = ( applicationPromise: Promise ): GlobalSearchResultProvider => { @@ -27,7 +29,7 @@ export const createApplicationResultProvider = ( return { id: 'application', find: ({ term, types, tags }, { aborted$, maxResults }) => { - if (tags || (types && !types.includes('application'))) { + if (tags || (types && !types.includes(applicationType))) { return of([]); } return searchableApps$.pipe( @@ -39,5 +41,6 @@ export const createApplicationResultProvider = ( }) ); }, + getSearchableTypes: () => [applicationType], }; }; diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index da9276278dbbf..5d24b33f2619e 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -115,117 +115,127 @@ describe('savedObjectsResultProvider', () => { expect(provider.id).toBe('savedObjects'); }); - it('calls `savedObjectClient.find` with the correct parameters', async () => { - await provider.find({ term: 'term' }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title', 'description'], - type: ['typeA', 'typeB'], + describe('#find()', () => { + it('calls `savedObjectClient.find` with the correct parameters', async () => { + await provider.find({ term: 'term' }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + type: ['typeA', 'typeB'], + }); }); - }); - it('filters searchable types depending on the `types` parameter', async () => { - await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title'], - type: ['typeA'], + it('filters searchable types depending on the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); }); - }); - it('ignore the case for the `types` parameter', async () => { - await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title'], - type: ['typeA'], + it('ignore the case for the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); }); - }); - it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { - await provider - .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) - .toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title', 'description'], - hasReference: [ - { type: 'tag', id: 'tag-id-1' }, - { type: 'tag', id: 'tag-id-2' }, - ], - type: ['typeA', 'typeB'], + it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { + await provider + .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) + .toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + hasReference: [ + { type: 'tag', id: 'tag-id-1' }, + { type: 'tag', id: 'tag-id-2' }, + ], + type: ['typeA', 'typeB'], + }); }); - }); - it('does not call `savedObjectClient.find` if all params are empty', async () => { - const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); + it('does not call `savedObjectClient.find` if all params are empty', async () => { + const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); - expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); - expect(results).toEqual([[]]); - }); + expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); + expect(results).toEqual([[]]); + }); - it('converts the saved objects to results', async () => { - context.core.savedObjects.client.find.mockResolvedValue( - createFindResponse([ - createObject({ id: 'resultA', type: 'typeA', score: 50 }, { title: 'titleA' }), - createObject({ id: 'resultB', type: 'typeB', score: 78 }, { description: 'titleB' }), - ]) - ); + it('converts the saved objects to results', async () => { + context.core.savedObjects.client.find.mockResolvedValue( + createFindResponse([ + createObject({ id: 'resultA', type: 'typeA', score: 50 }, { title: 'titleA' }), + createObject({ id: 'resultB', type: 'typeB', score: 78 }, { description: 'titleB' }), + ]) + ); - const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); - expect(results).toEqual([ - { - id: 'resultA', - title: 'titleA', - type: 'typeA', - url: '/type-a/resultA', - score: 50, - }, - { - id: 'resultB', - title: 'titleB', - type: 'typeB', - url: '/type-b/resultB', - score: 78, - }, - ]); - }); + const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); + expect(results).toEqual([ + { + id: 'resultA', + title: 'titleA', + type: 'typeA', + url: '/type-a/resultA', + score: 50, + }, + { + id: 'resultB', + title: 'titleB', + type: 'typeB', + url: '/type-b/resultB', + score: 78, + }, + ]); + }); - it('only emits results until `aborted$` emits', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - // 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 - context.core.savedObjects.client.find.mockReturnValue( - hot('---a', { a: createFindResponse([]) }) as any - ); + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + // 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 + context.core.savedObjects.client.find.mockReturnValue( + hot('---a', { a: createFindResponse([]) }) as any + ); + + const resultObs = provider.find( + { term: 'term' }, + { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, + context + ); + + expectObservable(resultObs).toBe('-|'); + }); + }); + }); - const resultObs = provider.find( - { term: 'term' }, - { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, - context - ); + describe('#getSearchableTypes', () => { + it('returns the searchable saved object types', async () => { + const types = await provider.getSearchableTypes(context); - expectObservable(resultObs).toBe('-|'); + expect(types.sort()).toEqual(['typeA', 'typeB']); }); }); }); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 3e2c42e7896fd..489e8f71c2d53 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -6,7 +6,7 @@ import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; -import { SavedObjectsFindOptionsReference } from 'src/core/server'; +import { SavedObjectsFindOptionsReference, ISavedObjectTypeRegistry } from 'src/core/server'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; @@ -23,10 +23,7 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = savedObjects: { client, typeRegistry }, } = core; - const searchableTypes = typeRegistry - .getVisibleTypes() - .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) - .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const searchableTypes = getSearchableTypes(typeRegistry, types); const searchFields = uniq( searchableTypes.map((type) => type.management!.defaultSearchField!) @@ -51,9 +48,21 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = map(([res, cap]) => mapToResults(res.saved_objects, typeRegistry, cap)) ); }, + getSearchableTypes: ({ core }) => { + const { + savedObjects: { typeRegistry }, + } = core; + return getSearchableTypes(typeRegistry).map((type) => type.name); + }, }; }; +const getSearchableTypes = (typeRegistry: ISavedObjectTypeRegistry, types?: string[]) => + typeRegistry + .getVisibleTypes() + .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) + .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const uniq = (values: T[]): T[] => [...new Set(values)]; const includeIgnoreCase = (list: string[], item: string) => diff --git a/x-pack/plugins/lens/public/search_provider.ts b/x-pack/plugins/lens/public/search_provider.ts index 02b7900a4c003..55454b54dde79 100644 --- a/x-pack/plugins/lens/public/search_provider.ts +++ b/x-pack/plugins/lens/public/search_provider.ts @@ -79,4 +79,5 @@ export const getSearchProvider: ( }) ); }, + getSearchableTypes: () => ['application'], }); diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index a8614f74125f4..70ba6c86e04cb 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -81,6 +81,7 @@ export class SavedObjectTaggingPlugin return { client: this.tagClient, + cache: this.tagCache, ui: getUiApi({ cache: this.tagCache, client: this.tagClient, diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts index 712b4665f32ef..0df62eb600428 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts @@ -7,12 +7,10 @@ import { Duration } from 'moment'; import { Observable, BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { ITagsCache } from '../../../../../../src/plugins/saved_objects_tagging_oss/public'; import { Tag, TagAttributes } from '../../../common/types'; -export interface ITagsCache { - getState(): Tag[]; - getState$(): Observable; -} +export { ITagsCache }; export interface ITagsChangeListener { onDelete: (id: string) => void; diff --git a/x-pack/test/functional/page_objects/navigational_search.ts b/x-pack/test/functional/page_objects/navigational_search.ts index 77df829e31019..0924b2f850739 100644 --- a/x-pack/test/functional/page_objects/navigational_search.ts +++ b/x-pack/test/functional/page_objects/navigational_search.ts @@ -43,6 +43,11 @@ export function NavigationalSearchProvider({ getService, getPageObjects }: FtrPr } } + async getFieldValue() { + const field = await testSubjects.find('nav-search-input'); + return field.getAttribute('value'); + } + async clearField() { const field = await testSubjects.find('nav-search-input'); await field.clearValueWithKeyboard(); diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts index 97d50bda899fd..f0c70ee8f718d 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts @@ -43,6 +43,47 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(await browser.getCurrentUrl()).to.contain('discover'); }); + describe('search suggestions', () => { + it('shows a suggestion when searching for a term matching a type', async () => { + await navigationalSearch.searchFor('dashboard'); + + let results = await navigationalSearch.getDisplayedResults(); + expect(results[0].label).to.eql('type: dashboard'); + + await navigationalSearch.clickOnOption(0); + await navigationalSearch.waitForResultsLoaded(); + + const searchTerm = await navigationalSearch.getFieldValue(); + expect(searchTerm).to.eql('type:dashboard'); + + results = await navigationalSearch.getDisplayedResults(); + expect(results.map((result) => result.label)).to.eql([ + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + it('shows a suggestion when searching for a term matching a tag name', async () => { + await navigationalSearch.searchFor('tag-1'); + + let results = await navigationalSearch.getDisplayedResults(); + expect(results[0].label).to.eql('tag: tag-1'); + + await navigationalSearch.clickOnOption(0); + await navigationalSearch.waitForResultsLoaded(); + + const searchTerm = await navigationalSearch.getFieldValue(); + expect(searchTerm).to.eql('tag:tag-1'); + + results = await navigationalSearch.getDisplayedResults(); + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + }); + describe('advanced search syntax', () => { it('allows to filter by type', async () => { await navigationalSearch.searchFor('type:dashboard'); From ec0bfe9f1485b1247453dc96543de3dae98c6da9 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 9 Dec 2020 12:16:42 +0100 Subject: [PATCH 03/18] clear using keyboard (#85042) --- x-pack/test/functional/page_objects/lens_page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 16a8e0594816f..2159f939a56f7 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -270,7 +270,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, async editDimensionLabel(label: string) { - await testSubjects.setValue('indexPattern-label-edit', label); + await testSubjects.setValue('indexPattern-label-edit', label, { clearWithKeyboard: true }); }, async editDimensionFormat(format: string) { const formatInput = await testSubjects.find('indexPattern-dimension-format'); From 7fc7fe325cb37b378d6f643499ac18a797e9e870 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 9 Dec 2020 12:29:42 +0100 Subject: [PATCH 04/18] [ILM] Show forcemerge in hot when rollover is searchable snapshot is enabled (#85292) * pivot to different rollover validation mechanism * implement stakeholder feedback to show forcemerge in hot * replace ternary with if..else statements * make rollover validation test more comprehensive --- .../__jest__/components/edit_policy.test.tsx | 7 +++- .../components/phases/hot_phase/hot_phase.tsx | 8 ++--- .../sections/edit_policy/form/schema.ts | 6 ++++ .../sections/edit_policy/form/validations.ts | 36 +++++++++---------- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 65952e81ae0ff..32964ab2ce84d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -324,7 +324,7 @@ describe('edit policy', () => { }); }); describe('hot phase', () => { - test('should show errors when trying to save with no max size and no max age', async () => { + test('should show errors when trying to save with no max size, no max age and no max docs', async () => { const rendered = mountWithIntl(component); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeFalsy(); await setPolicyName(rendered, 'mypolicy'); @@ -338,6 +338,11 @@ describe('edit policy', () => { maxAgeInput.simulate('change', { target: { value: '' } }); }); waitForFormLibValidation(rendered); + const maxDocsInput = findTestSubject(rendered, 'hot-selectedMaxDocuments'); + await act(async () => { + maxDocsInput.simulate('change', { target: { value: '' } }); + }); + waitForFormLibValidation(rendered); await save(rendered); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeTruthy(); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index e86bbd9e747bc..5ce4fae596e8e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -24,7 +24,7 @@ import { useFormData, UseField, SelectField, NumericField } from '../../../../.. import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues } from '../../../form'; +import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; import { useEditPolicyContext } from '../../../edit_policy_context'; @@ -51,8 +51,6 @@ export const HotPhase: FunctionComponent = () => { const isRolloverEnabled = get(formData, useRolloverPath); const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); - return ( <> { {(field) => { const showErrorCallout = field.errors.some( - (e) => e.validationType === ROLLOVER_EMPTY_VALIDATION + (e) => e.code === ROLLOVER_EMPTY_VALIDATION ); if (showErrorCallout !== showEmptyRolloverFieldsError) { setShowEmptyRolloverFieldsError(showErrorCallout); @@ -236,8 +234,8 @@ export const HotPhase: FunctionComponent = () => { {isRolloverEnabled && ( <> + {} {license.canUseSearchableSnapshot() && } - {!isUsingSearchableSnapshotInHotPhase && } )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index fa9def6864be0..6485122771a46 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -8,6 +8,9 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../../../shared_imports'; import { defaultSetPriority, defaultPhaseIndexPriority } from '../../../constants'; +import { ROLLOVER_FORM_PATHS } from '../constants'; + +const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); import { FormInternal } from '../types'; @@ -127,6 +130,7 @@ export const schema: FormSchema = { validator: ifExistsNumberGreaterThanZero, }, ], + fieldsToValidateOnChange: rolloverFormPaths, }, max_docs: { label: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.maximumDocumentsLabel', { @@ -141,6 +145,7 @@ export const schema: FormSchema = { }, ], serializer: serializers.stringToNumber, + fieldsToValidateOnChange: rolloverFormPaths, }, max_size: { label: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.maximumIndexSizeLabel', { @@ -154,6 +159,7 @@ export const schema: FormSchema = { validator: ifExistsNumberGreaterThanZero, }, ], + fieldsToValidateOnChange: rolloverFormPaths, }, }, forcemerge: { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts index f2e26a552efc9..a5d7d68d21915 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts @@ -56,33 +56,31 @@ export const ROLLOVER_EMPTY_VALIDATION = 'ROLLOVER_EMPTY_VALIDATION'; * This validator checks that and updates form values by setting errors states imperatively to * indicate this error state. */ -export const rolloverThresholdsValidator: ValidationFunc = ({ form }) => { +export const rolloverThresholdsValidator: ValidationFunc = ({ form, path }) => { const fields = form.getFields(); if ( !( - fields[ROLLOVER_FORM_PATHS.maxAge].value || - fields[ROLLOVER_FORM_PATHS.maxDocs].value || - fields[ROLLOVER_FORM_PATHS.maxSize].value + fields[ROLLOVER_FORM_PATHS.maxAge]?.value || + fields[ROLLOVER_FORM_PATHS.maxDocs]?.value || + fields[ROLLOVER_FORM_PATHS.maxSize]?.value ) ) { - fields[ROLLOVER_FORM_PATHS.maxAge].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + if (path === ROLLOVER_FORM_PATHS.maxAge) { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumAgeRequiredMessage, - }, - ]); - fields[ROLLOVER_FORM_PATHS.maxDocs].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + }; + } else if (path === ROLLOVER_FORM_PATHS.maxDocs) { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumDocumentsRequiredMessage, - }, - ]); - fields[ROLLOVER_FORM_PATHS.maxSize].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + }; + } else { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumSizeRequiredMessage, - }, - ]); + }; + } } else { fields[ROLLOVER_FORM_PATHS.maxAge].clearErrors(ROLLOVER_EMPTY_VALIDATION); fields[ROLLOVER_FORM_PATHS.maxDocs].clearErrors(ROLLOVER_EMPTY_VALIDATION); From 9d8dd6dc5763b700816843bf92d75d313e311635 Mon Sep 17 00:00:00 2001 From: Daniil Date: Wed, 9 Dec 2020 14:40:59 +0300 Subject: [PATCH 05/18] Fix agg select external link (#85380) --- .../vis_default_editor/public/components/agg_select.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/vis_default_editor/public/components/agg_select.tsx b/src/plugins/vis_default_editor/public/components/agg_select.tsx index 9d45b72d35cc0..689cc52691bb6 100644 --- a/src/plugins/vis_default_editor/public/components/agg_select.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_select.tsx @@ -77,15 +77,15 @@ function DefaultEditorAggSelect({ } const helpLink = value && aggHelpLink && ( - - + + - - + + ); const errors = aggError ? [aggError] : []; From 2a8c609bf91e8c0011d6e5cc058636d8479a78ed Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 9 Dec 2020 12:45:40 +0100 Subject: [PATCH 06/18] [Uptime]Refactor header and action menu (#83779) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../plugins/uptime/public/apps/render_app.tsx | 8 +- .../plugins/uptime/public/apps/uptime_app.tsx | 10 +- .../certificates/cert_refresh_btn.tsx | 51 ++ .../__snapshots__/page_header.test.tsx.snap | 442 ++++++++++++++---- .../header}/__tests__/page_header.test.tsx | 8 +- .../components/common/header/action_menu.tsx | 36 ++ .../components/common/header/page_header.tsx | 65 +++ .../components/common/header/page_tabs.tsx | 80 ++++ .../__snapshots__/monitor_list.test.tsx.snap | 31 +- .../columns/__tests__/enable_alert.test.tsx | 27 ++ .../columns/define_connectors.tsx | 6 +- .../monitor_list/monitor_list_header.tsx | 14 - .../uptime/public/pages/certificates.tsx | 125 ++--- .../plugins/uptime/public/pages/monitor.tsx | 32 +- .../plugins/uptime/public/pages/overview.tsx | 9 +- .../uptime/public/pages/page_header.tsx | 107 ----- .../plugins/uptime/public/pages/settings.tsx | 13 +- .../uptime/public/pages/translations.ts | 14 - x-pack/plugins/uptime/public/routes.tsx | 26 +- .../functional/services/uptime/overview.ts | 12 +- .../apps/uptime/simple_down_alert.ts | 9 +- 23 files changed, 722 insertions(+), 411 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx rename x-pack/plugins/uptime/public/{pages => components/common/header}/__tests__/__snapshots__/page_header.test.tsx.snap (64%) rename x-pack/plugins/uptime/public/{pages => components/common/header}/__tests__/page_header.test.tsx (82%) create mode 100644 x-pack/plugins/uptime/public/components/common/header/action_menu.tsx create mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.tsx create mode 100644 x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx delete mode 100644 x-pack/plugins/uptime/public/pages/page_header.tsx diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1f36a5a71537b..39b485d8875ba 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20323,8 +20323,6 @@ "xpack.uptime.breadcrumbs.overviewBreadcrumbText": "アップタイム", "xpack.uptime.certificates.heading": "TLS証明書({total})", "xpack.uptime.certificates.refresh": "更新", - "xpack.uptime.certificates.returnToOverviewLinkLabel": "概要に戻る", - "xpack.uptime.certificates.settingsLinkLabel": "設定", "xpack.uptime.certs.expired": "期限切れ", "xpack.uptime.certs.expires": "有効期限", "xpack.uptime.certs.expireSoon": "まもなく期限切れ", @@ -20462,7 +20460,6 @@ "xpack.uptime.monitorList.table.description": "列にステータス、名前、URL、IP、ダウンタイム履歴、統合が入力されたモニターステータス表です。この表は現在 {length} 項目を表示しています。", "xpack.uptime.monitorList.table.url.name": "Url", "xpack.uptime.monitorList.tlsColumnLabel": "TLS証明書", - "xpack.uptime.monitorList.viewCertificateTitle": "証明書ステータス", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "ミリ秒単位の監視時間", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "監視ステータス", "xpack.uptime.monitorStatusBar.loadingMessage": "読み込み中…", @@ -20528,7 +20525,6 @@ "xpack.uptime.settings.error.couldNotSave": "設定を保存できませんでした!", "xpack.uptime.settings.invalid.error": "値は0よりも大きい値でなければなりません。", "xpack.uptime.settings.invalid.nanError": "値は整数でなければなりません。", - "xpack.uptime.settings.returnToOverviewLinkLabel": "概要に戻る", "xpack.uptime.settings.saveSuccess": "設定が保存されました。", "xpack.uptime.settingsBreadcrumbText": "設定", "xpack.uptime.snapshot.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total}個中{down}個のモニターがダウンしています。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3151b863cc4a1..c3b910c47b121 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20342,8 +20342,6 @@ "xpack.uptime.breadcrumbs.overviewBreadcrumbText": "运行时间", "xpack.uptime.certificates.heading": "TLS 证书 ({total})", "xpack.uptime.certificates.refresh": "刷新", - "xpack.uptime.certificates.returnToOverviewLinkLabel": "返回到概览", - "xpack.uptime.certificates.settingsLinkLabel": "设置", "xpack.uptime.certs.expired": "已过期", "xpack.uptime.certs.expires": "过期", "xpack.uptime.certs.expireSoon": "即将过期", @@ -20481,7 +20479,6 @@ "xpack.uptime.monitorList.table.description": "具有“状态”、“名称”、“URL”、“IP”、“中断历史记录”和“集成”列的“监测状态”表。该表当前显示 {length} 个项目。", "xpack.uptime.monitorList.table.url.name": "URL", "xpack.uptime.monitorList.tlsColumnLabel": "TLS 证书", - "xpack.uptime.monitorList.viewCertificateTitle": "证书状态", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "监测持续时间(毫秒)", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "检测状态", "xpack.uptime.monitorStatusBar.loadingMessage": "正在加载……", @@ -20547,7 +20544,6 @@ "xpack.uptime.settings.error.couldNotSave": "无法保存设置!", "xpack.uptime.settings.invalid.error": "值必须大于 0。", "xpack.uptime.settings.invalid.nanError": "值必须为整数。", - "xpack.uptime.settings.returnToOverviewLinkLabel": "返回到概览", "xpack.uptime.settings.saveSuccess": "设置已保存!", "xpack.uptime.settingsBreadcrumbText": "设置", "xpack.uptime.snapshot.donutChart.ariaLabel": "显示当前状态的饼图。{total} 个监测中有 {down} 个已关闭。", diff --git a/x-pack/plugins/uptime/public/apps/render_app.tsx b/x-pack/plugins/uptime/public/apps/render_app.tsx index c0567ff956ce4..803431dc25b24 100644 --- a/x-pack/plugins/uptime/public/apps/render_app.tsx +++ b/x-pack/plugins/uptime/public/apps/render_app.tsx @@ -21,7 +21,7 @@ export function renderApp( core: CoreStart, plugins: ClientPluginsSetup, startPlugins: ClientPluginsStart, - { element, history }: AppMountParameters + appMountParameters: AppMountParameters ) { const { application: { capabilities }, @@ -47,7 +47,6 @@ export function renderApp( basePath: basePath.get(), darkMode: core.uiSettings.get(DEFAULT_DARK_MODE), commonlyUsedRanges: core.uiSettings.get(DEFAULT_TIMEPICKER_QUICK_RANGES), - history, isApmAvailable: apm, isInfraAvailable: infrastructure, isLogsAvailable: logs, @@ -68,12 +67,13 @@ export function renderApp( ], }), setBadge, + appMountParameters, setBreadcrumbs: core.chrome.setBreadcrumbs, }; - ReactDOM.render(, element); + ReactDOM.render(, appMountParameters.element); return () => { - ReactDOM.unmountComponentAtNode(element); + ReactDOM.unmountComponentAtNode(appMountParameters.element); }; } diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 9bbcde041a794..061398b25e452 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { Provider as ReduxProvider } from 'react-redux'; import { Router } from 'react-router-dom'; -import { I18nStart, ChromeBreadcrumb, CoreStart } from 'kibana/public'; +import { I18nStart, ChromeBreadcrumb, CoreStart, AppMountParameters } from 'kibana/public'; import { KibanaContextProvider, RedirectAppLinks, @@ -28,7 +28,7 @@ import { PageRouter } from '../routes'; import { UptimeAlertsFlyoutWrapper } from '../components/overview/alerts'; import { store } from '../state'; import { kibanaService } from '../state/kibana_service'; -import { ScopedHistory } from '../../../../../src/core/public'; +import { ActionMenu } from '../components/common/header/action_menu'; import { EuiThemeProvider } from '../../../observability/public'; export interface UptimeAppColors { @@ -47,7 +47,6 @@ export interface UptimeAppProps { canSave: boolean; core: CoreStart; darkMode: boolean; - history: ScopedHistory; i18n: I18nStart; isApmAvailable: boolean; isInfraAvailable: boolean; @@ -58,6 +57,7 @@ export interface UptimeAppProps { renderGlobalHelpControls(): void; commonlyUsedRanges: CommonlyUsedRange[]; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + appMountParameters: AppMountParameters; } const Application = (props: UptimeAppProps) => { @@ -71,6 +71,7 @@ const Application = (props: UptimeAppProps) => { renderGlobalHelpControls, setBadge, startPlugins, + appMountParameters, } = props; useEffect(() => { @@ -101,7 +102,7 @@ const Application = (props: UptimeAppProps) => { - + @@ -112,6 +113,7 @@ const Application = (props: UptimeAppProps) => {
+
diff --git a/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx b/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx new file mode 100644 index 0000000000000..d0823276f1885 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx @@ -0,0 +1,51 @@ +/* + * 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 React, { useContext } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHideFor, + EuiShowFor, +} from '@elastic/eui'; +import * as labels from '../../pages/translations'; +import { UptimeRefreshContext } from '../../contexts'; + +export const CertRefreshBtn = () => { + const { refreshApp } = useContext(UptimeRefreshContext); + + return ( + + + + + { + refreshApp(); + }} + data-test-subj="superDatePickerApplyTimeButton" + > + {labels.REFRESH_CERT} + + + + { + refreshApp(); + }} + data-test-subj="superDatePickerApplyTimeButton" + /> + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap similarity index 64% rename from x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap rename to x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap index 7bb578494ab44..05a78624848c6 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap @@ -107,97 +107,86 @@ Array [ }
-
-
- -
-
-
- -
@@ -308,7 +297,7 @@ Array [ }
, ] `; @@ -420,16 +409,91 @@ Array [ } +
+
- TestingHeading - +
+ +
+
, ] `; exports[`PageHeader shallow renders without the date picker: page_header_no_date_picker 1`] = ` Array [ -
, -
, - +
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
, -
, ] `; diff --git a/x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx b/x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx similarity index 82% rename from x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx rename to x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx index 63d4c24f965d9..0b72cc64f8102 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { PageHeader } from '../page_header'; -import { renderWithRouter, MountWithReduxProvider } from '../../lib'; +import { renderWithRouter, MountWithReduxProvider } from '../../../../lib'; describe('PageHeader', () => { it('shallow renders with the date picker', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); @@ -21,7 +21,7 @@ describe('PageHeader', () => { it('shallow renders without the date picker', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); @@ -30,7 +30,7 @@ describe('PageHeader', () => { it('shallow renders extra links', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_with_extra_links'); diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx new file mode 100644 index 0000000000000..b59470f66f796 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { HeaderMenuPortal } from '../../../../../observability/public'; +import { AppMountParameters } from '../../../../../../../src/core/public'; + +const ADD_DATA_LABEL = i18n.translate('xpack.uptime.addDataButtonLabel', { + defaultMessage: 'Add data', +}); + +export const ActionMenu = ({ appMountParameters }: { appMountParameters: AppMountParameters }) => { + const kibana = useKibana(); + + return ( + + + + + {ADD_DATA_LABEL} + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx new file mode 100644 index 0000000000000..63bcb6663619d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx @@ -0,0 +1,65 @@ +/* + * 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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { useRouteMatch } from 'react-router-dom'; +import { UptimeDatePicker } from '../uptime_date_picker'; +import { SyntheticsCallout } from '../../overview/synthetics_callout'; +import { PageTabs } from './page_tabs'; +import { CERTIFICATES_ROUTE, MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../common/constants'; +import { CertRefreshBtn } from '../../certificates/cert_refresh_btn'; +import { ToggleAlertFlyoutButton } from '../../overview/alerts/alerts_containers'; + +const StyledPicker = styled(EuiFlexItem)` + &&& { + @media only screen and (max-width: 1024px) and (min-width: 868px) { + .euiSuperDatePicker__flexWrapper { + width: 500px; + } + } + @media only screen and (max-width: 880px) { + flex-grow: 1; + .euiSuperDatePicker__flexWrapper { + width: calc(100% + 8px); + } + } + } +`; + +export const PageHeader = () => { + const isCertRoute = useRouteMatch(CERTIFICATES_ROUTE); + const isSettingsRoute = useRouteMatch(SETTINGS_ROUTE); + + const DatePickerComponent = () => + isCertRoute ? ( + + ) : ( + + + + ); + + const isMonRoute = useRouteMatch(MONITOR_ROUTE); + + return ( + <> + + + + + + + + + {!isSettingsRoute && } + + {isMonRoute && } + {!isMonRoute && } + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx b/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx new file mode 100644 index 0000000000000..68df15c52c65e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx @@ -0,0 +1,80 @@ +/* + * 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 React, { useEffect, useState } from 'react'; + +import { EuiTabs, EuiTab } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { CERTIFICATES_ROUTE, OVERVIEW_ROUTE, SETTINGS_ROUTE } from '../../../../common/constants'; + +const tabs = [ + { + id: OVERVIEW_ROUTE, + name: i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }), + dataTestSubj: 'uptimeSettingsToOverviewLink', + }, + { + id: CERTIFICATES_ROUTE, + name: 'Certificates', + dataTestSubj: 'uptimeCertificatesLink', + }, + { + id: SETTINGS_ROUTE, + dataTestSubj: 'settings-page-link', + name: i18n.translate('xpack.uptime.page_header.settingsLink', { + defaultMessage: 'Settings', + }), + }, +]; + +export const PageTabs = () => { + const [selectedTabId, setSelectedTabId] = useState(null); + + const history = useHistory(); + + const isOverView = useRouteMatch(OVERVIEW_ROUTE); + const isSettings = useRouteMatch(SETTINGS_ROUTE); + const isCerts = useRouteMatch(CERTIFICATES_ROUTE); + + useEffect(() => { + if (isOverView?.isExact) { + setSelectedTabId(OVERVIEW_ROUTE); + } + if (isCerts) { + setSelectedTabId(CERTIFICATES_ROUTE); + } + if (isSettings) { + setSelectedTabId(SETTINGS_ROUTE); + } + if (!isOverView?.isExact && !isCerts && !isSettings) { + setSelectedTabId(null); + } + }, [isCerts, isSettings, isOverView]); + + const renderTabs = () => { + return tabs.map(({ dataTestSubj, name, id }, index) => ( + setSelectedTabId(id)} + isSelected={id === selectedTabId} + key={index} + data-test-subj={dataTestSubj} + href={history.createHref({ pathname: id })} + > + {name} + + )); + }; + + return ( + + {renderTabs()} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 39f860f76f2bd..bd1aecc9ede48 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -826,26 +826,20 @@ exports[`MonitorList component renders loading state 1`] = ` `; exports[`MonitorList component renders the monitor list 1`] = ` -.c3 { +.c2 { padding-right: 4px; } -.c4 { +.c3 { padding-top: 12px; } -.c1 { - position: absolute; - right: 16px; - top: 16px; -} - .c0 { position: relative; } @media (max-width:574px) { - .c2 { + .c1 { min-width: 230px; } } @@ -936,13 +930,6 @@ exports[`MonitorList component renders the monitor list 1`] = `
- - Certificates status -