From 7d5fb8e83aa81ec9f777a23fc39d32ec83199c5b Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 24 Nov 2020 10:44:57 +0100 Subject: [PATCH] [GS] add search syntax support (#83422) * add search syntax parsing logic * fix ts types * use type filter in providers * move search syntax logic to the searchbar * fix test plugin types * fix test plugin types again * use `onSearch` prop to disable internal component search * add tag filter support * add FTR tests * move away from CI group 7 * fix unit tests * add unit tests * remove the API test suite * Add icons to the SO results * add test for unknown type / tag * nits * ignore case for the `type` filter * Add syntax help text * remove unused import * hide icon for non-application results * add tsdoc on query utils * coerce known filter values to string Co-authored-by: Ryan Keairns --- .../public/api.mock.ts | 1 + .../saved_objects_tagging_oss/public/api.ts | 8 +- x-pack/plugins/global_search/common/types.ts | 25 ++ x-pack/plugins/global_search/public/index.ts | 2 + .../services/fetch_server_results.test.ts | 21 +- .../public/services/fetch_server_results.ts | 6 +- .../public/services/search_service.test.ts | 40 +- .../public/services/search_service.ts | 21 +- x-pack/plugins/global_search/public/types.ts | 10 +- .../global_search/server/routes/find.ts | 10 +- .../routes/integration_tests/find.test.ts | 27 +- .../server/services/search_service.test.ts | 22 +- .../server/services/search_service.ts | 23 +- x-pack/plugins/global_search/server/types.ts | 11 +- x-pack/plugins/global_search_bar/kibana.json | 2 +- .../__snapshots__/search_bar.test.tsx.snap | 2 +- .../public/components/search_bar.test.tsx | 6 +- .../public/components/search_bar.tsx | 127 +++++-- .../global_search_bar/public/plugin.tsx | 70 ++-- .../public/search_syntax/index.ts | 8 + .../search_syntax/parse_search_params.test.ts | 87 +++++ .../search_syntax/parse_search_params.ts | 59 +++ .../public/search_syntax/query_utils.test.ts | 134 +++++++ .../public/search_syntax/query_utils.ts | 79 ++++ .../public/search_syntax/types.ts | 34 ++ .../public/providers/application.test.ts | 73 +++- .../public/providers/application.ts | 9 +- .../map_object_to_result.test.ts | 2 + .../saved_objects/map_object_to_result.ts | 1 + .../providers/saved_objects/provider.test.ts | 58 ++- .../providers/saved_objects/provider.ts | 15 +- x-pack/plugins/lens/public/search_provider.ts | 7 +- .../public/ui_api/index.ts | 3 +- x-pack/test/functional/page_objects/index.ts | 2 + .../page_objects/navigational_search.ts | 95 +++++ x-pack/test/plugin_functional/config.ts | 5 + .../global_search/search_syntax/data.json | 358 ++++++++++++++++++ .../global_search/search_syntax/mappings.json | 266 +++++++++++++ .../plugins/global_search_test/kibana.json | 2 +- .../global_search_test/public/plugin.ts | 40 +- .../global_search_test/server/index.ts | 21 - .../global_search_test/server/plugin.ts | 61 --- .../global_search/global_search_api.ts | 49 --- .../global_search/global_search_bar.ts | 144 ++++++- .../global_search/global_search_providers.ts | 2 +- .../test_suites/global_search/index.ts | 6 +- 46 files changed, 1703 insertions(+), 351 deletions(-) create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/index.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/types.ts create mode 100644 x-pack/test/functional/page_objects/navigational_search.ts create mode 100644 x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json create mode 100644 x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json delete mode 100644 x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts delete mode 100644 x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts delete mode 100644 x-pack/test/plugin_functional/test_suites/global_search/global_search_api.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 e29922c2481c43..87a3fd8f5b4997 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -60,6 +60,7 @@ const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { convertNameToReference: jest.fn(), parseSearchQuery: jest.fn(), getTagIdsFromReferences: jest.fn(), + getTagIdFromName: jest.fn(), updateTagsReferences: jest.fn(), }; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 71548cd5c7f510..81f7cc9326a77f 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -84,7 +84,7 @@ export interface SavedObjectsTaggingApiUi { /** * Convert given tag name to a {@link SavedObjectsFindOptionsReference | reference } * to be used to search using the savedObjects `_find` API. Will return `undefined` - * is the given name does not match any existing tag. + * if the given name does not match any existing tag. */ convertNameToReference(tagName: string): SavedObjectsFindOptionsReference | undefined; @@ -124,6 +124,12 @@ export interface SavedObjectsTaggingApiUi { references: Array ): string[]; + /** + * Returns the id for given tag name. Will return `undefined` + * if the given name does not match any existing tag. + */ + getTagIdFromName(tagName: string): string | undefined; + /** * Returns a new references array that replace the old tag references with references to the * new given tag ids, while preserving all non-tag references. diff --git a/x-pack/plugins/global_search/common/types.ts b/x-pack/plugins/global_search/common/types.ts index a08ecaf41b2137..7cc1d7ada44229 100644 --- a/x-pack/plugins/global_search/common/types.ts +++ b/x-pack/plugins/global_search/common/types.ts @@ -87,3 +87,28 @@ export interface GlobalSearchBatchedResults { */ results: GlobalSearchResult[]; } + +/** + * Search parameters for the {@link GlobalSearchPluginStart.find | `find` API} + * + * @public + */ +export interface GlobalSearchFindParams { + /** + * The term to search for. Can be undefined if searching by filters. + */ + term?: string; + /** + * The types of results to search for. + */ + types?: string[]; + /** + * The tag ids to filter search by. + */ + tags?: string[]; +} + +/** + * @public + */ +export type GlobalSearchProviderFindParams = GlobalSearchFindParams; diff --git a/x-pack/plugins/global_search/public/index.ts b/x-pack/plugins/global_search/public/index.ts index 18483cea725402..0e1cbaedae7821 100644 --- a/x-pack/plugins/global_search/public/index.ts +++ b/x-pack/plugins/global_search/public/index.ts @@ -25,6 +25,8 @@ export { GlobalSearchProviderResult, GlobalSearchProviderResultUrl, GlobalSearchResult, + GlobalSearchFindParams, + GlobalSearchProviderFindParams, } from '../common/types'; export { GlobalSearchPluginSetup, diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts index f62acd08633ff1..4794c355a161bc 100644 --- a/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts @@ -33,11 +33,18 @@ describe('fetchServerResults', () => { it('perform a POST request to the endpoint with valid options', () => { http.post.mockResolvedValue({ results: [] }); - fetchServerResults(http, 'some term', { preference: 'pref' }); + fetchServerResults( + http, + { term: 'some term', types: ['dashboard', 'map'] }, + { preference: 'pref' } + ); expect(http.post).toHaveBeenCalledTimes(1); expect(http.post).toHaveBeenCalledWith('/internal/global_search/find', { - body: JSON.stringify({ term: 'some term', options: { preference: 'pref' } }), + body: JSON.stringify({ + params: { term: 'some term', types: ['dashboard', 'map'] }, + options: { preference: 'pref' }, + }), }); }); @@ -47,7 +54,11 @@ describe('fetchServerResults', () => { http.post.mockResolvedValue({ results: [resultA, resultB] }); - const results = await fetchServerResults(http, 'some term', { preference: 'pref' }).toPromise(); + const results = await fetchServerResults( + http, + { term: 'some term' }, + { preference: 'pref' } + ).toPromise(); expect(http.post).toHaveBeenCalledTimes(1); expect(results).toHaveLength(2); @@ -65,7 +76,7 @@ describe('fetchServerResults', () => { getTestScheduler().run(({ expectObservable, hot }) => { http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); - const results = fetchServerResults(http, 'term', {}); + const results = fetchServerResults(http, { term: 'term' }, {}); expectObservable(results).toBe('---(a|)', { a: [], @@ -77,7 +88,7 @@ describe('fetchServerResults', () => { getTestScheduler().run(({ expectObservable, hot }) => { http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); const aborted$ = hot('-(a|)', { a: undefined }); - const results = fetchServerResults(http, 'term', { aborted$ }); + const results = fetchServerResults(http, { term: 'term' }, { aborted$ }); expectObservable(results).toBe('-|', { a: [], diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.ts index 3c06dfab9f50e3..7508c8db571656 100644 --- a/x-pack/plugins/global_search/public/services/fetch_server_results.ts +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.ts @@ -7,7 +7,7 @@ import { Observable, from, EMPTY } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { HttpStart } from 'src/core/public'; -import { GlobalSearchResult } from '../../common/types'; +import { GlobalSearchResult, GlobalSearchProviderFindParams } from '../../common/types'; import { GlobalSearchFindOptions } from './types'; interface ServerFetchResponse { @@ -24,7 +24,7 @@ interface ServerFetchResponse { */ export const fetchServerResults = ( http: HttpStart, - term: string, + params: GlobalSearchProviderFindParams, { preference, aborted$ }: GlobalSearchFindOptions ): Observable => { let controller: AbortController | undefined; @@ -36,7 +36,7 @@ export const fetchServerResults = ( } return from( http.post('/internal/global_search/find', { - body: JSON.stringify({ term, options: { preference } }), + body: JSON.stringify({ params, options: { preference } }), signal: controller?.signal, }) ).pipe( 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 350547a928fe4b..419ad847d6c29d 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 @@ -116,11 +116,14 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' } + ); expect(provider.find).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenCalledWith( - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref' }) ); }); @@ -129,12 +132,15 @@ describe('SearchService', () => { service.setup({ config: createConfig() }); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' } + ); expect(fetchServerResultsMock).toHaveBeenCalledTimes(1); expect(fetchServerResultsMock).toHaveBeenCalledWith( httpStart, - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref', aborted$: expect.any(Object) }) ); }); @@ -148,25 +154,25 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find({ term: 'foobar' }, { preference: 'pref' }); expect(getDefaultPreferenceMock).not.toHaveBeenCalled(); expect(provider.find).toHaveBeenNthCalledWith( 1, - 'foobar', + { term: 'foobar' }, expect.objectContaining({ preference: 'pref', }) ); - find('foobar', {}); + find({ term: 'foobar' }, {}); expect(getDefaultPreferenceMock).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenNthCalledWith( 2, - 'foobar', + { term: 'foobar' }, expect.objectContaining({ preference: 'default_pref', }) @@ -186,7 +192,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -207,7 +213,7 @@ describe('SearchService', () => { fetchServerResultsMock.mockReturnValue(serverResults); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -242,7 +248,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('ab-cd-|', { a: expectedBatch('A1', 'A2'), @@ -276,7 +282,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b--(c|)', { a: expectedBatch('P1'), @@ -301,7 +307,7 @@ describe('SearchService', () => { const aborted$ = hot('----a--|', { a: undefined }); const { find } = service.start(startDeps()); - const results = find('foo', { aborted$ }); + const results = find({ term: 'foobar' }, { aborted$ }); expectObservable(results).toBe('--a-|', { a: expectedBatch('1'), @@ -323,7 +329,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a 24ms b 74ms |', { a: expectedBatch('1'), @@ -359,7 +365,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('ab-(c|)', { a: expectedBatch('A1', 'A2'), @@ -392,7 +398,7 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - const batch = await find('foo', {}).pipe(take(1)).toPromise(); + const batch = await find({ term: 'foobar' }, {}).pipe(take(1)).toPromise(); expect(batch.results).toHaveLength(2); expect(batch.results[0]).toEqual({ @@ -420,7 +426,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe( '#', 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 62b347d9258688..64bd2fd6c930f7 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -9,7 +9,11 @@ import { map, takeUntil } from 'rxjs/operators'; import { duration } from 'moment'; import { i18n } from '@kbn/i18n'; import { HttpStart } from 'src/core/public'; -import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { + GlobalSearchFindParams, + GlobalSearchProviderResult, + GlobalSearchBatchedResults, +} from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; import { defaultMaxProviderResults } from '../../common/constants'; @@ -52,7 +56,7 @@ export interface SearchServiceStart { * * @example * ```ts - * startDeps.globalSearch.find('some term').subscribe({ + * startDeps.globalSearch.find({term: 'some term'}).subscribe({ * next: ({ results }) => { * addNewResultsToList(results); * }, @@ -67,7 +71,10 @@ export interface SearchServiceStart { * Emissions from the resulting observable will only contains **new** results. It is the consumer's * responsibility to aggregate the emission and sort the results if required. */ - find(term: string, options: GlobalSearchFindOptions): Observable; + find( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions + ): Observable; } interface SetupDeps { @@ -110,11 +117,11 @@ export class SearchService { this.licenseChecker = licenseChecker; return { - find: (term, options) => this.performFind(term, options), + find: (params, options) => this.performFind(params, options), }; } - private performFind(term: string, options: GlobalSearchFindOptions) { + private performFind(params: GlobalSearchFindParams, options: GlobalSearchFindOptions) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { return throwError( @@ -142,13 +149,13 @@ export class SearchService { const processResult = (result: GlobalSearchProviderResult) => processProviderResult(result, this.http!.basePath); - const serverResults$ = fetchServerResults(this.http!, term, { + const serverResults$ = fetchServerResults(this.http!, params, { preference, aborted$, }); const providersResults$ = [...this.providers.values()].map((provider) => - provider.find(term, providerOptions).pipe( + provider.find(params, providerOptions).pipe( takeInArray(this.maxProviderResults), takeUntil(aborted$), map((results) => results.map((r) => processResult(r))) diff --git a/x-pack/plugins/global_search/public/types.ts b/x-pack/plugins/global_search/public/types.ts index 42ef234504d12b..2707a2fded222a 100644 --- a/x-pack/plugins/global_search/public/types.ts +++ b/x-pack/plugins/global_search/public/types.ts @@ -5,7 +5,11 @@ */ import { Observable } from 'rxjs'; -import { GlobalSearchProviderFindOptions, GlobalSearchProviderResult } from '../common/types'; +import { + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, + GlobalSearchProviderFindParams, +} from '../common/types'; import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; @@ -29,7 +33,7 @@ export interface GlobalSearchResultProvider { * // returning all results in a single batch * setupDeps.globalSearch.registerResultProvider({ * id: 'my_provider', - * find: (term, { aborted$, preference, maxResults }, context) => { + * find: ({ term, filters }, { aborted$, preference, maxResults }, context) => { * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); * return from(resultPromise).pipe(takeUntil(aborted$)); * }, @@ -37,7 +41,7 @@ export interface GlobalSearchResultProvider { * ``` */ find( - term: string, + search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions ): Observable; } diff --git a/x-pack/plugins/global_search/server/routes/find.ts b/x-pack/plugins/global_search/server/routes/find.ts index a9063abda0e3eb..0b82a035348ed9 100644 --- a/x-pack/plugins/global_search/server/routes/find.ts +++ b/x-pack/plugins/global_search/server/routes/find.ts @@ -15,7 +15,11 @@ export const registerInternalFindRoute = (router: IRouter) => { path: '/internal/global_search/find', validate: { body: schema.object({ - term: schema.string(), + params: schema.object({ + term: schema.maybe(schema.string()), + types: schema.maybe(schema.arrayOf(schema.string())), + tags: schema.maybe(schema.arrayOf(schema.string())), + }), options: schema.maybe( schema.object({ preference: schema.maybe(schema.string()), @@ -25,10 +29,10 @@ export const registerInternalFindRoute = (router: IRouter) => { }, }, async (ctx, req, res) => { - const { term, options } = req.body; + const { params, options } = req.body; try { const allResults = await ctx - .globalSearch!.find(term, { ...options, aborted$: req.events.aborted$ }) + .globalSearch!.find(params, { ...options, aborted$: req.events.aborted$ }) .pipe( map((batch) => batch.results), reduce((acc, results) => [...acc, ...results]) diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts index ed28786782c354..c37bcdbf847438 100644 --- a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts +++ b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts @@ -62,7 +62,9 @@ describe('POST /internal/global_search/find', () => { await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, options: { preference: 'custom-pref', }, @@ -70,10 +72,13 @@ describe('POST /internal/global_search/find', () => { .expect(200); expect(globalSearchHandlerContext.find).toHaveBeenCalledTimes(1); - expect(globalSearchHandlerContext.find).toHaveBeenCalledWith('search', { - preference: 'custom-pref', - aborted$: expect.any(Object), - }); + expect(globalSearchHandlerContext.find).toHaveBeenCalledWith( + { term: 'search' }, + { + preference: 'custom-pref', + aborted$: expect.any(Object), + } + ); }); it('returns all the results returned from the service', async () => { @@ -84,7 +89,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(200); @@ -101,7 +108,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(403); @@ -119,7 +128,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(500); 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 2460100a46dbbe..c8d656a524e94e 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 @@ -97,11 +97,15 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); - find('foobar', { preference: 'pref' }, request); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' }, + request + ); expect(provider.find).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenCalledWith( - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref' }), expect.objectContaining({ core: expect.any(Object) }) ); @@ -121,7 +125,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -157,7 +161,7 @@ describe('SearchService', () => { ); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('ab-cd-|', { a: expectedBatch('A1', 'A2'), @@ -184,7 +188,7 @@ describe('SearchService', () => { const aborted$ = hot('----a--|', { a: undefined }); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', { aborted$ }, request); + const results = find({ term: 'foobar' }, { aborted$ }, request); expectObservable(results).toBe('--a-|', { a: expectedBatch('1'), @@ -207,7 +211,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('a 24ms b 74ms |', { a: expectedBatch('1'), @@ -244,7 +248,7 @@ describe('SearchService', () => { ); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('ab-(c|)', { a: expectedBatch('A1', 'A2'), @@ -278,7 +282,7 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); - const batch = await find('foo', {}, request).pipe(take(1)).toPromise(); + const batch = await find({ term: 'foobar' }, {}, request).pipe(take(1)).toPromise(); expect(batch.results).toHaveLength(2); expect(batch.results[0]).toEqual({ @@ -307,7 +311,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe( '#', 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 1897a24196cf10..9ea62abac704ca 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -8,12 +8,15 @@ import { Observable, timer, merge, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; -import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { + GlobalSearchProviderResult, + GlobalSearchBatchedResults, + GlobalSearchFindParams, +} from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; import { defaultMaxProviderResults } from '../../common/constants'; import { ILicenseChecker } from '../../common/license_checker'; - import { processProviderResult } from '../../common/process_result'; import { GlobalSearchConfigType } from '../config'; import { getContextFactory, GlobalSearchContextFactory } from './context'; @@ -46,7 +49,7 @@ export interface SearchServiceStart { * * @example * ```ts - * startDeps.globalSearch.find('some term').subscribe({ + * startDeps.globalSearch.find({ term: 'some term' }).subscribe({ * next: ({ results }) => { * addNewResultsToList(results); * }, @@ -64,7 +67,7 @@ export interface SearchServiceStart { * from the server-side `find` API. */ find( - term: string, + params: GlobalSearchFindParams, options: GlobalSearchFindOptions, request: KibanaRequest ): Observable; @@ -115,11 +118,15 @@ export class SearchService { this.licenseChecker = licenseChecker; this.contextFactory = getContextFactory(core); return { - find: (term, options, request) => this.performFind(term, options, request), + find: (params, options, request) => this.performFind(params, options, request), }; } - private performFind(term: string, options: GlobalSearchFindOptions, request: KibanaRequest) { + private performFind( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions, + request: KibanaRequest + ) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { return throwError( @@ -137,7 +144,7 @@ export class SearchService { const timeout$ = timer(this.config!.search_timeout.asMilliseconds()).pipe(map(mapToUndefined)); const aborted$ = options.aborted$ ? merge(options.aborted$, timeout$) : timeout$; - const providerOptions = { + const findOptions = { ...options, preference: options.preference ?? 'default', maxResults: this.maxProviderResults, @@ -148,7 +155,7 @@ export class SearchService { processProviderResult(result, basePath); const providersResults$ = [...this.providers.values()].map((provider) => - provider.find(term, providerOptions, context).pipe( + provider.find(params, findOptions, context).pipe( takeInArray(this.maxProviderResults), takeUntil(aborted$), map((results) => results.map((r) => processResult(r))) diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts index 07d21f54d7bf59..0878a965ea8c31 100644 --- a/x-pack/plugins/global_search/server/types.ts +++ b/x-pack/plugins/global_search/server/types.ts @@ -16,6 +16,8 @@ import { GlobalSearchBatchedResults, GlobalSearchProviderFindOptions, GlobalSearchProviderResult, + GlobalSearchProviderFindParams, + GlobalSearchFindParams, } from '../common/types'; import { SearchServiceSetup, SearchServiceStart } from './services'; @@ -31,7 +33,10 @@ export interface RouteHandlerGlobalSearchContext { /** * See {@link SearchServiceStart.find | the find API} */ - find(term: string, options: GlobalSearchFindOptions): Observable; + find( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions + ): Observable; } /** @@ -97,7 +102,7 @@ export interface GlobalSearchResultProvider { * // returning all results in a single batch * setupDeps.globalSearch.registerResultProvider({ * id: 'my_provider', - * find: (term, { aborted$, preference, maxResults }, context) => { + * find: ({term, filters }, { aborted$, preference, maxResults }, context) => { * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); * return from(resultPromise).pipe(takeUntil(aborted$)); * }, @@ -105,7 +110,7 @@ export interface GlobalSearchResultProvider { * ``` */ find( - term: string, + search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions, context: GlobalSearchProviderContext ): Observable; diff --git a/x-pack/plugins/global_search_bar/kibana.json b/x-pack/plugins/global_search_bar/kibana.json index bf0ae83a0d863f..85e091fe1abadd 100644 --- a/x-pack/plugins/global_search_bar/kibana.json +++ b/x-pack/plugins/global_search_bar/kibana.json @@ -5,6 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["globalSearch"], - "optionalPlugins": ["usageCollection"], + "optionalPlugins": ["usageCollection", "savedObjectsTagging"], "configPath": ["xpack", "global_search_bar"] } diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap index bf7eacd2b52a11..de45d8ea5dfaf1 100644 --- a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap +++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap @@ -36,7 +36,7 @@ exports[`SearchBar supports keyboard shortcuts 1`] = ` aria-label="Filter options" autocomplete="off" class="euiFieldSearch euiFieldSearch--fullWidth euiFieldSearch--compressed euiSelectableSearch euiSelectableTemplateSitewide__search" - data-test-subj="header-search" + data-test-subj="nav-search-input" placeholder="Search Elastic" type="search" value="" 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 a3e2d66eabe5b9..5ba00c293d2139 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 @@ -54,7 +54,7 @@ describe('SearchBar', () => { }); const triggerFocus = () => { - component.find('input[data-test-subj="header-search"]').simulate('focus'); + component.find('input[data-test-subj="nav-search-input"]').simulate('focus'); }; const update = () => { @@ -100,7 +100,7 @@ describe('SearchBar', () => { update(); expect(searchService.find).toHaveBeenCalledTimes(1); - expect(searchService.find).toHaveBeenCalledWith('', {}); + expect(searchService.find).toHaveBeenCalledWith({}, {}); expect(getDisplayedOptionsTitle()).toMatchSnapshot(); await simulateTypeChar('d'); @@ -108,7 +108,7 @@ describe('SearchBar', () => { expect(getDisplayedOptionsTitle()).toMatchSnapshot(); expect(searchService.find).toHaveBeenCalledTimes(2); - expect(searchService.find).toHaveBeenCalledWith('d', {}); + expect(searchService.find).toHaveBeenCalledWith({ term: 'd' }, {}); }); it('supports keyboard shortcuts', () => { diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index adc55329962e91..3746e636066a93 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -5,7 +5,7 @@ */ import { - EuiBadge, + EuiCode, EuiFlexGroup, EuiFlexItem, EuiHeaderSectionItemButton, @@ -25,11 +25,18 @@ import useDebounce from 'react-use/lib/useDebounce'; import useEvent from 'react-use/lib/useEvent'; import useMountedState from 'react-use/lib/useMountedState'; import { Subscription } from 'rxjs'; -import { GlobalSearchPluginStart, GlobalSearchResult } from '../../../global_search/public'; +import { + GlobalSearchPluginStart, + GlobalSearchResult, + GlobalSearchFindParams, +} from '../../../global_search/public'; +import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; +import { parseSearchParams } from '../search_syntax'; interface Props { globalSearch: GlobalSearchPluginStart['find']; navigateToUrl: ApplicationStart['navigateToUrl']; + taggingApi?: SavedObjectTaggingPluginStart; trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void; basePathUrl: string; darkMode: boolean; @@ -64,17 +71,17 @@ const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => { const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewideOption => { const { id, title, url, icon, type, meta } = result; + // only displaying icons for applications + const useIcon = type === 'application'; const option: EuiSelectableTemplateSitewideOption = { key: id, label: title, url, type, + icon: { type: useIcon && icon ? icon : 'empty' }, + 'data-test-subj': `nav-search-option`, }; - if (icon) { - option.icon = { type: icon }; - } - if (type === 'application') { option.meta = [{ text: meta?.categoryLabel as string }]; } else { @@ -86,6 +93,7 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi export function SearchBar({ globalSearch, + taggingApi, navigateToUrl, trackUiMetric, basePathUrl, @@ -119,8 +127,24 @@ export function SearchBar({ } let arr: GlobalSearchResult[] = []; - if (searchValue.length !== 0) trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); - searchSubscription.current = globalSearch(searchValue, {}).subscribe({ + if (searchValue.length !== 0) { + trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); + } + + const rawParams = parseSearchParams(searchValue); + const tagIds = + taggingApi && rawParams.filters.tags + ? rawParams.filters.tags.map( + (tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? '__unknown__' + ) + : undefined; + const searchParams: GlobalSearchFindParams = { + term: rawParams.term, + types: rawParams.filters.types, + tags: tagIds, + }; + + searchSubscription.current = globalSearch(searchParams, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { arr = [...results, ...arr].sort(sortByScore); @@ -197,7 +221,7 @@ export function SearchBar({ }; const emptyMessage = ( - + } searchProps={{ + onSearch: () => undefined, onKeyUpCapture: (e: React.KeyboardEvent) => setSearchValue(e.currentTarget.value), - 'data-test-subj': 'header-search', + 'data-test-subj': 'nav-search-input', inputRef: setSearchRef, compressed: true, placeholder: i18n.translate('xpack.globalSearchBar.searchBar.placeholder', { @@ -256,6 +281,8 @@ export function SearchBar({ }, }} popoverProps={{ + 'data-test-subj': 'nav-search-popover', + panelClassName: 'navSearch__panel', repositionOnScroll: true, buttonRef: setButtonRef, }} @@ -265,42 +292,58 @@ export function SearchBar({ - - - - ), - commandDescription: ( - - - {isMac ? ( - - ) : ( - - )} - - - ), - }} - /> + +

+ +   + type:  + +   + tag: +

+
+ +

+ + ), + commandDescription: ( + + {isMac ? ( + + ) : ( + + )} + + ), + }} + /> +

+
} diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx index 14ac0935467d7d..81951843ee8b5a 100644 --- a/x-pack/plugins/global_search_bar/public/plugin.tsx +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -4,19 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import ReactDOM from 'react-dom'; import { UiStatsMetricType } from '@kbn/analytics'; import { I18nProvider } from '@kbn/i18n/react'; import { ApplicationStart } from 'kibana/public'; -import React from 'react'; -import ReactDOM from 'react-dom'; import { CoreStart, Plugin } from 'src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { GlobalSearchPluginStart } from '../../global_search/public'; -import { SearchBar } from '../public/components/search_bar'; +import { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public'; +import { SearchBar } from './components/search_bar'; export interface GlobalSearchBarPluginStartDeps { globalSearch: GlobalSearchPluginStart; - usageCollection: UsageCollectionSetup; + savedObjectsTagging?: SavedObjectTaggingPluginStart; + usageCollection?: UsageCollectionSetup; } export class GlobalSearchBarPlugin implements Plugin<{}, {}> { @@ -24,49 +26,61 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { return {}; } - public start(core: CoreStart, { globalSearch, usageCollection }: GlobalSearchBarPluginStartDeps) { - let trackUiMetric = (metricType: UiStatsMetricType, eventName: string | string[]) => {}; - - if (usageCollection) { - trackUiMetric = usageCollection.reportUiStats.bind(usageCollection, 'global_search_bar'); - } + public start( + core: CoreStart, + { globalSearch, savedObjectsTagging, usageCollection }: GlobalSearchBarPluginStartDeps + ) { + const trackUiMetric = usageCollection + ? usageCollection.reportUiStats.bind(usageCollection, 'global_search_bar') + : (metricType: UiStatsMetricType, eventName: string | string[]) => {}; core.chrome.navControls.registerCenter({ order: 1000, - mount: (target) => - this.mount( - target, + mount: (container) => + this.mount({ + container, globalSearch, - core.application.navigateToUrl, - core.http.basePath.prepend('/plugins/globalSearchBar/assets/'), - core.uiSettings.get('theme:darkMode'), - trackUiMetric - ), + savedObjectsTagging, + navigateToUrl: core.application.navigateToUrl, + basePathUrl: core.http.basePath.prepend('/plugins/globalSearchBar/assets/'), + darkMode: core.uiSettings.get('theme:darkMode'), + trackUiMetric, + }), }); return {}; } - private mount( - targetDomElement: HTMLElement, - globalSearch: GlobalSearchPluginStart, - navigateToUrl: ApplicationStart['navigateToUrl'], - basePathUrl: string, - darkMode: boolean, - trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void - ) { + private mount({ + container, + globalSearch, + savedObjectsTagging, + navigateToUrl, + basePathUrl, + darkMode, + trackUiMetric, + }: { + container: HTMLElement; + globalSearch: GlobalSearchPluginStart; + savedObjectsTagging?: SavedObjectTaggingPluginStart; + navigateToUrl: ApplicationStart['navigateToUrl']; + basePathUrl: string; + darkMode: boolean; + trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void; + }) { ReactDOM.render( , - targetDomElement + container ); - return () => ReactDOM.unmountComponentAtNode(targetDomElement); + return () => ReactDOM.unmountComponentAtNode(container); } } diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/index.ts b/x-pack/plugins/global_search_bar/public/search_syntax/index.ts new file mode 100644 index 00000000000000..01c52e468af3a4 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { parseSearchParams } from './parse_search_params'; +export { ParsedSearchParams, FilterValues, FilterValueType } from './types'; diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts new file mode 100644 index 00000000000000..3b00389b8605d0 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts @@ -0,0 +1,87 @@ +/* + * 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 { parseSearchParams } from './parse_search_params'; + +describe('parseSearchParams', () => { + it('returns the correct term', () => { + const searchParams = parseSearchParams('tag:(my-tag OR other-tag) hello'); + expect(searchParams.term).toEqual('hello'); + }); + + it('returns the raw query as `term` in case of parsing error', () => { + const searchParams = parseSearchParams('tag:((()^invalid'); + expect(searchParams).toEqual({ + term: 'tag:((()^invalid', + filters: { + unknowns: {}, + }, + }); + }); + + it('returns `undefined` term if query only contains field clauses', () => { + const searchParams = parseSearchParams('tag:(my-tag OR other-tag)'); + expect(searchParams.term).toBeUndefined(); + }); + + it('returns correct filters when no field clause is defined', () => { + const searchParams = parseSearchParams('hello'); + expect(searchParams.filters).toEqual({ + tags: undefined, + types: undefined, + unknowns: {}, + }); + }); + + it('returns correct filters when field clauses are present', () => { + const searchParams = parseSearchParams('tag:foo type:bar hello tag:dolly'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo', 'dolly'], + types: ['bar'], + unknowns: {}, + }, + }); + }); + + it('handles unknowns field clauses', () => { + const searchParams = parseSearchParams('tag:foo unknown:bar hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo'], + unknowns: { + unknown: ['bar'], + }, + }, + }); + }); + + it('handles aliases field clauses', () => { + const searchParams = parseSearchParams('tag:foo tags:bar type:dash types:board hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo', 'bar'], + types: ['dash', 'board'], + unknowns: {}, + }, + }); + }); + + it('converts boolean and number values to string for known filters', () => { + const searchParams = parseSearchParams('tag:42 tags:true type:69 types:false hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['42', 'true'], + types: ['69', 'false'], + unknowns: {}, + }, + }); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts new file mode 100644 index 00000000000000..83117ddfb507d1 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts @@ -0,0 +1,59 @@ +/* + * 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 { Query } from '@elastic/eui'; +import { getSearchTerm, getFieldValueMap, applyAliases } from './query_utils'; +import { FilterValues, ParsedSearchParams } from './types'; + +const knownFilters = ['tag', 'type']; + +const aliasMap = { + tag: ['tags'], + type: ['types'], +}; + +export const parseSearchParams = (term: string): ParsedSearchParams => { + let query: Query; + + try { + query = Query.parse(term); + } catch (e) { + // if the query fails to parse, we just perform the search against the raw search term. + return { + term, + filters: { + unknowns: {}, + }, + }; + } + + const searchTerm = getSearchTerm(query); + const filterValues = applyAliases(getFieldValueMap(query), aliasMap); + + const unknownFilters = [...filterValues.entries()] + .filter(([key]) => !knownFilters.includes(key)) + .reduce((unknowns, [key, value]) => { + return { + ...unknowns, + [key]: value, + }; + }, {} as Record); + + const tags = filterValues.get('tag'); + const types = filterValues.get('type'); + + return { + term: searchTerm, + filters: { + tags: tags ? valuesToString(tags) : undefined, + types: types ? valuesToString(types) : undefined, + unknowns: unknownFilters, + }, + }; +}; + +const valuesToString = (raw: FilterValues): FilterValues => + raw.map((value) => String(value)); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts new file mode 100644 index 00000000000000..c04f5dddd34a26 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { Query } from '@elastic/eui'; +import { getSearchTerm, getFieldValueMap, applyAliases } from './query_utils'; +import { FilterValues } from './types'; + +describe('getSearchTerm', () => { + const searchTerm = (raw: string) => getSearchTerm(Query.parse(raw)); + + it('returns the search term when no field is present', () => { + expect(searchTerm('some plain query')).toEqual('some plain query'); + }); + + it('remove leading and trailing spaces', () => { + expect(searchTerm(' hello dolly ')).toEqual('hello dolly'); + }); + + it('remove duplicate whitespaces', () => { + expect(searchTerm(' foo bar ')).toEqual('foo bar'); + }); + + it('omits field terms', () => { + expect(searchTerm('some tag:foo query type:dashboard')).toEqual('some query'); + expect(searchTerm('tag:foo another query type:(dashboard OR vis)')).toEqual('another query'); + }); + + it('remove duplicate whitespaces when using field terms', () => { + expect(searchTerm(' over tag:foo 9000 ')).toEqual('over 9000'); + }); +}); + +describe('getFieldValueMap', () => { + const fieldValueMap = (raw: string) => getFieldValueMap(Query.parse(raw)); + + it('parses single value field term', () => { + const result = fieldValueMap('tag:foo'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo']); + }); + + it('parses multi-value field term', () => { + const result = fieldValueMap('tag:(foo OR bar)'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar']); + }); + + it('parses multiple single value field terms', () => { + const result = fieldValueMap('tag:foo tag:bar'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar']); + }); + + it('parses boolean field terms', () => { + const result = fieldValueMap('tag:true tag:false'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual([true, false]); + }); + + it('parses numeric field terms', () => { + const result = fieldValueMap('tag:42 tag:9000'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual([42, 9000]); + }); + + it('parses multiple mixed single/multi value field terms', () => { + const result = fieldValueMap('tag:foo tag:(bar OR hello) tag:dolly'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar', 'hello', 'dolly']); + }); + + it('parses distinct field terms', () => { + const result = fieldValueMap('tag:foo type:dashboard tag:dolly type:(config OR map) foo:bar'); + + expect(result.size).toBe(3); + expect(result.get('tag')).toEqual(['foo', 'dolly']); + expect(result.get('type')).toEqual(['dashboard', 'config', 'map']); + expect(result.get('foo')).toEqual(['bar']); + }); + + it('ignore the search terms', () => { + const result = fieldValueMap('tag:foo some type:dashboard query foo:bar'); + + expect(result.size).toBe(3); + expect(result.get('tag')).toEqual(['foo']); + expect(result.get('type')).toEqual(['dashboard']); + expect(result.get('foo')).toEqual(['bar']); + }); +}); + +describe('applyAliases', () => { + const getValueMap = (entries: Record) => + new Map([...Object.entries(entries)]); + + it('returns the map unchanged when no aliases are used', () => { + const result = applyAliases( + getValueMap({ + tag: ['tag-1', 'tag-2'], + type: ['dashboard'], + }), + {} + ); + + expect(result.size).toEqual(2); + expect(result.get('tag')).toEqual(['tag-1', 'tag-2']); + expect(result.get('type')).toEqual(['dashboard']); + }); + + it('apply the aliases', () => { + const result = applyAliases( + getValueMap({ + tag: ['tag-1'], + tags: ['tag-2', 'tag-3'], + type: ['dashboard'], + }), + { + tag: ['tags'], + } + ); + + expect(result.size).toEqual(2); + expect(result.get('tag')).toEqual(['tag-1', 'tag-2', 'tag-3']); + expect(result.get('type')).toEqual(['dashboard']); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts new file mode 100644 index 00000000000000..93fdd943a202c8 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts @@ -0,0 +1,79 @@ +/* + * 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 { Query } from '@elastic/eui'; +import { FilterValues } from './types'; + +/** + * Return a name->values map for all the field clauses of given query. + * + * @example + * ``` + * getFieldValueMap(Query.parse('foo:bar foo:baz hello:dolly term')); + * >> { foo: ['bar', 'baz'], hello: ['dolly] } + * ``` + */ +export const getFieldValueMap = (query: Query) => { + const fieldMap = new Map(); + + query.ast.clauses.forEach((clause) => { + if (clause.type === 'field') { + const { field, value } = clause; + fieldMap.set(field, [ + ...(fieldMap.get(field) ?? []), + ...((Array.isArray(value) ? value : [value]) as FilterValues), + ]); + } + }); + + return fieldMap; +}; + +/** + * Aggregate all term clauses from given query and concatenate them. + */ +export const getSearchTerm = (query: Query): string | undefined => { + let term: string | undefined; + if (query.ast.getTermClauses().length) { + term = query.ast + .getTermClauses() + .map((clause) => clause.value) + .join(' ') + .replace(/\s{2,}/g, ' ') + .trim(); + } + return term?.length ? term : undefined; +}; + +/** + * Apply given alias map to the value map, concatenating the aliases values to the alias target, and removing + * the alias entry. Any non-aliased entries will remain unchanged. + * + * @example + * ``` + * applyAliases({ field: ['foo'], alias: ['bar'], hello: ['dolly'] }, { field: ['alias']}); + * >> { field: ['foo', 'bar'], hello: ['dolly'] } + * ``` + */ +export const applyAliases = ( + valueMap: Map, + aliasesMap: Record +): Map => { + const reverseLookup: Record = {}; + Object.entries(aliasesMap).forEach(([canonical, aliases]) => { + aliases.forEach((alias) => { + reverseLookup[alias] = canonical; + }); + }); + + const resultMap = new Map(); + valueMap.forEach((values, field) => { + const targetKey = reverseLookup[field] ?? field; + resultMap.set(targetKey, [...(resultMap.get(targetKey) ?? []), ...values]); + }); + + return resultMap; +}; diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/types.ts b/x-pack/plugins/global_search_bar/public/search_syntax/types.ts new file mode 100644 index 00000000000000..8df025a478bc5e --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/types.ts @@ -0,0 +1,34 @@ +/* + * 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 type FilterValueType = string | boolean | number; + +export type FilterValues = ValueType[]; + +export interface ParsedSearchParams { + /** + * The parsed search term. + * Can be undefined if the query was only composed of field terms. + */ + term?: string; + /** + * The filters extracted from the field terms. + */ + filters: { + /** + * Aggregation of `tag` and `tags` field clauses + */ + tags?: FilterValues; + /** + * Aggregation of `type` and `types` field clauses + */ + types?: FilterValues; + /** + * All unknown field clauses + */ + unknowns: Record; + }; +} 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 8acbda5e0a6d46..2831550da00d97 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 @@ -61,6 +61,10 @@ describe('applicationResultProvider', () => { getAppResultsMock.mockReturnValue([]); }); + afterEach(() => { + getAppResultsMock.mockReset(); + }); + it('has the correct id', () => { const provider = createApplicationResultProvider(Promise.resolve(application)); expect(provider.id).toBe('application'); @@ -76,7 +80,7 @@ describe('applicationResultProvider', () => { ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledTimes(1); expect(getAppResultsMock).toHaveBeenCalledWith('term', [ @@ -86,6 +90,59 @@ describe('applicationResultProvider', () => { ]); }); + 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)); + + const results = await provider + .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + + 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([]); + }); + it('ignores inaccessible apps', async () => { application.applications$ = of( createAppMap([ @@ -94,7 +151,7 @@ describe('applicationResultProvider', () => { ]) ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -108,7 +165,7 @@ describe('applicationResultProvider', () => { ]) ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -122,7 +179,7 @@ describe('applicationResultProvider', () => { ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -136,7 +193,7 @@ describe('applicationResultProvider', () => { ]); const provider = createApplicationResultProvider(Promise.resolve(application)); - const results = await provider.find('term', defaultOption).toPromise(); + const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(results).toEqual([ expectResult('r100'), @@ -160,7 +217,7 @@ describe('applicationResultProvider', () => { ...defaultOption, maxResults: 2, }; - const results = await provider.find('term', options).toPromise(); + const results = await provider.find({ term: 'term' }, options).toPromise(); expect(results).toEqual([expectResult('r100'), expectResult('r75')]); }); @@ -184,7 +241,7 @@ describe('applicationResultProvider', () => { aborted$: hot('|'), }; - const resultObs = provider.find('term', options); + const resultObs = provider.find({ term: 'term' }, options); expectObservable(resultObs).toBe('--(a|)', { a: [] }); }); @@ -209,7 +266,7 @@ describe('applicationResultProvider', () => { aborted$: hot('-(a|)', { a: undefined }), }; - const resultObs = provider.find('term', options); + const resultObs = provider.find({ term: 'term' }, options); expectObservable(resultObs).toBe('-|'); }); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts index 45264a3b2c521a..fd6eb0dc1878b8 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { from } from 'rxjs'; +import { from, of } from 'rxjs'; import { take, map, takeUntil, mergeMap, shareReplay } from 'rxjs/operators'; import { ApplicationStart } from 'src/core/public'; import { GlobalSearchResultProvider } from '../../../global_search/public'; @@ -26,12 +26,15 @@ export const createApplicationResultProvider = ( return { id: 'application', - find: (term, { aborted$, maxResults }) => { + find: ({ term, types, tags }, { aborted$, maxResults }) => { + if (tags || (types && !types.includes('application'))) { + return of([]); + } return searchableApps$.pipe( takeUntil(aborted$), take(1), map((apps) => { - const results = getAppResults(term, [...apps.values()]); + const results = getAppResults(term ?? '', [...apps.values()]); return results.sort((a, b) => b.score - a.score).slice(0, maxResults); }) ); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts index 8798fe6694c96c..ca5dbf8026472d 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts @@ -42,6 +42,7 @@ describe('mapToResult', () => { name: 'dashboard', management: { defaultSearchField: 'title', + icon: 'dashboardApp', getInAppUrl: (obj) => ({ path: `/dashboard/${obj.id}`, uiCapabilitiesPath: '' }), }, }); @@ -62,6 +63,7 @@ describe('mapToResult', () => { title: 'My dashboard', type: 'dashboard', url: '/dashboard/dash1', + icon: 'dashboardApp', score: 42, }); }); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts index 14641e1aaffff4..ec55a2a78fa9e1 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts @@ -50,6 +50,7 @@ export const mapToResult = ( // so we are forced to cast the attributes to any to access the properties associated with it. title: (object.attributes as any)[defaultSearchField], type: object.type, + icon: type.management?.icon ?? undefined, url: getInAppUrl(object).path, score: object.score, }; 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 b556e2785b4b4a..da9276278dbbf9 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 @@ -116,7 +116,7 @@ describe('savedObjectsResultProvider', () => { }); it('calls `savedObjectClient.find` with the correct parameters', async () => { - await provider.find('term', defaultOption, context).toPromise(); + await provider.find({ term: 'term' }, defaultOption, context).toPromise(); expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ @@ -129,8 +129,56 @@ describe('savedObjectsResultProvider', () => { }); }); - it('does not call `savedObjectClient.find` if `term` is empty', async () => { - const results = await provider.find('', defaultOption, context).pipe(toArray()).toPromise(); + 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('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(); expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); expect(results).toEqual([[]]); @@ -144,7 +192,7 @@ describe('savedObjectsResultProvider', () => { ]) ); - const results = await provider.find('term', defaultOption, context).toPromise(); + const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); expect(results).toEqual([ { id: 'resultA', @@ -172,7 +220,7 @@ describe('savedObjectsResultProvider', () => { ); const resultObs = provider.find( - 'term', + { term: 'term' }, { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, context ); 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 3861858a536268..3e2c42e7896fda 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,14 +6,15 @@ import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; +import { SavedObjectsFindOptionsReference } from 'src/core/server'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider => { return { id: 'savedObjects', - find: (term, { aborted$, maxResults, preference }, { core }) => { - if (!term) { + find: ({ term, types, tags }, { aborted$, maxResults, preference }, { core }) => { + if (!term && !types && !tags) { return of([]); } @@ -24,15 +25,22 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = const searchableTypes = typeRegistry .getVisibleTypes() + .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const searchFields = uniq( searchableTypes.map((type) => type.management!.defaultSearchField!) ); + const references: SavedObjectsFindOptionsReference[] | undefined = tags + ? tags.map((tagId) => ({ type: 'tag', id: tagId })) + : undefined; + const responsePromise = client.find({ page: 1, perPage: maxResults, search: term ? `${term}*` : undefined, + ...(references ? { hasReference: references } : {}), preference, searchFields, type: searchableTypes.map((type) => type.name), @@ -47,3 +55,6 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = }; const uniq = (values: T[]): T[] => [...new Set(values)]; + +const includeIgnoreCase = (list: string[], item: string) => + list.find((e) => e.toLowerCase() === item.toLowerCase()) !== undefined; diff --git a/x-pack/plugins/lens/public/search_provider.ts b/x-pack/plugins/lens/public/search_provider.ts index c19e7970b45ae6..02b7900a4c0039 100644 --- a/x-pack/plugins/lens/public/search_provider.ts +++ b/x-pack/plugins/lens/public/search_provider.ts @@ -6,7 +6,7 @@ import levenshtein from 'js-levenshtein'; import { ApplicationStart } from 'kibana/public'; -import { from } from 'rxjs'; +import { from, of } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { GlobalSearchResultProvider } from '../../global_search/public'; @@ -26,7 +26,10 @@ export const getSearchProvider: ( uiCapabilities: Promise ) => GlobalSearchResultProvider = (uiCapabilities) => ({ id: 'lens', - find: (term) => { + find: ({ term = '', types, tags }) => { + if (tags || (types && !types.includes('application'))) { + return of([]); + } return from( uiCapabilities.then(({ navLinks: { visualize: visualizeNavLink } }) => { if (!visualizeNavLink) { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 52ce8812454d97..5d48404fca2b75 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -8,7 +8,7 @@ import { OverlayStart } from 'src/core/public'; import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; import { TagsCapabilities } from '../../common'; import { ITagsCache, ITagInternalClient } from '../tags'; -import { getTagIdsFromReferences, updateTagsReferences } from '../utils'; +import { getTagIdsFromReferences, updateTagsReferences, convertTagNameToId } from '../utils'; import { getComponents } from './components'; import { buildGetTableColumnDefinition } from './get_table_column_definition'; import { buildGetSearchBarFilter } from './get_search_bar_filter'; @@ -39,6 +39,7 @@ export const getUiApi = ({ convertNameToReference: buildConvertNameToReference({ cache }), hasTagDecoration, getTagIdsFromReferences, + getTagIdFromName: (tagName: string) => convertTagNameToId(tagName, cache.getState()), updateTagsReferences, }; }; diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index da5b55f4aa2a1f..4c523ec5706e1c 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -37,6 +37,7 @@ import { RoleMappingsPageProvider } from './role_mappings_page'; import { SpaceSelectorPageProvider } from './space_selector_page'; import { IngestPipelinesPageProvider } from './ingest_pipelines_page'; import { TagManagementPageProvider } from './tag_management_page'; +import { NavigationalSearchProvider } from './navigational_search'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -72,4 +73,5 @@ export const pageObjects = { lens: LensPageProvider, roleMappings: RoleMappingsPageProvider, ingestPipelines: IngestPipelinesPageProvider, + navigationalSearch: NavigationalSearchProvider, }; diff --git a/x-pack/test/functional/page_objects/navigational_search.ts b/x-pack/test/functional/page_objects/navigational_search.ts new file mode 100644 index 00000000000000..77df829e31019e --- /dev/null +++ b/x-pack/test/functional/page_objects/navigational_search.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../ftr_provider_context'; + +interface SearchResult { + label: string; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function NavigationalSearchProvider({ getService, getPageObjects }: FtrProviderContext) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + + class NavigationalSearch { + async focus() { + const field = await testSubjects.find('nav-search-input'); + await field.click(); + } + + async blur() { + await testSubjects.click('helpMenuButton'); + await testSubjects.click('helpMenuButton'); + await find.waitForDeletedByCssSelector('.navSearch__panel'); + } + + async searchFor( + term: string, + { clear = true, wait = true }: { clear?: boolean; wait?: boolean } = {} + ) { + if (clear) { + await this.clearField(); + } + const field = await testSubjects.find('nav-search-input'); + await field.type(term); + if (wait) { + await this.waitForResultsLoaded(); + } + } + + async clearField() { + const field = await testSubjects.find('nav-search-input'); + await field.clearValueWithKeyboard(); + } + + async isPopoverDisplayed() { + return await find.existsByCssSelector('.navSearch__panel'); + } + + async clickOnOption(index: number) { + const options = await testSubjects.findAll('nav-search-option'); + await options[index].click(); + } + + async waitForResultsLoaded(waitUntil: number = 3000) { + await testSubjects.exists('nav-search-option'); + // results are emitted in multiple batches. Each individual batch causes a re-render of + // the component, causing the current elements to become stale. We can't perform DOM access + // without heavy flakiness in this situation. + // there is NO ui indication of any kind to detect when all the emissions are done, + // so we are forced to fallback to awaiting a given amount of time once the first options are displayed. + await delay(waitUntil); + } + + async getDisplayedResults() { + const resultElements = await testSubjects.findAll('nav-search-option'); + return Promise.all(resultElements.map((el) => this.convertResultElement(el))); + } + + async isNoResultsPlaceholderDisplayed(checkAfter: number = 3000) { + // see comment in `waitForResultsLoaded` + await delay(checkAfter); + return testSubjects.exists('nav-search-no-results'); + } + + private async convertResultElement(resultEl: WebElementWrapper): Promise { + const labelEl = await find.allDescendantDisplayedByCssSelector( + '.euiSelectableTemplateSitewide__listItemTitle', + resultEl + ); + const label = await labelEl[0].getVisibleText(); + + return { + label, + }; + } + } + + return new NavigationalSearch(); +} diff --git a/x-pack/test/plugin_functional/config.ts b/x-pack/test/plugin_functional/config.ts index cb0b9f63906ce8..600c598fc6bdf1 100644 --- a/x-pack/test/plugin_functional/config.ts +++ b/x-pack/test/plugin_functional/config.ts @@ -5,6 +5,7 @@ */ import { resolve } from 'path'; import fs from 'fs'; +import { KIBANA_ROOT } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; import { pageObjects } from './page_objects'; @@ -39,6 +40,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalConfig.get('kbnTestServer'), serverArgs: [ ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${resolve( + KIBANA_ROOT, + 'test/plugin_functional/plugins/core_provider_plugin' + )}`, ...plugins.map((pluginDir) => `--plugin-path=${resolve(__dirname, 'plugins', pluginDir)}`), ], }, diff --git a/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json new file mode 100644 index 00000000000000..69220756639dcf --- /dev/null +++ b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json @@ -0,0 +1,358 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#11FF22" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-4", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-4", + "description": "Last", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:logstash-*", + "index": ".kibana", + "source": { + "index-pattern": { + "fieldFormatMap": "{\"bytes\":{\"id\":\"bytes\"}}", + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-1", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 1 (tag-1)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-2", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 2 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-3", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 3 (tag-1 + tag-3)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + }, + { "type": "tag", + "id": "tag-3", + "name": "tag-3-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-4", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 4 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-5", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "My awesome vis (tag-4)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-4", + "name": "tag-4-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-2", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 1 (tag-2)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 2 (tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-1-and-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 3 (tag-1 and tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-1", + "name": "tag-1-ref", + "type": "tag" + }, + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} diff --git a/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json new file mode 100644 index 00000000000000..ec28b51de1d10c --- /dev/null +++ b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json @@ -0,0 +1,266 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "migrationVersion": { + "dynamic": "true", + "properties": { + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json index 934c6cce633870..e081b47760b996 100644 --- a/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json +++ b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json @@ -4,6 +4,6 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "global_search_test"], "requiredPlugins": ["globalSearch"], - "server": true, + "server": false, "ui": true } diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts index aba3512788f9c3..4e5adee4bce9c8 100644 --- a/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts +++ b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { of } from 'rxjs'; import { map, reduce } from 'rxjs/operators'; import { Plugin, CoreSetup, CoreStart, AppMountParameters } from 'kibana/public'; import { @@ -12,13 +11,11 @@ import { GlobalSearchPluginStart, GlobalSearchResult, } from '../../../../../plugins/global_search/public'; -import { createResult } from '../common/utils'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface GlobalSearchTestPluginSetup {} export interface GlobalSearchTestPluginStart { - findTest: (term: string) => Promise; - findReal: (term: string) => Promise; + find: (term: string) => Promise; } export interface GlobalSearchTestPluginSetupDeps { @@ -48,25 +45,6 @@ export class GlobalSearchTestPlugin }, }); - globalSearch.registerResultProvider({ - id: 'gs_test_client', - find: (term, options) => { - if (term.includes('client')) { - return of([ - createResult({ - id: 'client1', - type: 'test_client_type', - }), - createResult({ - id: 'client2', - type: 'test_client_type', - }), - ]); - } - return of([]); - }, - }); - return {}; } @@ -75,23 +53,11 @@ export class GlobalSearchTestPlugin { globalSearch }: GlobalSearchTestPluginStartDeps ): GlobalSearchTestPluginStart { return { - findTest: (term) => - globalSearch - .find(term, {}) - .pipe( - map((batch) => batch.results), - // restrict to test type to avoid failure when real providers are present - map((results) => results.filter((r) => r.type.startsWith('test_'))), - reduce((memo, results) => [...memo, ...results]) - ) - .toPromise(), - findReal: (term) => + find: (term) => globalSearch - .find(term, {}) + .find({ term }, {}) .pipe( map((batch) => batch.results), - // remove test types - map((results) => results.filter((r) => !r.type.startsWith('test_'))), reduce((memo, results) => [...memo, ...results]) ) .toPromise(), diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts deleted file mode 100644 index 7f9cdf423718b2..00000000000000 --- a/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { PluginInitializer } from 'src/core/server'; -import { - GlobalSearchTestPlugin, - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps, -} from './plugin'; - -export const plugin: PluginInitializer< - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps -> = () => new GlobalSearchTestPlugin(); diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts deleted file mode 100644 index d8ad94ab74207e..00000000000000 --- a/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 { of } from 'rxjs'; -import { Plugin, CoreSetup, CoreStart } from 'kibana/server'; -import { - GlobalSearchPluginSetup, - GlobalSearchPluginStart, -} from '../../../../../plugins/global_search/server'; -import { createResult } from '../common/utils'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GlobalSearchTestPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GlobalSearchTestPluginStart {} - -export interface GlobalSearchTestPluginSetupDeps { - globalSearch: GlobalSearchPluginSetup; -} -export interface GlobalSearchTestPluginStartDeps { - globalSearch: GlobalSearchPluginStart; -} - -export class GlobalSearchTestPlugin - implements - Plugin< - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps - > { - public setup(core: CoreSetup, { globalSearch }: GlobalSearchTestPluginSetupDeps) { - globalSearch.registerResultProvider({ - id: 'gs_test_server', - find: (term, options, context) => { - if (term.includes('server')) { - return of([ - createResult({ - id: 'server1', - type: 'test_server_type', - }), - createResult({ - id: 'server2', - type: 'test_server_type', - }), - ]); - } - return of([]); - }, - }); - - return {}; - } - - public start(core: CoreStart) { - return {}; - } -} diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts deleted file mode 100644 index 146c4297fc2c8f..00000000000000 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { GlobalSearchResult } from '../../../../plugins/global_search/common/types'; -import { GlobalSearchTestApi } from '../../plugins/global_search_test/public/types'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const pageObjects = getPageObjects(['common']); - const browser = getService('browser'); - - const findResultsWithAPI = async (t: string): Promise => { - return browser.executeAsync(async (term, cb) => { - const { start } = window._coreProvider; - const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest; - globalSearchTestApi.findTest(term).then(cb); - }, t); - }; - - describe('GlobalSearch API', function () { - beforeEach(async function () { - await pageObjects.common.navigateToApp('globalSearchTestApp'); - }); - - it('return no results when no provider return results', async () => { - const results = await findResultsWithAPI('no_match'); - expect(results.length).to.be(0); - }); - it('return results from the client provider', async () => { - const results = await findResultsWithAPI('client'); - expect(results.length).to.be(2); - expect(results.map((r) => r.id)).to.eql(['client1', 'client2']); - }); - it('return results from the server provider', async () => { - const results = await findResultsWithAPI('server'); - expect(results.length).to.be(2); - expect(results.map((r) => r.id)).to.eql(['server1', 'server2']); - }); - it('return mixed results from both client and server providers', async () => { - const results = await findResultsWithAPI('server+client'); - expect(results.length).to.be(4); - expect(results.map((r) => r.id)).to.eql(['client1', 'client2', 'server1', 'server2']); - }); - }); -} 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 005d516e2943cf..97d50bda899fde 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 @@ -8,33 +8,149 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - // See: https://github.com/elastic/kibana/issues/81397 - describe.skip('GlobalSearchBar', function () { - const { common } = getPageObjects(['common']); - const find = getService('find'); - const testSubjects = getService('testSubjects'); + describe('GlobalSearchBar', function () { + const { common, navigationalSearch } = getPageObjects(['common', 'navigationalSearch']); + const esArchiver = getService('esArchiver'); const browser = getService('browser'); before(async () => { + await esArchiver.load('global_search/search_syntax'); await common.navigateToApp('home'); }); - it('basically works', async () => { - const field = await testSubjects.find('header-search'); - await field.click(); + after(async () => { + await esArchiver.unload('global_search/search_syntax'); + }); - expect((await testSubjects.findAll('header-search-option')).length).to.be(15); + afterEach(async () => { + await navigationalSearch.blur(); + }); - field.type('d'); + it('shows the popover on focus', async () => { + await navigationalSearch.focus(); - const options = await testSubjects.findAll('header-search-option'); + expect(await navigationalSearch.isPopoverDisplayed()).to.eql(true); - expect(options.length).to.be(6); + await navigationalSearch.blur(); - await options[1].click(); + expect(await navigationalSearch.isPopoverDisplayed()).to.eql(false); + }); + + it('redirects to the correct page', async () => { + await navigationalSearch.searchFor('type:application discover'); + await navigationalSearch.clickOnOption(0); expect(await browser.getCurrentUrl()).to.contain('discover'); - expect(await (await find.activeElement()).getTagName()).to.be('body'); + }); + + describe('advanced search syntax', () => { + it('allows to filter by type', async () => { + await navigationalSearch.searchFor('type:dashboard'); + + const 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('allows to filter by multiple types', async () => { + await navigationalSearch.searchFor('type:(dashboard OR visualization)'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 2 (tag-2)', + 'Visualization 3 (tag-1 + tag-3)', + 'Visualization 4 (tag-2)', + 'My awesome vis (tag-4)', + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by tag', async () => { + await navigationalSearch.searchFor('tag:tag-1'); + + const 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)', + ]); + }); + + it('allows to filter by multiple tags', async () => { + await navigationalSearch.searchFor('tag:tag-1 tag:tag-3'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by type and tag', async () => { + await navigationalSearch.searchFor('type:dashboard tag:tag-3'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by multiple types and tags', async () => { + await navigationalSearch.searchFor( + 'type:(dashboard OR visualization) tag:(tag-1 OR tag-3)' + ); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by term and type', async () => { + await navigationalSearch.searchFor('type:visualization awesome'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql(['My awesome vis (tag-4)']); + }); + + it('allows to filter by term and tag', async () => { + await navigationalSearch.searchFor('tag:tag-4 awesome'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql(['My awesome vis (tag-4)']); + }); + + it('returns no results when searching for an unknown tag', async () => { + await navigationalSearch.searchFor('tag:unknown'); + + expect(await navigationalSearch.isNoResultsPlaceholderDisplayed()).to.eql(true); + }); + + it('returns no results when searching for an unknown type', async () => { + await navigationalSearch.searchFor('type:unknown'); + + expect(await navigationalSearch.isNoResultsPlaceholderDisplayed()).to.eql(true); + }); }); }); } diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts index 4b5b372c926410..16dc7b379214a0 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts @@ -18,7 +18,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { return browser.executeAsync(async (term, cb) => { const { start } = window._coreProvider; const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest; - globalSearchTestApi.findReal(term).then(cb); + globalSearchTestApi.find(term).then(cb); }, t); }; diff --git a/x-pack/test/plugin_functional/test_suites/global_search/index.ts b/x-pack/test/plugin_functional/test_suites/global_search/index.ts index f43e293c30fd6b..f3557ee8cc8dbe 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/index.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/index.ts @@ -7,10 +7,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - // See https://github.com/elastic/kibana/issues/81397 - describe.skip('GlobalSearch API', function () { - this.tags('ciGroup7'); - loadTestFile(require.resolve('./global_search_api')); + describe('GlobalSearch API', function () { + this.tags('ciGroup10'); loadTestFile(require.resolve('./global_search_providers')); loadTestFile(require.resolve('./global_search_bar')); });