diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.js b/.buildkite/scripts/steps/storybooks/build_and_upload.js index becb8f1bd871f..c541f59548753 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.js +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.js @@ -38,6 +38,7 @@ const STORYBOOKS = [ 'security_solution', 'shared_ux', 'ui_actions_enhanced', + 'unified_search', ]; const GITHUB_CONTEXT = 'Build and Publish Storybooks'; diff --git a/packages/analytics/client/src/schema/types.test.ts b/packages/analytics/client/src/schema/types.test.ts index 3ed25e46d6dc9..05eccf2bb19c7 100644 --- a/packages/analytics/client/src/schema/types.test.ts +++ b/packages/analytics/client/src/schema/types.test.ts @@ -421,6 +421,48 @@ describe('schema types', () => { }; expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain }); + + test('it should expect support readonly arrays', () => { + let valueType: SchemaValue> = { + type: 'array', + items: { + properties: { + a_value: { + type: 'keyword', + _meta: { + description: 'Some description', + }, + }, + }, + }, + }; + + valueType = { + type: 'array', + items: { + properties: { + a_value: { + type: 'keyword', + _meta: { + description: 'Some description', + optional: false, + }, + }, + }, + _meta: { + description: 'Description at the object level', + }, + }, + }; + + // @ts-expect-error because it's missing the items definition + valueType = { type: 'array' }; + // @ts-expect-error because it's missing the items definition + valueType = { type: 'array', items: {} }; + // @ts-expect-error because it's missing the items' properties definition + valueType = { type: 'array', items: { properties: {} } }; + expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain + }); }); }); diff --git a/packages/analytics/client/src/schema/types.ts b/packages/analytics/client/src/schema/types.ts index 8bac1ceaad620..5043c46e73fd4 100644 --- a/packages/analytics/client/src/schema/types.ts +++ b/packages/analytics/client/src/schema/types.ts @@ -64,7 +64,7 @@ export type SchemaValue = ? // If the Value is unknown (TS can't infer the type), allow any type of schema SchemaArray | SchemaObject | SchemaChildValue : // Otherwise, try to infer the type and enforce the schema - NonNullable extends Array + NonNullable extends Array | ReadonlyArray ? SchemaArray : NonNullable extends object ? SchemaObject diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index a8b1234a406fd..53052809b6b2f 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -13,7 +13,7 @@ module.exports = { */ USES_STYLED_COMPONENTS: [ /packages[\/\\]kbn-ui-shared-deps-(npm|src)[\/\\]/, - /src[\/\\]plugins[\/\\](unified_search|kibana_react)[\/\\]/, + /src[\/\\]plugins[\/\\](kibana_react)[\/\\]/, /x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|osquery|security_solution|timelines|synthetics|ux)[\/\\]/, /x-pack[\/\\]test[\/\\]plugin_functional[\/\\]plugins[\/\\]resolver_test[\/\\]/, ], diff --git a/src/core/public/analytics/analytics_service.test.mocks.ts b/src/core/public/analytics/analytics_service.test.mocks.ts new file mode 100644 index 0000000000000..3d98cf4392926 --- /dev/null +++ b/src/core/public/analytics/analytics_service.test.mocks.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AnalyticsClient } from '@kbn/analytics-client'; +import { Subject } from 'rxjs'; + +export const analyticsClientMock: jest.Mocked = { + optIn: jest.fn(), + reportEvent: jest.fn(), + registerEventType: jest.fn(), + registerContextProvider: jest.fn(), + removeContextProvider: jest.fn(), + registerShipper: jest.fn(), + telemetryCounter$: new Subject(), + shutdown: jest.fn(), +}; + +jest.doMock('@kbn/analytics-client', () => ({ + createAnalytics: () => analyticsClientMock, +})); diff --git a/src/core/public/analytics/analytics_service.test.ts b/src/core/public/analytics/analytics_service.test.ts new file mode 100644 index 0000000000000..e2298a79ff134 --- /dev/null +++ b/src/core/public/analytics/analytics_service.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { firstValueFrom, Observable } from 'rxjs'; +import { analyticsClientMock } from './analytics_service.test.mocks'; +import { coreMock, injectedMetadataServiceMock } from '../mocks'; +import { AnalyticsService } from './analytics_service'; + +describe('AnalyticsService', () => { + let analyticsService: AnalyticsService; + beforeEach(() => { + jest.clearAllMocks(); + analyticsService = new AnalyticsService(coreMock.createCoreContext()); + }); + test('should register some context providers on creation', async () => { + expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(3); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$) + ).resolves.toMatchInlineSnapshot(` + Object { + "branch": "branch", + "buildNum": 100, + "buildSha": "buildSha", + "isDev": true, + "isDistributable": false, + "version": "version", + } + `); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[1][0].context$) + ).resolves.toEqual({ session_id: expect.any(String) }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[2][0].context$) + ).resolves.toEqual({ + preferred_language: 'en-US', + preferred_languages: ['en-US', 'en'], + user_agent: expect.any(String), + }); + }); + + test('setup should expose all the register APIs, reportEvent and opt-in', () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + expect(analyticsService.setup({ injectedMetadata })).toStrictEqual({ + registerShipper: expect.any(Function), + registerContextProvider: expect.any(Function), + removeContextProvider: expect.any(Function), + registerEventType: expect.any(Function), + reportEvent: expect.any(Function), + optIn: expect.any(Function), + telemetryCounter$: expect.any(Observable), + }); + }); + + test('setup should register the elasticsearch info context provider (undefined)', async () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + analyticsService.setup({ injectedMetadata }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$) + ).resolves.toMatchInlineSnapshot(`undefined`); + }); + + test('setup should register the elasticsearch info context provider (with info)', async () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getElasticsearchInfo.mockReturnValue({ + cluster_name: 'cluster_name', + cluster_uuid: 'cluster_uuid', + cluster_version: 'version', + }); + analyticsService.setup({ injectedMetadata }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$) + ).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster_name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "version", + } + `); + }); + + test('setup should expose only the APIs report and opt-in', () => { + expect(analyticsService.start()).toStrictEqual({ + reportEvent: expect.any(Function), + optIn: expect.any(Function), + telemetryCounter$: expect.any(Observable), + }); + }); +}); diff --git a/src/core/public/analytics/analytics_service.ts b/src/core/public/analytics/analytics_service.ts index 86b0977faa0c0..723122ffbaef2 100644 --- a/src/core/public/analytics/analytics_service.ts +++ b/src/core/public/analytics/analytics_service.ts @@ -8,7 +8,10 @@ import type { AnalyticsClient } from '@kbn/analytics-client'; import { createAnalytics } from '@kbn/analytics-client'; +import { of } from 'rxjs'; +import { InjectedMetadataSetup } from '../injected_metadata'; import { CoreContext } from '../core_system'; +import { getSessionId } from './get_session_id'; import { createLogger } from './logger'; /** @@ -27,6 +30,11 @@ export type AnalyticsServiceStart = Pick< 'optIn' | 'reportEvent' | 'telemetryCounter$' >; +/** @internal */ +export interface AnalyticsServiceSetupDeps { + injectedMetadata: InjectedMetadataSetup; +} + export class AnalyticsService { private readonly analyticsClient: AnalyticsClient; @@ -38,9 +46,18 @@ export class AnalyticsService { // For now, we are relying on whether it's a distributable or running from source. sendTo: core.env.packageInfo.dist ? 'production' : 'staging', }); + + this.registerBuildInfoAnalyticsContext(core); + + // We may eventually move the following to the client's package since they are not Kibana-specific + // and can benefit other consumers of the client. + this.registerSessionIdContext(); + this.registerBrowserInfoAnalyticsContext(); } - public setup(): AnalyticsServiceSetup { + public setup({ injectedMetadata }: AnalyticsServiceSetupDeps): AnalyticsServiceSetup { + this.registerElasticsearchInfoContext(injectedMetadata); + return { optIn: this.analyticsClient.optIn, registerContextProvider: this.analyticsClient.registerContextProvider, @@ -51,6 +68,7 @@ export class AnalyticsService { telemetryCounter$: this.analyticsClient.telemetryCounter$, }; } + public start(): AnalyticsServiceStart { return { optIn: this.analyticsClient.optIn, @@ -58,7 +76,119 @@ export class AnalyticsService { telemetryCounter$: this.analyticsClient.telemetryCounter$, }; } + public stop() { this.analyticsClient.shutdown(); } + + /** + * Enriches the events with a session_id, so we can correlate them and understand funnels. + * @private + */ + private registerSessionIdContext() { + this.analyticsClient.registerContextProvider({ + name: 'session-id', + context$: of({ session_id: getSessionId() }), + schema: { + session_id: { + type: 'keyword', + _meta: { description: 'Unique session ID for every browser session' }, + }, + }, + }); + } + + /** + * Enriches the event with the build information. + * @param core The core context. + * @private + */ + private registerBuildInfoAnalyticsContext(core: CoreContext) { + this.analyticsClient.registerContextProvider({ + name: 'build info', + context$: of({ + isDev: core.env.mode.dev, + isDistributable: core.env.packageInfo.dist, + version: core.env.packageInfo.version, + branch: core.env.packageInfo.branch, + buildNum: core.env.packageInfo.buildNum, + buildSha: core.env.packageInfo.buildSha, + }), + schema: { + isDev: { + type: 'boolean', + _meta: { description: 'Is it running in development mode?' }, + }, + isDistributable: { + type: 'boolean', + _meta: { description: 'Is it running from a distributable?' }, + }, + version: { type: 'keyword', _meta: { description: 'Version of the Kibana instance.' } }, + branch: { + type: 'keyword', + _meta: { description: 'Branch of source running Kibana from.' }, + }, + buildNum: { type: 'long', _meta: { description: 'Build number of the Kibana instance.' } }, + buildSha: { type: 'keyword', _meta: { description: 'Build SHA of the Kibana instance.' } }, + }, + }); + } + + /** + * Enriches events with the current Browser's information + * @private + */ + private registerBrowserInfoAnalyticsContext() { + this.analyticsClient.registerContextProvider({ + name: 'browser info', + context$: of({ + user_agent: navigator.userAgent, + preferred_language: navigator.language, + preferred_languages: navigator.languages, + }), + schema: { + user_agent: { + type: 'keyword', + _meta: { description: 'User agent of the browser.' }, + }, + preferred_language: { + type: 'keyword', + _meta: { description: 'Preferred language of the browser.' }, + }, + preferred_languages: { + type: 'array', + items: { + type: 'keyword', + _meta: { description: 'List of the preferred languages of the browser.' }, + }, + }, + }, + }); + } + + /** + * Enriches the events with the Elasticsearch info (cluster name, uuid and version). + * @param injectedMetadata The injected metadata service. + * @private + */ + private registerElasticsearchInfoContext(injectedMetadata: InjectedMetadataSetup) { + this.analyticsClient.registerContextProvider({ + name: 'elasticsearch info', + context$: of(injectedMetadata.getElasticsearchInfo()), + schema: { + cluster_name: { + type: 'keyword', + _meta: { description: 'The Cluster Name', optional: true }, + }, + cluster_uuid: { + type: 'keyword', + _meta: { description: 'The Cluster UUID', optional: true }, + }, + cluster_version: { + type: 'keyword', + _meta: { description: 'The Cluster version', optional: true }, + }, + }, + }); + } } diff --git a/src/core/public/analytics/get_session_id.test.ts b/src/core/public/analytics/get_session_id.test.ts new file mode 100644 index 0000000000000..85ac515e29f68 --- /dev/null +++ b/src/core/public/analytics/get_session_id.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getSessionId } from './get_session_id'; + +describe('getSessionId', () => { + test('should return a session id', () => { + const sessionId = getSessionId(); + expect(sessionId).toStrictEqual(expect.any(String)); + }); + + test('calling it twice should return the same value', () => { + const sessionId1 = getSessionId(); + const sessionId2 = getSessionId(); + expect(sessionId2).toStrictEqual(sessionId1); + }); +}); diff --git a/src/core/public/analytics/get_session_id.ts b/src/core/public/analytics/get_session_id.ts new file mode 100644 index 0000000000000..62bb3a4a1c336 --- /dev/null +++ b/src/core/public/analytics/get_session_id.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { v4 } from 'uuid'; + +/** + * Returns a session ID for the current user. + * We are storing it to the sessionStorage. This means it remains the same through refreshes, + * but it is not persisted when closing the browser/tab or manually navigating to another URL. + */ +export function getSessionId(): string { + const sessionId = sessionStorage.getItem('sessionId') ?? v4(); + sessionStorage.setItem('sessionId', sessionId); + return sessionId; +} diff --git a/src/core/public/analytics/logger.test.ts b/src/core/public/analytics/logger.test.ts new file mode 100644 index 0000000000000..2fbe17e3f7d22 --- /dev/null +++ b/src/core/public/analytics/logger.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { LogRecord } from '@kbn/logging'; +import { createLogger } from './logger'; + +describe('createLogger', () => { + // Calling `.mockImplementation` on all of them to avoid jest logging the console usage + const logErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const logWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const logInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + const logDebugSpy = jest.spyOn(console, 'debug').mockImplementation(); + const logTraceSpy = jest.spyOn(console, 'trace').mockImplementation(); + const logLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should create a logger', () => { + const logger = createLogger(false); + expect(logger).toStrictEqual( + expect.objectContaining({ + fatal: expect.any(Function), + error: expect.any(Function), + warn: expect.any(Function), + info: expect.any(Function), + debug: expect.any(Function), + trace: expect.any(Function), + log: expect.any(Function), + get: expect.any(Function), + }) + ); + }); + + test('when isDev === false, it should not log anything', () => { + const logger = createLogger(false); + logger.fatal('fatal'); + expect(logErrorSpy).not.toHaveBeenCalled(); + logger.error('error'); + expect(logErrorSpy).not.toHaveBeenCalled(); + logger.warn('warn'); + expect(logWarnSpy).not.toHaveBeenCalled(); + logger.info('info'); + expect(logInfoSpy).not.toHaveBeenCalled(); + logger.debug('debug'); + expect(logDebugSpy).not.toHaveBeenCalled(); + logger.trace('trace'); + expect(logTraceSpy).not.toHaveBeenCalled(); + logger.log({} as LogRecord); + expect(logLogSpy).not.toHaveBeenCalled(); + logger.get().warn('warn'); + expect(logWarnSpy).not.toHaveBeenCalled(); + }); + + test('when isDev === true, it should log everything', () => { + const logger = createLogger(true); + logger.fatal('fatal'); + expect(logErrorSpy).toHaveBeenCalledTimes(1); + logger.error('error'); + expect(logErrorSpy).toHaveBeenCalledTimes(2); // fatal + error + logger.warn('warn'); + expect(logWarnSpy).toHaveBeenCalledTimes(1); + logger.info('info'); + expect(logInfoSpy).toHaveBeenCalledTimes(1); + logger.debug('debug'); + expect(logDebugSpy).toHaveBeenCalledTimes(1); + logger.trace('trace'); + expect(logTraceSpy).toHaveBeenCalledTimes(1); + logger.log({} as LogRecord); + expect(logLogSpy).toHaveBeenCalledTimes(1); + logger.get().warn('warn'); + expect(logWarnSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index 6eddf08cd2ae1..ff24cc8839794 100644 --- a/src/core/public/core_system.test.mocks.ts +++ b/src/core/public/core_system.test.mocks.ts @@ -21,6 +21,20 @@ import { renderingServiceMock } from './rendering/rendering_service.mock'; import { integrationsServiceMock } from './integrations/integrations_service.mock'; import { coreAppMock } from './core_app/core_app.mock'; import { themeServiceMock } from './theme/theme_service.mock'; +import { analyticsServiceMock } from './analytics/analytics_service.mock'; + +export const analyticsServiceStartMock = analyticsServiceMock.createAnalyticsServiceStart(); +export const MockAnalyticsService = analyticsServiceMock.create(); +MockAnalyticsService.start.mockReturnValue(analyticsServiceStartMock); +export const AnalyticsServiceConstructor = jest.fn().mockReturnValue(MockAnalyticsService); +jest.doMock('./analytics', () => ({ + AnalyticsService: AnalyticsServiceConstructor, +})); + +export const fetchOptionalMemoryInfoMock = jest.fn(); +jest.doMock('./fetch_optional_memory_info', () => ({ + fetchOptionalMemoryInfo: fetchOptionalMemoryInfoMock, +})); export const MockInjectedMetadataService = injectedMetadataServiceMock.create(); export const InjectedMetadataServiceConstructor = jest diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 553c1668951e8..2a57364c9f93f 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -34,6 +34,10 @@ import { MockCoreApp, MockThemeService, ThemeServiceConstructor, + AnalyticsServiceConstructor, + MockAnalyticsService, + analyticsServiceStartMock, + fetchOptionalMemoryInfoMock, } from './core_system.test.mocks'; import { CoreSystem } from './core_system'; @@ -56,6 +60,7 @@ const defaultCoreSystemParams = { }, packageInfo: { dist: false, + version: '1.2.3', }, }, version: 'version', @@ -90,6 +95,7 @@ describe('constructor', () => { expect(IntegrationsServiceConstructor).toHaveBeenCalledTimes(1); expect(CoreAppConstructor).toHaveBeenCalledTimes(1); expect(ThemeServiceConstructor).toHaveBeenCalledTimes(1); + expect(AnalyticsServiceConstructor).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -146,6 +152,11 @@ describe('#setup()', () => { return core.setup(); } + it('calls analytics#setup()', async () => { + await setupCore(); + expect(MockAnalyticsService.setup).toHaveBeenCalledTimes(1); + }); + it('calls application#setup()', async () => { await setupCore(); expect(MockApplicationService.setup).toHaveBeenCalledTimes(1); @@ -222,6 +233,36 @@ describe('#start()', () => { ); }); + it('reports the event Loaded Kibana', async () => { + await startCore(); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version: '1.2.3', + }); + }); + + it('reports the event Loaded Kibana (with memory)', async () => { + fetchOptionalMemoryInfoMock.mockReturnValue({ + memory_js_heap_size_limit: 3, + memory_js_heap_size_total: 2, + memory_js_heap_size_used: 1, + }); + + await startCore(); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version: '1.2.3', + memory_js_heap_size_limit: 3, + memory_js_heap_size_total: 2, + memory_js_heap_size_used: 1, + }); + }); + + it('calls analytics#start()', async () => { + await startCore(); + expect(MockAnalyticsService.start).toHaveBeenCalledTimes(1); + }); + it('calls application#start()', async () => { await startCore(); expect(MockApplicationService.start).toHaveBeenCalledTimes(1); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 9334dd579f0f3..9ea1f16f7f226 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -32,7 +32,9 @@ import { ThemeService } from './theme'; import { CoreApp } from './core_app'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; import { ExecutionContextService } from './execution_context'; +import type { AnalyticsServiceSetup } from './analytics'; import { AnalyticsService } from './analytics'; +import { fetchOptionalMemoryInfo } from './fetch_optional_memory_info'; interface Params { rootDomElement: HTMLElement; @@ -148,9 +150,10 @@ export class CoreSystem { await this.integrations.setup(); this.docLinks.setup(); - const analytics = this.analytics.setup(); + const analytics = this.analytics.setup({ injectedMetadata }); + this.registerLoadedKibanaEventType(analytics); - const executionContext = this.executionContext.setup(); + const executionContext = this.executionContext.setup({ analytics }); const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup, @@ -273,6 +276,11 @@ export class CoreSystem { targetDomElement: coreUiTargetDomElement, }); + analytics.reportEvent('Loaded Kibana', { + kibana_version: this.coreContext.env.packageInfo.version, + ...fetchOptionalMemoryInfo(), + }); + return { application, executionContext, @@ -303,4 +311,28 @@ export class CoreSystem { this.analytics.stop(); this.rootDomElement.textContent = ''; } + + private registerLoadedKibanaEventType(analytics: AnalyticsServiceSetup) { + analytics.registerEventType({ + eventType: 'Loaded Kibana', + schema: { + kibana_version: { + type: 'keyword', + _meta: { description: 'The version of Kibana' }, + }, + memory_js_heap_size_limit: { + type: 'long', + _meta: { description: 'The maximum size of the heap', optional: true }, + }, + memory_js_heap_size_total: { + type: 'long', + _meta: { description: 'The total size of the heap', optional: true }, + }, + memory_js_heap_size_used: { + type: 'long', + _meta: { description: 'The used size of the heap', optional: true }, + }, + }, + }); + } } diff --git a/src/core/public/execution_context/execution_context_service.test.ts b/src/core/public/execution_context/execution_context_service.test.ts index 70e57b8993bb1..5c8f8bfae89f8 100644 --- a/src/core/public/execution_context/execution_context_service.test.ts +++ b/src/core/public/execution_context/execution_context_service.test.ts @@ -5,23 +5,45 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { ExecutionContextService, ExecutionContextSetup } from './execution_context_service'; +import type { AnalyticsServiceSetup } from '../analytics'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; describe('ExecutionContextService', () => { let execContext: ExecutionContextSetup; let curApp$: BehaviorSubject; let execService: ExecutionContextService; + let analytics: jest.Mocked; beforeEach(() => { + analytics = analyticsServiceMock.createAnalyticsServiceSetup(); execService = new ExecutionContextService(); - execContext = execService.setup(); + execContext = execService.setup({ analytics }); curApp$ = new BehaviorSubject('app1'); execContext = execService.start({ curApp$, }); }); + it('should extend the analytics context', async () => { + expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1); + const context$ = analytics.registerContextProvider.mock.calls[0][0].context$; + execContext.set({ + type: 'ghf', + description: 'first set', + }); + + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "applicationId": "app1", + "entityId": undefined, + "page": undefined, + "pageName": "ghf:app1", + } + `); + }); + it('app name updates automatically and clears everything else', () => { execContext.set({ type: 'ghf', diff --git a/src/core/public/execution_context/execution_context_service.ts b/src/core/public/execution_context/execution_context_service.ts index a14d876c9643c..c8d198b9c84f8 100644 --- a/src/core/public/execution_context/execution_context_service.ts +++ b/src/core/public/execution_context/execution_context_service.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ -import { isEqual, isUndefined, omitBy } from 'lodash'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { compact, isEqual, isUndefined, omitBy } from 'lodash'; +import { BehaviorSubject, Observable, Subscription, map } from 'rxjs'; +import { AnalyticsServiceSetup } from '../analytics'; import { CoreService, KibanaExecutionContext } from '../../types'; // Should be exported from elastic/apm-rum @@ -55,6 +56,10 @@ export interface ExecutionContextSetup { */ export type ExecutionContextStart = ExecutionContextSetup; +export interface SetupDeps { + analytics: AnalyticsServiceSetup; +} + export interface StartDeps { curApp$: Observable; } @@ -68,7 +73,9 @@ export class ExecutionContextService private subscription: Subscription = new Subscription(); private contract?: ExecutionContextSetup; - public setup() { + public setup({ analytics }: SetupDeps) { + this.enrichAnalyticsContext(analytics); + this.contract = { context$: this.context$.asObservable(), clear: () => { @@ -134,4 +141,45 @@ export class ExecutionContextService ...context, }; } + + /** + * Sets the analytics context provider based on the execution context details. + * @param analytics The analytics service + * @private + */ + private enrichAnalyticsContext(analytics: AnalyticsServiceSetup) { + analytics.registerContextProvider({ + name: 'execution_context', + context$: this.context$.pipe( + map(({ type, name, page, id }) => ({ + pageName: `${compact([type, name, page]).join(':')}`, + applicationId: name ?? type ?? 'unknown', + page, + entityId: id, + })) + ), + schema: { + pageName: { + type: 'keyword', + _meta: { description: 'The name of the current page' }, + }, + page: { + type: 'keyword', + _meta: { description: 'The current page', optional: true }, + }, + applicationId: { + type: 'keyword', + _meta: { description: 'The id of the current application' }, + }, + entityId: { + type: 'keyword', + _meta: { + description: + 'The id of the current entity (dashboard, visualization, canvas, lens, etc)', + optional: true, + }, + }, + }, + }); + } } diff --git a/src/core/public/fetch_optional_memory_info.test.ts b/src/core/public/fetch_optional_memory_info.test.ts new file mode 100644 index 0000000000000..f92fad9c14d63 --- /dev/null +++ b/src/core/public/fetch_optional_memory_info.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fetchOptionalMemoryInfo } from './fetch_optional_memory_info'; + +describe('fetchOptionalMemoryInfo', () => { + test('should return undefined if no memory info is available', () => { + expect(fetchOptionalMemoryInfo()).toBeUndefined(); + }); + + test('should return the memory info when available', () => { + // @ts-expect-error 2339 + window.performance.memory = { + get jsHeapSizeLimit() { + return 3; + }, + get totalJSHeapSize() { + return 2; + }, + get usedJSHeapSize() { + return 1; + }, + }; + expect(fetchOptionalMemoryInfo()).toEqual({ + memory_js_heap_size_limit: 3, + memory_js_heap_size_total: 2, + memory_js_heap_size_used: 1, + }); + }); +}); diff --git a/src/core/public/fetch_optional_memory_info.ts b/src/core/public/fetch_optional_memory_info.ts new file mode 100644 index 0000000000000..b18f3ca2698da --- /dev/null +++ b/src/core/public/fetch_optional_memory_info.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * `Performance.memory` output. + * https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory + */ +export interface BrowserPerformanceMemoryInfo { + /** + * The maximum size of the heap, in bytes, that is available to the context. + */ + memory_js_heap_size_limit: number; + /** + * The total allocated heap size, in bytes. + */ + memory_js_heap_size_total: number; + /** + * The currently active segment of JS heap, in bytes. + */ + memory_js_heap_size_used: number; +} + +/** + * Get performance information from the browser (non-standard property). + * @remarks Only available in Google Chrome and MS Edge for now. + */ +export function fetchOptionalMemoryInfo(): BrowserPerformanceMemoryInfo | undefined { + // @ts-expect-error 2339 + const memory = window.performance.memory; + if (memory) { + return { + memory_js_heap_size_limit: memory.jsHeapSizeLimit, + memory_js_heap_size_total: memory.totalJSHeapSize, + memory_js_heap_size_used: memory.usedJSHeapSize, + }; + } +} diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index dc8fe63724411..83903942df53d 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -16,6 +16,7 @@ const createSetupContractMock = () => { getPublicBaseUrl: jest.fn(), getKibanaVersion: jest.fn(), getKibanaBranch: jest.fn(), + getElasticsearchInfo: jest.fn(), getCspConfig: jest.fn(), getExternalUrlConfig: jest.fn(), getAnonymousStatusPage: jest.fn(), diff --git a/src/core/public/injected_metadata/injected_metadata_service.test.ts b/src/core/public/injected_metadata/injected_metadata_service.test.ts index 3237401b38fa8..ba0e2470d7f26 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.test.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.test.ts @@ -9,6 +9,36 @@ import { DiscoveredPlugin } from '../../server'; import { InjectedMetadataService } from './injected_metadata_service'; +describe('setup.getElasticsearchInfo()', () => { + it('returns elasticsearch info from injectedMetadata', () => { + const setup = new InjectedMetadataService({ + injectedMetadata: { + clusterInfo: { + cluster_uuid: 'foo', + cluster_name: 'cluster_name', + cluster_version: 'version', + }, + }, + } as any).setup(); + + expect(setup.getElasticsearchInfo()).toEqual({ + cluster_uuid: 'foo', + cluster_name: 'cluster_name', + cluster_version: 'version', + }); + }); + + it('returns elasticsearch info as undefined if not present in the injectedMetadata', () => { + const setup = new InjectedMetadataService({ + injectedMetadata: { + clusterInfo: {}, + }, + } as any).setup(); + + expect(setup.getElasticsearchInfo()).toEqual({}); + }); +}); + describe('setup.getKibanaBuildNumber()', () => { it('returns buildNumber from injectedMetadata', () => { const setup = new InjectedMetadataService({ diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 07f56b889fc79..2e19da5c2cffe 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -27,6 +27,12 @@ export interface InjectedPluginMetadata { }; } +export interface InjectedMetadataClusterInfo { + cluster_uuid?: string; + cluster_name?: string; + cluster_version?: string; +} + /** @internal */ export interface InjectedMetadataParams { injectedMetadata: { @@ -36,6 +42,7 @@ export interface InjectedMetadataParams { basePath: string; serverBasePath: string; publicBaseUrl: string; + clusterInfo: InjectedMetadataClusterInfo; category?: AppCategory; csp: { warnLegacyBrowsers: boolean; @@ -143,6 +150,10 @@ export class InjectedMetadataService { getTheme: () => { return this.state.theme; }, + + getElasticsearchInfo: () => { + return this.state.clusterInfo; + }, }; } } @@ -169,6 +180,7 @@ export interface InjectedMetadataSetup { darkMode: boolean; version: ThemeVersion; }; + getElasticsearchInfo: () => InjectedMetadataClusterInfo; /** * An array of frontend plugins in topological order. */ diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 3e431f07bd1cf..732ba71fcd2af 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1590,6 +1590,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:192:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:195:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/server/analytics/analytics_service.ts b/src/core/server/analytics/analytics_service.ts index 3afc997fd52ea..24389dfa7e938 100644 --- a/src/core/server/analytics/analytics_service.ts +++ b/src/core/server/analytics/analytics_service.ts @@ -8,6 +8,7 @@ import type { AnalyticsClient } from '@kbn/analytics-client'; import { createAnalytics } from '@kbn/analytics-client'; +import { of } from 'rxjs'; import type { CoreContext } from '../core_context'; /** @@ -43,6 +44,8 @@ export class AnalyticsService { // For now, we are relying on whether it's a distributable or running from source. sendTo: core.env.packageInfo.dist ? 'production' : 'staging', }); + + this.registerBuildInfoAnalyticsContext(core); } public preboot(): AnalyticsServicePreboot { @@ -74,7 +77,44 @@ export class AnalyticsService { telemetryCounter$: this.analyticsClient.telemetryCounter$, }; } + public stop() { this.analyticsClient.shutdown(); } + + /** + * Enriches the event with the build information. + * @param core The core context. + * @private + */ + private registerBuildInfoAnalyticsContext(core: CoreContext) { + this.analyticsClient.registerContextProvider({ + name: 'build info', + context$: of({ + isDev: core.env.mode.dev, + isDistributable: core.env.packageInfo.dist, + version: core.env.packageInfo.version, + branch: core.env.packageInfo.branch, + buildNum: core.env.packageInfo.buildNum, + buildSha: core.env.packageInfo.buildSha, + }), + schema: { + isDev: { + type: 'boolean', + _meta: { description: 'Is it running in development mode?' }, + }, + isDistributable: { + type: 'boolean', + _meta: { description: 'Is it running from a distributable?' }, + }, + version: { type: 'keyword', _meta: { description: 'Version of the Kibana instance.' } }, + branch: { + type: 'keyword', + _meta: { description: 'Branch of source running Kibana from.' }, + }, + buildNum: { type: 'long', _meta: { description: 'Build number of the Kibana instance.' } }, + buildSha: { type: 'keyword', _meta: { description: 'Build SHA of the Kibana instance.' } }, + }, + }); + } } diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 3ef44e2690a95..02a846a5b8011 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -25,6 +25,7 @@ import { } from './types'; import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus, ServiceStatusLevels } from '../status'; +import type { ClusterInfo } from './get_cluster_info'; type MockedElasticSearchServicePreboot = jest.Mocked; @@ -89,6 +90,11 @@ const createInternalSetupContractMock = () => { warningNodes: [], kibanaVersion: '8.0.0', }), + clusterInfo$: new BehaviorSubject({ + cluster_uuid: 'cluster-uuid', + cluster_name: 'cluster-name', + cluster_version: '8.0.0', + }), status$: new BehaviorSubject>({ level: ServiceStatusLevels.available, summary: 'Elasticsearch is available', diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index def2c400258b5..875995cd7cd96 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -34,6 +34,7 @@ import { elasticsearchClientMock } from './client/mocks'; import { duration } from 'moment'; import { isValidConnection as isValidConnectionMock } from './is_valid_connection'; import { pollEsNodesVersion as pollEsNodesVersionMocked } from './version_check/ensure_es_version'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; const { pollEsNodesVersion: pollEsNodesVersionActual } = jest.requireActual( './version_check/ensure_es_version' @@ -53,6 +54,7 @@ let setupDeps: SetupDeps; beforeEach(() => { setupDeps = { + analytics: analyticsServiceMock.createAnalyticsServiceSetup(), http: httpServiceMock.createInternalSetupContract(), executionContext: executionContextServiceMock.createInternalSetupContract(), }; diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index d0cf23c539416..09e8b3172c8e7 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -9,6 +9,8 @@ import { firstValueFrom, Observable, Subject } from 'rxjs'; import { map, shareReplay, takeUntil } from 'rxjs/operators'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; +import { AnalyticsServiceSetup } from '../analytics'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; @@ -29,8 +31,10 @@ import { isValidConnection } from './is_valid_connection'; import { isInlineScriptingEnabled } from './is_scripting_enabled'; import type { UnauthorizedErrorHandler } from './client/retry_unauthorized'; import { mergeConfig } from './merge_config'; +import { getClusterInfo$ } from './get_cluster_info'; export interface SetupDeps { + analytics: AnalyticsServiceSetup; http: InternalHttpServiceSetup; executionContext: InternalExecutionContextSetup; } @@ -92,10 +96,14 @@ export class ElasticsearchService this.esNodesCompatibility$ = esNodesCompatibility$; + const clusterInfo$ = getClusterInfo$(this.client.asInternalUser); + registerAnalyticsContextProvider(deps.analytics, clusterInfo$); + return { legacy: { config$: this.config$, }, + clusterInfo$, esNodesCompatibility$, status$: calculateStatus$(esNodesCompatibility$), setUnauthorizedErrorHandler: (handler) => { diff --git a/src/core/server/elasticsearch/get_cluster_info.test.ts b/src/core/server/elasticsearch/get_cluster_info.test.ts new file mode 100644 index 0000000000000..fd3b3b71844ac --- /dev/null +++ b/src/core/server/elasticsearch/get_cluster_info.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { elasticsearchClientMock } from './client/mocks'; +import { firstValueFrom } from 'rxjs'; +import { getClusterInfo$ } from './get_cluster_info'; + +describe('getClusterInfo', () => { + let internalClient: ReturnType; + const infoResponse = { + cluster_name: 'cluster-name', + cluster_uuid: 'cluster_uuid', + name: 'name', + tagline: 'tagline', + version: { + number: '1.2.3', + lucene_version: '1.2.3', + build_date: 'DateString', + build_flavor: 'string', + build_hash: 'string', + build_snapshot: true, + build_type: 'string', + minimum_index_compatibility_version: '1.2.3', + minimum_wire_compatibility_version: '1.2.3', + }, + }; + + beforeEach(() => { + internalClient = elasticsearchClientMock.createInternalClient(); + }); + + test('it provides the context', async () => { + internalClient.info.mockResolvedValue(infoResponse); + const context$ = getClusterInfo$(internalClient); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + }); + + test('it retries if it fails to fetch the cluster info', async () => { + internalClient.info.mockRejectedValueOnce(new Error('Failed to fetch cluster info')); + internalClient.info.mockResolvedValue(infoResponse); + const context$ = getClusterInfo$(internalClient); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + expect(internalClient.info).toHaveBeenCalledTimes(2); + }); + + test('multiple subscribers do not trigger more ES requests', async () => { + internalClient.info.mockResolvedValue(infoResponse); + const context$ = getClusterInfo$(internalClient); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + expect(internalClient.info).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/server/elasticsearch/get_cluster_info.ts b/src/core/server/elasticsearch/get_cluster_info.ts new file mode 100644 index 0000000000000..c807965d3bbf8 --- /dev/null +++ b/src/core/server/elasticsearch/get_cluster_info.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Observable } from 'rxjs'; +import { defer, map, retry, shareReplay } from 'rxjs'; +import type { ElasticsearchClient } from './client'; + +/** @private */ +export interface ClusterInfo { + cluster_name: string; + cluster_uuid: string; + cluster_version: string; +} + +/** + * Returns the cluster info from the Elasticsearch cluster. + * @param internalClient Elasticsearch client + * @private + */ +export function getClusterInfo$(internalClient: ElasticsearchClient): Observable { + return defer(() => internalClient.info()).pipe( + map((info) => ({ + cluster_name: info.cluster_name, + cluster_uuid: info.cluster_uuid, + cluster_version: info.version.number, + })), + retry({ delay: 1000 }), + shareReplay(1) + ); +} diff --git a/src/core/server/elasticsearch/register_analytics_context_provider.test.ts b/src/core/server/elasticsearch/register_analytics_context_provider.test.ts new file mode 100644 index 0000000000000..4f09ea8677f44 --- /dev/null +++ b/src/core/server/elasticsearch/register_analytics_context_provider.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { firstValueFrom, of } from 'rxjs'; +import type { AnalyticsServiceSetup } from '../analytics'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; + +describe('registerAnalyticsContextProvider', () => { + let analyticsMock: jest.Mocked; + + beforeEach(() => { + analyticsMock = analyticsServiceMock.createAnalyticsServiceSetup(); + }); + + test('it provides the context', async () => { + registerAnalyticsContextProvider( + analyticsMock, + of({ cluster_name: 'cluster-name', cluster_uuid: 'cluster_uuid', cluster_version: '1.2.3' }) + ); + const { context$ } = analyticsMock.registerContextProvider.mock.calls[0][0]; + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + }); +}); diff --git a/src/core/server/elasticsearch/register_analytics_context_provider.ts b/src/core/server/elasticsearch/register_analytics_context_provider.ts new file mode 100644 index 0000000000000..cc4523c0d4eb5 --- /dev/null +++ b/src/core/server/elasticsearch/register_analytics_context_provider.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Observable } from 'rxjs'; +import type { AnalyticsServiceSetup } from '../analytics'; +import type { ClusterInfo } from './get_cluster_info'; + +/** + * Registers the Analytics context provider to enrich events with the cluster info. + * @param analytics Analytics service. + * @param context$ Observable emitting the cluster info. + * @private + */ +export function registerAnalyticsContextProvider( + analytics: AnalyticsServiceSetup, + context$: Observable +) { + analytics.registerContextProvider({ + name: 'elasticsearch info', + context$, + schema: { + cluster_name: { type: 'keyword', _meta: { description: 'The Cluster Name' } }, + cluster_uuid: { type: 'keyword', _meta: { description: 'The Cluster UUID' } }, + cluster_version: { type: 'keyword', _meta: { description: 'The Cluster version' } }, + }, + }); +} diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 1f363804b3a33..12ba2575d2726 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -14,6 +14,7 @@ import { IClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus } from '../status'; import type { UnauthorizedErrorHandler } from './client/retry_unauthorized'; +import { ClusterInfo } from './get_cluster_info'; /** * @public @@ -97,6 +98,7 @@ export type InternalElasticsearchServicePreboot = ElasticsearchServicePreboot; /** @internal */ export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceSetup { + clusterInfo$: Observable; esNodesCompatibility$: Observable; status$: Observable>; } diff --git a/src/core/server/environment/environment_service.test.ts b/src/core/server/environment/environment_service.test.ts index 0817fad35f882..c285edc443ce8 100644 --- a/src/core/server/environment/environment_service.test.ts +++ b/src/core/server/environment/environment_service.test.ts @@ -13,10 +13,12 @@ import { resolveInstanceUuid } from './resolve_uuid'; import { createDataFolder } from './create_data_folder'; import { writePidFile } from './write_pid_file'; import { CoreContext } from '../core_context'; +import type { AnalyticsServicePreboot } from '../analytics'; import { configServiceMock } from '../config/mocks'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { mockCoreContext } from '../core_context.mock'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; jest.mock('./resolve_uuid', () => ({ resolveInstanceUuid: jest.fn().mockResolvedValue('SOME_UUID'), @@ -63,11 +65,13 @@ describe('UuidService', () => { let configService: ReturnType; let coreContext: CoreContext; let service: EnvironmentService; + let analytics: AnalyticsServicePreboot; beforeEach(async () => { logger = loggingSystemMock.create(); configService = getConfigService(); coreContext = mockCoreContext.create({ logger, configService }); + analytics = analyticsServiceMock.createAnalyticsServicePreboot(); service = new EnvironmentService(coreContext); }); @@ -78,7 +82,7 @@ describe('UuidService', () => { describe('#preboot()', () => { it('calls resolveInstanceUuid with correct parameters', async () => { - await service.preboot(); + await service.preboot({ analytics }); expect(resolveInstanceUuid).toHaveBeenCalledTimes(1); expect(resolveInstanceUuid).toHaveBeenCalledWith({ @@ -89,7 +93,7 @@ describe('UuidService', () => { }); it('calls createDataFolder with correct parameters', async () => { - await service.preboot(); + await service.preboot({ analytics }); expect(createDataFolder).toHaveBeenCalledTimes(1); expect(createDataFolder).toHaveBeenCalledWith({ @@ -99,7 +103,7 @@ describe('UuidService', () => { }); it('calls writePidFile with correct parameters', async () => { - await service.preboot(); + await service.preboot({ analytics }); expect(writePidFile).toHaveBeenCalledTimes(1); expect(writePidFile).toHaveBeenCalledWith({ @@ -109,14 +113,14 @@ describe('UuidService', () => { }); it('returns the uuid resolved from resolveInstanceUuid', async () => { - const preboot = await service.preboot(); + const preboot = await service.preboot({ analytics }); expect(preboot.instanceUuid).toEqual('SOME_UUID'); }); describe('process warnings', () => { it('logs warnings coming from the process', async () => { - await service.preboot(); + await service.preboot({ analytics }); const warning = new Error('something went wrong'); process.emit('warning', warning); @@ -126,7 +130,7 @@ describe('UuidService', () => { }); it('does not log deprecation warnings', async () => { - await service.preboot(); + await service.preboot({ analytics }); const warning = new Error('something went wrong'); warning.name = 'DeprecationWarning'; @@ -139,7 +143,7 @@ describe('UuidService', () => { // TODO: From Nodejs v16 emitting an unhandledRejection will kill the process describe.skip('unhandledRejection warnings', () => { it('logs warn for an unhandeld promise rejected with an Error', async () => { - await service.preboot(); + await service.preboot({ analytics }); const err = new Error('something went wrong'); process.emit('unhandledRejection', err, new Promise((res, rej) => rej(err))); @@ -151,7 +155,7 @@ describe('UuidService', () => { }); it('logs warn for an unhandeld promise rejected with a string', async () => { - await service.preboot(); + await service.preboot({ analytics }); const err = 'something went wrong'; process.emit('unhandledRejection', err, new Promise((res, rej) => rej(err))); @@ -166,7 +170,7 @@ describe('UuidService', () => { describe('#setup()', () => { it('returns the uuid resolved from resolveInstanceUuid', async () => { - await expect(service.preboot()).resolves.toEqual({ instanceUuid: 'SOME_UUID' }); + await expect(service.preboot({ analytics })).resolves.toEqual({ instanceUuid: 'SOME_UUID' }); expect(service.setup()).toEqual({ instanceUuid: 'SOME_UUID' }); }); }); diff --git a/src/core/server/environment/environment_service.ts b/src/core/server/environment/environment_service.ts index 65c03b108b28a..28e2da446eb95 100644 --- a/src/core/server/environment/environment_service.ts +++ b/src/core/server/environment/environment_service.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { PathConfigType, config as pathConfigDef } from '@kbn/utils'; +import type { AnalyticsServicePreboot } from '../analytics'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { IConfigService } from '../config'; @@ -17,6 +18,16 @@ import { resolveInstanceUuid } from './resolve_uuid'; import { createDataFolder } from './create_data_folder'; import { writePidFile } from './write_pid_file'; +/** + * @internal + */ +export interface PrebootDeps { + /** + * {@link AnalyticsServicePreboot} + */ + analytics: AnalyticsServicePreboot; +} + /** * @internal */ @@ -45,7 +56,7 @@ export class EnvironmentService { this.configService = core.configService; } - public async preboot() { + public async preboot({ analytics }: PrebootDeps) { // IMPORTANT: This code is based on the assumption that none of the configuration values used // here is supposed to change during preboot phase and it's safe to read them only once. const [pathConfig, serverConfig, pidConfig] = await Promise.all([ @@ -77,6 +88,24 @@ export class EnvironmentService { logger: this.log, }); + analytics.registerContextProvider({ + name: 'kibana info', + context$: of({ + kibana_uuid: this.uuid, + pid: process.pid, + }), + schema: { + kibana_uuid: { + type: 'keyword', + _meta: { description: 'Kibana instance UUID' }, + }, + pid: { + type: 'long', + _meta: { description: 'Process ID' }, + }, + }, + }); + return { instanceUuid: this.uuid, }; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index f251d3fb64cab..557a10da0839d 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -45,8 +45,9 @@ export type HttpServiceSetupMock = jest.Mocked< createRouter: jest.MockedFunction<() => RouterMock>; }; export type InternalHttpServiceSetupMock = jest.Mocked< - Omit + Omit > & { + auth: AuthMocked; basePath: BasePathMocked; createRouter: jest.MockedFunction<(path: string) => RouterMock>; authRequestHeaders: jest.Mocked; diff --git a/src/core/server/rendering/__mocks__/params.ts b/src/core/server/rendering/__mocks__/params.ts index 091d185cceefc..b4ead2e628688 100644 --- a/src/core/server/rendering/__mocks__/params.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -7,6 +7,7 @@ */ import { mockCoreContext } from '../../core_context.mock'; +import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; import { httpServiceMock } from '../../http/http_service.mock'; import { pluginServiceMock } from '../../plugins/plugins_service.mock'; import { statusServiceMock } from '../../status/status_service.mock'; @@ -15,6 +16,7 @@ const context = mockCoreContext.create(); const httpPreboot = httpServiceMock.createInternalPrebootContract(); const httpSetup = httpServiceMock.createInternalSetupContract(); const status = statusServiceMock.createInternalSetupContract(); +const elasticsearch = elasticsearchServiceMock.createInternalSetup(); export const mockRenderingServiceParams = context; export const mockRenderingPrebootDeps = { @@ -22,6 +24,7 @@ export const mockRenderingPrebootDeps = { uiPlugins: pluginServiceMock.createUiPlugins(), }; export const mockRenderingSetupDeps = { + elasticsearch, http: httpSetup, uiPlugins: pluginServiceMock.createUiPlugins(), status, diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 4abf24911808c..9fe0cb545e7aa 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -6,6 +6,7 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -61,6 +62,7 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -120,6 +122,7 @@ Object { "basePath": "", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -169,12 +172,69 @@ Object { } `; +exports[`RenderingService preboot() render() renders "core" page for unauthenticated requests 1`] = ` +Object { + "anonymousStatusPage": false, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "clusterInfo": Object {}, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + }, + "publicBaseUrl": "http://myhost.com/mock-server-basepath", + "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + exports[`RenderingService preboot() render() renders "core" with excluded user settings 1`] = ` Object { "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -230,6 +290,7 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -285,6 +346,11 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, "csp": Object { "warnLegacyBrowsers": true, }, @@ -344,6 +410,11 @@ Object { "basePath": "", "branch": Any, "buildNumber": Any, + "clusterInfo": Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, "csp": Object { "warnLegacyBrowsers": true, }, @@ -393,12 +464,73 @@ Object { } `; +exports[`RenderingService setup() render() renders "core" page for unauthenticated requests 1`] = ` +Object { + "anonymousStatusPage": false, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "clusterInfo": Object {}, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + }, + "publicBaseUrl": "http://myhost.com/mock-server-basepath", + "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + exports[`RenderingService setup() render() renders "core" with excluded user settings 1`] = ` Object { "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, "csp": Object { "warnLegacyBrowsers": true, }, diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts index cb10d01e85773..8aecc536d8846 100644 --- a/src/core/server/rendering/rendering_service.test.ts +++ b/src/core/server/rendering/rendering_service.test.ts @@ -25,6 +25,7 @@ import { } from './__mocks__/params'; import { InternalRenderingServicePreboot, InternalRenderingServiceSetup } from './types'; import { RenderingService } from './rendering_service'; +import { AuthStatus } from '../http/auth_state_storage'; const INJECTED_METADATA = { version: expect.any(String), @@ -75,6 +76,23 @@ function renderTestCases( expect(data).toMatchSnapshot(INJECTED_METADATA); }); + it('renders "core" page for unauthenticated requests', async () => { + mockRenderingSetupDeps.http.auth.get.mockReturnValueOnce({ + status: AuthStatus.unauthenticated, + state: {}, + }); + + const [render] = await getRender(); + const content = await render( + createKibanaRequest({ auth: { isAuthenticated: false } }), + uiSettings + ); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + it('renders "core" page for blank basepath', async () => { const [render, deps] = await getRender(); deps.http.basePath.get.mockReturnValueOnce(''); diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 73746a8f202ff..3e50aac6fcbdd 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -8,10 +8,11 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { take } from 'rxjs/operators'; +import { catchError, take, timeout } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; +import { firstValueFrom, of } from 'rxjs'; import type { UiPlugins } from '../plugins'; import { CoreContext } from '../core_context'; import { Template } from './views'; @@ -25,11 +26,13 @@ import { } from './types'; import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap'; import { getSettingValue, getStylesheetPaths } from './render_utils'; -import { KibanaRequest } from '../http'; +import type { HttpAuth, KibanaRequest } from '../http'; import { IUiSettingsClient } from '../ui_settings'; import { filterUiPlugins } from './filter_ui_plugins'; -type RenderOptions = (RenderingPrebootDeps & { status?: never }) | RenderingSetupDeps; +type RenderOptions = + | (RenderingPrebootDeps & { status?: never; elasticsearch?: never }) + | RenderingSetupDeps; /** @internal */ export class RenderingService { @@ -57,6 +60,7 @@ export class RenderingService { } public async setup({ + elasticsearch, http, status, uiPlugins, @@ -72,12 +76,12 @@ export class RenderingService { }); return { - render: this.render.bind(this, { http, uiPlugins, status }), + render: this.render.bind(this, { elasticsearch, http, uiPlugins, status }), }; } private async render( - { http, uiPlugins, status }: RenderOptions, + { elasticsearch, http, uiPlugins, status }: RenderOptions, request: KibanaRequest, uiSettings: IUiSettingsClient, { isAnonymousPage = false, vars, includeExposedConfigKeys }: IRenderOptions = {} @@ -94,6 +98,21 @@ export class RenderingService { user: isAnonymousPage ? {} : await uiSettings.getUserProvided(), }; + let clusterInfo = {}; + try { + // Only provide the clusterInfo if the request is authenticated and the elasticsearch service is available. + if (isAuthenticated(http.auth, request) && elasticsearch) { + clusterInfo = await firstValueFrom( + elasticsearch.clusterInfo$.pipe( + timeout(50), // If not available, just return undefined + catchError(() => of({})) + ) + ); + } + } catch (err) { + // swallow error + } + const darkMode = getSettingValue('theme:darkMode', settings, Boolean); const themeVersion: ThemeVersion = 'v8'; @@ -123,6 +142,7 @@ export class RenderingService { serverBasePath, publicBaseUrl, env, + clusterInfo, anonymousStatusPage: status?.isStatusPageAnonymous() ?? false, i18n: { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, @@ -164,3 +184,9 @@ const getUiConfig = async (uiPlugins: UiPlugins, pluginId: string) => { exposedConfigKeys: {}, }) as { browserConfig: Record; exposedConfigKeys: Record }; }; + +const isAuthenticated = (auth: HttpAuth, request: KibanaRequest) => { + const { status: authStatus } = auth.get(request); + // status is 'unknown' when auth is disabled. we just need to not be `unauthenticated` here. + return authStatus !== 'unauthenticated'; +}; diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 2c0aafe61e018..82758018b859d 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; +import { InternalElasticsearchServiceSetup } from '../elasticsearch'; import { EnvironmentMode, PackageInfo } from '../config'; import { ICspConfig } from '../csp'; import { InternalHttpServicePreboot, InternalHttpServiceSetup, KibanaRequest } from '../http'; @@ -38,6 +39,11 @@ export interface InjectedMetadata { basePath: string; serverBasePath: string; publicBaseUrl?: string; + clusterInfo: { + cluster_uuid?: string; + cluster_name?: string; + cluster_version?: string; + }; env: { mode: EnvironmentMode; packageInfo: PackageInfo; @@ -74,6 +80,7 @@ export interface RenderingPrebootDeps { /** @internal */ export interface RenderingSetupDeps { + elasticsearch: InternalElasticsearchServiceSetup; http: InternalHttpServiceSetup; status: InternalStatusServiceSetup; uiPlugins: UiPlugins; diff --git a/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts b/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts index 37b278fe9ccf0..525b9b3585c3f 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts @@ -114,7 +114,7 @@ describe('unsupported_cluster_routing_allocation', () => { await root.setup(); await expect(root.start()).rejects.toThrowError( - /Unable to complete saved object migrations for the \[\.kibana\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./ + /Unable to complete saved object migrations for the \[\.kibana.*\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./ ); await retryAsync( @@ -149,7 +149,7 @@ describe('unsupported_cluster_routing_allocation', () => { await root.setup(); await expect(root.start()).rejects.toThrowError( - /Unable to complete saved object migrations for the \[\.kibana\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./ + /Unable to complete saved object migrations for the \[\.kibana.*\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./ ); }); }); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index bc5048de45cba..234630734d437 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -56,10 +56,25 @@ import { config as executionContextConfig } from './execution_context'; import { PrebootCoreRouteHandlerContext } from './preboot_core_route_handler_context'; import { PrebootService } from './preboot'; import { DiscoveredPlugins } from './plugins'; -import { AnalyticsService } from './analytics'; +import { AnalyticsService, AnalyticsServiceSetup } from './analytics'; const coreId = Symbol('core'); const rootConfigPath = ''; +const KIBANA_STARTED_EVENT = 'kibana_started'; + +/** @internal */ +interface UptimePerStep { + start: number; + end: number; +} + +/** @internal */ +interface UptimeSteps { + constructor: UptimePerStep; + preboot: UptimePerStep; + setup: UptimePerStep; + start: UptimePerStep; +} export class Server { public readonly configService: ConfigService; @@ -94,11 +109,15 @@ export class Server { private discoveredPlugins?: DiscoveredPlugins; private readonly logger: LoggerFactory; + private readonly uptimePerStep: Partial = {}; + constructor( rawConfigProvider: RawConfigurationProvider, public readonly env: Env, private readonly loggingSystem: ILoggingSystem ) { + const constructorStartUptime = process.uptime(); + this.logger = this.loggingSystem.asLoggerFactory(); this.log = this.logger.get('server'); this.configService = new ConfigService(rawConfigProvider, env, this.logger); @@ -129,15 +148,18 @@ export class Server { this.savedObjectsStartPromise = new Promise((resolve) => { this.resolveSavedObjectsStartPromise = resolve; }); + + this.uptimePerStep.constructor = { start: constructorStartUptime, end: process.uptime() }; } public async preboot() { this.log.debug('prebooting server'); + const prebootStartUptime = process.uptime(); const prebootTransaction = apm.startTransaction('server-preboot', 'kibana-platform'); const analyticsPreboot = this.analytics.preboot(); - const environmentPreboot = await this.environment.preboot(); + const environmentPreboot = await this.environment.preboot({ analytics: analyticsPreboot }); // Discover any plugins before continuing. This allows other systems to utilize the plugin dependency graph. this.discoveredPlugins = await this.plugins.discover({ environment: environmentPreboot }); @@ -187,15 +209,19 @@ export class Server { this.coreApp.preboot(corePreboot, uiPlugins); prebootTransaction?.end(); + this.uptimePerStep.preboot = { start: prebootStartUptime, end: process.uptime() }; return corePreboot; } public async setup() { this.log.debug('setting up server'); + const setupStartUptime = process.uptime(); const setupTransaction = apm.startTransaction('server-setup', 'kibana-platform'); const analyticsSetup = this.analytics.setup(); + this.registerKibanaStartedEventType(analyticsSetup); + const environmentSetup = this.environment.setup(); // Configuration could have changed after preboot. @@ -223,6 +249,7 @@ export class Server { const capabilitiesSetup = this.capabilities.setup({ http: httpSetup }); const elasticsearchServiceSetup = await this.elasticsearch.setup({ + analytics: analyticsSetup, http: httpSetup, executionContext: executionContextSetup, }); @@ -249,6 +276,7 @@ export class Server { }); const statusSetup = await this.status.setup({ + analytics: analyticsSetup, elasticsearch: elasticsearchServiceSetup, pluginDependencies: pluginTree.asNames, savedObjects: savedObjectsSetup, @@ -259,6 +287,7 @@ export class Server { }); const renderingSetup = await this.rendering.setup({ + elasticsearch: elasticsearchServiceSetup, http: httpSetup, status: statusSetup, uiPlugins, @@ -299,11 +328,13 @@ export class Server { this.coreApp.setup(coreSetup, uiPlugins); setupTransaction?.end(); + this.uptimePerStep.setup = { start: setupStartUptime, end: process.uptime() }; return coreSetup; } public async start() { this.log.debug('starting server'); + const startStartUptime = process.uptime(); const startTransaction = apm.startTransaction('server-start', 'kibana-platform'); const analyticsStart = this.analytics.start(); @@ -352,6 +383,9 @@ export class Server { startTransaction?.end(); + this.uptimePerStep.start = { start: startStartUptime, end: process.uptime() }; + analyticsStart.reportEvent(KIBANA_STARTED_EVENT, { uptime_per_step: this.uptimePerStep }); + return this.coreStart; } @@ -405,4 +439,92 @@ export class Server { this.configService.setSchema(descriptor.path, descriptor.schema); } } + + private registerKibanaStartedEventType(analyticsSetup: AnalyticsServiceSetup) { + analyticsSetup.registerEventType<{ uptime_per_step: UptimeSteps }>({ + eventType: KIBANA_STARTED_EVENT, + schema: { + uptime_per_step: { + properties: { + constructor: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until the constructor was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until the constructor finished', + }, + }, + }, + }, + preboot: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `preboot` was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `preboot` finished', + }, + }, + }, + }, + setup: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `setup` was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `setup` finished', + }, + }, + }, + }, + start: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `start` was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `start` finished', + }, + }, + }, + }, + }, + _meta: { + description: + 'Number of seconds the Node.js process has been running until each phase of the server execution is called and finished.', + }, + }, + }, + }); + } } diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index 262667fddf26a..70181db9380ff 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -6,11 +6,16 @@ * Side Public License, v 1. */ -import { of, BehaviorSubject } from 'rxjs'; - -import { ServiceStatus, ServiceStatusLevels, CoreStatus } from './types'; +import { of, BehaviorSubject, firstValueFrom } from 'rxjs'; + +import { + ServiceStatus, + ServiceStatusLevels, + CoreStatus, + InternalStatusServiceSetup, +} from './types'; import { StatusService } from './status_service'; -import { first } from 'rxjs/operators'; +import { first, take, toArray } from 'rxjs/operators'; import { mockCoreContext } from '../core_context.mock'; import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; import { environmentServiceMock } from '../environment/environment_service.mock'; @@ -19,6 +24,8 @@ import { mockRouter, RouterMock } from '../http/router/router.mock'; import { metricsServiceMock } from '../metrics/metrics_service.mock'; import { configServiceMock } from '../config/mocks'; import { coreUsageDataServiceMock } from '../core_usage_data/core_usage_data_service.mock'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; +import { AnalyticsServiceSetup } from '..'; expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer); @@ -47,6 +54,7 @@ describe('StatusService', () => { type SetupDeps = Parameters[0]; const setupDeps = (overrides: Partial): SetupDeps => { return { + analytics: analyticsServiceMock.createAnalyticsServiceSetup(), elasticsearch: { status$: of(available), }, @@ -535,5 +543,50 @@ describe('StatusService', () => { ); }); }); + + describe('analytics', () => { + let analyticsMock: jest.Mocked; + let setup: InternalStatusServiceSetup; + + beforeEach(async () => { + analyticsMock = analyticsServiceMock.createAnalyticsServiceSetup(); + setup = await service.setup(setupDeps({ analytics: analyticsMock })); + }); + + test('registers a context provider', async () => { + expect(analyticsMock.registerContextProvider).toHaveBeenCalledTimes(1); + const { context$ } = analyticsMock.registerContextProvider.mock.calls[0][0]; + await expect(firstValueFrom(context$.pipe(take(2), toArray()))).resolves + .toMatchInlineSnapshot(` + Array [ + Object { + "overall_status_level": "initializing", + "overall_status_summary": "Kibana is starting up", + }, + Object { + "overall_status_level": "available", + "overall_status_summary": "All services are available", + }, + ] + `); + }); + + test('registers and reports an event', async () => { + expect(analyticsMock.registerEventType).toHaveBeenCalledTimes(1); + expect(analyticsMock.reportEvent).toHaveBeenCalledTimes(0); + // wait for an emission of overall$ + await firstValueFrom(setup.overall$); + expect(analyticsMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsMock.reportEvent.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "core-overall_status_changed", + Object { + "overall_status_level": "available", + "overall_status_summary": "All services are available", + }, + ] + `); + }); + }); }); }); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 6c8f8716c036e..a3dc0335c88af 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -6,10 +6,21 @@ * Side Public License, v 1. */ -import { Observable, combineLatest, Subscription, Subject, firstValueFrom } from 'rxjs'; -import { map, distinctUntilChanged, shareReplay, debounceTime } from 'rxjs/operators'; +import { + Observable, + combineLatest, + Subscription, + Subject, + firstValueFrom, + tap, + BehaviorSubject, +} from 'rxjs'; +import { map, distinctUntilChanged, shareReplay, debounceTime, takeUntil } from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; +import type { RootSchema } from '@kbn/analytics-client'; + +import { AnalyticsServiceSetup } from '../analytics'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { Logger, LogMeta } from '../logging'; @@ -32,7 +43,13 @@ interface StatusLogMeta extends LogMeta { kibana: { status: ServiceStatus }; } +interface StatusAnalyticsPayload { + overall_status_level: string; + overall_status_summary: string; +} + export interface SetupDeps { + analytics: AnalyticsServiceSetup; elasticsearch: Pick; environment: InternalEnvironmentServiceSetup; pluginDependencies: ReadonlyMap; @@ -57,6 +74,7 @@ export class StatusService implements CoreService { } public async setup({ + analytics, elasticsearch, pluginDependencies, http, @@ -88,6 +106,8 @@ export class StatusService implements CoreService { shareReplay(1) ); + this.setupAnalyticsContextAndEvents(analytics); + const coreOverall$ = core$.pipe( // Prevent many emissions at once from dependency status resolution from making this too noisy debounceTime(25), @@ -192,4 +212,40 @@ export class StatusService implements CoreService { shareReplay(1) ); } + + private setupAnalyticsContextAndEvents(analytics: AnalyticsServiceSetup) { + // Set an initial "initializing" status, so we can attach it to early events. + const context$ = new BehaviorSubject({ + overall_status_level: 'initializing', + overall_status_summary: 'Kibana is starting up', + }); + + // The schema is the same for the context and the events. + const schema: RootSchema = { + overall_status_level: { + type: 'keyword', + _meta: { description: 'The current availability level of the service.' }, + }, + overall_status_summary: { + type: 'text', + _meta: { description: 'A high-level summary of the service status.' }, + }, + }; + + const overallStatusChangedEventName = 'core-overall_status_changed'; + + analytics.registerEventType({ eventType: overallStatusChangedEventName, schema }); + analytics.registerContextProvider({ name: 'status info', context$, schema }); + + this.overall$!.pipe( + takeUntil(this.stop$), + map(({ level, summary }) => ({ + overall_status_level: level.toString(), + overall_status_summary: summary, + })), + // Emit the event before spreading the status to the context. + // This way we see from the context the previous status and the current one. + tap((statusPayload) => analytics.reportEvent(overallStatusChangedEventName, statusPayload)) + ).subscribe(context$); + } } diff --git a/src/core/types/execution_context.ts b/src/core/types/execution_context.ts index d790b8d855fd4..d1e5cd10e5e91 100644 --- a/src/core/types/execution_context.ts +++ b/src/core/types/execution_context.ts @@ -14,7 +14,7 @@ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type KibanaExecutionContext = { /** - * Kibana application initated an operation. + * Kibana application initiated an operation. * */ readonly type?: string; // 'visualization' | 'actions' | 'server' | ..; /** public name of an application or a user-facing feature */ diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 4167719d3bb31..45b8aad7df8cf 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -8,6 +8,7 @@ // Please also add new aliases to test/scripts/jenkins_storybook.sh export const storybookAliases = { + unified_search: 'src/plugins/unified_search/.storybook', coloring: 'packages/kbn-coloring/.storybook', apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 5907c2caff105..7095ad34cd189 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -491,7 +491,7 @@ export function DashboardTopNav({ const showQueryInput = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowQueryInput)); const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); - const showQueryBar = showQueryInput || showDatePicker; + const showQueryBar = showQueryInput || showDatePicker || showFilterBar; const showSearchBar = showQueryBar || showFilterBar; const screenTitle = dashboardState.title; diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts index 9ac9c4a057ee9..a3cd83f6ba67a 100644 --- a/src/plugins/data/common/search/search_source/types.ts +++ b/src/plugins/data/common/search/search_source/types.ts @@ -217,3 +217,13 @@ export interface ShardFailure { }; shard: number; } + +export function isSerializedSearchSource( + maybeSerializedSearchSource: unknown +): maybeSerializedSearchSource is SerializedSearchSourceFields { + return ( + typeof maybeSerializedSearchSource === 'object' && + maybeSerializedSearchSource !== null && + !Array.isArray(maybeSerializedSearchSource) + ); +} diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 69c2035b28e5c..58b66bb74b5e3 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -108,6 +108,7 @@ export interface IDataPluginServices extends Partial { uiSettings: CoreStart['uiSettings']; savedObjects: CoreStart['savedObjects']; notifications: CoreStart['notifications']; + application: CoreStart['application']; http: CoreStart['http']; storage: IStorageWrapper; data: DataPublicPluginStart; diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.scss b/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.scss deleted file mode 100644 index ca230711827dc..0000000000000 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.scss +++ /dev/null @@ -1,5 +0,0 @@ -.testScript__searchBar { - .globalQueryBar { - padding: $euiSize 0 0; - } -} diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx b/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx index 52bf882331698..0eb0898f41b60 100644 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx +++ b/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import './test_script.scss'; - import React, { Component, Fragment } from 'react'; import { @@ -223,8 +221,11 @@ export class TestScript extends Component { /> + +
{ const topNavProps = { appName: 'context', showSearchBar: true, - showQueryBar: false, + showQueryBar: true, + showQueryInput: false, showFilterBar: true, showSaveQuery: false, showDatePicker: false, diff --git a/src/plugins/discover/public/application/context/context_app.tsx b/src/plugins/discover/public/application/context/context_app.tsx index e84bbf644a895..1f886fdacac6b 100644 --- a/src/plugins/discover/public/application/context/context_app.tsx +++ b/src/plugins/discover/public/application/context/context_app.tsx @@ -133,7 +133,8 @@ export const ContextApp = ({ indexPattern, anchorId }: ContextAppProps) => { return { appName: 'context', showSearchBar: true, - showQueryBar: false, + showQueryBar: true, + showQueryInput: false, showFilterBar: true, showSaveQuery: false, showDatePicker: false, diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss index 1d074c002e340..9ea41f343b885 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss @@ -29,15 +29,14 @@ discover-app { .dscPageBody__contents { overflow: hidden; - padding-top: $euiSizeXS / 2; // A little breathing room for the index pattern button } .dscPageContent__wrapper { - padding: 0 $euiSize $euiSize 0; + padding: $euiSizeS $euiSizeS $euiSizeS 0; overflow: hidden; // Ensures horizontal scroll of table @include euiBreakpoint('xs', 's') { - padding: 0 $euiSize $euiSize; + padding: 0 $euiSizeS $euiSizeS; } } diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index ad1c96e308d12..6cbc8add99c39 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -226,6 +226,9 @@ export function DiscoverLayout({ stateContainer={stateContainer} updateQuery={onUpdateQuery} resetSavedSearch={resetSavedSearch} + onChangeIndexPattern={onChangeIndexPattern} + onEditRuntimeField={onEditRuntimeField} + useNewFieldsApi={useNewFieldsApi} /> - + - - - } - closePopover={[Function]} - data-test-subj="discover-addRuntimeField-popover" - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
-
- - - -
-
-
-
- -`; diff --git a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.test.tsx deleted file mode 100644 index a5e93c1d895bc..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.test.tsx +++ /dev/null @@ -1,71 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import React from 'react'; -import { EuiSelectable } from '@elastic/eui'; -import { ShallowWrapper } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import { shallowWithIntl } from '@kbn/test-jest-helpers'; -import { ChangeIndexPattern } from './change_indexpattern'; -import { indexPatternMock } from '../../../../__mocks__/index_pattern'; -import { indexPatternWithTimefieldMock } from '../../../../__mocks__/index_pattern_with_timefield'; -import { IndexPatternRef } from './types'; - -function getProps() { - return { - indexPatternId: indexPatternMock.id, - indexPatternRefs: [ - indexPatternMock as IndexPatternRef, - indexPatternWithTimefieldMock as IndexPatternRef, - ], - onChangeIndexPattern: jest.fn(), - trigger: { - label: indexPatternMock.title, - title: indexPatternMock.title, - 'data-test-subj': 'indexPattern-switch-link', - }, - }; -} - -function getIndexPatternPickerList(instance: ShallowWrapper) { - return instance.find(EuiSelectable).first(); -} - -function getIndexPatternPickerOptions(instance: ShallowWrapper) { - return getIndexPatternPickerList(instance).prop('options'); -} - -export function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { - const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( - instance - ).map((option: { label: string }) => - option.label === selectedLabel - ? { ...option, checked: 'on' } - : { ...option, checked: undefined } - ); - return getIndexPatternPickerList(instance).prop('onChange')!(options); -} - -describe('ChangeIndexPattern', () => { - test('switching index pattern to the same index pattern does not trigger onChangeIndexPattern', async () => { - const props = getProps(); - const comp = shallowWithIntl(); - await act(async () => { - selectIndexPatternPickerOption(comp, indexPatternMock.title); - }); - expect(props.onChangeIndexPattern).toHaveBeenCalledTimes(0); - }); - test('switching index pattern to a different index pattern triggers onChangeIndexPattern', async () => { - const props = getProps(); - const comp = shallowWithIntl(); - await act(async () => { - selectIndexPatternPickerOption(comp, indexPatternWithTimefieldMock.title); - }); - expect(props.onChangeIndexPattern).toHaveBeenCalledTimes(1); - expect(props.onChangeIndexPattern).toHaveBeenCalledWith(indexPatternWithTimefieldMock.id); - }); -}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.tsx b/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.tsx deleted file mode 100644 index ceee905cff6fa..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.tsx +++ /dev/null @@ -1,109 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { - EuiButton, - EuiPopover, - EuiPopoverTitle, - EuiSelectable, - EuiButtonProps, -} from '@elastic/eui'; -import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; -import { IndexPatternRef } from './types'; - -export type ChangeIndexPatternTriggerProps = EuiButtonProps & { - label: string; - title?: string; -}; - -// TODO: refactor to shared component with ../../../../../../../../x-pack/legacy/plugins/lens/public/indexpattern_plugin/change_indexpattern - -export function ChangeIndexPattern({ - indexPatternId, - indexPatternRefs, - onChangeIndexPattern, - selectableProps, - trigger, -}: { - indexPatternId?: string; - indexPatternRefs: IndexPatternRef[]; - onChangeIndexPattern: (newId: string) => void; - selectableProps?: EuiSelectableProps<{ value: string }>; - trigger: ChangeIndexPatternTriggerProps; -}) { - const [isPopoverOpen, setPopoverIsOpen] = useState(false); - - const createTrigger = function () { - const { label, title, ...rest } = trigger; - return ( - setPopoverIsOpen(!isPopoverOpen)} - {...rest} - > - {label} - - ); - }; - - return ( - setPopoverIsOpen(false)} - display="block" - panelPaddingSize="s" - > -
- - {i18n.translate('discover.fieldChooser.indexPattern.changeDataViewTitle', { - defaultMessage: 'Change data view', - })} - - - data-test-subj="indexPattern-switcher" - {...selectableProps} - searchable - singleSelection="always" - options={indexPatternRefs.map(({ title, id }) => ({ - label: title, - key: id, - value: id, - checked: id === indexPatternId ? 'on' : undefined, - }))} - onChange={(choices) => { - const choice = choices.find(({ checked }) => checked) as unknown as { - value: string; - }; - if (choice.value !== indexPatternId) { - onChangeIndexPattern(choice.value); - } - setPopoverIsOpen(false); - }} - searchProps={{ - compressed: true, - ...(selectableProps ? selectableProps.searchProps : undefined), - }} - > - {(list, search) => ( - <> - {search} - {list} - - )} - -
-
- ); -} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.test.tsx deleted file mode 100644 index d640e2fa11594..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.test.tsx +++ /dev/null @@ -1,95 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; -import { ShallowWrapper } from 'enzyme'; -import { ChangeIndexPattern } from './change_indexpattern'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObject } from '@kbn/core/server'; -import { DiscoverIndexPattern, DiscoverIndexPatternProps } from './discover_index_pattern'; -import { EuiSelectable } from '@elastic/eui'; -import type { DataView, DataViewAttributes } from '@kbn/data-views-plugin/public'; -import { indexPatternsMock } from '../../../../__mocks__/index_patterns'; - -const indexPattern = { - id: 'the-index-pattern-id-first', - title: 'test1 title', -} as DataView; - -const indexPattern1 = { - id: 'the-index-pattern-id-first', - attributes: { - title: 'test1 title', - }, -} as SavedObject; - -const indexPattern2 = { - id: 'the-index-pattern-id', - attributes: { - title: 'test2 title', - }, -} as SavedObject; - -const defaultProps = { - indexPatternList: [indexPattern1, indexPattern2], - selectedIndexPattern: indexPattern, - useNewFieldsApi: true, - indexPatterns: indexPatternsMock, - onChangeIndexPattern: jest.fn(), -}; - -function getIndexPatternPickerList(instance: ShallowWrapper) { - return instance.find(ChangeIndexPattern).first().dive().find(EuiSelectable); -} - -function getIndexPatternPickerOptions(instance: ShallowWrapper) { - return getIndexPatternPickerList(instance).prop('options'); -} - -function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { - const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( - instance - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ).map((option: any) => - option.label === selectedLabel - ? { ...option, checked: 'on' } - : { ...option, checked: undefined } - ); - return getIndexPatternPickerList(instance).prop('onChange')!(options); -} - -describe('DiscoverIndexPattern', () => { - test('Invalid props dont cause an exception', () => { - const props = { - indexPatternList: null, - selectedIndexPattern: null, - onChangeIndexPattern: jest.fn(), - } as unknown as DiscoverIndexPatternProps; - - expect(shallow()).toMatchSnapshot(`""`); - }); - test('should list all index patterns', () => { - const instance = shallow(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(getIndexPatternPickerOptions(instance)!.map((option: any) => option.label)).toEqual([ - 'test1 title', - 'test2 title', - ]); - }); - - test('should switch data panel to target index pattern', async () => { - const instance = shallow(); - await act(async () => { - selectIndexPatternPickerOption(instance, 'test2 title'); - }); - expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('the-index-pattern-id'); - }); -}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.tsx deleted file mode 100644 index 83aa3ce478215..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.tsx +++ /dev/null @@ -1,74 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useState, useEffect } from 'react'; -import { SavedObject } from '@kbn/core/public'; -import type { DataView, DataViewAttributes } from '@kbn/data-views-plugin/public'; -import { IndexPatternRef } from './types'; -import { ChangeIndexPattern } from './change_indexpattern'; - -export interface DiscoverIndexPatternProps { - /** - * list of available index patterns, if length > 1, component offers a "change" link - */ - indexPatternList: Array>; - /** - * Callback function when changing an index pattern - */ - onChangeIndexPattern: (id: string) => void; - /** - * currently selected index pattern - */ - selectedIndexPattern: DataView; -} - -/** - * Component allows you to select an index pattern in discovers side bar - */ -export function DiscoverIndexPattern({ - indexPatternList, - onChangeIndexPattern, - selectedIndexPattern, -}: DiscoverIndexPatternProps) { - const options: IndexPatternRef[] = (indexPatternList || []).map((entity) => ({ - id: entity.id, - title: entity.attributes!.title, - })); - const { id: selectedId, title: selectedTitle } = selectedIndexPattern || {}; - - const [selected, setSelected] = useState({ - id: selectedId, - title: selectedTitle || '', - }); - useEffect(() => { - const { id, title } = selectedIndexPattern; - setSelected({ id, title }); - }, [selectedIndexPattern]); - if (!selectedId) { - return null; - } - - return ( - { - const indexPattern = options.find((pattern) => pattern.id === id); - if (indexPattern) { - onChangeIndexPattern(id); - setSelected(indexPattern); - } - }} - /> - ); -} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx deleted file mode 100644 index cddbe087030e7..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx +++ /dev/null @@ -1,118 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { mountWithIntl, findTestSubject } from '@kbn/test-jest-helpers'; -import { EuiContextMenuPanel, EuiPopover, EuiContextMenuItem } from '@elastic/eui'; -import { DiscoverServices } from '../../../../build_services'; -import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; -import { stubLogstashIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; - -const mockServices = { - history: () => ({ - location: { - search: '', - }, - }), - capabilities: { - visualize: { - show: true, - }, - discover: { - save: false, - }, - }, - core: { - application: { - navigateToApp: jest.fn(), - }, - }, - uiSettings: { - get: (key: string) => { - if (key === 'fields:popularLimit') { - return 5; - } - }, - }, - dataViewFieldEditor: { - openEditor: jest.fn(), - userPermissions: { - editIndexPattern: () => { - return true; - }, - }, - }, -} as unknown as DiscoverServices; - -describe('Discover DataView Management', () => { - const indexPattern = stubLogstashIndexPattern; - - const editField = jest.fn(); - const createNewDataView = jest.fn(); - - const mountComponent = () => { - return mountWithIntl( - - - - ); - }; - - test('renders correctly', () => { - const component = mountComponent(); - expect(component).toMatchSnapshot(); - expect(component.find(EuiPopover).length).toBe(1); - }); - - test('click on a button opens popover', () => { - const component = mountComponent(); - expect(component.find(EuiContextMenuPanel).length).toBe(0); - - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - expect(component.find(EuiContextMenuPanel).length).toBe(1); - expect(component.find(EuiContextMenuItem).length).toBe(3); - }); - - test('click on an add button executes editField callback', () => { - const component = mountComponent(); - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - const addButton = findTestSubject(component, 'indexPattern-add-field'); - addButton.simulate('click'); - expect(editField).toHaveBeenCalledWith(undefined); - }); - - test('click on a manage button navigates away from discover', () => { - const component = mountComponent(); - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - const manageButton = findTestSubject(component, 'indexPattern-manage-field'); - manageButton.simulate('click'); - expect(mockServices.core.application.navigateToApp).toHaveBeenCalled(); - }); - - test('click on add dataView button executes createNewDataView callback', () => { - const component = mountComponent(); - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - const manageButton = findTestSubject(component, 'dataview-create-new'); - manageButton.simulate('click'); - expect(createNewDataView).toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx deleted file mode 100644 index 823aa9c0050c0..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx +++ /dev/null @@ -1,130 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useState } from 'react'; -import { - EuiButtonIcon, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiHorizontalRule, - EuiPopover, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import { useDiscoverServices } from '../../../../utils/use_discover_services'; - -export interface DiscoverIndexPatternManagementProps { - /** - * Currently selected index pattern - */ - selectedIndexPattern?: DataView; - /** - * Read from the Fields API - */ - useNewFieldsApi?: boolean; - /** - * Callback to execute on edit field action - * @param fieldName - */ - editField: (fieldName?: string) => void; - - /** - * Callback to execute on create new data action - */ - createNewDataView: () => void; -} - -export function DiscoverIndexPatternManagement(props: DiscoverIndexPatternManagementProps) { - const { dataViewFieldEditor, core } = useDiscoverServices(); - const { useNewFieldsApi, selectedIndexPattern, editField, createNewDataView } = props; - const dataViewEditPermission = dataViewFieldEditor?.userPermissions.editIndexPattern(); - const canEditDataViewField = !!dataViewEditPermission && useNewFieldsApi; - const [isAddIndexPatternFieldPopoverOpen, setIsAddIndexPatternFieldPopoverOpen] = useState(false); - - if (!useNewFieldsApi || !selectedIndexPattern || !canEditDataViewField) { - return null; - } - - const addField = () => { - editField(undefined); - }; - - return ( - { - setIsAddIndexPatternFieldPopoverOpen(false); - }} - ownFocus - data-test-subj="discover-addRuntimeField-popover" - button={ - { - setIsAddIndexPatternFieldPopoverOpen(!isAddIndexPatternFieldPopoverOpen); - }} - /> - } - > - { - setIsAddIndexPatternFieldPopoverOpen(false); - addField(); - }} - > - {i18n.translate('discover.fieldChooser.indexPatterns.addFieldButton', { - defaultMessage: 'Add field', - })} - , - { - setIsAddIndexPatternFieldPopoverOpen(false); - core.application.navigateToApp('management', { - path: `/kibana/indexPatterns/patterns/${props.selectedIndexPattern?.id}`, - }); - }} - > - {i18n.translate('discover.fieldChooser.indexPatterns.manageFieldButton', { - defaultMessage: 'Manage settings', - })} - , - , - { - setIsAddIndexPatternFieldPopoverOpen(false); - createNewDataView(); - }} - > - {i18n.translate('discover.fieldChooser.dataViews.createNewDataView', { - defaultMessage: 'Create new data view', - })} - , - ]} - /> - - ); -} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss index 9ef123fa1a60f..6845b1c89901d 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss @@ -2,7 +2,7 @@ overflow: hidden; margin: 0 !important; flex-grow: 1; - padding-left: $euiSize; + padding: $euiSizeS 0 $euiSizeS $euiSizeS; width: $euiSize * 19; height: 100%; @@ -19,7 +19,7 @@ .dscSidebar__mobile { width: 100%; - padding: $euiSize $euiSize 0; + padding: $euiSizeS $euiSizeS 0; .dscSidebar__mobileBadge { margin-left: $euiSizeS; diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx index fb6af1bc1b775..22f954e714987 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx @@ -20,16 +20,17 @@ import { EuiNotificationBadge, EuiPageSideBar, useResizeObserver, + EuiButton, } from '@elastic/eui'; import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; -import { isEqual, sortBy } from 'lodash'; +import { isEqual } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; import { indexPatterns as indexPatternUtils } from '@kbn/data-plugin/public'; +import { DataViewPicker } from '@kbn/unified-search-plugin/public'; import { DataViewField } from '@kbn/data-views-plugin/public'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; import { DiscoverField } from './discover_field'; -import { DiscoverIndexPattern } from './discover_index_pattern'; import { DiscoverFieldSearch } from './discover_field_search'; import { FIELDS_LIMIT_SETTING } from '../../../../../common'; import { groupFields } from './lib/group_fields'; @@ -37,7 +38,6 @@ import { getDetails } from './lib/get_details'; import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive'; -import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { VIEW_MODE } from '../../../../components/view_mode_toggle'; import { ElasticSearchHit } from '../../../../types'; @@ -83,6 +83,8 @@ export interface DiscoverSidebarProps extends Omit(null); @@ -297,34 +299,6 @@ export function DiscoverSidebarComponent({ return null; } - if (useFlyout) { - return ( -
- - - o.attributes.title)} - onChangeIndexPattern={onChangeIndexPattern} - /> - - - - - -
- ); - } - return ( - - - - o.attributes.title)} - onChangeIndexPattern={onChangeIndexPattern} - /> - - - - - - + {Boolean(showDataViewPicker) && ( + + )}
+ + editField()} + size="s" + > + {i18n.translate('discover.fieldChooser.addField.label', { + defaultMessage: 'Add a field', + })} + + ); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx index f2f58c43d5e7f..f7664197ca98c 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx @@ -7,7 +7,6 @@ */ import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { sortBy } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { UiCounterMetricType } from '@kbn/analytics'; @@ -19,21 +18,16 @@ import { EuiBadge, EuiFlyoutHeader, EuiFlyout, - EuiSpacer, EuiIcon, EuiLink, EuiPortal, - EuiFlexGroup, - EuiFlexItem, } from '@elastic/eui'; import type { DataViewField, DataView, DataViewAttributes } from '@kbn/data-views-plugin/public'; import { SavedObject } from '@kbn/core/types'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; -import { DiscoverIndexPattern } from './discover_index_pattern'; import { getDefaultFieldFilter } from './lib/field_filter'; import { DiscoverSidebar } from './discover_sidebar'; import { AppState } from '../../services/discover_state'; -import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { AvailableFields$, DataDocuments$ } from '../../utils/use_saved_search'; import { calcFieldCounts } from '../../utils/calc_field_counts'; import { VIEW_MODE } from '../../../../components/view_mode_toggle'; @@ -91,10 +85,6 @@ export interface DiscoverSidebarResponsiveProps { * @param eventName */ trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; - /** - * Shows index pattern and a button that displays the sidebar in a flyout - */ - useFlyout?: boolean; /** * Read from the Fields API */ @@ -124,13 +114,7 @@ export interface DiscoverSidebarResponsiveProps { */ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) { const services = useDiscoverServices(); - const { - selectedIndexPattern, - onEditRuntimeField, - useNewFieldsApi, - onChangeIndexPattern, - onDataViewCreated, - } = props; + const { selectedIndexPattern, onEditRuntimeField, useNewFieldsApi, onDataViewCreated } = props; const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter()); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); /** @@ -291,34 +275,6 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) )}
-
- - - o.attributes.title)} - /> - - - - - -
- -
diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index 938d2d55df004..7b8831f734279 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -40,6 +40,8 @@ function getProps(savePermissions = true): DiscoverTopNavProps { onOpenInspector: jest.fn(), searchSource: {} as ISearchSource, resetSavedSearch: () => {}, + onEditRuntimeField: jest.fn(), + onChangeIndexPattern: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index 8656a2fdb7072..87d2f04bd604b 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { Query, TimeRange } from '@kbn/data-plugin/public'; import { DataViewType } from '@kbn/data-views-plugin/public'; @@ -25,6 +25,9 @@ export type DiscoverTopNavProps = Pick< updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; stateContainer: GetStateReturn; resetSavedSearch: () => void; + onChangeIndexPattern: (indexPattern: string) => void; + onEditRuntimeField: () => void; + useNewFieldsApi?: boolean; }; export const DiscoverTopNav = ({ @@ -38,6 +41,9 @@ export const DiscoverTopNav = ({ navigateTo, savedSearch, resetSavedSearch, + onChangeIndexPattern, + onEditRuntimeField, + useNewFieldsApi = false, }: DiscoverTopNavProps) => { const history = useHistory(); const showDatePicker = useMemo( @@ -45,7 +51,16 @@ export const DiscoverTopNav = ({ [indexPattern] ); const services = useDiscoverServices(); - const { TopNavMenu } = services.navigation.ui; + const { dataViewEditor, navigation, dataViewFieldEditor, data } = services; + const editPermission = useMemo( + () => dataViewFieldEditor.userPermissions.editIndexPattern(), + [dataViewFieldEditor] + ); + const canEditDataViewField = !!editPermission && useNewFieldsApi; + const closeFieldEditor = useRef<() => void | undefined>(); + const closeDataViewEditor = useRef<() => void | undefined>(); + + const { TopNavMenu } = navigation.ui; const onOpenSavedSearch = useCallback( (newSavedSearchId: string) => { @@ -58,6 +73,64 @@ export const DiscoverTopNav = ({ [history, resetSavedSearch, savedSearch.id] ); + useEffect(() => { + return () => { + // Make sure to close the editors when unmounting + if (closeFieldEditor.current) { + closeFieldEditor.current(); + } + if (closeDataViewEditor.current) { + closeDataViewEditor.current(); + } + }; + }, []); + + const editField = useMemo( + () => + canEditDataViewField + ? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => { + if (indexPattern?.id) { + const indexPatternInstance = await data.dataViews.get(indexPattern.id); + closeFieldEditor.current = dataViewFieldEditor.openEditor({ + ctx: { + dataView: indexPatternInstance, + }, + fieldName, + onSave: async () => { + onEditRuntimeField(); + }, + }); + } + } + : undefined, + [ + canEditDataViewField, + indexPattern?.id, + data.dataViews, + dataViewFieldEditor, + onEditRuntimeField, + ] + ); + + const addField = useMemo( + () => (canEditDataViewField && editField ? () => editField(undefined, 'add') : undefined), + [editField, canEditDataViewField] + ); + + const createNewDataView = useCallback(() => { + const indexPatternFieldEditPermission = dataViewEditor.userPermissions.editDataView; + if (!indexPatternFieldEditPermission) { + return; + } + closeDataViewEditor.current = dataViewEditor.openEditor({ + onSave: async (dataView) => { + if (dataView.id) { + onChangeIndexPattern(dataView.id); + } + }, + }); + }, [dataViewEditor, onChangeIndexPattern]); + const topNavMenu = useMemo( () => getTopNavLinks({ @@ -99,6 +172,18 @@ export const DiscoverTopNav = ({ return getHeaderActionMenuMounter(); }, []); + const dataViewPickerProps = { + trigger: { + label: indexPattern?.title || '', + 'data-test-subj': 'discover-dataView-switch-link', + title: indexPattern?.title || '', + }, + currentDataViewId: indexPattern?.id, + onAddField: addField, + onDataViewCreated: createNewDataView, + onChangeDataView: (newIndexPatternId: string) => onChangeIndexPattern(newIndexPatternId), + }; + return ( ); }; diff --git a/src/plugins/discover/public/application/main/discover_main_app.test.tsx b/src/plugins/discover/public/application/main/discover_main_app.test.tsx index ceb06df058fae..d2f0c7e2dd005 100644 --- a/src/plugins/discover/public/application/main/discover_main_app.test.tsx +++ b/src/plugins/discover/public/application/main/discover_main_app.test.tsx @@ -9,11 +9,11 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { DiscoverMainApp } from './discover_main_app'; +import { DiscoverTopNav } from './components/top_nav/discover_topnav'; import { savedSearchMock } from '../../__mocks__/saved_search'; import { SavedObject } from '@kbn/core/types'; import type { DataViewAttributes } from '@kbn/data-views-plugin/public'; import { setHeaderActionMenuMounter } from '../../kibana_services'; -import { findTestSubject } from '@elastic/eui/lib/test'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { discoverServiceMock } from '../../__mocks__/services'; import { Router } from 'react-router-dom'; @@ -42,8 +42,7 @@ describe('DiscoverMainApp', () => { ); - expect(findTestSubject(component, 'indexPattern-switch-link').text()).toBe( - indexPatternMock.title - ); + expect(component.find(DiscoverTopNav).exists()).toBe(true); + expect(component.find(DiscoverTopNav).prop('indexPattern')).toEqual(indexPatternMock); }); }); diff --git a/src/plugins/discover/server/saved_objects/search_migrations.test.ts b/src/plugins/discover/server/saved_objects/search_migrations.test.ts index 9563bd6dc86c3..fcce5d41fe90b 100644 --- a/src/plugins/discover/server/saved_objects/search_migrations.test.ts +++ b/src/plugins/discover/server/saved_objects/search_migrations.test.ts @@ -350,6 +350,7 @@ Object { testMigrateMatchAllQuery(migrationFn); }); }); + it('should apply search source migrations within saved search', () => { const savedSearch = { attributes: { @@ -379,4 +380,27 @@ Object { }, }); }); + + it('should not apply search source migrations within saved search when searchSourceJSON is not an object', () => { + const savedSearch = { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '9.1.2'; + const migrations = getAllMigrations({ + [versionToTest]: (state) => ({ ...state, migrated: true }), + }); + + expect(migrations[versionToTest](savedSearch, {} as SavedObjectMigrationContext)).toEqual({ + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + }); + }); }); diff --git a/src/plugins/discover/server/saved_objects/search_migrations.ts b/src/plugins/discover/server/saved_objects/search_migrations.ts index 95da82fa38acf..2fb49628f53bc 100644 --- a/src/plugins/discover/server/saved_objects/search_migrations.ts +++ b/src/plugins/discover/server/saved_objects/search_migrations.ts @@ -17,7 +17,7 @@ import type { import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { DEFAULT_QUERY_LANGUAGE } from '@kbn/data-plugin/server'; import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; -import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { isSerializedSearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; export interface SavedSearchMigrationAttributes extends SavedObjectAttributes { kibanaSavedObjectMeta: { @@ -135,27 +135,31 @@ const migrateSearchSortToNestedArray: SavedObjectMigrationFn = (doc) = /** * This creates a migration map that applies search source migrations */ -const getSearchSourceMigrations = (searchSourceMigrations: MigrateFunctionsObject) => +const getSearchSourceMigrations = ( + searchSourceMigrations: MigrateFunctionsObject +): MigrateFunctionsObject => mapValues( searchSourceMigrations, (migrate: MigrateFunction): MigrateFunction => (state) => { - const _state = state as unknown as { attributes: SavedSearchMigrationAttributes }; - - const parsedSearchSourceJSON = _state.attributes.kibanaSavedObjectMeta.searchSourceJSON; - - if (!parsedSearchSourceJSON) return _state; - - return { - ..._state, - attributes: { - ..._state.attributes, - kibanaSavedObjectMeta: { - ..._state.attributes.kibanaSavedObjectMeta, - searchSourceJSON: JSON.stringify(migrate(JSON.parse(parsedSearchSourceJSON))), + const _state = state as { attributes: SavedSearchMigrationAttributes }; + + const parsedSearchSourceJSON = JSON.parse( + _state.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + if (isSerializedSearchSource(parsedSearchSourceJSON)) { + return { + ..._state, + attributes: { + ..._state.attributes, + kibanaSavedObjectMeta: { + ..._state.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(migrate(parsedSearchSourceJSON)), + }, }, - }, - }; + }; + } + return _state; } ); @@ -171,6 +175,6 @@ export const getAllMigrations = ( ): SavedObjectMigrationMap => { return mergeSavedObjectMigrationMaps( searchMigrations, - getSearchSourceMigrations(searchSourceMigrations) as unknown as SavedObjectMigrationMap + getSearchSourceMigrations(searchSourceMigrations) as SavedObjectMigrationMap ); }; diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 817e73f16617e..9915680ada26e 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -25,6 +25,7 @@ { "path": "../data_view_field_editor/tsconfig.json"}, { "path": "../field_formats/tsconfig.json" }, { "path": "../data_views/tsconfig.json" }, + { "path": "../unified_search/tsconfig.json" }, { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, { "path": "../data_view_editor/tsconfig.json" }, { "path": "../../../x-pack/plugins/triggers_actions_ui/tsconfig.json" } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 85a9803ffced6..aee35c1f331c7 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -159,7 +159,6 @@ describe('TopNavMenu', () => { await refresh(); - expect(component.find(WRAPPER_SELECTOR).length).toBe(1); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); // menu is rendered outside of the component diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 7eb7365ed79f3..62dc67a3ee941 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -117,15 +117,15 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { {renderMenu(menuClassName)} - {renderSearchBar()} + {renderSearchBar()} ); } else { return ( - - {renderMenu(menuClassName)} + <> + {renderMenu(menuClassName)} {renderSearchBar()} - + ); } } diff --git a/src/plugins/unified_search/.storybook/main.js b/src/plugins/unified_search/.storybook/main.js new file mode 100644 index 0000000000000..8dc3c5d1518f4 --- /dev/null +++ b/src/plugins/unified_search/.storybook/main.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/unified_search/public/__stories__/search_bar.stories.tsx b/src/plugins/unified_search/public/__stories__/search_bar.stories.tsx new file mode 100644 index 0000000000000..49e25e04d01a8 --- /dev/null +++ b/src/plugins/unified_search/public/__stories__/search_bar.stories.tsx @@ -0,0 +1,431 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; +import { SearchBar, SearchBarProps } from '../search_bar'; +import { setIndexPatterns } from '../services'; + +const mockIndexPatterns = [ + { + id: '1234', + title: 'logstash-*', + fields: [ + { + name: 'response', + type: 'number', + esTypes: ['integer'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], + }, + { + id: '1235', + title: 'test-*', + fields: [ + { + name: 'response', + type: 'number', + esTypes: ['integer'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], + }, +] as DataView[]; + +const mockTimeHistory = { + get: () => { + return []; + }, + add: action('set'), + get$: () => { + return { + pipe: () => {}, + }; + }, +}; + +const createMockWebStorage = () => ({ + clear: action('clear'), + getItem: action('getItem'), + key: action('key'), + removeItem: action('removeItem'), + setItem: action('setItem'), + length: 0, +}); + +const createMockStorage = () => ({ + storage: createMockWebStorage(), + set: action('set'), + remove: action('remove'), + clear: action('clear'), + get: () => true, +}); + +const services = { + uiSettings: { + get: () => {}, + }, + savedObjects: action('savedObjects'), + notifications: action('notifications'), + http: { + basePath: { + prepend: () => 'http://test', + }, + }, + docLinks: { + links: { + query: { + kueryQuerySyntax: '', + }, + }, + }, + storage: createMockStorage(), + data: { + query: { + savedQueries: { + findSavedQueries: () => + Promise.resolve({ + queries: [ + { + id: 'testwewe', + attributes: { + title: 'Saved query 1', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + { + id: '0173d0d0-b19a-11ec-8323-837d6b231b82', + attributes: { + title: 'test', + description: '', + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + }, + }, + ], + }), + }, + }, + autocomplete: { + hasQuerySuggestions: () => Promise.resolve(false), + getQuerySuggestions: () => [], + }, + dataViews: { + getIdsWithTitle: () => [ + { id: '1234', title: 'logstash-*' }, + { id: '1235', title: 'test-*' }, + ], + }, + }, +}; + +setIndexPatterns({ + get: () => Promise.resolve(mockIndexPatterns[0]), +} as unknown as DataViewsContract); + +function wrapSearchBarInContext(testProps: SearchBarProps) { + const defaultOptions = { + appName: 'test', + timeHistory: mockTimeHistory, + intl: null as any, + showQueryBar: true, + showFilterBar: true, + showDatePicker: true, + showAutoRefreshOnly: false, + showSaveQuery: true, + showQueryInput: true, + indexPatterns: mockIndexPatterns, + dateRangeFrom: 'now-15m', + dateRangeTo: 'now', + query: { query: '', language: 'kuery' }, + filters: [], + onClearSavedQuery: action('onClearSavedQuery'), + onFiltersUpdated: action('onFiltersUpdated'), + } as unknown as SearchBarProps; + + return ( + + + + + + ); +} + +storiesOf('SearchBar', module) + .add('default', () => wrapSearchBarInContext({ showQueryInput: true } as SearchBarProps)) + .add('with dataviewPicker', () => + wrapSearchBarInContext({ + dataViewPickerComponentProps: { + currentDataViewId: '1234', + trigger: { + 'data-test-subj': 'dataView-switch-link', + label: 'logstash-*', + title: 'logstash-*', + }, + onChangeDataView: action('onChangeDataView'), + }, + } as SearchBarProps) + ) + .add('with dataviewPicker enhanced', () => + wrapSearchBarInContext({ + dataViewPickerComponentProps: { + currentDataViewId: '1234', + trigger: { + 'data-test-subj': 'dataView-switch-link', + label: 'logstash-*', + title: 'logstash-*', + }, + onChangeDataView: action('onChangeDataView'), + onAddField: action('onAddField'), + onDataViewCreated: action('onDataViewCreated'), + }, + } as SearchBarProps) + ) + .add('with filterBar off', () => + wrapSearchBarInContext({ + showFilterBar: false, + } as SearchBarProps) + ) + .add('with query input off', () => + wrapSearchBarInContext({ + showQueryInput: false, + } as SearchBarProps) + ) + .add('with date picker off', () => + wrapSearchBarInContext({ + showDatePicker: false, + } as SearchBarProps) + ) + .add('with date picker off', () => + wrapSearchBarInContext({ + showDatePicker: false, + } as SearchBarProps) + ) + .add('with only the date picker on', () => + wrapSearchBarInContext({ + showDatePicker: true, + showFilterBar: false, + showQueryInput: false, + } as SearchBarProps) + ) + .add('with only the filter bar on', () => + wrapSearchBarInContext({ + showDatePicker: false, + showFilterBar: true, + showQueryInput: false, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + } as unknown as SearchBarProps) + ) + .add('with only the query bar on', () => + wrapSearchBarInContext({ + showDatePicker: false, + showFilterBar: false, + showQueryInput: true, + query: { query: 'Test: miaou', language: 'kuery' }, + } as unknown as SearchBarProps) + ) + .add('with only the filter bar and the date picker on', () => + wrapSearchBarInContext({ + showDatePicker: true, + showFilterBar: true, + showQueryInput: false, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + } as unknown as SearchBarProps) + ) + .add('with loaded saved query without changes', () => + wrapSearchBarInContext({ + savedQuery: { + id: '0173d0d0-b19a-11ec-8323-837d6b231b82', + attributes: { + title: 'test', + description: '', + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + }, + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + } as unknown as SearchBarProps) + ) + .add('with loaded saved query with changes', () => + wrapSearchBarInContext({ + savedQuery: { + id: '0173d0d0-b19a-11ec-8323-837d6b231b82', + attributes: { + title: 'test', + description: '', + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + }, + }, + } as unknown as SearchBarProps) + ) + .add('show only query bar without submit', () => + wrapSearchBarInContext({ + showDatePicker: false, + showFilterBar: false, + showAutoRefreshOnly: false, + showQueryInput: true, + showSubmitButton: false, + } as SearchBarProps) + ); diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.styles.ts b/src/plugins/unified_search/public/dataview_picker/change_dataview.styles.ts new file mode 100644 index 0000000000000..1c505752d392c --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.styles.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const DATA_VIEW_POPOVER_CONTENT_WIDTH = 280; + +export const changeDataViewStyles = ({ fullWidth }: { fullWidth?: boolean }) => { + return { + trigger: { + maxWidth: fullWidth ? undefined : DATA_VIEW_POPOVER_CONTENT_WIDTH, + }, + popoverContent: { + width: DATA_VIEW_POPOVER_CONTENT_WIDTH, + }, + }; +}; diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.test.tsx b/src/plugins/unified_search/public/dataview_picker/change_dataview.test.tsx new file mode 100644 index 0000000000000..d3081561a0c4e --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { ChangeDataView } from './change_dataview'; +import { EuiTourStep } from '@elastic/eui'; +import type { DataViewPickerProps } from '.'; + +describe('DataView component', () => { + const createMockWebStorage = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + key: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + length: 0, + }); + + const createMockStorage = () => ({ + storage: createMockWebStorage(), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }); + const getStorage = (v: boolean) => { + const storage = createMockStorage(); + storage.get.mockReturnValue(v); + return storage; + }; + + function wrapDataViewComponentInContext(testProps: DataViewPickerProps, storageValue: boolean) { + let dataMock = dataPluginMock.createStartContract(); + dataMock = { + ...dataMock, + dataViews: { + ...dataMock.dataViews, + getIdsWithTitle: jest.fn(), + }, + }; + const services = { + data: dataMock, + storage: getStorage(storageValue), + }; + + return ( + + + + + + ); + } + let props: DataViewPickerProps; + beforeEach(() => { + props = { + currentDataViewId: 'dataview-1', + trigger: { + label: 'Dataview 1', + title: 'Dataview 1', + fullWidth: true, + 'data-test-subj': 'dataview-trigger', + }, + onChangeDataView: jest.fn(), + }; + }); + it('should not render the tour component by default', async () => { + await act(async () => { + const component = mount(wrapDataViewComponentInContext(props, true)); + expect(component.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); + }); + it('should render the tour component if the showNewMenuTour is true', async () => { + const component = mount( + wrapDataViewComponentInContext({ ...props, showNewMenuTour: true }, false) + ); + expect(component.find(EuiTourStep).prop('isStepOpen')).toBe(true); + }); + + it('should not render the add runtime field menu if addField is not given', async () => { + await act(async () => { + const component = mount(wrapDataViewComponentInContext(props, true)); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="indexPattern-add-field"]').length).toBe(0); + }); + }); + + it('should render the add runtime field menu if addField is given', async () => { + const addFieldSpy = jest.fn(); + const component = mount( + wrapDataViewComponentInContext( + { ...props, onAddField: addFieldSpy, showNewMenuTour: true }, + false + ) + ); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="indexPattern-add-field"]').at(0).text()).toContain( + 'Add a field to this data view' + ); + component.find('[data-test-subj="indexPattern-add-field"]').first().simulate('click'); + expect(addFieldSpy).toHaveBeenCalled(); + }); + + it('should not render the add datavuew menu if onDataViewCreated is not given', async () => { + await act(async () => { + const component = mount(wrapDataViewComponentInContext(props, true)); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="idataview-create-new"]').length).toBe(0); + }); + }); + + it('should render the add datavuew menu if onDataViewCreated is given', async () => { + const addDataViewSpy = jest.fn(); + const component = mount( + wrapDataViewComponentInContext( + { ...props, onDataViewCreated: addDataViewSpy, showNewMenuTour: true }, + false + ) + ); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="dataview-create-new"]').at(0).text()).toContain( + 'Create a data view' + ); + component.find('[data-test-subj="dataview-create-new"]').first().simulate('click'); + expect(addDataViewSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx new file mode 100644 index 0000000000000..3e0ed7cc8a266 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx @@ -0,0 +1,244 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState, useEffect } from 'react'; +import { css } from '@emotion/react'; +import { + EuiPopover, + EuiHorizontalRule, + EuiButton, + EuiContextMenuPanel, + EuiContextMenuItem, + useEuiTheme, + useGeneratedHtmlId, + EuiIcon, + EuiLink, + EuiText, + EuiTourStep, + EuiContextMenuPanelProps, +} from '@elastic/eui'; +import type { DataViewListItem } from '@kbn/data-views-plugin/public'; +import { IDataPluginServices } from '@kbn/data-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { DataViewPickerProps } from '.'; +import { DataViewsList } from './dataview_list'; +import { changeDataViewStyles } from './change_dataview.styles'; + +const NEW_DATA_VIEW_MENU_STORAGE_KEY = 'data.newDataViewMenu'; + +const newMenuTourTitle = i18n.translate('unifiedSearch.query.dataViewMenu.newMenuTour.title', { + defaultMessage: 'A better data view menu', +}); + +const newMenuTourDescription = i18n.translate( + 'unifiedSearch.query.dataViewMenu.newMenuTour.description', + { + defaultMessage: + 'This menu now offers all the tools you need to create, find, and edit your data views.', + } +); + +const newMenuTourDismissLabel = i18n.translate( + 'unifiedSearch.query.dataViewMenu.newMenuTour.dismissLabel', + { + defaultMessage: 'Got it', + } +); + +export function ChangeDataView({ + isMissingCurrent, + currentDataViewId, + onChangeDataView, + onAddField, + onDataViewCreated, + trigger, + selectableProps, + showNewMenuTour = false, +}: DataViewPickerProps) { + const { euiTheme } = useEuiTheme(); + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const [dataViewsList, setDataViewsList] = useState([]); + const [triggerLabel, setTriggerLabel] = useState(''); + const kibana = useKibana(); + const { application, data, storage } = kibana.services; + const styles = changeDataViewStyles({ fullWidth: trigger.fullWidth }); + + const [isTourDismissed, setIsTourDismissed] = useState(() => + Boolean(storage.get(NEW_DATA_VIEW_MENU_STORAGE_KEY)) + ); + const [isTourOpen, setIsTourOpen] = useState(false); + + useEffect(() => { + if (showNewMenuTour && !isTourDismissed) { + setIsTourOpen(true); + } + }, [isTourDismissed, setIsTourOpen, showNewMenuTour]); + + const onTourDismiss = () => { + storage.set(NEW_DATA_VIEW_MENU_STORAGE_KEY, true); + setIsTourDismissed(true); + setIsTourOpen(false); + }; + + // Create a reusable id to ensure search input is the first focused item in the popover even though it's not the first item + const searchListInputId = useGeneratedHtmlId({ prefix: 'dataviewPickerListSearchInput' }); + + useEffect(() => { + const fetchDataViews = async () => { + const dataViewsRefs = await data.dataViews.getIdsWithTitle(); + setDataViewsList(dataViewsRefs); + }; + fetchDataViews(); + }, [data, currentDataViewId]); + + useEffect(() => { + if (trigger.label) { + setTriggerLabel(trigger.label); + } + }, [trigger.label]); + + const createTrigger = function () { + const { label, title, 'data-test-subj': dataTestSubj, fullWidth, ...rest } = trigger; + return ( + { + setPopoverIsOpen(!isPopoverOpen); + setIsTourOpen(false); + // onTourDismiss(); TODO: Decide if opening the menu should also dismiss the tour + }} + color={isMissingCurrent ? 'danger' : 'primary'} + iconSide="right" + iconType="arrowDown" + title={title} + fullWidth={fullWidth} + {...rest} + > + {triggerLabel} + + ); + }; + + const getPanelItems = () => { + const panelItems: EuiContextMenuPanelProps['items'] = []; + if (onAddField) { + panelItems.push( + { + setPopoverIsOpen(false); + onAddField(); + }} + > + {i18n.translate('unifiedSearch.query.queryBar.indexPattern.addFieldButton', { + defaultMessage: 'Add a field to this data view', + })} + , + { + setPopoverIsOpen(false); + application.navigateToApp('management', { + path: `/kibana/indexPatterns/patterns/${currentDataViewId}`, + }); + }} + > + {i18n.translate('unifiedSearch.query.queryBar.indexPattern.manageFieldButton', { + defaultMessage: 'Manage this data view', + })} + , + + ); + } + panelItems.push( + { + onChangeDataView(newId); + setPopoverIsOpen(false); + }} + currentDataViewId={currentDataViewId} + selectableProps={selectableProps} + searchListInputId={searchListInputId} + /> + ); + + if (onDataViewCreated) { + panelItems.push( + , + { + setPopoverIsOpen(false); + onDataViewCreated(); + }} + > + {i18n.translate('unifiedSearch.query.queryBar.indexPattern.addNewDataView', { + defaultMessage: 'Create a data view', + })} + + ); + } + + return panelItems; + }; + + return ( + +   {newMenuTourTitle} + + } + content={ + +

{newMenuTourDescription}

+
+ } + isStepOpen={isTourOpen} + onFinish={onTourDismiss} + step={1} + stepsTotal={1} + footerAction={ + + {newMenuTourDismissLabel} + + } + repositionOnScroll + display="block" + > + setPopoverIsOpen(false)} + panelPaddingSize="none" + initialFocus={`#${searchListInputId}`} + display="block" + buffer={8} + > +
+ +
+
+
+ ); +} diff --git a/src/plugins/unified_search/public/dataview_picker/dataview_list.test.tsx b/src/plugins/unified_search/public/dataview_picker/dataview_list.test.tsx new file mode 100644 index 0000000000000..813beae20369c --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/dataview_list.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiSelectable } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { ShallowWrapper } from 'enzyme'; +import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; +import { DataViewsList, DataViewsListProps } from './dataview_list'; + +function getDataViewPickerList(instance: ShallowWrapper) { + return instance.find(EuiSelectable).first(); +} + +function getDataViewPickerOptions(instance: ShallowWrapper) { + return getDataViewPickerList(instance).prop('options'); +} + +function selectDataViewPickerOption(instance: ShallowWrapper, selectedLabel: string) { + const options: Array<{ label: string; checked?: 'on' | 'off' }> = getDataViewPickerOptions( + instance + ).map((option: { label: string }) => + option.label === selectedLabel + ? { ...option, checked: 'on' } + : { ...option, checked: undefined } + ); + return getDataViewPickerList(instance).prop('onChange')!(options); +} + +describe('DataView list component', () => { + const list = [ + { + id: 'dataview-1', + title: 'dataview-1', + }, + { + id: 'dataview-2', + title: 'dataview-2', + }, + ]; + const changeDataViewSpy = jest.fn(); + let props: DataViewsListProps; + beforeEach(() => { + props = { + currentDataViewId: 'dataview-1', + onChangeDataView: changeDataViewSpy, + dataViewsList: list, + }; + }); + it('should trigger the onChangeDataView if a new dataview is selected', async () => { + const component = shallow(); + await act(async () => { + selectDataViewPickerOption(component, 'dataview-2'); + }); + expect(changeDataViewSpy).toHaveBeenCalled(); + }); + + it('should list all dataviiew', () => { + const component = shallow(); + + expect(getDataViewPickerOptions(component)!.map((option: any) => option.label)).toEqual([ + 'dataview-1', + 'dataview-2', + ]); + }); +}); diff --git a/src/plugins/unified_search/public/dataview_picker/dataview_list.tsx b/src/plugins/unified_search/public/dataview_picker/dataview_list.tsx new file mode 100644 index 0000000000000..153cbdd3cf3f2 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/dataview_list.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiSelectable, EuiSelectableProps, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { DataViewListItem } from '@kbn/data-views-plugin/public'; + +export interface DataViewsListProps { + dataViewsList: DataViewListItem[]; + onChangeDataView: (newId: string) => void; + currentDataViewId?: string; + selectableProps?: EuiSelectableProps; + searchListInputId?: string; +} + +export function DataViewsList({ + dataViewsList, + onChangeDataView, + currentDataViewId, + selectableProps, + searchListInputId, +}: DataViewsListProps) { + return ( + + {...selectableProps} + data-test-subj="indexPattern-switcher" + searchable + singleSelection="always" + options={dataViewsList?.map(({ title, id }) => ({ + key: id, + label: title, + value: id, + checked: id === currentDataViewId ? 'on' : undefined, + }))} + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + onChangeDataView(choice.value); + }} + searchProps={{ + id: searchListInputId, + compressed: true, + placeholder: i18n.translate('unifiedSearch.query.queryBar.indexPattern.findDataView', { + defaultMessage: 'Find a data view', + }), + ...(selectableProps ? selectableProps.searchProps : undefined), + }} + > + {(list, search) => ( + + {search} + {list} + + )} + + ); +} diff --git a/src/plugins/unified_search/public/dataview_picker/index.tsx b/src/plugins/unified_search/public/dataview_picker/index.tsx new file mode 100644 index 0000000000000..bd24aef0498ef --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { EuiButtonProps, EuiSelectableProps } from '@elastic/eui'; +import { ChangeDataView } from './change_dataview'; + +export type ChangeDataViewTriggerProps = EuiButtonProps & { + label: string; + title?: string; +}; + +/** @public */ +export interface DataViewPickerProps { + trigger: ChangeDataViewTriggerProps; + isMissingCurrent?: boolean; + onChangeDataView: (newId: string) => void; + currentDataViewId?: string; + selectableProps?: EuiSelectableProps; + onAddField?: () => void; + onDataViewCreated?: () => void; + showNewMenuTour?: boolean; +} + +export const DataViewPicker = ({ + isMissingCurrent, + currentDataViewId, + onChangeDataView, + onAddField, + onDataViewCreated, + trigger, + selectableProps, + showNewMenuTour, +}: DataViewPickerProps) => { + return ( + + ); +}; diff --git a/src/plugins/unified_search/public/filter_bar/_global_filter_group.scss b/src/plugins/unified_search/public/filter_bar/_global_filter_group.scss deleted file mode 100644 index 24f3ca05a5685..0000000000000 --- a/src/plugins/unified_search/public/filter_bar/_global_filter_group.scss +++ /dev/null @@ -1,54 +0,0 @@ -// SASSTODO: Probably not the right file for this selector, but temporary until the files get re-organized -.globalQueryBar { - padding: 0 $euiSizeS $euiSizeS $euiSizeS; -} - -.globalQueryBar:first-child { - padding-top: $euiSizeS; -} - -.globalQueryBar:not(:empty) { - padding-bottom: $euiSizeS; -} - -.globalQueryBar--inPage { - padding: 0; -} - -.globalFilterGroup__filterBar { - margin-top: $euiSizeXS; -} - -.globalFilterBar__addButton { - min-height: $euiSizeL + $euiSizeXS; // same height as the badges -} - -// sass-lint:disable quotes -.globalFilterGroup__branch { - padding: $euiSizeS $euiSizeM 0 0; - background-repeat: no-repeat; - background-position: $euiSizeM ($euiSizeS * -1); - background-image: url("data:image/svg+xml,%0A%3Csvg width='28px' height='28px' viewBox='0 0 28 28' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='#{hexToRGB($euiColorLightShade)}'%3E%3Crect x='14' y='27' width='14' height='1'%3E%3C/rect%3E%3C/g%3E%3C/svg%3E"); -} - -.globalFilterGroup__wrapper { - line-height: 1; // Override kuiLocalNav & kuiLocalNavRow - overflow: hidden; - transition: height $euiAnimSpeedNormal $euiAnimSlightResistance; -} - -.globalFilterGroup__filterFlexItem { - overflow: hidden; - padding-bottom: 2px; // Allow the shadows of the pills to show -} - -.globalFilterBar__flexItem { - max-width: calc(100% - #{$euiSizeXS}); // Width minus margin around each flex itm -} - -@include euiBreakpoint('xs', 's') { - .globalFilterGroup__wrapper-isVisible { - // EUI Flexbox adds too much margin between responded items, this just moves it up - margin-top: $euiSize * -1; - } -} diff --git a/src/plugins/unified_search/public/filter_bar/_index.scss b/src/plugins/unified_search/public/filter_bar/_index.scss deleted file mode 100644 index 5333aff8b87da..0000000000000 --- a/src/plugins/unified_search/public/filter_bar/_index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'variables'; -@import 'global_filter_group'; -@import 'global_filter_item'; diff --git a/src/plugins/unified_search/public/filter_bar/filter_bar.styles.ts b/src/plugins/unified_search/public/filter_bar/filter_bar.styles.ts new file mode 100644 index 0000000000000..919655e0af160 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_bar.styles.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UseEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export const filterBarStyles = ({ euiTheme }: UseEuiTheme, afterQueryBar?: boolean) => { + return { + group: css` + gap: ${euiTheme.size.xs}; + + &:not(:empty) { + margin-top: ${afterQueryBar ? euiTheme.size.s : 0}; + } + `, + }; +}; diff --git a/src/plugins/unified_search/public/filter_bar/filter_bar.tsx b/src/plugins/unified_search/public/filter_bar/filter_bar.tsx index 43b511b2c9f7d..ec7205d3a7df7 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_bar.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_bar.tsx @@ -6,224 +6,49 @@ * Side Public License, v 1. */ -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; -import { - buildEmptyFilter, - Filter, - enableFilter, - disableFilter, - pinFilter, - toggleFilterDisabled, - toggleFilterNegated, - unpinFilter, -} from '@kbn/es-query'; -import classNames from 'classnames'; -import React, { useState, useRef } from 'react'; - -import { METRIC_TYPE } from '@kbn/analytics'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { UI_SETTINGS } from '@kbn/data-plugin/common'; -import { IDataPluginServices } from '@kbn/data-plugin/public'; +import { EuiFlexGroup, useEuiTheme } from '@elastic/eui'; +import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; +import type { Filter } from '@kbn/es-query'; +import React, { useRef } from 'react'; import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterOptions } from './filter_options'; -import { FILTER_EDITOR_WIDTH, FilterItem } from './filter_item'; -import { FilterEditor } from './filter_editor'; +import FilterItems from './filter_item/filter_items'; + +import { filterBarStyles } from './filter_bar.styles'; export interface Props { filters: Filter[]; onFiltersUpdated?: (filters: Filter[]) => void; - className: string; + className?: string; indexPatterns: DataView[]; intl: InjectedIntl; - appName: string; timeRangeForSuggestionsOverride?: boolean; + /** + * Applies extra styles necessary when coupled with the query bar + */ + afterQueryBar?: boolean; } const FilterBarUI = React.memo(function FilterBarUI(props: Props) { + const euiTheme = useEuiTheme(); + const styles = filterBarStyles(euiTheme, props.afterQueryBar); const groupRef = useRef(null); - const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); - const kibana = useKibana(); - const { appName, usageCollection, uiSettings } = kibana.services; - if (!uiSettings) return null; - - const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); - - function onFiltersUpdated(filters: Filter[]) { - if (props.onFiltersUpdated) { - props.onFiltersUpdated(filters); - } - } - - const onAddFilterClick = () => setIsAddFilterPopoverOpen(!isAddFilterPopoverOpen); - - function renderItems() { - return props.filters.map((filter, i) => ( - - onUpdate(i, newFilter)} - onRemove={() => onRemove(i)} - indexPatterns={props.indexPatterns} - uiSettings={uiSettings!} - timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} - /> - - )); - } - - function renderAddFilter() { - const isPinned = uiSettings!.get(UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT); - const [indexPattern] = props.indexPatterns; - const index = indexPattern && indexPattern.id; - const newFilter = buildEmptyFilter(isPinned, index); - - const button = ( - - +{' '} - - - ); - - return ( - - setIsAddFilterPopoverOpen(false)} - anchorPosition="downLeft" - panelPaddingSize="none" - initialFocus=".filterEditor__hiddenItem" - ownFocus - repositionOnScroll - > - -
- setIsAddFilterPopoverOpen(false)} - key={JSON.stringify(newFilter)} - timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} - /> -
-
-
-
- ); - } - - function onAdd(filter: Filter) { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:added`); - setIsAddFilterPopoverOpen(false); - - const filters = [...props.filters, filter]; - onFiltersUpdated(filters); - } - - function onRemove(i: number) { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:removed`); - const filters = [...props.filters]; - filters.splice(i, 1); - onFiltersUpdated(filters); - groupRef.current?.focus(); - } - - function onUpdate(i: number, filter: Filter) { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:edited`); - const filters = [...props.filters]; - filters[i] = filter; - onFiltersUpdated(filters); - } - - function onEnableAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`); - const filters = props.filters.map(enableFilter); - onFiltersUpdated(filters); - } - - function onDisableAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:disable_all`); - const filters = props.filters.map(disableFilter); - onFiltersUpdated(filters); - } - - function onPinAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:pin_all`); - const filters = props.filters.map(pinFilter); - onFiltersUpdated(filters); - } - - function onUnpinAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:unpin_all`); - const filters = props.filters.map(unpinFilter); - onFiltersUpdated(filters); - } - - function onToggleAllNegated() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:invert_all`); - const filters = props.filters.map(toggleFilterNegated); - onFiltersUpdated(filters); - } - - function onToggleAllDisabled() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:toggle_all`); - const filters = props.filters.map(toggleFilterDisabled); - onFiltersUpdated(filters); - } - - function onRemoveAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:remove_all`); - onFiltersUpdated([]); - } - - const classes = classNames('globalFilterBar', props.className); return ( - - - - - - - {renderItems()} - {renderAddFilter()} - - + ); }); diff --git a/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.scss b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.scss new file mode 100644 index 0000000000000..95b87e1d827c6 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.scss @@ -0,0 +1,24 @@ +.kbnFilterButtonGroup { + height: $euiFormControlHeight; + background-color: $euiFormInputGroupLabelBackground; + border-radius: $euiFormControlBorderRadius; + box-shadow: 0 0 1px inset rgba($euiFormBorderOpaqueColor, .4); + + // Targets any interactable elements + *:enabled { + transform: none !important; + } + + &--s { + height: $euiFormControlCompressedHeight; + } + + &--attached { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + > *:not(:last-of-type) { + border-right: 1px solid $euiFormBorderColor; + } +} diff --git a/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.tsx b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.tsx new file mode 100644 index 0000000000000..1de5c71f4a301 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import './filter_button_group.scss'; + +import React, { FC, ReactNode } from 'react'; +import classNames from 'classnames'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface Props { + items: ReactNode[]; + /** + * Displays the last item without a border radius as if attached to the next DOM node + */ + attached?: boolean; + /** + * Matches overall height with standard form/button sizes + */ + size?: 'm' | 's'; +} + +export const FilterButtonGroup: FC = ({ items, attached, size = 'm', ...rest }: Props) => { + return ( + + {items.map((item, i) => + item == null ? undefined : ( + + {item} + + ) + )} + + ); +}; diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx index 972bf657723fc..ae7917e7a1c7a 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx @@ -14,6 +14,7 @@ import { EuiFlexItem, EuiForm, EuiFormRow, + EuiPopoverFooter, EuiPopoverTitle, EuiSpacer, EuiSwitch, @@ -55,6 +56,7 @@ export interface Props { onCancel: () => void; intl: InjectedIntl; timeRangeForSuggestionsOverride?: boolean; + mode?: 'edit' | 'add'; } interface State { @@ -68,6 +70,20 @@ interface State { isCustomEditorOpen: boolean; } +const panelTitleAdd = i18n.translate('unifiedSearch.filter.filterEditor.addFilterPopupTitle', { + defaultMessage: 'Add filter', +}); +const panelTitleEdit = i18n.translate('unifiedSearch.filter.filterEditor.editFilterPopupTitle', { + defaultMessage: 'Edit filter', +}); + +const addButtonLabel = i18n.translate('unifiedSearch.filter.filterEditor.addButtonLabel', { + defaultMessage: 'Add filter', +}); +const updateButtonLabel = i18n.translate('unifiedSearch.filter.filterEditor.updateButtonLabel', { + defaultMessage: 'Update filter', +}); + class FilterEditorUI extends Component { constructor(props: Props) { super(props); @@ -86,14 +102,9 @@ class FilterEditorUI extends Component { public render() { return (
- + - - - + {this.props.mode === 'add' ? panelTitleAdd : panelTitleEdit} { -
- + +
{this.renderIndexPatternInput()} {this.state.isCustomEditorOpen ? this.renderCustomEditor() : this.renderRegularEditor()} @@ -154,9 +165,9 @@ class FilterEditorUI extends Component {
)} +
- - + { isDisabled={!this.isFilterValid()} data-test-subj="saveFilter" > - + {this.props.mode === 'add' ? addButtonLabel : updateButtonLabel} @@ -185,8 +193,8 @@ class FilterEditorUI extends Component { - -
+ +
); } @@ -207,32 +215,31 @@ class FilterEditorUI extends Component { } const { selectedIndexPattern } = this.state; return ( - - - + + - indexPattern.title} - onChange={this.onIndexPatternChange} - singleSelection={{ asPlainText: true }} - isClearable={false} - data-test-subj="filterIndexPatternsSelect" - /> - - - + options={this.props.indexPatterns} + selectedOptions={selectedIndexPattern ? [selectedIndexPattern] : []} + getLabel={(indexPattern) => indexPattern.title} + onChange={this.onIndexPatternChange} + singleSelection={{ asPlainText: true }} + isClearable={false} + data-test-subj="filterIndexPatternsSelect" + /> + + + ); } diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx index 3f2aaa50af0fc..601cf68141c49 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx @@ -11,7 +11,7 @@ import { EuiTextColor } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Filter, FILTERS } from '@kbn/data-plugin/common'; import { existsOperator, isOneOfOperator } from './filter_operators'; -import type { FilterLabelStatus } from '../../filter_item'; +import type { FilterLabelStatus } from '../../filter_item/filter_item'; export interface FilterLabelProps { filter: Filter; diff --git a/src/plugins/unified_search/public/filter_bar/_variables.scss b/src/plugins/unified_search/public/filter_bar/filter_item/_variables.scss similarity index 100% rename from src/plugins/unified_search/public/filter_bar/_variables.scss rename to src/plugins/unified_search/public/filter_bar/filter_item/_variables.scss diff --git a/src/plugins/unified_search/public/filter_bar/_global_filter_item.scss b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss similarity index 82% rename from src/plugins/unified_search/public/filter_bar/_global_filter_item.scss rename to src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss index 1c9cea7291770..94f64bdce2f65 100644 --- a/src/plugins/unified_search/public/filter_bar/_global_filter_item.scss +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss @@ -1,3 +1,5 @@ +@import './variables'; + /** * 1. Allow wrapping of long filter items */ @@ -6,26 +8,14 @@ line-height: $euiSize; border: none; color: $euiTextColor; - padding-top: $euiSizeM / 2; - padding-bottom: $euiSizeM / 2; + padding-top: $euiSizeM / 2 + 1px; + padding-bottom: $euiSizeM / 2 + 1px; white-space: normal; /* 1 */ - .euiBadge__childButton { - flex-shrink: 1; /* 1 */ - } - - .euiBadge__iconButton:focus { - background-color: transparentize($euiColorPrimary, .9); - } - &:not(.globalFilterItem-isDisabled) { @include euiFormControlDefaultShadow; box-shadow: #{$euiFormControlBoxShadow}, inset 0 0 0 1px $kbnGlobalFilterItemBorderColor; // Make the actual border more visible } - - &:focus-within { - animation: none !important; // Remove focus ring animation otherwise it overrides simulated border via box-shadow - } } .globalFilterItem-isDisabled { @@ -81,7 +71,7 @@ } .globalFilterItem__editorForm { - padding: $euiSizeM; + padding: $euiSizeS; } .globalFilterItem__popover, diff --git a/src/plugins/unified_search/public/filter_bar/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx similarity index 97% rename from src/plugins/unified_search/public/filter_bar/filter_item.tsx rename to src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 16234a2953dc7..6b06461b4f297 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import './filter_item.scss'; + import { EuiContextMenu, EuiPopover, EuiPopoverProps } from '@elastic/eui'; import { InjectedIntl } from '@kbn/i18n-react'; import { @@ -24,9 +26,9 @@ import { getDisplayValueFromFilter, getFieldDisplayValueFromFilter, } from '@kbn/data-plugin/public'; -import { FilterEditor } from './filter_editor'; -import { FilterView } from './filter_view'; -import { getIndexPatterns } from '../services'; +import { FilterEditor } from '../filter_editor'; +import { FilterView } from '../filter_view'; +import { getIndexPatterns } from '../../services'; type PanelOptions = 'pinFilter' | 'editFilter' | 'negateFilter' | 'disableFilter' | 'deleteFilter'; @@ -101,6 +103,11 @@ export function FilterItem(props: FilterItemProps) { } } + function handleIconClick(e: MouseEvent) { + props.onRemove(); + setIsPopoverOpen(false); + } + function onSubmit(f: Filter) { setIsPopoverOpen(false); props.onUpdate(f); @@ -363,7 +370,7 @@ export function FilterItem(props: FilterItemProps) { filterLabelStatus: valueLabelConfig.status, errorMessage: valueLabelConfig.message, className: getClasses(!!filter.meta.negate, valueLabelConfig), - iconOnClick: props.onRemove, + iconOnClick: handleIconClick, onClick: handleBadgeClick, 'data-test-subj': getDataTestSubj(valueLabelConfig), readonly: props.readonly, diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx new file mode 100644 index 0000000000000..95d49450dd390 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useRef } from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexItem } from '@elastic/eui'; +import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; +import type { Filter } from '@kbn/es-query'; +import { IDataPluginServices } from '@kbn/data-plugin/public'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { FilterItem } from './filter_item'; + +export interface Props { + filters: Filter[]; + onFiltersUpdated?: (filters: Filter[]) => void; + indexPatterns: DataView[]; + intl: InjectedIntl; + timeRangeForSuggestionsOverride?: boolean; +} + +const FilterItemsUI = React.memo(function FilterItemsUI(props: Props) { + const groupRef = useRef(null); + const kibana = useKibana(); + const { appName, usageCollection, uiSettings } = kibana.services; + if (!uiSettings) return null; + + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + + function onFiltersUpdated(filters: Filter[]) { + if (props.onFiltersUpdated) { + props.onFiltersUpdated(filters); + } + } + + function renderItems() { + return props.filters.map((filter, i) => ( + + onUpdate(i, newFilter)} + onRemove={() => onRemove(i)} + indexPatterns={props.indexPatterns} + uiSettings={uiSettings!} + timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} + /> + + )); + } + + function onRemove(i: number) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:removed`); + const filters = [...props.filters]; + filters.splice(i, 1); + onFiltersUpdated(filters); + groupRef.current?.focus(); + } + + function onUpdate(i: number, filter: Filter) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:edited`); + const filters = [...props.filters]; + filters[i] = filter; + onFiltersUpdated(filters); + } + + return <>{renderItems()}; +}); + +const FilterItems = injectI18n(FilterItemsUI); +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default FilterItems; diff --git a/src/plugins/unified_search/public/filter_bar/filter_options.tsx b/src/plugins/unified_search/public/filter_bar/filter_options.tsx deleted file mode 100644 index d2e229c988711..0000000000000 --- a/src/plugins/unified_search/public/filter_bar/filter_options.tsx +++ /dev/null @@ -1,176 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { EuiButtonIcon, EuiContextMenu, EuiPopover, EuiPopoverTitle } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; -import { Component } from 'react'; -import React from 'react'; - -interface Props { - onEnableAll: () => void; - onDisableAll: () => void; - onPinAll: () => void; - onUnpinAll: () => void; - onToggleAllNegated: () => void; - onToggleAllDisabled: () => void; - onRemoveAll: () => void; - intl: InjectedIntl; -} - -interface State { - isPopoverOpen: boolean; -} - -class FilterOptionsUI extends Component { - private buttonRef = React.createRef(); - - public state: State = { - isPopoverOpen: false, - }; - - public togglePopover = () => { - this.setState((prevState) => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - public closePopover = () => { - this.setState({ isPopoverOpen: false }); - this.buttonRef.current?.focus(); - }; - - public render() { - const panelTree = { - id: 0, - items: [ - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.enableAllFiltersButtonLabel', - defaultMessage: 'Enable all', - }), - icon: 'eye', - onClick: () => { - this.closePopover(); - this.props.onEnableAll(); - }, - 'data-test-subj': 'enableAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.disableAllFiltersButtonLabel', - defaultMessage: 'Disable all', - }), - icon: 'eyeClosed', - onClick: () => { - this.closePopover(); - this.props.onDisableAll(); - }, - 'data-test-subj': 'disableAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.pinAllFiltersButtonLabel', - defaultMessage: 'Pin all', - }), - icon: 'pin', - onClick: () => { - this.closePopover(); - this.props.onPinAll(); - }, - 'data-test-subj': 'pinAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.unpinAllFiltersButtonLabel', - defaultMessage: 'Unpin all', - }), - icon: 'pin', - onClick: () => { - this.closePopover(); - this.props.onUnpinAll(); - }, - 'data-test-subj': 'unpinAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.invertNegatedFiltersButtonLabel', - defaultMessage: 'Invert inclusion', - }), - icon: 'invert', - onClick: () => { - this.closePopover(); - this.props.onToggleAllNegated(); - }, - 'data-test-subj': 'invertInclusionAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.invertDisabledFiltersButtonLabel', - defaultMessage: 'Invert enabled/disabled', - }), - icon: 'eye', - onClick: () => { - this.closePopover(); - this.props.onToggleAllDisabled(); - }, - 'data-test-subj': 'invertEnableDisableAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.deleteAllFiltersButtonLabel', - defaultMessage: 'Remove all', - }), - icon: 'trash', - onClick: () => { - this.closePopover(); - this.props.onRemoveAll(); - }, - 'data-test-subj': 'removeAllFilters', - }, - ], - }; - - return ( - - } - anchorPosition="rightUp" - panelPaddingSize="none" - repositionOnScroll - > - - - - - - ); - } -} - -export const FilterOptions = injectI18n(FilterOptionsUI); diff --git a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx index 29ff160d50db6..d399bb0025a10 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import React, { FC } from 'react'; import { Filter, isFilterPinned } from '@kbn/es-query'; import { FilterLabel } from '..'; -import type { FilterLabelStatus } from '../filter_item'; +import type { FilterLabelStatus } from '../filter_item/filter_item'; interface Props { filter: Filter; diff --git a/src/plugins/unified_search/public/filter_bar/index.tsx b/src/plugins/unified_search/public/filter_bar/index.tsx index 70a108f359790..30f94c3972ee1 100644 --- a/src/plugins/unified_search/public/filter_bar/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/index.tsx @@ -17,6 +17,13 @@ export const FilterBar = (props: React.ComponentProps) => ); +const LazyFilterItems = React.lazy(() => import('./filter_item/filter_items')); +export const FilterItems = (props: React.ComponentProps) => ( + }> + + +); + const LazyFilterLabel = React.lazy(() => import('./filter_editor/lib/filter_label')); export const FilterLabel = (props: React.ComponentProps) => ( }> @@ -24,7 +31,7 @@ export const FilterLabel = (props: React.ComponentProps) ); -const LazyFilterItem = React.lazy(() => import('./filter_item')); +const LazyFilterItem = React.lazy(() => import('./filter_item/filter_item')); export const FilterItem = (props: React.ComponentProps) => ( }> diff --git a/src/plugins/unified_search/public/index.scss b/src/plugins/unified_search/public/index.scss index 7f7704c64e9b4..72e1c0c313f74 100755 --- a/src/plugins/unified_search/public/index.scss +++ b/src/plugins/unified_search/public/index.scss @@ -3,5 +3,3 @@ @import './saved_query_management/index'; @import './query_string_input/index'; - -@import './filter_bar/index'; diff --git a/src/plugins/unified_search/public/index.ts b/src/plugins/unified_search/public/index.ts index 93805c6cfec1c..bc7974b42efb3 100755 --- a/src/plugins/unified_search/public/index.ts +++ b/src/plugins/unified_search/public/index.ts @@ -15,6 +15,8 @@ export type { StatefulSearchBarProps, SearchBarProps } from './search_bar'; export type { UnifiedSearchPublicPluginStart, UnifiedSearchPluginSetup } from './types'; export { SearchBar } from './search_bar'; export { FilterLabel, FilterItem } from './filter_bar'; +export { DataViewsList } from './dataview_picker/dataview_list'; +export { DataViewPicker } from './dataview_picker'; export type { ApplyGlobalFilterActionContext } from './actions'; export { ACTION_GLOBAL_APPLY_FILTER } from './actions'; diff --git a/src/plugins/unified_search/public/plugin.ts b/src/plugins/unified_search/public/plugin.ts index 5ba2474066275..26727b56094a0 100755 --- a/src/plugins/unified_search/public/plugin.ts +++ b/src/plugins/unified_search/public/plugin.ts @@ -5,9 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import './index.scss'; - import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { Storage, IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; diff --git a/src/plugins/unified_search/public/query_string_input/_query_bar.scss b/src/plugins/unified_search/public/query_string_input/_query_bar.scss index f8c2f067d9ec5..7b9a735d1556f 100644 --- a/src/plugins/unified_search/public/query_string_input/_query_bar.scss +++ b/src/plugins/unified_search/public/query_string_input/_query_bar.scss @@ -1,61 +1,34 @@ .kbnQueryBar__wrap { - max-width: 100%; + width: 100%; z-index: $euiZContentMenu; -} + height: $euiFormControlHeight; + display: flex; -// Uses the append style, but no bordering -.kqlQueryBar__languageSwitcherButton { - border-right: none !important; - border-left: $euiFormInputGroupBorder; + > [aria-expanded='true'] { + // Using filter allows it to adhere the children's bounds + filter: drop-shadow(0 5.7px 12px rgba($euiShadowColor, shadowOpacity(.05))); + } } .kbnQueryBar__textareaWrap { + position: relative; overflow: visible !important; // Override EUI form control display: flex; flex: 1 1 100%; - position: relative; - background-color: $euiFormBackgroundColor; - border-radius: $euiFormControlBorderRadius; - - &.kbnQueryBar__textareaWrap--hasPrepend { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - - &.kbnQueryBar__textareaWrap--hasAppend { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } } .kbnQueryBar__textarea { z-index: $euiZContentMenu; resize: none !important; // When in the group, it will autosize - height: $euiFormControlHeight - 2px; + height: $euiFormControlHeight; // Unlike most inputs within layout control groups, the text area still needs a border // for multi-line content. These adjusts help it sit above the control groups // shadow to line up correctly. - padding: $euiSizeS; - box-shadow: 0 0 0 1px $euiFormBorderColor; - padding-bottom: $euiSizeS + 1px; + padding: ($euiSizeS + 2px) $euiSizeS $euiSizeS; // Firefox adds margin to textarea margin: 0; - &.kbnQueryBar__textarea--hasPrepend { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - &.kbnQueryBar__textarea--hasAppend { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - &:not(.kbnQueryBar__textarea--autoHeight):not(:invalid) { - @include euiYScrollWithShadows; - } - &:not(.kbnQueryBar__textarea--autoHeight) { - white-space: nowrap; overflow-y: hidden; overflow-x: hidden; } @@ -65,18 +38,35 @@ overflow-x: auto; overflow-y: auto; white-space: normal; - box-shadow: 0 0 0 1px $euiFormBorderColor; + + } + + &.kbnQueryBar__textarea--isSuggestionsVisible { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + &--isClearable { + @include euiFormControlWithIcon($isIconOptional: false, $side: 'right'); } @include euiFormControlWithIcon($isIconOptional: true); + ~ .euiFormControlLayoutIcons { // By default form control layout icon is vertically centered, but our textarea // can expand to be multi-line, so we position it with padding that matches // the parent textarea padding z-index: $euiZContentMenu + 1; - top: $euiSizeS + 3px; + top: $euiSizeM; bottom: unset; } + + &--withPrepend { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: -1px; + width: calc(100% + 1px); + } } .kbnQueryBar__datePickerWrapper { @@ -92,32 +82,3 @@ } } } - -@include euiBreakpoint('xs', 's') { - .kbnQueryBar--withDatePicker { - > :first-child { - // Change the order of the query bar and date picker so that the date picker is top and the query bar still aligns with filters - order: 1; - // EUI Flexbox adds too much margin between responded items, this just moves it up - margin-top: $euiSizeS * -1; - } - } -} - -// IE specific fix for the datepicker to not collapse -@include euiBreakpoint('m', 'l', 'xl') { - .kbnQueryBar__datePickerWrapper { - max-width: 40vw; - // sass-lint:disable-block no-important - flex-grow: 0 !important; - flex-basis: auto !important; - - &.kbnQueryBar__datePickerWrapper-isHidden { - // sass-lint:disable-block no-important - margin-right: -$euiSizeXS !important; - width: 0; - overflow: hidden; - max-width: 0; - } - } -} diff --git a/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx new file mode 100644 index 0000000000000..b86d7d7f02498 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexItem, + EuiButtonIcon, + EuiPopover, + EuiButtonIconProps, + EuiToolTip, +} from '@elastic/eui'; +import { Filter } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { FilterEditorWrapper } from './filter_editor_wrapper'; + +interface AddFilterPopoverProps { + indexPatterns?: Array; + filters: Filter[]; + timeRangeForSuggestionsOverride?: boolean; + onFiltersUpdated?: (filters: Filter[]) => void; + buttonProps?: Partial; +} + +export const AddFilterPopover = React.memo(function AddFilterPopover({ + indexPatterns, + filters, + timeRangeForSuggestionsOverride, + onFiltersUpdated, + buttonProps, +}: AddFilterPopoverProps) { + const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); + + const buttonIconLabel = i18n.translate('unifiedSearch.filter.filterBar.addFilterButtonLabel', { + defaultMessage: 'Add filter', + }); + + const button = ( + + setIsAddFilterPopoverOpen(!isAddFilterPopoverOpen)} + size="m" + {...buttonProps} + /> + + ); + + return ( + + setIsAddFilterPopoverOpen(false)} + anchorPosition="downLeft" + panelPaddingSize="none" + initialFocus=".filterEditor__hiddenItem" + ownFocus + repositionOnScroll + > + setIsAddFilterPopoverOpen(false)} + /> + + + ); +}); diff --git a/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx b/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx new file mode 100644 index 0000000000000..dd106607353f2 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect } from 'react'; +import { Filter, buildEmptyFilter } from '@kbn/es-query'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { IDataPluginServices } from '@kbn/data-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { FILTER_EDITOR_WIDTH } from '../filter_bar/filter_item/filter_item'; +import { FilterEditor } from '../filter_bar/filter_editor'; +import { fetchIndexPatterns } from './fetch_index_patterns'; + +interface FilterEditorWrapperProps { + indexPatterns?: Array; + filters: Filter[]; + timeRangeForSuggestionsOverride?: boolean; + closePopover?: () => void; + onFiltersUpdated?: (filters: Filter[]) => void; +} + +export const FilterEditorWrapper = React.memo(function FilterEditorWrapper({ + indexPatterns, + filters, + timeRangeForSuggestionsOverride, + closePopover, + onFiltersUpdated, +}: FilterEditorWrapperProps) { + const kibana = useKibana(); + const { uiSettings, data, usageCollection, appName } = kibana.services; + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + const [dataViews, setDataviews] = useState([]); + const [newFilter, setNewFilter] = useState(undefined); + const isPinned = uiSettings!.get(UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT); + + useEffect(() => { + const fetchDataViews = async () => { + const stringPatterns = indexPatterns?.filter( + (indexPattern) => typeof indexPattern === 'string' + ) as string[]; + const objectPatterns = indexPatterns?.filter( + (indexPattern) => typeof indexPattern !== 'string' + ) as DataView[]; + + const objectPatternsFromStrings = (await fetchIndexPatterns( + data.dataViews, + stringPatterns + )) as DataView[]; + setDataviews([...objectPatterns, ...objectPatternsFromStrings]); + const [dataView] = [...objectPatterns, ...objectPatternsFromStrings]; + const index = dataView && dataView.id; + const emptyFilter = buildEmptyFilter(isPinned, index); + setNewFilter(emptyFilter); + }; + if (indexPatterns) { + fetchDataViews(); + } + }, [data.dataViews, indexPatterns, isPinned]); + + function onAdd(filter: Filter) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:added`); + closePopover?.(); + const updatedFilters = [...filters, filter]; + onFiltersUpdated?.(updatedFilters); + } + + return ( +
+ {newFilter && ( + closePopover?.()} + key={JSON.stringify(newFilter)} + timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride} + mode="add" + /> + )} +
+ ); +}); diff --git a/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx b/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx index 0223fc85a3ddb..591fe94360793 100644 --- a/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx @@ -11,7 +11,7 @@ import { QueryLanguageSwitcher, QueryLanguageSwitcherProps } from './language_sw import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { coreMock } from '@kbn/core/public/mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiButtonEmpty, EuiIcon, EuiPopover } from '@elastic/eui'; +import { EuiButtonIcon, EuiIcon, EuiPopover } from '@elastic/eui'; const startMock = coreMock.createStart(); describe('LanguageSwitcher', () => { @@ -28,7 +28,7 @@ describe('LanguageSwitcher', () => { ); } - it('should toggle off if language is lucene', () => { + it('should select the lucene context menu if language is lucene', () => { const component = mountWithIntl( wrapInContext({ language: 'lucene', @@ -37,12 +37,14 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeFalsy(); + expect(component.find('[data-test-subj="luceneLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); }); - it('should toggle on if language is kuery', () => { + it('should select the kql context menu if language is kuery', () => { const component = mountWithIntl( wrapInContext({ language: 'kuery', @@ -51,12 +53,14 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeTruthy(); + expect(component.find('[data-test-subj="kqlLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); }); - it('should toggle off if language is text', () => { + it('should select the lucene context menu if language is text', () => { const component = mountWithIntl( wrapInContext({ language: 'text', @@ -65,9 +69,11 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeFalsy(); + expect(component.find('[data-test-subj="luceneLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); }); it('it set language on nonKql mode text', () => { const onSelectLanguage = jest.fn(); @@ -79,11 +85,13 @@ describe('LanguageSwitcher', () => { onSelectLanguage, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeTruthy(); + expect(component.find('[data-test-subj="kqlLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find('[data-test-subj="luceneLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('text'); }); @@ -97,8 +105,8 @@ describe('LanguageSwitcher', () => { onSelectLanguage, }) ); - component.find(EuiButtonEmpty).simulate('click'); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); + component.find('[data-test-subj="luceneLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('lucene'); }); @@ -114,10 +122,10 @@ describe('LanguageSwitcher', () => { }) ); - expect(component.find(EuiIcon).prop('type')).toBe('boxesVertical'); + expect(component.find(EuiIcon).prop('type')).toBe('filter'); - component.find(EuiButtonEmpty).simulate('click'); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); + component.find('[data-test-subj="kqlLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('kuery'); }); @@ -132,13 +140,12 @@ describe('LanguageSwitcher', () => { onSelectLanguage, }) ); - - expect(component.find('[data-test-subj="switchQueryLanguageButton"]').at(0).text()).toBe( - 'Lucene' + component.find(EuiButtonIcon).simulate('click'); + expect(component.find('[data-test-subj="luceneLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' ); - component.find(EuiButtonEmpty).simulate('click'); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find('[data-test-subj="kqlLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('kuery'); }); diff --git a/src/plugins/unified_search/public/query_string_input/language_switcher.tsx b/src/plugins/unified_search/public/query_string_input/language_switcher.tsx index db42339e464c3..a48901ef17f86 100644 --- a/src/plugins/unified_search/public/query_string_input/language_switcher.tsx +++ b/src/plugins/unified_search/public/query_string_input/language_switcher.tsx @@ -7,17 +7,13 @@ */ import { - EuiButtonEmpty, - EuiForm, - EuiFormRow, - EuiIcon, - EuiLink, EuiPopover, EuiPopoverTitle, - EuiSpacer, - EuiSwitch, - EuiText, PopoverAnchorPosition, + EuiContextMenuItem, + toSentenceCase, + EuiHorizontalRule, + EuiButtonIcon, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -29,7 +25,7 @@ export interface QueryLanguageSwitcherProps { onSelectLanguage: (newLanguage: string) => void; anchorPosition?: PopoverAnchorPosition; nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; + isOnTopBarMenu?: boolean; } export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({ @@ -37,124 +33,78 @@ export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({ anchorPosition, onSelectLanguage, nonKqlMode = 'lucene', - nonKqlModeHelpText, + isOnTopBarMenu, }: QueryLanguageSwitcherProps) { const kibana = useKibana(); const kueryQuerySyntaxDocs = kibana.services.docLinks!.links.query.kueryQuerySyntax; const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const luceneLabel = ( - - ); - const kqlLabel = ( - - ); - - const kqlFullName = ( - - ); - - const kqlModeTitle = i18n.translate('unifiedSearch.query.queryBar.languageSwitcher.toText', { - defaultMessage: 'Switch to Kibana Query Language for search', - }); const button = ( - setIsPopoverOpen(!isPopoverOpen)} className="euiFormControlLayout__append kqlQueryBar__languageSwitcherButton" data-test-subj={'switchQueryLanguageButton'} - > - {language === 'kuery' ? ( - kqlLabel - ) : nonKqlMode === 'lucene' ? ( - luceneLabel - ) : ( - - )} - + aria-label={i18n.translate('unifiedSearch.switchLanguage.buttonText', { + defaultMessage: 'Switch language button.', + })} + /> + ); + + const languageMenuItem = ( +
+ { + onSelectLanguage('kuery'); + }} + > + KQL + + { + onSelectLanguage(nonKqlMode); + }} + > + {toSentenceCase(nonKqlMode)} + + + + Documentation + +
); - return ( + const languageQueryStringComponent = ( setIsPopoverOpen(false)} repositionOnScroll - ownFocus={true} - initialFocus={'[role="switch"]'} + panelPaddingSize="none" > - + -
- -

- - {kqlFullName} - - ), - nonKqlModeHelpText: - nonKqlModeHelpText || - i18n.translate( - 'unifiedSearch.query.queryBar.syntaxOptionsDescription.nonKqlModeHelpText', - { - defaultMessage: 'Kibana uses Lucene.', - } - ), - }} - /> -

-
- - - - - - - ) : ( - - ) - } - checked={language === 'kuery'} - onChange={() => { - const newLanguage = language === 'kuery' ? nonKqlMode : 'kuery'; - onSelectLanguage(newLanguage); - }} - data-test-subj="languageToggle" - /> - - -
+ {languageMenuItem}
); + + return Boolean(isOnTopBarMenu) ? languageMenuItem : languageQueryStringComponent; }); diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx new file mode 100644 index 0000000000000..7a04b92c7e063 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx @@ -0,0 +1,278 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { Filter } from '@kbn/es-query'; +import { QueryBarMenuProps, QueryBarMenu } from './query_bar_menu'; +import { EuiPopover } from '@elastic/eui'; + +describe('Querybar Menu component', () => { + const createMockWebStorage = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + key: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + length: 0, + }); + + const createMockStorage = () => ({ + storage: createMockWebStorage(), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }); + const getStorage = (v: string) => { + const storage = createMockStorage(); + storage.get.mockReturnValue(v); + return storage; + }; + + const startMock = coreMock.createStart(); + let dataMock = dataPluginMock.createStartContract(); + function wrapQueryBarMenuComponentInContext(testProps: QueryBarMenuProps, storageValue: string) { + dataMock = { + ...dataMock, + dataViews: { + ...dataMock.dataViews, + getIdsWithTitle: jest.fn(), + }, + }; + const services = { + data: dataMock, + storage: getStorage(storageValue), + uiSettings: startMock.uiSettings, + }; + + return ( + + + + + + ); + } + let props: QueryBarMenuProps; + beforeEach(() => { + props = { + language: 'kuery', + onQueryChange: jest.fn(), + onQueryBarSubmit: jest.fn(), + toggleFilterBarMenuPopover: jest.fn(), + openQueryBarMenu: false, + savedQueryService: { + ...dataMock.query.savedQueries, + findSavedQueries: jest.fn().mockResolvedValue({ + queries: [ + { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Test', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + ], + }), + }, + }; + }); + it('should not render the popover if the openQueryBarMenu prop is false', async () => { + await act(async () => { + const component = mount(wrapQueryBarMenuComponentInContext(props, 'kuery')); + expect(component.find(EuiPopover).prop('isOpen')).toBe(false); + }); + }); + + it('should render the popover if the openQueryBarMenu prop is true', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + }; + await act(async () => { + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + expect(component.find(EuiPopover).prop('isOpen')).toBe(true); + }); + }); + + it('should render the context menu by default', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + expect(component.find('[data-test-subj="queryBarMenuPanel"]')).toBeTruthy(); + }); + + it('should render the saved filter sets panels if the showQueryInput prop is true but disabled', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showQueryInput: true, + showFilterBar: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const saveFilterSetButton = component.find( + '[data-test-subj="saved-query-management-save-button"]' + ); + const loadFilterSetButton = component.find( + '[data-test-subj="saved-query-management-load-button"]' + ); + expect(saveFilterSetButton.length).toBeTruthy(); + expect(saveFilterSetButton.first().prop('disabled')).toBe(true); + expect(loadFilterSetButton.length).toBeTruthy(); + expect(loadFilterSetButton.first().prop('disabled')).toBe(true); + }); + + it('should render the filter sets panels if the showFilterBar is true but disabled', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const applyToAllFiltersButton = component.find( + '[data-test-subj="filter-sets-applyToAllFilters"]' + ); + const removeAllFiltersButton = component.find( + '[data-test-subj="filter-sets-removeAllFilters"]' + ); + expect(applyToAllFiltersButton.length).toBeTruthy(); + expect(applyToAllFiltersButton.first().prop('disabled')).toBe(true); + expect(removeAllFiltersButton.length).toBeTruthy(); + expect(removeAllFiltersButton.first().prop('disabled')).toBe(true); + }); + + it('should enable the clear all button if query is given', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const removeAllFiltersButton = component.find( + '[data-test-subj="filter-sets-removeAllFilters"]' + ); + expect(removeAllFiltersButton.first().prop('disabled')).toBe(false); + }); + + it('should enable the apply to all button if filter is given', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + filters: [ + { + meta: { + index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ] as Filter[], + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const applyToAllFiltersButton = component.find( + '[data-test-subj="filter-sets-applyToAllFilters"]' + ); + expect(applyToAllFiltersButton.first().prop('disabled')).toBe(false); + }); + + it('should render the language switcher panel', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + showQueryInput: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const languageSwitcher = component.find('[data-test-subj="switchQueryLanguageButton"]'); + expect(languageSwitcher.length).toBeTruthy(); + }); + + it('should render the save query quick buttons', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showSaveQuery: true, + filters: [ + { + meta: { + index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ] as Filter[], + savedQuery: { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Test', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const saveChangesButton = component.find( + '[data-test-subj="saved-query-management-save-changes-button"]' + ); + expect(saveChangesButton.length).toBeTruthy(); + const saveChangesAsNewButton = component.find( + '[data-test-subj="saved-query-management-save-as-new-button"]' + ); + expect(saveChangesAsNewButton.length).toBeTruthy(); + }); +}); diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx new file mode 100644 index 0000000000000..2b34aef33eeee --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiButtonIcon, + EuiContextMenu, + EuiContextMenuPanel, + EuiPopover, + useGeneratedHtmlId, + EuiButtonIconProps, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { Filter, Query } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { TimeRange, SavedQueryService, SavedQuery } from '@kbn/data-plugin/public'; +import { QueryBarMenuPanels } from './query_bar_menu_panels'; +import { FilterEditorWrapper } from './filter_editor_wrapper'; + +export interface QueryBarMenuProps { + language: string; + onQueryChange: (payload: { dateRange: TimeRange; query?: Query }) => void; + onQueryBarSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; + toggleFilterBarMenuPopover: (value: boolean) => void; + openQueryBarMenu: boolean; + nonKqlMode?: 'lucene' | 'text'; + dateRangeFrom?: string; + dateRangeTo?: string; + savedQueryService: SavedQueryService; + saveAsNewQueryFormComponent?: JSX.Element; + saveFormComponent?: JSX.Element; + manageFilterSetComponent?: JSX.Element; + onFiltersUpdated?: (filters: Filter[]) => void; + filters?: Filter[]; + query?: Query; + savedQuery?: SavedQuery; + onClearSavedQuery?: () => void; + showQueryInput?: boolean; + showFilterBar?: boolean; + showSaveQuery?: boolean; + timeRangeForSuggestionsOverride?: boolean; + indexPatterns?: Array; + buttonProps?: Partial; +} + +export function QueryBarMenu({ + language, + nonKqlMode, + dateRangeFrom, + dateRangeTo, + onQueryChange, + onQueryBarSubmit, + savedQueryService, + saveAsNewQueryFormComponent, + saveFormComponent, + manageFilterSetComponent, + openQueryBarMenu, + toggleFilterBarMenuPopover, + onFiltersUpdated, + filters, + query, + savedQuery, + onClearSavedQuery, + showQueryInput, + showFilterBar, + showSaveQuery, + indexPatterns, + timeRangeForSuggestionsOverride, + buttonProps, +}: QueryBarMenuProps) { + const [renderedComponent, setRenderedComponent] = useState('menu'); + + useEffect(() => { + if (openQueryBarMenu) { + setRenderedComponent('menu'); + } + }, [openQueryBarMenu]); + + const normalContextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'normalContextMenuPopover', + }); + const onButtonClick = () => { + toggleFilterBarMenuPopover(!openQueryBarMenu); + }; + + const closePopover = () => { + toggleFilterBarMenuPopover(false); + }; + + const buttonLabel = i18n.translate('unifiedSearch.filter.options.filterSetButtonLabel', { + defaultMessage: 'Filter set menu', + }); + + const button = ( + + + + ); + + const panels = QueryBarMenuPanels({ + filters, + savedQuery, + language, + dateRangeFrom, + dateRangeTo, + query, + showSaveQuery, + showFilterBar, + showQueryInput, + savedQueryService, + saveAsNewQueryFormComponent, + manageFilterSetComponent, + nonKqlMode, + closePopover, + onQueryBarSubmit, + onFiltersUpdated, + onClearSavedQuery, + onQueryChange, + setRenderedComponent, + }); + + const renderComponent = () => { + switch (renderedComponent) { + case 'menu': + default: + return ( + + ); + case 'saveForm': + return ( + {saveFormComponent}]} /> + ); + case 'saveAsNewForm': + return ( + {saveAsNewQueryFormComponent}]} + /> + ); + case 'addFilter': + return ( + , + ]} + /> + ); + } + }; + + return ( + <> + + {renderComponent()} + + + ); +} diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx new file mode 100644 index 0000000000000..de70e66fda5fc --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx @@ -0,0 +1,494 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; +import { + EuiContextMenuPanelDescriptor, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { + Filter, + Query, + enableFilter, + disableFilter, + toggleFilterNegated, + pinFilter, + unpinFilter, +} from '@kbn/es-query'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { KIBANA_USER_QUERY_LANGUAGE_KEY, UI_SETTINGS } from '@kbn/data-plugin/common'; +import type { + IDataPluginServices, + TimeRange, + SavedQueryService, + SavedQuery, +} from '@kbn/data-plugin/public'; +import { fromUser } from './from_user'; +import { QueryLanguageSwitcher } from './language_switcher'; + +interface QueryBarMenuPanelProps { + filters?: Filter[]; + savedQuery?: SavedQuery; + language: string; + dateRangeFrom?: string; + dateRangeTo?: string; + query?: Query; + showSaveQuery?: boolean; + showQueryInput?: boolean; + showFilterBar?: boolean; + savedQueryService: SavedQueryService; + saveAsNewQueryFormComponent?: JSX.Element; + manageFilterSetComponent?: JSX.Element; + nonKqlMode?: 'lucene' | 'text'; + closePopover: () => void; + onQueryBarSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; + onFiltersUpdated?: (filters: Filter[]) => void; + onClearSavedQuery?: () => void; + onQueryChange: (payload: { dateRange: TimeRange; query?: Query }) => void; + setRenderedComponent: (component: string) => void; +} + +export function QueryBarMenuPanels({ + filters, + savedQuery, + language, + dateRangeFrom, + dateRangeTo, + query, + showSaveQuery, + showFilterBar, + showQueryInput, + savedQueryService, + saveAsNewQueryFormComponent, + manageFilterSetComponent, + nonKqlMode, + closePopover, + onQueryBarSubmit, + onFiltersUpdated, + onClearSavedQuery, + onQueryChange, + setRenderedComponent, +}: QueryBarMenuPanelProps) { + const kibana = useKibana(); + const { appName, usageCollection, uiSettings, http, storage } = kibana.services; + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + const cancelPendingListingRequest = useRef<() => void>(() => {}); + + const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); + const [hasFiltersOrQuery, setHasFiltersOrQuery] = useState(false); + const [savedQueryHasChanged, setSavedQueryHasChanged] = useState(false); + + useEffect(() => { + const fetchSavedQueries = async () => { + cancelPendingListingRequest.current(); + let requestGotCancelled = false; + cancelPendingListingRequest.current = () => { + requestGotCancelled = true; + }; + + const { queries: savedQueryItems } = await savedQueryService.findSavedQueries(''); + + if (requestGotCancelled) return; + + setSavedQueries(savedQueryItems.reverse().slice(0, 5)); + }; + if (showQueryInput && showFilterBar) { + fetchSavedQueries(); + } + }, [savedQueryService, savedQuery, showQueryInput, showFilterBar]); + + useEffect(() => { + if (savedQuery) { + let filtersHaveChanged = filters?.length !== savedQuery.attributes?.filters?.length; + if (filters?.length === savedQuery.attributes?.filters?.length) { + filtersHaveChanged = Boolean( + filters?.some( + (filter, index) => + !isEqual(filter.query, savedQuery.attributes?.filters?.[index]?.query) + ) + ); + } + if (filtersHaveChanged || !isEqual(query, savedQuery?.attributes.query)) { + setSavedQueryHasChanged(true); + } else { + setSavedQueryHasChanged(false); + } + } + }, [filters, query, savedQuery, savedQuery?.attributes.filters, savedQuery?.attributes.query]); + + useEffect(() => { + const hasFilters = Boolean(filters && filters.length > 0); + const hasQuery = Boolean(query && query.query); + setHasFiltersOrQuery(hasFilters || hasQuery); + }, [filters, onClearSavedQuery, query, savedQuery]); + + const getDateRange = () => { + const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + return { + from: dateRangeFrom || defaultTimeSetting.from, + to: dateRangeTo || defaultTimeSetting.to, + }; + }; + + const handleSaveAsNew = useCallback(() => { + setRenderedComponent('saveAsNewForm'); + }, [setRenderedComponent]); + + const handleSave = useCallback(() => { + setRenderedComponent('saveForm'); + }, [setRenderedComponent]); + + const onEnableAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`); + const enabledFilters = filters?.map(enableFilter); + if (enabledFilters) { + onFiltersUpdated?.(enabledFilters); + } + }; + + const onDisableAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:disable_all`); + const disabledFilters = filters?.map(disableFilter); + if (disabledFilters) { + onFiltersUpdated?.(disabledFilters); + } + }; + + const onToggleAllNegated = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:invert_all`); + const negatedFilters = filters?.map(toggleFilterNegated); + if (negatedFilters) { + onFiltersUpdated?.(negatedFilters); + } + }; + + const onRemoveAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:remove_all`); + onFiltersUpdated?.([]); + }; + + const onPinAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:pin_all`); + const pinnedFilters = filters?.map(pinFilter); + if (pinnedFilters) { + onFiltersUpdated?.(pinnedFilters); + } + }; + + const onUnpinAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:unpin_all`); + const unPinnedFilters = filters?.map(unpinFilter); + if (unPinnedFilters) { + onFiltersUpdated?.(unPinnedFilters); + } + }; + + const onQueryStringChange = (value: string) => { + onQueryChange({ + query: { query: value, language }, + dateRange: getDateRange(), + }); + }; + + const onSelectLanguage = (lang: string) => { + http.post('/api/kibana/kql_opt_in_stats', { + body: JSON.stringify({ opt_in: lang === 'kuery' }), + }); + + const storageKey = KIBANA_USER_QUERY_LANGUAGE_KEY; + storage.set(storageKey!, lang); + + const newQuery = { query: '', language: lang }; + onQueryStringChange(newQuery.query); + onQueryBarSubmit({ + query: { query: fromUser(newQuery.query), language: newQuery.language }, + dateRange: getDateRange(), + }); + }; + + const luceneLabel = i18n.translate('unifiedSearch.query.queryBar.luceneLanguageName', { + defaultMessage: 'Lucene', + }); + const kqlLabel = i18n.translate('unifiedSearch.query.queryBar.kqlLanguageName', { + defaultMessage: 'KQL', + }); + + const filtersRelatedPanels = [ + { + name: i18n.translate('unifiedSearch.filter.options.addFilterButtonLabel', { + defaultMessage: 'Add filter', + }), + icon: 'plus', + onClick: () => { + setRenderedComponent('addFilter'); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', { + defaultMessage: 'Apply to all', + }), + icon: 'filter', + panel: 2, + disabled: !Boolean(filters && filters.length > 0), + 'data-test-subj': 'filter-sets-applyToAllFilters', + }, + ]; + + const queryAndFiltersRelatedPanels = [ + { + name: savedQuery + ? i18n.translate('unifiedSearch.filter.options.loadOtherFilterSetLabel', { + defaultMessage: 'Load other filter set', + }) + : i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', { + defaultMessage: 'Load filter set', + }), + panel: 4, + width: 350, + icon: 'filter', + 'data-test-subj': 'saved-query-management-load-button', + disabled: !savedQueries.length, + }, + { + name: savedQuery + ? i18n.translate('unifiedSearch.filter.options.saveAsNewFilterSetLabel', { + defaultMessage: 'Save as new', + }) + : i18n.translate('unifiedSearch.filter.options.saveFilterSetLabel', { + defaultMessage: 'Save filter set', + }), + icon: 'save', + disabled: + !Boolean(showSaveQuery) || !hasFiltersOrQuery || (savedQuery && !savedQueryHasChanged), + panel: 1, + 'data-test-subj': 'saved-query-management-save-button', + }, + { isSeparator: true }, + ]; + + const items = []; + // apply to all actions are only shown when there are filters + if (showFilterBar) { + items.push(...filtersRelatedPanels); + } + // clear all actions are only shown when there are filters or query + if (showFilterBar || showQueryInput) { + items.push( + { + name: i18n.translate('unifiedSearch.filter.options.clearllFiltersButtonLabel', { + defaultMessage: 'Clear all', + }), + disabled: !hasFiltersOrQuery && !Boolean(savedQuery), + icon: 'crossInACircleFilled', + 'data-test-subj': 'filter-sets-removeAllFilters', + onClick: () => { + closePopover(); + onQueryBarSubmit({ + query: { query: '', language }, + dateRange: getDateRange(), + }); + onRemoveAll(); + onClearSavedQuery?.(); + }, + }, + { isSeparator: true } + ); + } + // saved queries actions are only shown when the showQueryInput and showFilterBar is true + if (showQueryInput && showFilterBar) { + items.push(...queryAndFiltersRelatedPanels); + } + + // language menu appears when the showQueryInput is true + if (showQueryInput) { + items.push({ + name: `Language: ${language === 'kuery' ? kqlLabel : luceneLabel}`, + panel: 3, + 'data-test-subj': 'switchQueryLanguageButton', + }); + } + + const panels = [ + { + id: 0, + title: ( + <> + + + + {savedQuery ? savedQuery.attributes.title : 'Filter set'} + + + {savedQuery && savedQueryHasChanged && Boolean(showSaveQuery) && hasFiltersOrQuery && ( + + + + + {i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText', + { + defaultMessage: 'Save changes', + } + )} + + + + + {i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText', + { + defaultMessage: 'Save as new', + } + )} + + + + + )} + + + ), + items, + }, + { + id: 1, + title: i18n.translate('unifiedSearch.filter.options.saveCurrentFilterSetLabel', { + defaultMessage: 'Save current filter set', + }), + disabled: !Boolean(showSaveQuery), + content:
{saveAsNewQueryFormComponent}
, + }, + { + id: 2, + initialFocusedItemIndex: 1, + title: i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', { + defaultMessage: 'Apply to all', + }), + items: [ + { + name: i18n.translate('unifiedSearch.filter.options.enableAllFiltersButtonLabel', { + defaultMessage: 'Enable all', + }), + icon: 'eye', + 'data-test-subj': 'filter-sets-enableAllFilters', + onClick: () => { + closePopover(); + onEnableAll(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.disableAllFiltersButtonLabel', { + defaultMessage: 'Disable all', + }), + 'data-test-subj': 'filter-sets-disableAllFilters', + icon: 'eyeClosed', + onClick: () => { + closePopover(); + onDisableAll(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.invertNegatedFiltersButtonLabel', { + defaultMessage: 'Invert inclusion', + }), + 'data-test-subj': 'filter-sets-invertAllFilters', + icon: 'invert', + onClick: () => { + closePopover(); + onToggleAllNegated(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.pinAllFiltersButtonLabel', { + defaultMessage: 'Pin all', + }), + 'data-test-subj': 'filter-sets-pinAllFilters', + icon: 'pin', + onClick: () => { + closePopover(); + onPinAll(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.unpinAllFiltersButtonLabel', { + defaultMessage: 'Unpin all', + }), + 'data-test-subj': 'filter-sets-unpinAllFilters', + icon: 'pin', + onClick: () => { + closePopover(); + onUnpinAll(); + }, + }, + ], + }, + { + id: 3, + title: i18n.translate('unifiedSearch.filter.options.filterLanguageLabel', { + defaultMessage: 'Filter language', + }), + content: ( + + ), + }, + { + id: 4, + title: i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', { + defaultMessage: 'Load filter set', + }), + width: 400, + content:
{manageFilterSetComponent}
, + }, + ] as EuiContextMenuPanelDescriptor[]; + + return panels; +} diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index de1fa659aa133..bb01338d8d5a0 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -11,6 +11,7 @@ import classNames from 'classnames'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import useObservable from 'react-use/lib/useObservable'; +import type { Filter } from '@kbn/es-query'; import { EMPTY } from 'rxjs'; import { map } from 'rxjs/operators'; import { @@ -20,8 +21,9 @@ import { EuiFieldText, usePrettyDuration, EuiIconProps, - EuiSuperUpdateButton, OnRefreshProps, + useIsWithinBreakpoints, + EuiSuperUpdateButton, } from '@elastic/eui'; import { IDataPluginServices, @@ -30,20 +32,21 @@ import { Query, getQueryLog, } from '@kbn/data-plugin/public'; +import { i18n } from '@kbn/i18n'; import { DataView } from '@kbn/data-views-plugin/public'; import type { PersistedLog } from '@kbn/data-plugin/public'; import { useKibana, withKibana } from '@kbn/kibana-react-plugin/public'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import QueryStringInputUI from './query_string_input'; import { NoDataPopover } from './no_data_popover'; -import { shallowEqual } from '../utils'; +import { shallowEqual } from '../utils/shallow_equal'; +import { AddFilterPopover } from './add_filter_popover'; +import { DataViewPicker, DataViewPickerProps } from '../dataview_picker'; +import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; const SuperDatePicker = React.memo( EuiSuperDatePicker as any ) as unknown as typeof EuiSuperDatePicker; -const SuperUpdateButton = React.memo( - EuiSuperUpdateButton as any -) as unknown as typeof EuiSuperUpdateButton; const QueryStringInput = withKibana(QueryStringInputUI); @@ -63,7 +66,6 @@ export interface QueryBarTopRowProps { isLoading?: boolean; isRefreshPaused?: boolean; nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; onChange: (payload: { dateRange: TimeRange; query?: Query }) => void; onRefresh?: (payload: { dateRange: TimeRange }) => void; onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; @@ -74,10 +76,17 @@ export interface QueryBarTopRowProps { refreshInterval?: number; screenTitle?: string; showQueryInput?: boolean; + showAddFilter?: boolean; showDatePicker?: boolean; showAutoRefreshOnly?: boolean; timeHistory?: TimeHistoryContract; timeRangeForSuggestionsOverride?: boolean; + filters: Filter[]; + onFiltersUpdated?: (filters: Filter[]) => void; + dataViewPickerComponentProps?: DataViewPickerProps; + filterBar?: React.ReactNode; + showDatePickerAsBadge?: boolean; + showSubmitButton?: boolean; } const SharingMetaFields = React.memo(function SharingMetaFields({ @@ -114,7 +123,13 @@ const SharingMetaFields = React.memo(function SharingMetaFields({ export const QueryBarTopRow = React.memo( function QueryBarTopRow(props: QueryBarTopRowProps) { - const { showQueryInput = true, showDatePicker = true, showAutoRefreshOnly = false } = props; + const isMobile = useIsWithinBreakpoints(['xs', 's']); + const { + showQueryInput = true, + showDatePicker = true, + showAutoRefreshOnly = false, + showSubmitButton = true, + } = props; const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); @@ -283,14 +298,27 @@ export const QueryBarTopRow = React.memo( return Boolean(showDatePicker || showAutoRefreshOnly); } + function renderFilterMenuOnly(): boolean { + return !Boolean(props.showAddFilter) && Boolean(props.prepend); + } + + function shouldRenderUpdatebutton(): boolean { + return ( + Boolean(showSubmitButton) && + Boolean(showQueryInput || showDatePicker || showAutoRefreshOnly) + ); + } + + function shouldShowDatePickerAsBadge(): boolean { + return Boolean(props.showDatePickerAsBadge) && !shouldRenderQueryInput(); + } + function renderDatePicker() { if (!shouldRenderDatePicker()) { return null; } - const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { - 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, - }); + const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper'); return ( @@ -308,23 +336,52 @@ export const QueryBarTopRow = React.memo( dateFormat={uiSettings.get('dateFormat')} isAutoRefreshOnly={showAutoRefreshOnly} className="kbnQueryBar__datePicker" + isQuickSelectOnly={isMobile ? false : isQueryInputFocused} + width={isMobile ? 'full' : 'auto'} + compressed={shouldShowDatePickerAsBadge()} /> ); } function renderUpdateButton() { + if (!shouldRenderUpdatebutton()) { + return null; + } + + const buttonLabelUpdate = i18n.translate('unifiedSearch.queryBarTopRow.submitButton.update', { + defaultMessage: 'Needs updating', + }); + const buttonLabelRefresh = i18n.translate( + 'unifiedSearch.queryBarTopRow.submitButton.refresh', + { + defaultMessage: 'Refresh query', + } + ); + const button = props.customSubmitButton ? ( React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) ) : ( - + + + ); if (!shouldRenderDatePicker()) { @@ -332,61 +389,118 @@ export const QueryBarTopRow = React.memo( } return ( - - - {renderDatePicker()} - {button} - - + + + + {renderDatePicker()} + {button} + + + ); } - function renderQueryInput() { - if (!shouldRenderQueryInput()) return; + function renderDataViewsPicker() { + if (!props.dataViewPickerComponentProps) return; return ( - - + ); } - const classes = classNames('kbnQueryBar', { - 'kbnQueryBar--withDatePicker': showDatePicker, - }); + function renderAddButton() { + return ( + Boolean(props.showAddFilter) && ( + + + + ) + ); + } + + function renderFilterButtonGroup() { + return ( + (Boolean(props.showAddFilter) || Boolean(props.prepend)) && ( + + + + ) + ); + } + + function renderQueryInput() { + return ( + + {!renderFilterMenuOnly() && renderFilterButtonGroup()} + {shouldRenderQueryInput() && ( + + + + )} + + ); + } return ( - - {renderQueryInput()} + <> - {renderUpdateButton()} - + + {renderDataViewsPicker()} + + {renderQueryInput()} + + {shouldShowDatePickerAsBadge() && props.filterBar} + {renderUpdateButton()} + + {!shouldShowDatePickerAsBadge() && props.filterBar} + ); }, ({ query: prevQuery, ...prevProps }, { query: nextQuery, ...nextProps }) => { diff --git a/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx b/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx index b4eed13da7f58..7437bf5fd4ece 100644 --- a/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx @@ -110,7 +110,6 @@ describe('QueryStringInput', () => { ); await waitFor(() => getByText(kqlQuery.query)); - await waitFor(() => getByText('KQL')); }); it('Should pass the query language to the language switcher', () => { diff --git a/src/plugins/unified_search/public/query_string_input/query_string_input.tsx b/src/plugins/unified_search/public/query_string_input/query_string_input.tsx index a9f4127809ab7..31ff302f9e699 100644 --- a/src/plugins/unified_search/public/query_string_input/query_string_input.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_string_input.tsx @@ -24,9 +24,10 @@ import { EuiTextArea, htmlIdGenerator, PopoverAnchorPosition, + toSentenceCase, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { compact, debounce, isEqual, isFunction } from 'lodash'; +import { compact, debounce, isEmpty, isEqual, isFunction } from 'lodash'; import { Toast } from '@kbn/core/public'; import { IDataPluginServices, Query, getQueryLog } from '@kbn/data-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; @@ -41,6 +42,7 @@ import { QueryLanguageSwitcher } from './language_switcher'; import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { SuggestionsComponent } from '../typeahead'; import { onRaf } from '../utils'; +import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; import { QuerySuggestion, QuerySuggestionTypes } from '../autocomplete'; import { getTheme, getAutocomplete } from '../services'; @@ -72,7 +74,6 @@ export interface QueryStringInputProps { * this params add another option text, which is just a simple keyword search mode, the way a simple search box works */ nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; /** * @param autoSubmit if user selects a value, in that case kuery will be auto submitted */ @@ -124,6 +125,8 @@ const KEY_CODES = { export default class QueryStringInputUI extends PureComponent { static defaultProps = { storageKey: KIBANA_USER_QUERY_LANGUAGE_KEY, + iconType: 'search', + isClearable: true, }; public state: State = { @@ -678,31 +681,59 @@ export default class QueryStringInputUI extends PureComponent { this.handleAutoHeight(); }; + getSearchInputPlaceholder = () => { + let placeholder = ''; + if (!this.props.query.language || this.props.query.language === 'text') { + placeholder = i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholderForText', { + defaultMessage: 'Filter your data', + }); + } else { + const language = + this.props.query.language === 'kuery' ? 'KQL' : toSentenceCase(this.props.query.language); + + placeholder = i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholder', { + defaultMessage: 'Filter your data using {language} syntax', + values: { language }, + }); + } + + return placeholder; + }; + public render() { const isSuggestionsVisible = this.state.isSuggestionsVisible && { 'aria-controls': 'kbnTypeahead__items', 'aria-owns': 'kbnTypeahead__items', }; const ariaCombobox = { ...isSuggestionsVisible, role: 'combobox' }; - const containerClassName = classNames( - 'euiFormControlLayout euiFormControlLayout--group kbnQueryBar__wrap', - this.props.className - ); - const inputClassName = classNames( - 'kbnQueryBar__textarea', - this.props.iconType ? 'kbnQueryBar__textarea--withIcon' : null, - this.props.prepend ? 'kbnQueryBar__textarea--hasPrepend' : null, - !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textarea--hasAppend' : null - ); - const inputWrapClassName = classNames( - 'euiFormControlLayout__childrenWrapper kbnQueryBar__textareaWrap', - this.props.prepend ? 'kbnQueryBar__textareaWrap--hasPrepend' : null, - !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textareaWrap--hasAppend' : null + + const simpleLanguageSwitcher = this.props.disableLanguageSwitcher ? null : ( + ); + const prependElement = + this.props.prepend || simpleLanguageSwitcher ? ( + + ) : undefined; + + const containerClassName = classNames('kbnQueryBar__wrap', this.props.className); + const inputClassName = classNames('kbnQueryBar__textarea', { + 'kbnQueryBar__textarea--withIcon': this.props.iconType, + 'kbnQueryBar__textarea--isClearable': this.props.isClearable, + 'kbnQueryBar__textarea--withPrepend': prependElement, + 'kbnQueryBar__textarea--isSuggestionsVisible': + isSuggestionsVisible && !isEmpty(this.state.suggestions), + }); + const inputWrapClassName = classNames('kbnQueryBar__textareaWrap'); return (
- {this.props.prepend} + {prependElement} +
{ >
{
- {this.props.disableLanguageSwitcher ? null : ( - - )}
); } diff --git a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx index 53c5ec3310da2..186c1f072aedd 100644 --- a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx +++ b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx @@ -7,20 +7,7 @@ */ import React, { useEffect, useState, useCallback } from 'react'; -import { - EuiButtonEmpty, - EuiModal, - EuiButton, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, - EuiModalFooter, - EuiForm, - EuiFormRow, - EuiFieldText, - EuiSwitch, - EuiText, -} from '@elastic/eui'; +import { EuiButton, EuiForm, EuiFormRow, EuiFieldText, EuiSwitch } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { sortBy, isEqual } from 'lodash'; import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; @@ -51,8 +38,6 @@ export function SaveQueryForm({ showTimeFilterOption = true, }: Props) { const [title, setTitle] = useState(savedQuery?.attributes.title ?? ''); - const [enabledSaveButton, setEnabledSaveButton] = useState(Boolean(savedQuery)); - const [description, setDescription] = useState(savedQuery?.attributes.description ?? ''); const [savedQueries, setSavedQueries] = useState([]); const [shouldIncludeFilters, setShouldIncludeFilters] = useState( Boolean(savedQuery?.attributes.filters ?? true) @@ -72,10 +57,10 @@ export function SaveQueryForm({ } ); - const savedQueryDescriptionText = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryDescriptionText', + const titleExistsErrorText = i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryForm.titleExistsText', { - defaultMessage: 'Save query text and filters that you want to use again.', + defaultMessage: 'Name is required.', } ); @@ -98,36 +83,40 @@ export function SaveQueryForm({ errors.push(titleConflictErrorText); } + if (!title) { + errors.push(titleExistsErrorText); + } + if (!isEqual(errors, formErrors)) { setFormErrors(errors); return false; } return !formErrors.length; - }, [savedQueries, savedQuery, title, titleConflictErrorText, formErrors]); + }, [savedQueries, formErrors, title, savedQuery, titleConflictErrorText, titleExistsErrorText]); const onClickSave = useCallback(() => { if (validate()) { onSave({ id: savedQuery?.id, title, - description, + description: '', shouldIncludeFilters, shouldIncludeTimefilter, }); + onClose(); } }, [ validate, onSave, + onClose, savedQuery?.id, title, - description, shouldIncludeFilters, shouldIncludeTimefilter, ]); const onInputChange = useCallback((event) => { - setEnabledSaveButton(Boolean(event.target.value)); setFormErrors([]); setTitle(event.target.value); }, []); @@ -143,18 +132,16 @@ export function SaveQueryForm({ const saveQueryForm = ( - - {savedQueryDescriptionText} - - - { - setDescription(event.target.value); - }} - data-test-subj="saveQueryFormDescription" - /> - {showFilterOption && ( - + + )} - - ); - - return ( - - - - {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormTitle', { - defaultMessage: 'Save query', - })} - - - - {saveQueryForm} - - - - {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormCancelButtonText', { - defaultMessage: 'Cancel', - })} - + {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormSaveButtonText', { - defaultMessage: 'Save', + defaultMessage: 'Save filter set', })} - - + + ); + + return <>{saveQueryForm}; } diff --git a/src/plugins/unified_search/public/saved_query_management/_index.scss b/src/plugins/unified_search/public/saved_query_management/_index.scss index 0580e857e8494..0c90d7817b685 100644 --- a/src/plugins/unified_search/public/saved_query_management/_index.scss +++ b/src/plugins/unified_search/public/saved_query_management/_index.scss @@ -1,2 +1 @@ -@import './saved_query_management_component'; -@import './saved_query_list_item'; +@import './saved_query_management_list'; diff --git a/src/plugins/unified_search/public/saved_query_management/_saved_query_list_item.scss b/src/plugins/unified_search/public/saved_query_management/_saved_query_list_item.scss deleted file mode 100644 index 714ba82dfb476..0000000000000 --- a/src/plugins/unified_search/public/saved_query_management/_saved_query_list_item.scss +++ /dev/null @@ -1,21 +0,0 @@ -.kbnSavedQueryListItem { - margin-top: 0; - color: $euiLinkColor; -} - -// Can't actually target the button with classes, but styles to override -// are just user agent styles -.kbnSavedQueryListItem-selected button { - font-weight: $euiFontWeightBold; -} - -// This will ensure the info icon and tooltip shows even if the label gets truncated -.kbnSavedQueryListItem__label { - display: flex; - align-items: center; -} - -.kbnSavedQueryListItem__labelText { - @include euiTextTruncate; - margin-right: $euiSizeXS; -} diff --git a/src/plugins/unified_search/public/saved_query_management/_saved_query_management_component.scss b/src/plugins/unified_search/public/saved_query_management/_saved_query_management_list.scss similarity index 76% rename from src/plugins/unified_search/public/saved_query_management/_saved_query_management_component.scss rename to src/plugins/unified_search/public/saved_query_management/_saved_query_management_list.scss index 928cb5a34d6de..7ce304310ae56 100644 --- a/src/plugins/unified_search/public/saved_query_management/_saved_query_management_component.scss +++ b/src/plugins/unified_search/public/saved_query_management/_saved_query_management_list.scss @@ -1,18 +1,9 @@ -.kbnSavedQueryManagement__popover { - max-width: $euiFormMaxWidth; -} - .kbnSavedQueryManagement__listWrapper { // Addition height will ensure one item is "cutoff" to indicate more below the scroll max-height: $euiFormMaxWidth + $euiSize; overflow-y: hidden; } -.kbnSavedQueryManagement__pagination { - justify-content: center; - padding: ($euiSizeM / 2) $euiSizeM $euiSizeM; -} - .kbnSavedQueryManagement__text { padding: $euiSizeM $euiSizeM ($euiSizeM / 2) $euiSizeM; } diff --git a/src/plugins/unified_search/public/saved_query_management/index.ts b/src/plugins/unified_search/public/saved_query_management/index.ts index 4ead1907cd23b..134b24a4fb85c 100644 --- a/src/plugins/unified_search/public/saved_query_management/index.ts +++ b/src/plugins/unified_search/public/saved_query_management/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { SavedQueryManagementComponent } from './saved_query_management_component'; +export { SavedQueryManagementList } from './saved_query_management_list'; diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_list_item.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_list_item.tsx deleted file mode 100644 index 71fbd8aad6e48..0000000000000 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_list_item.tsx +++ /dev/null @@ -1,154 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { EuiListGroupItem, EuiConfirmModal, EuiIconTip } from '@elastic/eui'; - -import React, { Fragment, useState } from 'react'; -import classNames from 'classnames'; -import { i18n } from '@kbn/i18n'; -import { SavedQuery } from '@kbn/data-plugin/public'; - -interface Props { - savedQuery: SavedQuery; - isSelected: boolean; - showWriteOperations: boolean; - onSelect: (savedQuery: SavedQuery) => void; - onDelete: (savedQuery: SavedQuery) => void; -} - -export const SavedQueryListItem = ({ - savedQuery, - isSelected, - onSelect, - onDelete, - showWriteOperations, -}: Props) => { - const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false); - - const selectButtonAriaLabelText = isSelected - ? i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel', - { - defaultMessage: - 'Saved query button selected {savedQueryName}. Press to clear any changes.', - values: { savedQueryName: savedQuery.attributes.title }, - } - ) - : i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSavedQueryListItemButtonAriaLabel', - { - defaultMessage: 'Saved query button {savedQueryName}', - values: { savedQueryName: savedQuery.attributes.title }, - } - ); - - const selectButtonDataTestSubj = isSelected - ? `load-saved-query-${savedQuery.attributes.title}-button saved-query-list-item-selected` - : `load-saved-query-${savedQuery.attributes.title}-button`; - - const classes = classNames('kbnSavedQueryListItem', { - 'kbnSavedQueryListItem-selected': isSelected, - }); - - const label = ( - - {savedQuery.attributes.title}{' '} - {savedQuery.attributes.description && ( - - )} - - ); - - return ( - - { - onSelect(savedQuery); - }} - aria-label={selectButtonAriaLabelText} - label={label} - iconType={isSelected ? 'check' : undefined} - extraAction={ - showWriteOperations - ? { - color: 'danger', - onClick: () => setShowDeletionConfirmationModal(true), - iconType: 'trash', - iconSize: 's', - 'aria-label': i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverDeleteButtonAriaLabel', - { - defaultMessage: 'Delete saved query {savedQueryName}', - values: { savedQueryName: savedQuery.attributes.title }, - } - ), - title: i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverDeleteButtonAriaLabel', - { - defaultMessage: 'Delete saved query {savedQueryName}', - values: { savedQueryName: savedQuery.attributes.title }, - } - ), - 'data-test-subj': `delete-saved-query-${savedQuery.attributes.title}-button`, - } - : undefined - } - /> - - {showDeletionConfirmationModal && ( - { - onDelete(savedQuery); - setShowDeletionConfirmationModal(false); - }} - buttonColor="danger" - onCancel={() => { - setShowDeletionConfirmationModal(false); - }} - /> - )} - - ); -}; diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_component.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_component.tsx deleted file mode 100644 index 07d3a9d799a66..0000000000000 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_component.tsx +++ /dev/null @@ -1,340 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { - EuiPopover, - EuiPopoverTitle, - EuiPopoverFooter, - EuiButtonEmpty, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiListGroup, - EuiPagination, - EuiText, - EuiSpacer, - EuiIcon, -} from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; -import React, { useCallback, useEffect, useState, Fragment, useRef } from 'react'; -import { sortBy } from 'lodash'; -import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; -import { SavedQueryListItem } from './saved_query_list_item'; - -const perPage = 50; -interface Props { - showSaveQuery?: boolean; - loadedSavedQuery?: SavedQuery; - savedQueryService: SavedQueryService; - onSave: () => void; - onSaveAsNew: () => void; - onLoad: (savedQuery: SavedQuery) => void; - onClearSavedQuery: () => void; -} - -export function SavedQueryManagementComponent({ - showSaveQuery, - loadedSavedQuery, - onSave, - onSaveAsNew, - onLoad, - onClearSavedQuery, - savedQueryService, -}: Props) { - const [isOpen, setIsOpen] = useState(false); - const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); - const [count, setTotalCount] = useState(0); - const [activePage, setActivePage] = useState(0); - const cancelPendingListingRequest = useRef<() => void>(() => {}); - - useEffect(() => { - const fetchCountAndSavedQueries = async () => { - cancelPendingListingRequest.current(); - let requestGotCancelled = false; - cancelPendingListingRequest.current = () => { - requestGotCancelled = true; - }; - - const { total: savedQueryCount, queries: savedQueryItems } = - await savedQueryService.findSavedQueries('', perPage, activePage + 1); - - if (requestGotCancelled) return; - - const sortedSavedQueryItems = sortBy(savedQueryItems, 'attributes.title'); - setTotalCount(savedQueryCount); - setSavedQueries(sortedSavedQueryItems); - }; - if (isOpen) { - fetchCountAndSavedQueries(); - } - }, [isOpen, activePage, savedQueryService]); - - const handleTogglePopover = useCallback( - () => setIsOpen((currentState) => !currentState), - [setIsOpen] - ); - - const handleClosePopover = useCallback(() => setIsOpen(false), []); - - const handleSave = useCallback(() => { - handleClosePopover(); - onSave(); - }, [handleClosePopover, onSave]); - - const handleSaveAsNew = useCallback(() => { - handleClosePopover(); - onSaveAsNew(); - }, [handleClosePopover, onSaveAsNew]); - - const handleSelect = useCallback( - (savedQueryToSelect) => { - handleClosePopover(); - onLoad(savedQueryToSelect); - }, - [handleClosePopover, onLoad] - ); - - const handleDelete = useCallback( - (savedQueryToDelete: SavedQuery) => { - const onDeleteSavedQuery = async (savedQuery: SavedQuery) => { - cancelPendingListingRequest.current(); - setSavedQueries( - savedQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQuery.id) - ); - - if (loadedSavedQuery && loadedSavedQuery.id === savedQuery.id) { - onClearSavedQuery(); - } - - await savedQueryService.deleteSavedQuery(savedQuery.id); - setActivePage(0); - }; - - onDeleteSavedQuery(savedQueryToDelete); - handleClosePopover(); - }, - [handleClosePopover, loadedSavedQuery, onClearSavedQuery, savedQueries, savedQueryService] - ); - - const savedQueryDescriptionText = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryDescriptionText', - { - defaultMessage: 'Save query text and filters that you want to use again.', - } - ); - - const noSavedQueriesDescriptionText = - i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', { - defaultMessage: 'There are no saved queries.', - }) + - ' ' + - savedQueryDescriptionText; - - const savedQueryPopoverTitleText = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverTitleText', - { - defaultMessage: 'Saved Queries', - } - ); - - const goToPage = (pageNumber: number) => { - setActivePage(pageNumber); - }; - - const savedQueryPopoverButton = ( - - - - - ); - - const savedQueryRows = () => { - const savedQueriesWithoutCurrent = savedQueries.filter((savedQuery) => { - if (!loadedSavedQuery) return true; - return savedQuery.id !== loadedSavedQuery.id; - }); - const savedQueriesReordered = - loadedSavedQuery && savedQueriesWithoutCurrent.length !== savedQueries.length - ? [loadedSavedQuery, ...savedQueriesWithoutCurrent] - : [...savedQueriesWithoutCurrent]; - return savedQueriesReordered.map((savedQuery) => ( - - )); - }; - - return ( - - -
- - {savedQueryPopoverTitleText} - - {savedQueries.length > 0 ? ( - - -

{savedQueryDescriptionText}

-
-
- - {savedQueryRows()} - -
- -
- ) : ( - - -

{noSavedQueriesDescriptionText}

-
- -
- )} - - - {showSaveQuery && loadedSavedQuery && ( - - - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText', - { - defaultMessage: 'Save changes', - } - )} - - - - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText', - { - defaultMessage: 'Save as new', - } - )} - - - - )} - {showSaveQuery && !loadedSavedQuery && ( - - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSaveButtonText', - { - defaultMessage: 'Save current query', - } - )} - - - )} - - - {loadedSavedQuery && ( - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverClearButtonText', - { - defaultMessage: 'Clear', - } - )} - - )} - - - -
-
-
- ); -} diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx new file mode 100644 index 0000000000000..7c2d0ebd1faad --- /dev/null +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiSelectable } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import { act } from 'react-dom/test-utils'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { ReactWrapper } from 'enzyme'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { coreMock, applicationServiceMock } from '@kbn/core/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { + SavedQueryManagementListProps, + SavedQueryManagementList, +} from './saved_query_management_list'; + +describe('Saved query management list component', () => { + const startMock = coreMock.createStart(); + const dataMock = dataPluginMock.createStartContract(); + const applicationMock = applicationServiceMock.createStartContract(); + const application = { + ...applicationMock, + capabilities: { + ...applicationMock.capabilities, + savedObjectsManagement: { edit: true }, + }, + }; + function wrapSavedQueriesListComponentInContext(testProps: SavedQueryManagementListProps) { + const services = { + uiSettings: startMock.uiSettings, + http: startMock.http, + application, + }; + + return ( + + + + + + ); + } + + function flushEffect(component: ReactWrapper) { + return act(async () => { + await component; + await new Promise((r) => setImmediate(r)); + component.update(); + }); + } + let props: SavedQueryManagementListProps; + beforeEach(() => { + props = { + onLoad: jest.fn(), + onClearSavedQuery: jest.fn(), + onClose: jest.fn(), + showSaveQuery: true, + hasFiltersOrQuery: false, + savedQueryService: { + ...dataMock.query.savedQueries, + findSavedQueries: jest.fn().mockResolvedValue({ + queries: [ + { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Test', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + ], + }), + deleteSavedQuery: jest.fn(), + }, + }; + }); + it('should render the list component if saved queries exist', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + expect(component.find('[data-test-subj="saved-query-management-list"]').length).toBe(1); + }); + + it('should not rendet the list component if not saved queries exist', async () => { + const newProps = { + ...props, + savedQueryService: { + ...dataMock.query.savedQueries, + findSavedQueries: jest.fn().mockResolvedValue({ + queries: [], + }), + }, + }; + const component = mount(wrapSavedQueriesListComponentInContext(newProps)); + await flushEffect(component); + expect(component.find('[data-test-subj="saved-query-management-empty"]').length).toBeTruthy(); + }); + + it('should render the saved queries on the selectable component', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + expect(component.find(EuiSelectable).prop('options').length).toBe(1); + expect(component.find(EuiSelectable).prop('options')[0].label).toBe('Test'); + }); + + it('should call the onLoad function', async () => { + const onLoadSpy = jest.fn(); + const newProps = { + ...props, + onLoad: onLoadSpy, + }; + const component = mount(wrapSavedQueriesListComponentInContext(newProps)); + await flushEffect(component); + component.find('[data-test-subj="load-saved-query-Test-button"]').first().simulate('click'); + expect( + component.find('[data-test-subj="saved-query-management-apply-changes-button"]').length + ).toBeTruthy(); + component + .find('[data-test-subj="saved-query-management-apply-changes-button"]') + .first() + .simulate('click'); + expect(onLoadSpy).toBeCalled(); + }); + + it('should render the button with the correct text', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + expect( + component + .find('[data-test-subj="saved-query-management-apply-changes-button"]') + .first() + .text() + ).toBe('Apply filter set'); + + const newProps = { + ...props, + hasFiltersOrQuery: true, + }; + const updatedComponent = mount(wrapSavedQueriesListComponentInContext(newProps)); + await flushEffect(component); + expect( + updatedComponent + .find('[data-test-subj="saved-query-management-apply-changes-button"]') + .first() + .text() + ).toBe('Replace with selected filter set'); + }); + + it('should render the modal on delete', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + findTestSubject(component, 'delete-saved-query-Test-button').simulate('click'); + expect(component.find('[data-test-subj="confirmModalConfirmButton"]').length).toBeTruthy(); + }); +}); diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx new file mode 100644 index 0000000000000..7568bb9375fa6 --- /dev/null +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx @@ -0,0 +1,385 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiSelectable, + EuiText, + EuiPopoverFooter, + EuiButtonIcon, + EuiButtonEmpty, + EuiConfirmModal, + usePrettyDuration, + ShortDate, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import { css } from '@emotion/react'; +import { sortBy } from 'lodash'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { IDataPluginServices, SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; +import type { SavedQueryAttributes } from '@kbn/data-plugin/common'; + +export interface SavedQueryManagementListProps { + showSaveQuery?: boolean; + loadedSavedQuery?: SavedQuery; + savedQueryService: SavedQueryService; + onLoad: (savedQuery: SavedQuery) => void; + onClearSavedQuery: () => void; + onClose: () => void; + hasFiltersOrQuery: boolean; +} + +interface SelectableProps { + key?: string; + label: string; + value?: string; + checked?: 'on' | 'off' | undefined; +} + +interface DurationRange { + end: ShortDate; + label?: string; + start: ShortDate; +} + +const commonDurationRanges: DurationRange[] = [ + { start: 'now/d', end: 'now/d', label: 'Today' }, + { start: 'now/w', end: 'now/w', label: 'This week' }, + { start: 'now/M', end: 'now/M', label: 'This month' }, + { start: 'now/y', end: 'now/y', label: 'This year' }, + { start: 'now-1d/d', end: 'now-1d/d', label: 'Yesterday' }, + { start: 'now/w', end: 'now', label: 'Week to date' }, + { start: 'now/M', end: 'now', label: 'Month to date' }, + { start: 'now/y', end: 'now', label: 'Year to date' }, +]; + +const itemTitle = (attributes: SavedQueryAttributes, format: string) => { + let label = attributes.title; + const prettifier = usePrettyDuration; + + if (attributes.description) { + label += `; ${attributes.description}`; + } + + if (attributes.timefilter) { + label += `; ${prettifier({ + timeFrom: attributes.timefilter?.from, + timeTo: attributes.timefilter?.to, + quickRanges: commonDurationRanges, + dateFormat: format, + })}`; + } + + return label; +}; + +const itemLabel = (attributes: SavedQueryAttributes) => { + let label: React.ReactNode = attributes.title; + + if (attributes.description) { + label = ( + <> + {label} + + ); + } + + if (attributes.timefilter) { + label = ( + <> + {label} + + ); + } + + return label; +}; + +export function SavedQueryManagementList({ + showSaveQuery, + loadedSavedQuery, + onLoad, + onClearSavedQuery, + savedQueryService, + onClose, + hasFiltersOrQuery, +}: SavedQueryManagementListProps) { + const kibana = useKibana(); + const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); + const [selectedSavedQuery, setSelectedSavedQuery] = useState(null as SavedQuery | null); + const [toBeDeletedSavedQuery, setToBeDeletedSavedQuery] = useState(null as SavedQuery | null); + const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false); + const cancelPendingListingRequest = useRef<() => void>(() => {}); + const { uiSettings, http, application } = kibana.services; + const format = uiSettings.get('dateFormat'); + + useEffect(() => { + const fetchCountAndSavedQueries = async () => { + cancelPendingListingRequest.current(); + let requestGotCancelled = false; + cancelPendingListingRequest.current = () => { + requestGotCancelled = true; + }; + + const { queries: savedQueryItems } = await savedQueryService.findSavedQueries(); + + if (requestGotCancelled) return; + + const sortedSavedQueryItems = sortBy(savedQueryItems, 'attributes.title'); + setSavedQueries(sortedSavedQueryItems); + }; + fetchCountAndSavedQueries(); + }, [savedQueryService]); + + const handleLoad = useCallback(() => { + if (selectedSavedQuery) { + onLoad(selectedSavedQuery); + onClose(); + } + }, [onLoad, selectedSavedQuery, onClose]); + + const handleSelect = useCallback((savedQueryToSelect) => { + setSelectedSavedQuery(savedQueryToSelect); + }, []); + + const handleDelete = useCallback((savedQueryToDelete: SavedQuery) => { + setShowDeletionConfirmationModal(true); + setToBeDeletedSavedQuery(savedQueryToDelete); + }, []); + + const onDelete = useCallback( + (savedQueryToDelete: string) => { + const onDeleteSavedQuery = async (savedQueryId: string) => { + cancelPendingListingRequest.current(); + setSavedQueries( + savedQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQueryId) + ); + + if (loadedSavedQuery && loadedSavedQuery.id === savedQueryId) { + onClearSavedQuery(); + } + + await savedQueryService.deleteSavedQuery(savedQueryId); + }; + + onDeleteSavedQuery(savedQueryToDelete); + }, + [loadedSavedQuery, onClearSavedQuery, savedQueries, savedQueryService] + ); + + const savedQueryDescriptionText = i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryDescriptionText', + { + defaultMessage: 'Save query text and filters that you want to use again.', + } + ); + + const noSavedQueriesDescriptionText = + i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', { + defaultMessage: 'No saved queries.', + }) + + ' ' + + savedQueryDescriptionText; + + const savedQueriesOptions = () => { + const savedQueriesWithoutCurrent = savedQueries.filter((savedQuery) => { + if (!loadedSavedQuery) return true; + return savedQuery.id !== loadedSavedQuery.id; + }); + const savedQueriesReordered = + loadedSavedQuery && savedQueriesWithoutCurrent.length !== savedQueries.length + ? [loadedSavedQuery, ...savedQueriesWithoutCurrent] + : [...savedQueriesWithoutCurrent]; + + return savedQueriesReordered.map((savedQuery) => { + return { + key: savedQuery.id, + label: itemLabel(savedQuery.attributes), + title: itemTitle(savedQuery.attributes, format), + 'data-test-subj': `load-saved-query-${savedQuery.attributes.title}-button`, + value: savedQuery.id, + checked: + (loadedSavedQuery && savedQuery.id === loadedSavedQuery.id) || + (selectedSavedQuery && savedQuery.id === selectedSavedQuery.id) + ? 'on' + : undefined, + append: !!showSaveQuery && ( + handleDelete(savedQuery)} + color="danger" + /> + ), + }; + }) as unknown as SelectableProps[]; + }; + + const canEditSavedObjects = application.capabilities.savedObjectsManagement.edit; + + const listComponent = ( + <> + {savedQueries.length > 0 ? ( + <> +
+ + aria-label="Basic example" + options={savedQueriesOptions()} + searchable + singleSelection="always" + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + if (choice) { + handleSelect(savedQueries.find((savedQuery) => savedQuery.id === choice.value)); + } + }} + searchProps={{ + compressed: true, + placeholder: i18n.translate( + 'unifiedSearch.query.queryBar.indexPattern.findFilterSet', + { + defaultMessage: 'Find a filter set', + } + ), + }} + listProps={{ + isVirtualized: true, + }} + > + {(list, search) => ( + <> + + {search} + + {list} + + )} + +
+ + ) : ( + <> + +

{noSavedQueriesDescriptionText}

+
+ + )} + + + {canEditSavedObjects && ( + + + Manage + + + )} + + + {hasFiltersOrQuery + ? i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverReplaceFilterSetLabel', + { + defaultMessage: 'Replace with selected filter set', + } + ) + : i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel', + { + defaultMessage: 'Apply filter set', + } + )} + + + + + {showDeletionConfirmationModal && toBeDeletedSavedQuery && ( + { + onDelete(toBeDeletedSavedQuery.id); + setShowDeletionConfirmationModal(false); + }} + buttonColor="danger" + onCancel={() => { + setShowDeletionConfirmationModal(false); + }} + /> + )} + + ); + + return listComponent; +} diff --git a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx index 3d8aa26af22af..c4e54995b5979 100644 --- a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx @@ -191,11 +191,12 @@ export function createSearchBar({ core, storage, data, usageCollection }: Statef onSaved={defaultOnSavedQueryUpdated(props, setSavedQuery)} iconType={props.iconType} nonKqlMode={props.nonKqlMode} - nonKqlModeHelpText={props.nonKqlModeHelpText} customSubmitButton={props.customSubmitButton} isClearable={props.isClearable} placeholder={props.placeholder} {...overrideDefaultBehaviors(props)} + dataViewPickerComponentProps={props.dataViewPickerComponentProps} + displayStyle={props.displayStyle} /> ); diff --git a/src/plugins/unified_search/public/search_bar/search_bar.styles.ts b/src/plugins/unified_search/public/search_bar/search_bar.styles.ts new file mode 100644 index 0000000000000..1072a684eeaad --- /dev/null +++ b/src/plugins/unified_search/public/search_bar/search_bar.styles.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UseEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export const searchBarStyles = ({ euiTheme }: UseEuiTheme) => { + return { + uniSearchBar: css` + padding: ${euiTheme.size.s}; + `, + detached: css` + border-bottom: ${euiTheme.border.thin}; + `, + inPage: css` + padding: 0; + `, + }; +}; diff --git a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx index 14310b69809e0..fe5e03ab7fb37 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx @@ -16,24 +16,21 @@ import { coreMock } from '@kbn/core/public/mocks'; const startMock = coreMock.createStart(); import { mount } from 'enzyme'; -import { IIndexPattern } from '@kbn/data-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { EuiThemeProvider } from '@elastic/eui'; const mockTimeHistory = { get: () => { return []; }, + add: jest.fn(), + get$: () => { + return { + pipe: () => {}, + }; + }, }; -jest.mock('../filter_bar', () => { - return { - FilterBar: () =>
, - }; -}); - -jest.mock('../query_string_input/query_bar_top_row', () => { - return () =>
; -}); - const noop = jest.fn(); const createMockWebStorage = () => ({ @@ -66,7 +63,7 @@ const mockIndexPattern = { searchable: true, }, ], -} as IIndexPattern; +} as DataView; const kqlQuery = { query: 'response:200', @@ -88,24 +85,45 @@ function wrapSearchBarInContext(testProps: any) { storage: createMockStorage(), data: { query: { - savedQueries: {}, + savedQueries: { + findSavedQueries: () => + Promise.resolve({ + queries: [ + { + id: 'testwewe', + attributes: { + title: 'Saved query 1', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + ], + }), + }, }, }, }; return ( - - - - - + + + + + + + ); } describe('SearchBar', () => { - const SEARCH_BAR_ROOT = '.globalQueryBar'; - const FILTER_BAR = '.filterBar'; - const QUERY_BAR = '.queryBar'; + const SEARCH_BAR_ROOT = '.uniSearchBar'; + const FILTER_BAR = '[data-test-subj="unifiedFilterBar"]'; + const QUERY_BAR = '.kbnQueryBar'; + const QUERY_INPUT = '[data-test-subj="unifiedQueryInput"]'; beforeEach(() => { jest.clearAllMocks(); @@ -118,22 +136,9 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(1); - }); - - it('Should render empty when timepicker is off and no options provided', () => { - const component = mount( - wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], - showDatePicker: false, - }) - ); - - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); it('Should render filter bar, when required fields are provided', () => { @@ -141,14 +146,16 @@ describe('SearchBar', () => { wrapSearchBarInContext({ indexPatterns: [mockIndexPattern], showDatePicker: false, + showQueryInput: true, + showFilterBar: true, onFiltersUpdated: noop, filters: [], }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(1); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeTruthy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); it('Should NOT render filter bar, if disabled', () => { @@ -162,9 +169,9 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); it('Should render query bar, when required fields are provided', () => { @@ -177,12 +184,12 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(1); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); - it('Should NOT render query bar, if disabled', () => { + it('Should NOT render the input query input, if disabled', () => { const component = mount( wrapSearchBarInContext({ indexPatterns: [mockIndexPattern], @@ -190,12 +197,13 @@ describe('SearchBar', () => { onQuerySubmit: noop, query: kqlQuery, showQueryBar: false, + showQueryInput: false, }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_INPUT).length).toBeFalsy(); }); it('Should render query bar and filter bar', () => { @@ -203,6 +211,7 @@ describe('SearchBar', () => { wrapSearchBarInContext({ indexPatterns: [mockIndexPattern], screenTitle: 'test screen', + showQueryInput: true, onQuerySubmit: noop, query: kqlQuery, filters: [], @@ -210,8 +219,9 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(1); - expect(component.find(QUERY_BAR).length).toBe(1); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeTruthy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); + expect(component.find(QUERY_INPUT).length).toBeTruthy(); }); }); diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index c829ab66bb60a..ab59511ea6811 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -11,22 +11,27 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import classNames from 'classnames'; import React, { Component } from 'react'; import { get, isEqual } from 'lodash'; -import { EuiIconProps } from '@elastic/eui'; +import { EuiIconProps, withEuiTheme, WithEuiThemeProps } from '@elastic/eui'; import memoizeOne from 'memoize-one'; import { METRIC_TYPE } from '@kbn/analytics'; import { Query, Filter } from '@kbn/es-query'; import { withKibana, KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; - import type { TimeHistoryContract, SavedQuery } from '@kbn/data-plugin/public'; import type { SavedQueryAttributes } from '@kbn/data-plugin/common'; import { IDataPluginServices } from '@kbn/data-plugin/public'; import { TimeRange } from '@kbn/data-plugin/common'; import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterBar } from '../filter_bar'; -import QueryBarTopRow from '../query_string_input/query_bar_top_row'; + import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form'; -import { SavedQueryManagementComponent } from '../saved_query_management'; +import { SavedQueryManagementList } from '../saved_query_management'; +import { QueryBarMenu } from '../query_string_input/query_bar_menu'; +import type { DataViewPickerProps } from '../dataview_picker'; +import QueryBarTopRow from '../query_string_input/query_bar_top_row'; +import { FilterBar, FilterItems } from '../filter_bar'; +import { searchBarStyles } from './search_bar.styles'; + +import '../index.scss'; export interface SearchBarInjectedDeps { kibana: KibanaReactContextValue; @@ -77,19 +82,20 @@ export interface SearchBarOwnProps { isClearable?: boolean; iconType?: EuiIconProps['type']; nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; - // defines padding; use 'inPage' to avoid extra padding; use 'detached' if the searchBar appears at the very top of the view, without any wrapper + // defines padding and border; use 'inPage' to avoid any padding or border; + // use 'detached' if the searchBar appears at the very top of the view, without any wrapper displayStyle?: 'inPage' | 'detached'; // super update button background fill control fillSubmitButton?: boolean; + dataViewPickerComponentProps?: DataViewPickerProps; + showSubmitButton?: boolean; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; interface State { isFiltersVisible: boolean; - showSaveQueryModal: boolean; - showSaveNewQueryModal: boolean; + openQueryBarMenu: boolean; showSavedQueryPopover: boolean; currentProps?: SearchBarProps; query?: Query; @@ -97,11 +103,12 @@ interface State { dateRangeTo: string; } -class SearchBarUI extends Component { +class SearchBarUI extends Component { public static defaultProps = { showQueryBar: true, showFilterBar: true, showDatePicker: true, + showSubmitButton: true, showAutoRefreshOnly: false, }; @@ -168,8 +175,7 @@ class SearchBarUI extends Component { */ public state = { isFiltersVisible: true, - showSaveQueryModal: false, - showSaveNewQueryModal: false, + openQueryBarMenu: false, showSavedQueryPopover: false, currentProps: this.props, query: this.props.query ? { ...this.props.query } : undefined, @@ -193,13 +199,6 @@ class SearchBarUI extends Component { this.renderSavedQueryManagement.clear(); } - private shouldRenderQueryBar() { - const showDatePicker = this.props.showDatePicker || this.props.showAutoRefreshOnly; - const showQueryInput = - this.props.showQueryInput && this.props.indexPatterns && this.state.query; - return this.props.showQueryBar && (showDatePicker || showQueryInput); - } - private shouldRenderFilterBar() { return ( this.props.showFilterBar && @@ -266,11 +265,6 @@ class SearchBarUI extends Component { `Your query "${response.attributes.title}" was saved` ); - this.setState({ - showSaveQueryModal: false, - showSaveNewQueryModal: false, - }); - if (this.props.onSaved) { this.props.onSaved(response); } @@ -282,18 +276,6 @@ class SearchBarUI extends Component { } }; - public onInitiateSave = () => { - this.setState({ - showSaveQueryModal: true, - }); - }; - - public onInitiateSaveNew = () => { - this.setState({ - showSaveNewQueryModal: true, - }); - }; - public onQueryBarChange = (queryAndDateRange: { dateRange: TimeRange; query?: Query }) => { this.setState({ query: queryAndDateRange.query, @@ -305,6 +287,12 @@ class SearchBarUI extends Component { } }; + public toggleFilterBarMenuPopover = (value: boolean) => { + this.setState({ + openQueryBarMenu: value, + }); + }; + public onQueryBarSubmit = (queryAndDateRange: { dateRange?: TimeRange; query?: Query }) => { this.setState( { @@ -349,12 +337,104 @@ class SearchBarUI extends Component { } }; + private shouldShowDatePickerAsBadge() { + return this.shouldRenderFilterBar() && !this.props.showQueryInput; + } + public render() { + const { theme } = this.props; + const styles = searchBarStyles(theme); + const cssStyles = [ + styles.uniSearchBar, + this.props.displayStyle && styles[this.props.displayStyle], + ]; + + const classes = classNames('uniSearchBar', { + [`uniSearchBar--${this.props.displayStyle}`]: this.props.displayStyle, + }); + const timeRangeForSuggestionsOverride = this.props.showDatePicker ? undefined : false; - let queryBar; - if (this.shouldRenderQueryBar()) { - queryBar = ( + const saveAsNewQueryFormComponent = ( + this.onSave(savedQueryMeta, true)} + onClose={() => this.setState({ openQueryBarMenu: false })} + showFilterOption={this.props.showFilterBar} + showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} + /> + ); + + const saveQueryFormComponent = ( + this.setState({ openQueryBarMenu: false })} + showFilterOption={this.props.showFilterBar} + showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} + /> + ); + + const queryBarMenu = ( + + ); + + let filterBar; + if (this.shouldRenderFilterBar()) { + filterBar = this.shouldShowDatePickerAsBadge() ? ( + + ) : ( + + ); + } + + return ( +
{ indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} fillSubmitButton={this.props.fillSubmitButton || false} - prepend={ - this.props.showFilterBar && this.state.query - ? this.renderSavedQueryManagement( - this.props.onClearSavedQuery, - this.props.showSaveQuery, - this.props.savedQuery - ) - : undefined - } + prepend={this.props.showFilterBar || this.props.showQueryInput ? queryBarMenu : undefined} showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} @@ -379,6 +451,7 @@ class SearchBarUI extends Component { refreshInterval={this.props.refreshInterval} showAutoRefreshOnly={this.props.showAutoRefreshOnly} showQueryInput={this.props.showQueryInput} + showAddFilter={this.props.showFilterBar} onRefresh={this.props.onRefresh} onRefreshChange={this.props.onRefreshChange} onChange={this.onQueryBarChange} @@ -386,70 +459,30 @@ class SearchBarUI extends Component { customSubmitButton={ this.props.customSubmitButton ? this.props.customSubmitButton : undefined } + showSubmitButton={this.props.showSubmitButton} dataTestSubj={this.props.dataTestSubj} indicateNoData={this.props.indicateNoData} placeholder={this.props.placeholder} isClearable={this.props.isClearable} iconType={this.props.iconType} nonKqlMode={this.props.nonKqlMode} - nonKqlModeHelpText={this.props.nonKqlModeHelpText} timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride} + filters={this.props.filters!} + onFiltersUpdated={this.props.onFiltersUpdated} + dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} + showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} + filterBar={filterBar} /> - ); - } - - let filterBar; - if (this.shouldRenderFilterBar()) { - const filterGroupClasses = classNames('globalFilterGroup__wrapper', { - 'globalFilterGroup__wrapper-isVisible': this.state.isFiltersVisible, - }); - - filterBar = ( -
- -
- ); - } - - const globalQueryBarClasses = classNames('globalQueryBar', { - 'globalQueryBar--inPage': this.props.displayStyle === 'inPage', - }); - - return ( -
- {queryBar} - {filterBar} - - {this.state.showSaveQueryModal ? ( - this.setState({ showSaveQueryModal: false })} - showFilterOption={this.props.showFilterBar} - showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} - /> - ) : null} - {this.state.showSaveNewQueryModal ? ( - this.onSave(savedQueryMeta, true)} - onClose={() => this.setState({ showSaveNewQueryModal: false })} - showFilterOption={this.props.showFilterBar} - showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} - /> - ) : null}
); } + private hasFiltersOrQuery() { + const hasFilters = Boolean(this.props.filters && this.props.filters.length > 0); + const hasQuery = Boolean(this.state.query && this.state.query.query); + return hasFilters || hasQuery; + } + private renderSavedQueryManagement = memoizeOne( ( onClearSavedQuery: SearchBarOwnProps['onClearSavedQuery'], @@ -457,14 +490,14 @@ class SearchBarUI extends Component { savedQuery: SearchBarOwnProps['savedQuery'] ) => { const savedQueryManagement = onClearSavedQuery && ( - this.setState({ openQueryBarMenu: false })} + hasFiltersOrQuery={this.hasFiltersOrQuery()} /> ); @@ -475,4 +508,4 @@ class SearchBarUI extends Component { // Needed for React.lazy // eslint-disable-next-line import/no-default-export -export default injectI18n(withKibana(SearchBarUI)); +export default injectI18n(withEuiTheme(withKibana(SearchBarUI))); diff --git a/src/plugins/unified_search/public/typeahead/_suggestion.scss b/src/plugins/unified_search/public/typeahead/_suggestion.scss index e466a52e7fc10..a59e53a102d6c 100644 --- a/src/plugins/unified_search/public/typeahead/_suggestion.scss +++ b/src/plugins/unified_search/public/typeahead/_suggestion.scss @@ -15,12 +15,16 @@ $kbnTypeaheadTypes: ( @include euiBottomShadowFlat; border-top-left-radius: $euiBorderRadius; border-top-right-radius: $euiBorderRadius; + // Clips the shadow so it doesn't show above the input (below) + clip-path: polygon(-50px -50px, calc(100% + 50px) -50px, calc(100% + 50px) 100%, -50px 100%); } .kbnTypeahead__popover--bottom { @include euiBottomShadow; border-bottom-left-radius: $euiBorderRadius; border-bottom-right-radius: $euiBorderRadius; + // Clips the shadow so it doesn't show above the input (top) + clip-path: polygon(-50px 1px, calc(100% + 50px) 1px, calc(100% + 50px) calc(100% + 50px), -50px calc(100% + 50px)); } .kbnTypeahead { @@ -59,7 +63,6 @@ $kbnTypeaheadTypes: ( .kbnTypeahead__item:first-child { border-bottom: none; - border-radius: $euiBorderRadius $euiBorderRadius 0 0; } .kbnTypeahead__item.active { diff --git a/src/plugins/unified_search/public/typeahead/suggestions_component.tsx b/src/plugins/unified_search/public/typeahead/suggestions_component.tsx index 75e446cf2d6e8..ebeddfaaff81f 100644 --- a/src/plugins/unified_search/public/typeahead/suggestions_component.tsx +++ b/src/plugins/unified_search/public/typeahead/suggestions_component.tsx @@ -9,8 +9,7 @@ import React, { PureComponent, ReactNode } from 'react'; import { isEmpty } from 'lodash'; import classNames from 'classnames'; - -import styled from 'styled-components'; +import { css } from '@emotion/react'; import useRafState from 'react-use/lib/useRafState'; import { QuerySuggestion } from '../autocomplete'; @@ -146,15 +145,6 @@ export default class SuggestionsComponent extends PureComponent ` - position: absolute; - z-index: 4001; - left: ${props.left}px; - width: ${props.width}px; - ${props.verticalListPosition}`} -`; - const ResizableSuggestionsListDiv: React.FC<{ inputContainer: HTMLElement; suggestionsSize?: SuggestionsListSize; @@ -174,12 +164,16 @@ const ResizableSuggestionsListDiv: React.FC<{ ? `top: ${pageYOffset + containerRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;` : `bottom: ${documentHeight - (pageYOffset + containerRect.top)}px;`; + const divPosition = css` + position: absolute; + z-index: 4001; + left: ${containerRect.left}px; + width: ${containerRect.width}px; + ${verticalListPosition} + `; + return ( - +
- +
); }); diff --git a/src/plugins/vis_type_markdown/public/markdown_vis.ts b/src/plugins/vis_type_markdown/public/markdown_vis.ts index 7fcf9fb6311e6..a4b4010064e78 100644 --- a/src/plugins/vis_type_markdown/public/markdown_vis.ts +++ b/src/plugins/vis_type_markdown/public/markdown_vis.ts @@ -58,6 +58,8 @@ export const markdownVisDefinition: VisTypeDefinition = { options: { showTimePicker: false, showFilterBar: false, + showQueryBar: true, + showQueryInput: false, }, inspectorAdapters: {}, }; diff --git a/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx b/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx index f32a485ac2565..f8d7415f6aefe 100644 --- a/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx +++ b/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx @@ -66,8 +66,9 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) }, options: { showIndexSelection: false, - showQueryBar: false, + showQueryBar: true, showFilterBar: false, + showQueryInput: false, }, requiresSearch: true, }; diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index 80295e5af2e40..bb197e219f439 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -18,6 +18,7 @@ const defaultOptions: VisTypeOptions = { showQueryBar: true, showFilterBar: true, showIndexSelection: true, + showQueryInput: true, hierarchicalData: false, // we should get rid of this i guess ? }; diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 0e7e44b6ea38e..383a238621e1e 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -20,6 +20,7 @@ export interface VisTypeOptions { showQueryBar: boolean; showFilterBar: boolean; showIndexSelection: boolean; + showQueryInput: boolean; hierarchicalData: boolean; } diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index a6c1710afbed8..e42ee1d0cd6c0 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -161,7 +161,8 @@ const TopNav = ({ return vis.type.options.showTimePicker && hasTimeField; }; const showFilterBar = vis.type.options.showFilterBar; - const showQueryInput = vis.type.requiresSearch && vis.type.options.showQueryBar; + const showQueryInput = + vis.type.requiresSearch && vis.type.options.showQueryBar && vis.type.options.showQueryInput; useEffect(() => { return () => { diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 99dbf548e6f44..19f117ec18cc8 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2470,6 +2470,31 @@ describe('migration visualization', () => { }); }); + it('should not apply search source migrations within visualization when searchSourceJSON is not an object', () => { + const visualizationDoc = { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '1.2.4'; + const visMigrations = getAllMigrations({ + [versionToTest]: (state) => ({ ...state, migrated: true }), + }); + + expect( + visMigrations[versionToTest](visualizationDoc, {} as SavedObjectMigrationContext) + ).toEqual({ + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + }); + }); + describe('8.1.0 pie - labels and addLegend migration', () => { const getDoc = (addLegend: boolean, lastLevel: boolean = false) => ({ attributes: { diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index 4b729afa62307..d236ad83c853a 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -11,7 +11,11 @@ import type { SavedObjectMigrationFn, SavedObjectMigrationMap } from '@kbn/core/ import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; -import { DEFAULT_QUERY_LANGUAGE, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { + DEFAULT_QUERY_LANGUAGE, + isSerializedSearchSource, + SerializedSearchSourceFields, +} from '@kbn/data-plugin/common'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; import { commonAddSupportOfDualIndexSelectionModeInTSVB, @@ -1215,27 +1219,31 @@ const visualizationSavedObjectTypeMigrations = { /** * This creates a migration map that applies search source migrations to legacy visualization SOs */ -const getVisualizationSearchSourceMigrations = (searchSourceMigrations: MigrateFunctionsObject) => +const getVisualizationSearchSourceMigrations = ( + searchSourceMigrations: MigrateFunctionsObject +): MigrateFunctionsObject => mapValues( searchSourceMigrations, (migrate: MigrateFunction): MigrateFunction => (state) => { - const _state = state as unknown as { attributes: VisualizationSavedObjectAttributes }; - - const parsedSearchSourceJSON = _state.attributes.kibanaSavedObjectMeta.searchSourceJSON; - - if (!parsedSearchSourceJSON) return _state; - - return { - ..._state, - attributes: { - ..._state.attributes, - kibanaSavedObjectMeta: { - ..._state.attributes.kibanaSavedObjectMeta, - searchSourceJSON: JSON.stringify(migrate(JSON.parse(parsedSearchSourceJSON))), + const _state = state as { attributes: VisualizationSavedObjectAttributes }; + + const parsedSearchSourceJSON = JSON.parse( + _state.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + if (isSerializedSearchSource(parsedSearchSourceJSON)) { + return { + ..._state, + attributes: { + ..._state.attributes, + kibanaSavedObjectMeta: { + ..._state.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(migrate(parsedSearchSourceJSON)), + }, }, - }, - }; + }; + } + return _state; } ); @@ -1244,7 +1252,5 @@ export const getAllMigrations = ( ): SavedObjectMigrationMap => mergeSavedObjectMigrationMaps( visualizationSavedObjectTypeMigrations, - getVisualizationSearchSourceMigrations( - searchSourceMigrations - ) as unknown as SavedObjectMigrationMap + getVisualizationSearchSourceMigrations(searchSourceMigrations) as SavedObjectMigrationMap ); diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 867e146e64ca3..5a3e881b86471 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'discover', 'header', 'share', 'timePicker']); const a11y = getService('a11y'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const inspector = getService('inspector'); const testSubjects = getService('testSubjects'); const TEST_COLUMN_NAMES = ['dayOfWeek', 'DestWeather']; @@ -93,11 +94,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('a11y test on saved queries list panel', async () => { + await savedQueryManagementComponent.loadSavedQuery('test'); await PageObjects.discover.clickSavedQueriesPopOver(); - await testSubjects.moveMouseTo( - 'saved-query-list-item load-saved-query-test-button saved-query-list-item-selected saved-query-list-item-selected' - ); - await testSubjects.find('delete-saved-query-test-button'); + await testSubjects.click('saved-query-management-load-button'); + await savedQueryManagementComponent.deleteSavedQuery('test'); await a11y.testAppSnapshot(); }); }); diff --git a/test/accessibility/apps/filter_panel.ts b/test/accessibility/apps/filter_panel.ts index deb1e9512cd81..b479c62f48975 100644 --- a/test/accessibility/apps/filter_panel.ts +++ b/test/accessibility/apps/filter_panel.ts @@ -43,38 +43,47 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // the following tests filter panel options which changes UI it('a11y test on filter panel options panel', async () => { await filterBar.addFilter('DestCountry', 'is', 'AU'); - await testSubjects.click('showFilterActions'); + await testSubjects.click('showQueryBarMenu'); await a11y.testAppSnapshot(); }); it('a11y test on disable all filter options view', async () => { - await testSubjects.click('disableAllFilters'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-disableAllFilters'); await a11y.testAppSnapshot(); }); - it('a11y test on pin filters view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('enableAllFilters'); - await testSubjects.click('showFilterActions'); - await testSubjects.click('pinAllFilters'); + it('a11y test on enable all filters view', async () => { + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-enableAllFilters'); + await a11y.testAppSnapshot(); + }); + + it('a11y test on pin all filters view', async () => { + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-pinAllFilters'); await a11y.testAppSnapshot(); }); it('a11y test on unpin all filters view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('unpinAllFilters'); + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-unpinAllFilters'); await a11y.testAppSnapshot(); }); it('a11y test on invert inclusion of all filters view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('invertInclusionAllFilters'); + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-invertAllFilters'); await a11y.testAppSnapshot(); }); it('a11y test on remove all filtes view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('removeAllFilters'); + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-removeAllFilters'); await a11y.testAppSnapshot(); }); }); diff --git a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts index ad45ba871f2c7..97bf37749c256 100644 --- a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts +++ b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts @@ -7,14 +7,21 @@ */ import { Subject } from 'rxjs'; +import type { AnalyticsClientInitContext } from '@kbn/analytics-client'; import type { Event, IShipper } from '@kbn/core/public'; export class CustomShipper implements IShipper { public static shipperName = 'FTR-helpers-shipper'; - constructor(private readonly events$: Subject) {} + constructor( + private readonly events$: Subject, + private readonly initContext: AnalyticsClientInitContext + ) {} public reportEvents(events: Event[]) { + this.initContext.logger.info( + `Reporting ${events.length} events to ${CustomShipper.shipperName}: ${JSON.stringify(events)}` + ); events.forEach((event) => { this.events$.next(event); }); diff --git a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts index ed63f9a8db02f..c76f30c94572e 100644 --- a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts +++ b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts @@ -7,14 +7,21 @@ */ import { Subject } from 'rxjs'; +import type { AnalyticsClientInitContext } from '@kbn/analytics-client'; import type { IShipper, Event } from '@kbn/core/server'; export class CustomShipper implements IShipper { public static shipperName = 'FTR-helpers-shipper'; - constructor(private readonly events$: Subject) {} + constructor( + private readonly events$: Subject, + private readonly initContext: AnalyticsClientInitContext + ) {} public reportEvents(events: Event[]) { + this.initContext.logger.info( + `Reporting ${events.length} events to ${CustomShipper.shipperName}: ${JSON.stringify(events)}` + ); events.forEach((event) => { this.events$.next(event); }); diff --git a/test/analytics/config.ts b/test/analytics/config.ts index 9dee422762e15..ecb9792b0dff1 100644 --- a/test/analytics/config.ts +++ b/test/analytics/config.ts @@ -34,7 +34,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.get('kbnTestServer'), serverArgs: [ ...functionalConfig.get('kbnTestServer.serverArgs'), - // Disabling telemetry so it doesn't call opt-in before the tests run. + // Disabling telemetry, so it doesn't call opt-in before the tests run. '--telemetry.enabled=false', `--plugin-path=${path.resolve(__dirname, './__fixtures__/plugins/analytics_plugin_a')}`, `--plugin-path=${path.resolve(__dirname, './__fixtures__/plugins/analytics_ftr_helpers')}`, diff --git a/test/analytics/services/kibana_ebt.ts b/test/analytics/services/kibana_ebt.ts index fd64cbbbc0105..281794e899a3c 100644 --- a/test/analytics/services/kibana_ebt.ts +++ b/test/analytics/services/kibana_ebt.ts @@ -12,24 +12,27 @@ import '@kbn/analytics-ftr-helpers-plugin/public/types'; export function KibanaEBTServerProvider({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const setOptIn = async (optIn: boolean) => { + await supertest + .post(`/internal/analytics_ftr_helpers/opt_in`) + .set('kbn-xsrf', 'xxx') + .query({ consent: optIn }) + .expect(200); + }; + return { /** * Change the opt-in state of the Kibana EBT client. * @param optIn `true` to opt-in, `false` to opt-out. */ - setOptIn: async (optIn: boolean) => { - await supertest - .post(`/internal/analytics_ftr_helpers/opt_in`) - .set('kbn-xsrf', 'xxx') - .query({ consent: optIn }) - .expect(200); - }, + setOptIn, /** * Returns the last events of the specified types. * @param numberOfEvents - number of events to return * @param eventTypes (Optional) array of event types to return */ getLastEvents: async (takeNumberOfEvents: number, eventTypes: string[] = []) => { + await setOptIn(true); const resp = await supertest .get(`/internal/analytics_ftr_helpers/events`) .query({ takeNumberOfEvents, eventTypes: JSON.stringify(eventTypes) }) @@ -45,6 +48,10 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC const { common } = getPageObjects(['common']); const browser = getService('browser'); + const setOptIn = async (optIn: boolean) => { + await browser.execute((isOptIn) => window.__analytics_ftr_helpers__.setOptIn(isOptIn), optIn); + }; + return { /** * Change the opt-in state of the Kibana EBT client. @@ -52,7 +59,7 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC */ setOptIn: async (optIn: boolean) => { await common.navigateToApp('home'); - await browser.execute((isOptIn) => window.__analytics_ftr_helpers__.setOptIn(isOptIn), optIn); + await setOptIn(optIn); }, /** * Returns the last events of the specified types. @@ -60,6 +67,7 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC * @param eventTypes (Optional) array of event types to return */ getLastEvents: async (numberOfEvents: number, eventTypes: string[] = []) => { + await setOptIn(true); const events = await browser.execute( ({ eventTypes: _eventTypes, numberOfEvents: _numberOfEvents }) => window.__analytics_ftr_helpers__.getLastEvents(_numberOfEvents, _eventTypes), diff --git a/test/analytics/tests/analytics_from_the_browser.ts b/test/analytics/tests/analytics_from_the_browser.ts index 7acabf2112c5d..c05492fe30961 100644 --- a/test/analytics/tests/analytics_from_the_browser.ts +++ b/test/analytics/tests/analytics_from_the_browser.ts @@ -72,6 +72,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(context).to.have.property('user_agent'); expect(context.user_agent).to.be.a('string'); + // Some context providers emit very early. We are OK with that. + const initialContext = actions[2].meta[0].context; + const reportEventContext = actions[2].meta[1].context; expect(reportEventContext).to.have.property('user_agent'); expect(reportEventContext.user_agent).to.be.a('string'); @@ -85,7 +88,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { @@ -103,7 +106,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { diff --git a/test/analytics/tests/analytics_from_the_server.ts b/test/analytics/tests/analytics_from_the_server.ts index e5e3573b20fcd..820f7e51adc96 100644 --- a/test/analytics/tests/analytics_from_the_server.ts +++ b/test/analytics/tests/analytics_from_the_server.ts @@ -63,11 +63,19 @@ export default function ({ getService }: FtrProviderContext) { await ebtServerHelper.setOptIn(true); const actions = await getActions(3); + // Validating the remote PID because that's the only field that it's added by the FTR plugin. const context = actions[1].meta; expect(context).to.have.property('pid'); expect(context.pid).to.be.a('number'); + // Some context providers emit very early. We are OK with that. + const initialContext = actions[2].meta[0].context; + + const reportEventContext = actions[2].meta[1].context; + expect(context).to.have.property('pid'); + expect(context.pid).to.be.a('number'); + expect(actions).to.eql([ { action: 'optIn', meta: true }, { action: 'extendContext', meta: context }, @@ -77,13 +85,13 @@ export default function ({ getService }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { timestamp: actions[2].meta[1].timestamp, event_type: 'test-plugin-lifecycle', - context, + context: reportEventContext, properties: { plugin: 'analyticsPluginA', step: 'start' }, }, ], @@ -96,13 +104,13 @@ export default function ({ getService }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { timestamp: actions[2].meta[1].timestamp, event_type: 'test-plugin-lifecycle', - context, + context: reportEventContext, properties: { plugin: 'analyticsPluginA', step: 'start' }, }, ]); diff --git a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts new file mode 100644 index 0000000000000..58d8de723639d --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { Event } from '@kbn/core/public'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const deployment = getService('deployment'); + const ebtUIHelper = getService('kibana_ebt_ui'); + const { common } = getPageObjects(['common']); + + describe('Core Context Providers', () => { + let event: Event; + before(async () => { + await common.navigateToApp('home'); + [event] = await ebtUIHelper.getLastEvents(1, ['Loaded Kibana']); // Get the loaded Kibana event + }); + + it('should have the properties provided by the "cluster info" context provider', () => { + expect(event.context).to.have.property('cluster_uuid'); + expect(event.context.cluster_uuid).to.be.a('string'); + expect(event.context).to.have.property('cluster_name'); + expect(event.context.cluster_name).to.be.a('string'); + expect(event.context).to.have.property('cluster_version'); + expect(event.context.cluster_version).to.be.a('string'); + }); + + it('should have the properties provided by the "build info" context provider', () => { + expect(event.context).to.have.property('isDev'); + expect(event.context.isDev).to.be.a('boolean'); + expect(event.context).to.have.property('isDistributable'); + expect(event.context.isDistributable).to.be.a('boolean'); + expect(event.context).to.have.property('version'); + expect(event.context.version).to.be.a('string'); + expect(event.context).to.have.property('branch'); + expect(event.context.branch).to.be.a('string'); + expect(event.context).to.have.property('buildNum'); + expect(event.context.buildNum).to.be.a('number'); + expect(event.context).to.have.property('buildSha'); + expect(event.context.buildSha).to.be.a('string'); + }); + + it('should have the properties provided by the "session-id" context provider', () => { + expect(event.context).to.have.property('session_id'); + expect(event.context.session_id).to.be.a('string'); + }); + + it('should have the properties provided by the "browser info" context provider', () => { + expect(event.context).to.have.property('user_agent'); + expect(event.context.user_agent).to.be.a('string'); + expect(event.context).to.have.property('preferred_language'); + expect(event.context.preferred_language).to.be.a('string'); + expect(event.context).to.have.property('preferred_languages'); + expect(event.context.preferred_languages).to.be.an('array'); + (event.context.preferred_languages as unknown[]).forEach((lang) => + expect(lang).to.be.a('string') + ); + }); + + it('should have the properties provided by the "execution_context" context provider', () => { + expect(event.context).to.have.property('pageName'); + expect(event.context.pageName).to.be.a('string'); + expect(event.context).to.have.property('applicationId'); + expect(event.context.applicationId).to.be.a('string'); + expect(event.context).not.to.have.property('entityId'); // In the Home app it's not available. + expect(event.context).not.to.have.property('page'); // In the Home app it's not available. + }); + + it('should have the properties provided by the "license info" context provider', () => { + expect(event.context).to.have.property('license_id'); + expect(event.context.license_id).to.be.a('string'); + expect(event.context).to.have.property('license_status'); + expect(event.context.license_status).to.be.a('string'); + expect(event.context).to.have.property('license_type'); + expect(event.context.license_type).to.be.a('string'); + }); + + it('should have the properties provided by the "Cloud Deployment ID" context provider', async () => { + if (await deployment.isCloud()) { + expect(event.context).to.have.property('cloudId'); + expect(event.context.cloudId).to.be.a('string'); + } else { + expect(event.context).not.to.have.property('cloudId'); + } + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_browser/index.ts b/test/analytics/tests/instrumented_events/from_the_browser/index.ts index daf21180d2328..69aff97006d72 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/index.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/index.ts @@ -8,13 +8,10 @@ import { FtrProviderContext } from '../../../services'; -export default function ({ getService }: FtrProviderContext) { +export default function ({ loadTestFile }: FtrProviderContext) { describe('from the browser', () => { - beforeEach(async () => { - await getService('kibana_ebt_ui').setOptIn(true); - }); - // Add tests for UI-instrumented events here: - // loadTestFile(require.resolve('./some_event')); + loadTestFile(require.resolve('./loaded_kibana')); + loadTestFile(require.resolve('./core_context_providers')); }); } diff --git a/test/analytics/tests/instrumented_events/from_the_browser/loaded_kibana.ts b/test/analytics/tests/instrumented_events/from_the_browser/loaded_kibana.ts new file mode 100644 index 0000000000000..c7d3291cb03d4 --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_browser/loaded_kibana.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const ebtUIHelper = getService('kibana_ebt_ui'); + const { common } = getPageObjects(['common']); + const browser = getService('browser'); + + describe('Loaded Kibana', () => { + beforeEach(async () => { + await common.navigateToApp('home'); + }); + + it('should emit the "Loaded Kibana" event', async () => { + const [event] = await ebtUIHelper.getLastEvents(1, ['Loaded Kibana']); + expect(event.event_type).to.eql('Loaded Kibana'); + expect(event.properties).to.have.property('kibana_version'); + expect(event.properties.kibana_version).to.be.a('string'); + + if (browser.isChromium) { + expect(event.properties).to.have.property('memory_js_heap_size_limit'); + expect(event.properties.memory_js_heap_size_limit).to.be.a('number'); + expect(event.properties).to.have.property('memory_js_heap_size_total'); + expect(event.properties.memory_js_heap_size_total).to.be.a('number'); + expect(event.properties).to.have.property('memory_js_heap_size_used'); + expect(event.properties.memory_js_heap_size_used).to.be.a('number'); + } + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_server/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_server/core_context_providers.ts new file mode 100644 index 0000000000000..743a32fcc58ac --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_server/core_context_providers.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { Event } from '@kbn/core/public'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService }: FtrProviderContext) { + const deployment = getService('deployment'); + const ebtServerHelper = getService('kibana_ebt_server'); + + describe('Core Context Providers', () => { + let event: Event; + before(async () => { + // Wait for the 2nd "status_changed" event. At that point all the context providers should be set up. + [, event] = await ebtServerHelper.getLastEvents(2, ['core-overall_status_changed']); + }); + + it('should have the properties provided by the "kibana info" context provider', () => { + expect(event.context).to.have.property('kibana_uuid'); + expect(event.context.kibana_uuid).to.be.a('string'); + expect(event.context).to.have.property('pid'); + expect(event.context.pid).to.be.a('number'); + }); + + it('should have the properties provided by the "build info" context provider', () => { + expect(event.context).to.have.property('isDev'); + expect(event.context.isDev).to.be.a('boolean'); + expect(event.context).to.have.property('isDistributable'); + expect(event.context.isDistributable).to.be.a('boolean'); + expect(event.context).to.have.property('version'); + expect(event.context.version).to.be.a('string'); + expect(event.context).to.have.property('branch'); + expect(event.context.branch).to.be.a('string'); + expect(event.context).to.have.property('buildNum'); + expect(event.context.buildNum).to.be.a('number'); + expect(event.context).to.have.property('buildSha'); + expect(event.context.buildSha).to.be.a('string'); + }); + + it('should have the properties provided by the "cluster info" context provider', () => { + expect(event.context).to.have.property('cluster_uuid'); + expect(event.context.cluster_uuid).to.be.a('string'); + expect(event.context).to.have.property('cluster_name'); + expect(event.context.cluster_name).to.be.a('string'); + expect(event.context).to.have.property('cluster_version'); + expect(event.context.cluster_version).to.be.a('string'); + }); + + it('should have the properties provided by the "status info" context provider', () => { + expect(event.context).to.have.property('overall_status_level'); + expect(event.context.overall_status_level).to.be.a('string'); + expect(event.context).to.have.property('overall_status_summary'); + expect(event.context.overall_status_summary).to.be.a('string'); + }); + + it('should have the properties provided by the "license info" context provider', () => { + expect(event.context).to.have.property('license_id'); + expect(event.context.license_id).to.be.a('string'); + expect(event.context).to.have.property('license_status'); + expect(event.context.license_status).to.be.a('string'); + expect(event.context).to.have.property('license_type'); + expect(event.context.license_type).to.be.a('string'); + }); + + it('should have the properties provided by the "Cloud Deployment ID" context provider', async () => { + if (await deployment.isCloud()) { + expect(event.context).to.have.property('cloudId'); + expect(event.context.cloudId).to.be.a('string'); + } else { + expect(event.context).not.to.have.property('cloudId'); + } + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_server/core_overall_status_changed.ts b/test/analytics/tests/instrumented_events/from_the_server/core_overall_status_changed.ts new file mode 100644 index 0000000000000..fa94e2b69fc3f --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_server/core_overall_status_changed.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { Event } from '@kbn/analytics-client'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService }: FtrProviderContext) { + const ebtServerHelper = getService('kibana_ebt_server'); + + describe('core-overall_status_changed', () => { + let initialEvent: Event; + let secondEvent: Event; + + before(async () => { + [initialEvent, secondEvent] = await ebtServerHelper.getLastEvents(2, [ + 'core-overall_status_changed', + ]); + }); + + it('should emit the initial "degraded" event with the context set to `initializing`', () => { + expect(initialEvent.event_type).to.eql('core-overall_status_changed'); + expect(initialEvent.context).to.have.property('overall_status_level', 'initializing'); + expect(initialEvent.context).to.have.property( + 'overall_status_summary', + 'Kibana is starting up' + ); + expect(initialEvent.properties).to.have.property('overall_status_level', 'degraded'); + expect(initialEvent.properties.overall_status_summary).to.be.a('string'); + }); + + it('should emit the 2nd event as `available` with the context set to the previous values', () => { + expect(secondEvent.event_type).to.eql('core-overall_status_changed'); + expect(secondEvent.context).to.have.property( + 'overall_status_level', + initialEvent.properties.overall_status_level + ); + expect(secondEvent.context).to.have.property( + 'overall_status_summary', + initialEvent.properties.overall_status_summary + ); + expect(secondEvent.properties.overall_status_level).to.be.a('string'); // Ideally we would test it as `available`, but we can't do that as it may result flaky for many side effects in the CI. + expect(secondEvent.properties.overall_status_summary).to.be.a('string'); + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_server/index.ts b/test/analytics/tests/instrumented_events/from_the_server/index.ts index 8961b9e92994c..d8150b0519fde 100644 --- a/test/analytics/tests/instrumented_events/from_the_server/index.ts +++ b/test/analytics/tests/instrumented_events/from_the_server/index.ts @@ -8,13 +8,11 @@ import { FtrProviderContext } from '../../../services'; -export default function ({ getService }: FtrProviderContext) { +export default function ({ loadTestFile }: FtrProviderContext) { describe('from the server', () => { - beforeEach(async () => { - await getService('kibana_ebt_server').setOptIn(true); - }); - - // Add tests for UI-instrumented events here: - // loadTestFile(require.resolve('./some_event')); + // Add tests for Server-instrumented events here: + loadTestFile(require.resolve('./core_context_providers')); + loadTestFile(require.resolve('./kibana_started')); + loadTestFile(require.resolve('./core_overall_status_changed')); }); } diff --git a/test/analytics/tests/instrumented_events/from_the_server/kibana_started.ts b/test/analytics/tests/instrumented_events/from_the_server/kibana_started.ts new file mode 100644 index 0000000000000..86917b937cbab --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_server/kibana_started.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService }: FtrProviderContext) { + const ebtServerHelper = getService('kibana_ebt_server'); + + describe('kibana_started', () => { + it('should emit the "kibana_started" event', async () => { + const [event] = await ebtServerHelper.getLastEvents(1, ['kibana_started']); + expect(event.event_type).to.eql('kibana_started'); + expect(event.properties.uptime_per_step.constructor.start).to.be.a('number'); + expect(event.properties.uptime_per_step.constructor.end).to.be.a('number'); + expect(event.properties.uptime_per_step.preboot.start).to.be.a('number'); + expect(event.properties.uptime_per_step.preboot.end).to.be.a('number'); + expect(event.properties.uptime_per_step.setup.start).to.be.a('number'); + expect(event.properties.uptime_per_step.setup.end).to.be.a('number'); + expect(event.properties.uptime_per_step.start.start).to.be.a('number'); + expect(event.properties.uptime_per_step.start.end).to.be.a('number'); + }); + }); +} diff --git a/test/functional/apps/dashboard/group1/embed_mode.ts b/test/functional/apps/dashboard/group1/embed_mode.ts index 25f48236ab7d5..482c976d98689 100644 --- a/test/functional/apps/dashboard/group1/embed_mode.ts +++ b/test/functional/apps/dashboard/group1/embed_mode.ts @@ -59,7 +59,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('top-nav'); await testSubjects.missingOrFail('queryInput'); await testSubjects.missingOrFail('superDatePickerToggleQuickMenuButton'); - await testSubjects.existOrFail('showFilterActions'); + await testSubjects.existOrFail('showQueryBarMenu'); const currentUrl = await browser.getCurrentUrl(); const newUrl = [currentUrl].concat(urlParamExtensions).join('&'); @@ -70,7 +70,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('top-nav'); await testSubjects.existOrFail('queryInput'); await testSubjects.existOrFail('superDatePickerToggleQuickMenuButton'); - await testSubjects.missingOrFail('showFilterActions'); }); after(async function () { diff --git a/test/functional/apps/dashboard/group2/dashboard_saved_query.ts b/test/functional/apps/dashboard/group2/dashboard_saved_query.ts index ac9613f4bf400..1dad54234e8a3 100644 --- a/test/functional/apps/dashboard/group2/dashboard_saved_query.ts +++ b/test/functional/apps/dashboard/group2/dashboard_saved_query.ts @@ -40,22 +40,30 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickNewDashboard(); }); - it('should show the saved query management component when there are no saved queries', async () => { - await savedQueryManagementComponent.openSavedQueryManagementComponent(); - const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); - expect(descriptionText).to.eql( - 'Saved Queries\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' - ); + it('should show the saved query management load button as disabled when there are no saved queries', async () => { + await testSubjects.click('showQueryBarMenu'); + const loadFilterSetBtn = await testSubjects.find('saved-query-management-load-button'); + const isDisabled = await loadFilterSetBtn.getAttribute('disabled'); + expect(isDisabled).to.equal('true'); }); it('should allow a query to be saved via the saved objects management component', async () => { await queryBar.setQuery('response:200'); + await queryBar.clickQuerySubmitButton(); + await testSubjects.click('showQueryBarMenu'); await savedQueryManagementComponent.saveNewQuery( 'OkResponse', '200 responses for .jpg over 24 hours', true, true ); + const contextMenuPanelTitleButton = await testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await testSubjects.click('contextMenuPanelTitleButton'); + } + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); await savedQueryManagementComponent.savedQueryTextExist('response:200'); }); @@ -81,6 +89,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await queryBar.setQuery('response:404'); await savedQueryManagementComponent.updateCurrentlyLoadedQuery('OkResponse', false, false); await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + const contextMenuPanelTitleButton = await testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await testSubjects.click('contextMenuPanelTitleButton'); + } await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); expect(await queryBar.getQueryString()).to.eql(''); await savedQueryManagementComponent.loadSavedQuery('OkResponse'); @@ -88,9 +102,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows saving the currently loaded query as a new query', async () => { + await queryBar.setQuery('response:400'); await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( 'OkResponseCopy', - '200 responses', + '400 responses', false, false ); diff --git a/test/functional/apps/dashboard/group3/dashboard_state.ts b/test/functional/apps/dashboard/group3/dashboard_state.ts index 48fb9233682ad..c5306f4ab4ff3 100644 --- a/test/functional/apps/dashboard/group3/dashboard_state.ts +++ b/test/functional/apps/dashboard/group3/dashboard_state.ts @@ -48,13 +48,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { isNewChartsLibraryEnabled = await PageObjects.visChart.isNewChartsLibraryEnabled(); await PageObjects.dashboard.initTests(); await PageObjects.dashboard.preserveCrossAppState(); + await browser.setLocalStorageItem('data.newDataViewMenu', 'true'); if (isNewChartsLibraryEnabled) { await kibanaServer.uiSettings.update({ 'visualization:visualize:legacyPieChartsLibrary': false, }); - await browser.refresh(); } + await browser.refresh(); }); after(async function () { diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index a4da1c217f92b..de3144d98beab 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -286,7 +286,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboard.waitForRenderComplete(); await header.waitUntilLoadingHasFinished(); await ensureAvailableOptionsEql(allAvailableOptions); - await filterBar.removeAllFilters(); + await filterBar.removeFilter('sound.keyword'); }); it('Does not apply time range to options list control', async () => { @@ -406,6 +406,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Options List dashboard no validation', async () => { before(async () => { + await filterBar.removeAllFilters(); + await queryBar.clickQuerySubmitButton(); await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverSelectOption('meow'); await dashboardControls.optionsListPopoverSelectOption('bark'); @@ -431,6 +433,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await filterBar.removeAllFilters(); + await queryBar.clickQuerySubmitButton(); await dashboardControls.clearAllControls(); }); }); diff --git a/test/functional/apps/discover/_indexpattern_without_timefield.ts b/test/functional/apps/discover/_indexpattern_without_timefield.ts index 2d5892fa6e6ca..6c936f63e999d 100644 --- a/test/functional/apps/discover/_indexpattern_without_timefield.ts +++ b/test/functional/apps/discover/_indexpattern_without_timefield.ts @@ -91,7 +91,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.goBack(); await PageObjects.discover.waitForDocTableLoadingComplete(); return ( - (await testSubjects.getVisibleText('indexPattern-switch-link')) === 'without-timefield' + (await testSubjects.getVisibleText('discover-dataView-switch-link')) === + 'without-timefield' ); } ); diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 79d49131df138..d56b5032a430b 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -144,12 +144,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('saved query management component functionality', function () { before(async () => await setUpQueriesWithFilters()); - it('should show the saved query management component when there are no saved queries', async () => { + it('should show the saved query management load button as disabled when there are no saved queries', async () => { await savedQueryManagementComponent.openSavedQueryManagementComponent(); - const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); - expect(descriptionText).to.eql( - 'Saved Queries\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' - ); + const loadFilterSetBtn = await testSubjects.find('saved-query-management-load-button'); + const isDisabled = await loadFilterSetBtn.getAttribute('disabled'); + expect(isDisabled).to.equal('true'); }); it('should allow a query to be saved via the saved objects management component', async () => { @@ -189,9 +188,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows saving changes to a currently loaded query via the saved query management component', async () => { + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); await queryBar.setQuery('response:404'); await savedQueryManagementComponent.updateCurrentlyLoadedQuery('OkResponse', false, false); await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + const contextMenuPanelTitleButton = await testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await testSubjects.click('contextMenuPanelTitleButton'); + } await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); expect(await queryBar.getQueryString()).to.eql(''); await savedQueryManagementComponent.loadSavedQuery('OkResponse'); @@ -199,9 +205,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows saving the currently loaded query as a new query', async () => { + await queryBar.setQuery('response:400'); await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( 'OkResponseCopy', - '200 responses', + '400 responses', false, false ); @@ -215,6 +222,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('does not allow saving a query with a non-unique name', async () => { + // this check allows this test to run stand alone, also should fix occacional flakiness + const savedQueryExists = await savedQueryManagementComponent.savedQueryExist('OkResponse'); + if (!savedQueryExists) { + await savedQueryManagementComponent.saveNewQuery( + 'OkResponse', + '200 responses for .jpg over 24 hours', + true, + true + ); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + } + await queryBar.setQuery('response:400'); await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse'); }); @@ -232,17 +251,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows clearing if non default language was remembered in localstorage', async () => { + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.switchQueryLanguage('lucene'); await PageObjects.common.navigateToApp('discover'); // makes sure discovered is reloaded without any state in url + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.expectQueryLanguageOrFail('lucene'); // make sure lucene is remembered after refresh (comes from localstorage) await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.expectQueryLanguageOrFail('kql'); await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.expectQueryLanguageOrFail('lucene'); }); it('changing language removes saved query', async () => { await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.switchQueryLanguage('lucene'); expect(await queryBar.getQueryString()).to.eql(''); }); diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index 016cead53f0c4..1d9d02d5e94b5 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); - const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker']); + const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker', 'unifiedSearch']); const appsMenu = getService('appsMenu'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); @@ -37,6 +37,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Navigate to discover app await appsMenu.clickLink('Discover'); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); const discoverUrl = await browser.getCurrentUrl(); await PageObjects.timePicker.setDefaultAbsoluteRange(); const modifiedTimeDiscoverUrl = await browser.getCurrentUrl(); diff --git a/test/functional/apps/visualize/group2/_gauge_chart.ts b/test/functional/apps/visualize/group2/_gauge_chart.ts index 2c20c913b4d16..08425fcd78b5f 100644 --- a/test/functional/apps/visualize/group2/_gauge_chart.ts +++ b/test/functional/apps/visualize/group2/_gauge_chart.ts @@ -102,6 +102,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show correct values for fields with fieldFormatters', async () => { + await filterBar.removeAllFilters(); const expectedTexts = ['2,904', 'win 8: Count', '0B', 'win 8: Min bytes']; await PageObjects.visEditor.selectAggregation('Terms'); @@ -117,8 +118,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(expectedTexts).to.eql(metricValue); }); }); - - afterEach(async () => await filterBar.removeAllFilters()); }); }); } diff --git a/test/functional/apps/visualize/group6/_vega_chart.ts b/test/functional/apps/visualize/group6/_vega_chart.ts index 78a370523071b..1d802065ad137 100644 --- a/test/functional/apps/visualize/group6/_vega_chart.ts +++ b/test/functional/apps/visualize/group6/_vega_chart.ts @@ -220,7 +220,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('Vega extension functions', () => { beforeEach(async () => { - await filterBar.removeAllFilters(); + const filtersCount = await filterBar.getFilterCount(); + if (filtersCount > 0) { + await filterBar.removeAllFilters(); + } }); const fillSpecAndGo = async (newSpec: string) => { diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 28ac88674b4a6..206cc82912c36 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -281,6 +281,7 @@ export class CommonPageObject extends FtrService { } if (appName === 'discover') { await this.browser.setLocalStorageItem('data.autocompleteFtuePopover', 'true'); + await this.browser.setLocalStorageItem('data.newDataViewMenu', 'true'); } return currentUrl; }); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index ce25370493823..5691b4f5609c7 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -22,6 +22,9 @@ export class DiscoverPageObject extends FtrService { private readonly config = this.ctx.getService('config'); private readonly dataGrid = this.ctx.getService('dataGrid'); private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly queryBar = this.ctx.getService('queryBar'); + + private readonly unifiedSearch = this.ctx.getPageObject('unifiedSearch'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); @@ -365,8 +368,7 @@ export class DiscoverPageObject extends FtrService { public async clickIndexPatternActions() { await this.retry.try(async () => { - await this.testSubjects.click('discoverIndexPatternActions'); - await this.testSubjects.existOrFail('discover-addRuntimeField-popover'); + await this.testSubjects.click('discover-dataView-switch-link'); }); } @@ -494,7 +496,7 @@ export class DiscoverPageObject extends FtrService { } public async selectIndexPattern(indexPattern: string) { - await this.testSubjects.click('indexPattern-switch-link'); + await this.testSubjects.click('discover-dataView-switch-link'); await this.find.setValue('[data-test-subj="indexPattern-switcher"] input', indexPattern); await this.find.clickByCssSelector( `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` @@ -557,6 +559,7 @@ export class DiscoverPageObject extends FtrService { await this.retry.waitFor('Discover app on screen', async () => { return await this.isDiscoverAppOnScreen(); }); + await this.unifiedSearch.closeTourPopoverByLocalStorage(); } public async showAllFilterActions() { @@ -564,10 +567,13 @@ export class DiscoverPageObject extends FtrService { } public async clickSavedQueriesPopOver() { - await this.testSubjects.click('saved-query-management-popover-button'); + await this.testSubjects.click('showQueryBarMenu'); } public async clickCurrentSavedQuery() { + await this.queryBar.setQuery('Cancelled : true'); + await this.queryBar.clickQuerySubmitButton(); + await this.testSubjects.click('showQueryBarMenu'); await this.testSubjects.click('saved-query-management-save-button'); } @@ -630,7 +636,7 @@ export class DiscoverPageObject extends FtrService { public async getCurrentlySelectedDataView() { await this.testSubjects.existOrFail('discover-sidebar'); - const button = await this.testSubjects.find('indexPattern-switch-link'); + const button = await this.testSubjects.find('discover-dataView-switch-link'); return button.getAttribute('title'); } } diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 826c4b78d1d0f..bdfe91efef900 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -31,6 +31,7 @@ import { SavedObjectsPageObject } from './management/saved_objects_page'; import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; import { IndexPatternFieldEditorPageObject } from './management/indexpattern_field_editor_page'; import { DashboardPageControls } from './dashboard_page_controls'; +import { UnifiedSearchPageObject } from './unified_search_page'; export const pageObjects = { common: CommonPageObject, @@ -58,4 +59,5 @@ export const pageObjects = { vegaChart: VegaChartPageObject, savedObjects: SavedObjectsPageObject, indexPatternFieldEditorObjects: IndexPatternFieldEditorPageObject, + unifiedSearch: UnifiedSearchPageObject, }; diff --git a/test/functional/page_objects/unified_search_page.ts b/test/functional/page_objects/unified_search_page.ts new file mode 100644 index 0000000000000..b1bcd0662f77e --- /dev/null +++ b/test/functional/page_objects/unified_search_page.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrService } from '../ftr_provider_context'; + +export class UnifiedSearchPageObject extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + + public async closeTour() { + const tourPopoverIsOpen = await this.testSubjects.exists('dataViewPickerTourLink'); + if (tourPopoverIsOpen) { + await this.testSubjects.click('dataViewPickerTourLink'); + } + } + + public async closeTourPopoverByLocalStorage() { + await this.browser.setLocalStorageItem('data.newDataViewMenu', 'true'); + await this.browser.refresh(); + } +} diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 20aec8ba5d984..e087d50f21003 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -39,6 +39,7 @@ export class VisualizePageObject extends FtrService { private readonly elasticChart = this.ctx.getService('elasticChart'); private readonly common = this.ctx.getPageObject('common'); private readonly header = this.ctx.getPageObject('header'); + private readonly unifiedSearch = this.ctx.getPageObject('unifiedSearch'); private readonly visEditor = this.ctx.getPageObject('visEditor'); private readonly visChart = this.ctx.getPageObject('visChart'); @@ -154,6 +155,10 @@ export class VisualizePageObject extends FtrService { public async clickVisType(type: string) { await this.testSubjects.click(`visType-${type}`); await this.header.waitUntilLoadingHasFinished(); + + if (type === 'lens') { + await this.unifiedSearch.closeTour(); + } } public async clickAreaChart() { diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index 8688d375f7a7b..48828798a4efa 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -17,6 +17,7 @@ export class DashboardVisualizationsService extends FtrService { private readonly visualize = this.ctx.getPageObject('visualize'); private readonly visEditor = this.ctx.getPageObject('visEditor'); private readonly header = this.ctx.getPageObject('header'); + private readonly unifiedSearch = this.ctx.getPageObject('unifiedSearch'); private readonly discover = this.ctx.getPageObject('discover'); private readonly timePicker = this.ctx.getPageObject('timePicker'); @@ -43,6 +44,7 @@ export class DashboardVisualizationsService extends FtrService { }) { this.log.debug(`createSavedSearch(${name})`); await this.header.clickDiscover(true); + await this.unifiedSearch.closeTourPopoverByLocalStorage(); await this.timePicker.setHistoricalDataRange(); if (query) { diff --git a/test/functional/services/filter_bar.ts b/test/functional/services/filter_bar.ts index eee1a1027f541..7178013d5b9fd 100644 --- a/test/functional/services/filter_bar.ts +++ b/test/functional/services/filter_bar.ts @@ -64,8 +64,8 @@ export class FilterBarService extends FtrService { * Removes all filters */ public async removeAllFilters(): Promise { - await this.testSubjects.click('showFilterActions'); - await this.testSubjects.click('removeAllFilters'); + await this.testSubjects.click('showQueryBarMenu'); + await this.testSubjects.click('filter-sets-removeAllFilters'); await this.header.waitUntilLoadingHasFinished(); await this.common.waitUntilUrlIncludes('filters:!()'); } diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index ec5fc039101a5..ca6c161accc39 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -16,7 +16,6 @@ export class QueryBarService extends FtrService { private readonly common = this.ctx.getPageObject('common'); private readonly header = this.ctx.getPageObject('header'); private readonly find = this.ctx.getService('find'); - private readonly browser = this.ctx.getService('browser'); async getQueryString(): Promise { return await this.testSubjects.getAttribute('queryInput', 'value'); @@ -60,20 +59,19 @@ export class QueryBarService extends FtrService { public async switchQueryLanguage(lang: 'kql' | 'lucene'): Promise { await this.testSubjects.click('switchQueryLanguageButton'); - const kqlToggle = await this.testSubjects.find('languageToggle'); - const currentLang = - (await kqlToggle.getAttribute('aria-checked')) === 'true' ? 'kql' : 'lucene'; - if (lang !== currentLang) { - await kqlToggle.click(); + await this.testSubjects.click(`${lang}LanguageMenuItem`); + const contextMenuPanelTitleButton = await this.testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await this.testSubjects.click('contextMenuPanelTitleButton'); } - - await this.browser.pressKeys(this.browser.keys.ESCAPE); // close popover await this.expectQueryLanguageOrFail(lang); // make sure lang is switched } public async expectQueryLanguageOrFail(lang: 'kql' | 'lucene'): Promise { const queryLanguageButton = await this.testSubjects.find('switchQueryLanguageButton'); - expect((await queryLanguageButton.getVisibleText()).toLowerCase()).to.eql(lang); + expect((await queryLanguageButton.getVisibleText()).toLowerCase()).to.eql(`language: ${lang}`); } /** diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index a216f8cb0469e..7822ed8f77a89 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -19,7 +19,7 @@ export class SavedQueryManagementComponentService extends FtrService { public async getCurrentlyLoadedQueryID() { await this.openSavedQueryManagementComponent(); try { - return await this.testSubjects.getVisibleText('~saved-query-list-item-selected'); + return await this.testSubjects.getVisibleText('savedQueryTitle'); } catch { return undefined; } @@ -53,7 +53,12 @@ export class SavedQueryManagementComponentService extends FtrService { return saveQueryFormSaveButtonStatus === false; }); - await this.testSubjects.click('savedQueryFormCancelButton'); + const contextMenuPanelTitleButton = await this.testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await this.testSubjects.click('contextMenuPanelTitleButton'); + } } public async saveCurrentlyLoadedAsNewQuery( @@ -63,7 +68,7 @@ export class SavedQueryManagementComponentService extends FtrService { includeTimeFilter: boolean ) { await this.openSavedQueryManagementComponent(); - await this.testSubjects.click('saved-query-management-save-as-new-button'); + await this.testSubjects.click('saved-query-management-save-button'); await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); } @@ -79,12 +84,12 @@ export class SavedQueryManagementComponentService extends FtrService { public async loadSavedQuery(title: string) { await this.openSavedQueryManagementComponent(); + await this.testSubjects.click('saved-query-management-load-button'); await this.testSubjects.click(`~load-saved-query-${title}-button`); + await this.testSubjects.click('saved-query-management-apply-changes-button'); await this.retry.try(async () => { await this.openSavedQueryManagementComponent(); - const selectedSavedQueryText = await this.testSubjects.getVisibleText( - '~saved-query-list-item-selected' - ); + const selectedSavedQueryText = await this.testSubjects.getVisibleText('savedQueryTitle'); expect(selectedSavedQueryText).to.eql(title); }); await this.closeSavedQueryManagementComponent(); @@ -92,13 +97,24 @@ export class SavedQueryManagementComponentService extends FtrService { public async deleteSavedQuery(title: string) { await this.openSavedQueryManagementComponent(); - await this.testSubjects.click(`~delete-saved-query-${title}-button`); + const shouldClickLoadMenu = await this.testSubjects.exists( + 'saved-query-management-load-button' + ); + if (shouldClickLoadMenu) { + await this.testSubjects.click('saved-query-management-load-button'); + } + await this.testSubjects.click(`~load-saved-query-${title}-button`); + await this.retry.waitFor('delete saved query', async () => { + await this.testSubjects.click(`delete-saved-query-${title}-button`); + const exists = await this.testSubjects.exists('confirmModalTitleText'); + return exists === true; + }); await this.common.clickConfirmOnModal(); } async clearCurrentlyLoadedQuery() { await this.openSavedQueryManagementComponent(); - await this.testSubjects.click('saved-query-management-clear-button'); + await this.testSubjects.click('filter-sets-removeAllFilters'); await this.closeSavedQueryManagementComponent(); const queryString = await this.queryBar.getQueryString(); expect(queryString).to.be.empty(); @@ -113,7 +129,6 @@ export class SavedQueryManagementComponentService extends FtrService { if (title) { await this.testSubjects.setValue('saveQueryFormTitle', title); } - await this.testSubjects.setValue('saveQueryFormDescription', description); const currentIncludeFiltersValue = (await this.testSubjects.getAttribute( @@ -138,6 +153,7 @@ export class SavedQueryManagementComponentService extends FtrService { async savedQueryExist(title: string) { await this.openSavedQueryManagementComponent(); + await this.testSubjects.click('saved-query-management-load-button'); const exists = await this.testSubjects.exists(`~load-saved-query-${title}-button`); await this.closeSavedQueryManagementComponent(); return exists; @@ -145,6 +161,13 @@ export class SavedQueryManagementComponentService extends FtrService { async savedQueryExistOrFail(title: string) { await this.openSavedQueryManagementComponent(); + await this.retry.waitFor('load saved query', async () => { + const shouldClickLoadMenu = await this.testSubjects.exists( + 'saved-query-management-load-button' + ); + return shouldClickLoadMenu === true; + }); + await this.testSubjects.click('saved-query-management-load-button'); await this.testSubjects.existOrFail(`~load-saved-query-${title}-button`); } @@ -163,24 +186,19 @@ export class SavedQueryManagementComponentService extends FtrService { } async openSavedQueryManagementComponent() { - const isOpenAlready = await this.testSubjects.exists('saved-query-management-popover'); + const isOpenAlready = await this.testSubjects.exists('queryBarMenuPanel'); if (isOpenAlready) return; - await this.testSubjects.click('saved-query-management-popover-button'); - - await this.retry.waitFor('saved query management popover to have any text', async () => { - const queryText = await this.testSubjects.getVisibleText('saved-query-management-popover'); - return queryText.length > 0; - }); + await this.testSubjects.click('showQueryBarMenu'); } async closeSavedQueryManagementComponent() { - const isOpenAlready = await this.testSubjects.exists('saved-query-management-popover'); + const isOpenAlready = await this.testSubjects.exists('queryBarMenuPanel'); if (!isOpenAlready) return; await this.retry.try(async () => { - await this.testSubjects.click('saved-query-management-popover-button'); - await this.testSubjects.missingOrFail('saved-query-management-popover'); + await this.testSubjects.click('showQueryBarMenu'); + await this.testSubjects.missingOrFail('queryBarMenuPanel'); }); } @@ -197,7 +215,9 @@ export class SavedQueryManagementComponentService extends FtrService { async saveNewQueryMissingOrFail() { await this.openSavedQueryManagementComponent(); - await this.testSubjects.missingOrFail('saved-query-management-save-button'); + const saveFilterSetBtn = await this.testSubjects.find('saved-query-management-save-button'); + const isDisabled = await saveFilterSetBtn.getAttribute('disabled'); + expect(isDisabled).to.equal('true'); } async updateCurrentlyLoadedQueryMissingOrFail() { diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index b342eddaa0c1b..5eba1353df216 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -21,6 +21,7 @@ import { eventLogMock } from '@kbn/event-log-plugin/server/mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { monitoringCollectionMock } from '@kbn/monitoring-collection-plugin/server/mocks'; +import { PluginSetup as DataPluginSetup } from '@kbn/data-plugin/server'; import { spacesMock } from '@kbn/spaces-plugin/server/mocks'; const generateAlertingConfig = (): AlertingConfig => ({ @@ -66,6 +67,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }; let plugin: AlertingPlugin; @@ -207,6 +209,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }); const startContract = plugin.start(coreMock.createStart(), { @@ -246,6 +249,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }); const startContract = plugin.start(coreMock.createStart(), { @@ -296,6 +300,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }); const startContract = plugin.start(coreMock.createStart(), { diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 6589b1537f766..063c221ea98db 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -10,6 +10,7 @@ import { BehaviorSubject } from 'rxjs'; import { pick } from 'lodash'; import { UsageCollectionSetup, UsageCounter } from '@kbn/usage-collection-plugin/server'; import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; +import { PluginSetup as DataPluginSetup } from '@kbn/data-plugin/server'; import { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, @@ -140,6 +141,7 @@ export interface AlertingPluginsSetup { eventLog: IEventLogService; statusService: StatusServiceSetup; monitoringCollection: MonitoringCollectionSetup; + data: DataPluginSetup; } export interface AlertingPluginsStart { @@ -247,12 +249,16 @@ export class AlertingPlugin { // Usage counter for telemetry this.usageCounter = plugins.usageCollection?.createUsageCounter(ALERTS_FEATURE_ID); + const getSearchSourceMigrations = plugins.data.search.searchSource.getAllMigrations.bind( + plugins.data.search.searchSource + ); setupSavedObjects( core.savedObjects, plugins.encryptedSavedObjects, this.ruleTypeRegistry, this.logger, - plugins.actions.isPreconfiguredConnector + plugins.actions.isPreconfiguredConnector, + getSearchSourceMigrations ); initializeApiKeyInvalidator( diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 85e4dc5a8e05b..6566fee15d4a8 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -12,6 +12,7 @@ import type { SavedObjectsServiceSetup, } from '@kbn/core/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; +import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { alertMappings } from './mappings'; import { getMigrations } from './migrations'; import { transformRulesForExport } from './transform_rule_for_export'; @@ -51,14 +52,15 @@ export function setupSavedObjects( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, ruleTypeRegistry: RuleTypeRegistry, logger: Logger, - isPreconfigured: (connectorId: string) => boolean + isPreconfigured: (connectorId: string) => boolean, + getSearchSourceMigrations: () => MigrateFunctionsObject ) { savedObjects.registerType({ name: 'alert', hidden: true, namespaceType: 'multiple-isolated', convertToMultiNamespaceTypeVersion: '8.0.0', - migrations: getMigrations(encryptedSavedObjects, isPreconfigured), + migrations: getMigrations(encryptedSavedObjects, getSearchSourceMigrations(), isPreconfigured), mappings: alertMappings, management: { displayName: 'rule', diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 921412d4e79e8..c83d0a95dfdcb 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -8,7 +8,7 @@ import uuid from 'uuid'; import { getMigrations, isAnyActionSupportIncidents } from './migrations'; import { RawRule } from '../types'; -import { SavedObjectUnsanitizedDoc } from '@kbn/core/server'; +import { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from '@kbn/core/server'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { migrationMocks } from '@kbn/core/server/mocks'; import { RuleType, ruleTypeMappings } from '@kbn/securitysolution-rules'; @@ -25,7 +25,7 @@ describe('successful migrations', () => { }); describe('7.10.0', () => { test('marks alerts as legacy', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({}); expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, @@ -39,7 +39,7 @@ describe('successful migrations', () => { }); test('migrates the consumer for metrics', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'metrics', }); @@ -56,7 +56,7 @@ describe('successful migrations', () => { }); test('migrates the consumer for siem', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'securitySolution', }); @@ -73,7 +73,7 @@ describe('successful migrations', () => { }); test('migrates the consumer for alerting', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'alerting', }); @@ -90,7 +90,7 @@ describe('successful migrations', () => { }); test('migrates PagerDuty actions to set a default dedupkey of the AlertId', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ actions: [ { @@ -127,7 +127,7 @@ describe('successful migrations', () => { }); test('skips PagerDuty actions with a specified dedupkey', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ actions: [ { @@ -165,7 +165,7 @@ describe('successful migrations', () => { }); test('skips PagerDuty actions with an eventAction of "trigger"', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ actions: [ { @@ -204,7 +204,7 @@ describe('successful migrations', () => { }); test('creates execution status', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData(); const dateStart = Date.now(); const migratedAlert = migration710(alert, migrationContext); @@ -232,7 +232,7 @@ describe('successful migrations', () => { describe('7.11.0', () => { test('add updatedAt field to alert - set to SavedObject updated_at attribute', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({}, true); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -245,7 +245,7 @@ describe('successful migrations', () => { }); test('add updatedAt field to alert - set to createdAt when SavedObject updated_at is not defined', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({}); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -258,7 +258,7 @@ describe('successful migrations', () => { }); test('add notifyWhen=onActiveAlert when throttle is null', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({}); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -271,7 +271,7 @@ describe('successful migrations', () => { }); test('add notifyWhen=onActiveAlert when throttle is set', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({ throttle: '5m' }); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -286,7 +286,9 @@ describe('successful migrations', () => { describe('7.11.2', () => { test('transforms connectors that support incident correctly', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -428,7 +430,9 @@ describe('successful migrations', () => { }); test('it transforms only subAction=pushToService', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -447,7 +451,9 @@ describe('successful migrations', () => { }); test('it does not transforms other connectors', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -526,7 +532,9 @@ describe('successful migrations', () => { }); test('it does not transforms alerts when the right structure connectors is already applied', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -563,7 +571,9 @@ describe('successful migrations', () => { }); test('if incident attribute is an empty object, copy back the related attributes from subActionParams back to incident', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -625,7 +635,9 @@ describe('successful migrations', () => { }); test('custom action does not get migrated/loss', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -654,7 +666,7 @@ describe('successful migrations', () => { describe('7.13.0', () => { test('security solution alerts get migrated and remove null values', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -748,7 +760,7 @@ describe('successful migrations', () => { }); test('non-null values in security solution alerts are not modified', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -815,7 +827,7 @@ describe('successful migrations', () => { }); test('security solution threshold alert with string in threshold.field is migrated to array', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -846,7 +858,7 @@ describe('successful migrations', () => { }); test('security solution threshold alert with empty string in threshold.field is migrated to empty array', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -877,7 +889,7 @@ describe('successful migrations', () => { }); test('security solution threshold alert with array in threshold.field and cardinality is left alone', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -919,7 +931,7 @@ describe('successful migrations', () => { }); test('security solution ML alert with string in machineLearningJobId is converted to an array', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -945,7 +957,7 @@ describe('successful migrations', () => { }); test('security solution ML alert with an array in machineLearningJobId is preserved', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -973,7 +985,9 @@ describe('successful migrations', () => { describe('7.14.1', () => { test('security solution author field is migrated to array if it is undefined', () => { - const migration7141 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.14.1']; + const migration7141 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.14.1' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: {}, @@ -991,7 +1005,9 @@ describe('successful migrations', () => { }); test('security solution author field does not override existing values if they exist', () => { - const migration7141 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.14.1']; + const migration7141 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.14.1' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1015,7 +1031,9 @@ describe('successful migrations', () => { describe('7.15.0', () => { test('security solution is migrated to saved object references if it has 1 exceptionsList', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1044,7 +1062,9 @@ describe('successful migrations', () => { }); test('security solution is migrated to saved object references if it has 2 exceptionsLists', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1084,7 +1104,9 @@ describe('successful migrations', () => { }); test('security solution is migrated to saved object references if it has 3 exceptionsLists', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1135,7 +1157,9 @@ describe('successful migrations', () => { }); test('security solution does not change anything if exceptionsList is missing', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1147,7 +1171,9 @@ describe('successful migrations', () => { }); test('security solution will keep existing references if we do not have an exceptionsList but we do already have references', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1177,7 +1203,9 @@ describe('successful migrations', () => { }); test('security solution keep any foreign references if they exist but still migrate other references', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1242,7 +1270,9 @@ describe('successful migrations', () => { }); test('security solution is idempotent and if re-run on the same migrated data will keep the same items', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1282,7 +1312,9 @@ describe('successful migrations', () => { }); test('security solution will migrate with only missing data if we have partially migrated data', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1331,7 +1363,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate if exception list if it is invalid data', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1345,7 +1379,9 @@ describe('successful migrations', () => { }); test('security solution will migrate valid data if it is mixed with invalid data', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1387,7 +1423,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate if exception list is invalid data but will keep existing references', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1419,7 +1457,7 @@ describe('successful migrations', () => { describe('7.16.0', () => { test('add legacyId field to alert - set to SavedObject id attribute', () => { - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const alert = getMockData({}, true); expect(migration716(alert, migrationContext)).toEqual({ ...alert, @@ -1434,7 +1472,7 @@ describe('successful migrations', () => { isPreconfigured.mockReset(); isPreconfigured.mockReturnValueOnce(true); isPreconfigured.mockReturnValueOnce(false); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1510,7 +1548,7 @@ describe('successful migrations', () => { isPreconfigured.mockReturnValueOnce(true); isPreconfigured.mockReturnValueOnce(false); isPreconfigured.mockReturnValueOnce(false); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1593,7 +1631,7 @@ describe('successful migrations', () => { test('does nothing to rules with no references', () => { isPreconfigured.mockReset(); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1629,7 +1667,7 @@ describe('successful migrations', () => { test('does nothing to rules with no action references', () => { isPreconfigured.mockReset(); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1671,7 +1709,7 @@ describe('successful migrations', () => { test('does nothing to rules with references but no actions', () => { isPreconfigured.mockReset(); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [], @@ -1699,7 +1737,9 @@ describe('successful migrations', () => { }); test('security solution is migrated to saved object references if it has a "ruleAlertId"', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = getMockData({ alertTypeId: 'siem.notifications', params: { @@ -1724,7 +1764,9 @@ describe('successful migrations', () => { }); test('security solution does not migrate anything if its type is not siem.notifications', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = getMockData({ alertTypeId: 'other-type', params: { @@ -1741,7 +1783,9 @@ describe('successful migrations', () => { }); }); test('security solution does not change anything if "ruleAlertId" is missing', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = getMockData({ alertTypeId: 'siem.notifications', params: {}, @@ -1757,7 +1801,9 @@ describe('successful migrations', () => { }); test('security solution will keep existing references if we do not have a "ruleAlertId" but we do already have references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1789,7 +1835,9 @@ describe('successful migrations', () => { }); test('security solution will keep any foreign references if they exist but still migrate other "ruleAlertId" references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1828,7 +1876,9 @@ describe('successful migrations', () => { }); test('security solution is idempotent and if re-run on the same migrated data will keep the same items "ruleAlertId" references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1862,7 +1912,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate "ruleAlertId" if it is invalid data', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1882,7 +1934,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate "ruleAlertId" if it is invalid data but will keep existing references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1916,7 +1970,9 @@ describe('successful migrations', () => { }); test('geo-containment alert migration extracts boundary and index references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: '.geo-containment', @@ -1944,7 +2000,9 @@ describe('successful migrations', () => { }); test('geo-containment alert migration should preserve foreign references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: '.geo-containment', @@ -1984,7 +2042,9 @@ describe('successful migrations', () => { }); test('geo-containment alert migration ignores other alert-types', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: '.foo', @@ -2008,13 +2068,13 @@ describe('successful migrations', () => { describe('8.0.0', () => { test('no op migration for rules SO', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData({}, true); expect(migration800(alert, migrationContext)).toEqual(alert); }); test('add threatIndicatorPath default value to threat match rules if missing', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { type: 'threat_match' }, alertTypeId: 'siem.signals' }, true @@ -2025,7 +2085,7 @@ describe('successful migrations', () => { }); test('doesnt change threatIndicatorPath value in threat match rules if value is present', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { type: 'threat_match', threatIndicatorPath: 'custom.indicator.path' }, @@ -2039,7 +2099,7 @@ describe('successful migrations', () => { }); test('doesnt change threatIndicatorPath value in other rules', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData({ params: { type: 'eql' }, alertTypeId: 'siem.signals' }, true); expect(migration800(alert, migrationContext).attributes.params.threatIndicatorPath).toEqual( undefined @@ -2047,7 +2107,7 @@ describe('successful migrations', () => { }); test('doesnt change threatIndicatorPath value if not a siem.signals rule', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { type: 'threat_match' }, alertTypeId: 'not.siem.signals' }, true @@ -2058,7 +2118,7 @@ describe('successful migrations', () => { }); test('doesnt change AAD rule params if not a siem.signals rule', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { outputIndex: 'output-index', type: 'query' }, alertTypeId: 'not.siem.signals' }, true @@ -2073,7 +2133,9 @@ describe('successful migrations', () => { test.each(Object.keys(ruleTypeMappings) as RuleType[])( 'changes AAD rule params accordingly if rule is a siem.signals %p rule', (ruleType) => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.0.0' + ]; const alert = getMockData( { params: { outputIndex: 'output-index', type: ruleType }, alertTypeId: 'siem.signals' }, true @@ -2118,7 +2180,7 @@ describe('successful migrations', () => { ); test('Does not update rule tags if rule has already been enabled', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration800 = migrations['8.0.0']; const migration801 = migrations['8.0.1']; @@ -2141,7 +2203,7 @@ describe('successful migrations', () => { }); test('Does not update rule tags if rule was already disabled before upgrading to 8.0', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration800 = migrations['8.0.0']; const migration801 = migrations['8.0.1']; @@ -2161,7 +2223,7 @@ describe('successful migrations', () => { }); test('Updates rule tags if rule was auto-disabled in 8.0 upgrade and not reenabled', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration800 = migrations['8.0.0']; const migration801 = migrations['8.0.1']; @@ -2181,7 +2243,7 @@ describe('successful migrations', () => { }); test('Updates rule tags correctly if tags are undefined', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration801 = migrations['8.0.1']; const alert = { @@ -2204,7 +2266,7 @@ describe('successful migrations', () => { }); test('Updates rule tags correctly if tags are null', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration801 = migrations['8.0.1']; const alert = { @@ -2231,7 +2293,9 @@ describe('successful migrations', () => { describe('8.2.0', () => { test('migrates params to mapped_params', () => { - const migration820 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.2.0']; + const migration820 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.2.0' + ]; const alert = getMockData( { params: { @@ -2254,8 +2318,29 @@ describe('successful migrations', () => { }); describe('8.3.0', () => { + test('migrates es_query alert params', () => { + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; + const alert = getMockData( + { + params: { esQuery: '{ "query": "test-query" }' }, + alertTypeId: '.es-query', + }, + true + ); + const migratedAlert820 = migration830(alert, migrationContext); + + expect(migratedAlert820.attributes.params).toEqual({ + esQuery: '{ "query": "test-query" }', + searchType: 'esQuery', + }); + }); + test('removes internal tags', () => { - const migration830 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.3.0']; + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; const alert = getMockData( { tags: [ @@ -2274,7 +2359,9 @@ describe('successful migrations', () => { }); test('do not remove internal tags if rule is not Security solution rule', () => { - const migration830 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.3.0']; + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; const alert = getMockData( { tags: ['__internal_immutable:false', 'tag-1'], @@ -2290,7 +2377,9 @@ describe('successful migrations', () => { describe('Metrics Inventory Threshold rule', () => { test('Migrates incorrect action group spelling', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.0.0' + ]; const actions = [ { @@ -2317,7 +2406,9 @@ describe('successful migrations', () => { }); test('Works with the correct action group spelling', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.0.0' + ]; const actions = [ { @@ -2346,6 +2437,72 @@ describe('successful migrations', () => { }); }); +describe('search source migration', () => { + it('should apply migration within es query alert rule', () => { + const esQueryRuleSavedObject = { + attributes: { + params: { + searchConfiguration: { + some: 'prop', + migrated: false, + }, + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '9.1.3'; + const migrations = getMigrations( + encryptedSavedObjectsSetup, + { + [versionToTest]: (state) => ({ ...state, migrated: true }), + }, + isPreconfigured + ); + + expect( + migrations[versionToTest](esQueryRuleSavedObject, {} as SavedObjectMigrationContext) + ).toEqual({ + attributes: { + params: { + searchConfiguration: { + some: 'prop', + migrated: true, + }, + }, + }, + }); + }); + + it('should not apply migration within es query alert rule when searchConfiguration not an object', () => { + const esQueryRuleSavedObject = { + attributes: { + params: { + searchConfiguration: 5, + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '9.1.4'; + const migrations = getMigrations( + encryptedSavedObjectsSetup, + { + [versionToTest]: (state) => ({ ...state, migrated: true }), + }, + isPreconfigured + ); + + expect( + migrations[versionToTest](esQueryRuleSavedObject, {} as SavedObjectMigrationContext) + ).toEqual({ + attributes: { + params: { + searchConfiguration: 5, + }, + }, + }); + }); +}); + describe('handles errors during migrations', () => { beforeEach(() => { jest.resetAllMocks(); @@ -2355,7 +2512,7 @@ describe('handles errors during migrations', () => { }); describe('7.10.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'alerting', }); @@ -2380,7 +2537,7 @@ describe('handles errors during migrations', () => { describe('7.11.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({ consumer: 'alerting', }); @@ -2405,7 +2562,9 @@ describe('handles errors during migrations', () => { describe('7.11.2 throws if migration fails', () => { test('should show the proper exception', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ consumer: 'alerting', }); @@ -2430,7 +2589,9 @@ describe('handles errors during migrations', () => { describe('7.13.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration7130 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration7130 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.13.0' + ]; const alert = getMockData({ consumer: 'alerting', }); @@ -2455,7 +2616,9 @@ describe('handles errors during migrations', () => { describe('7.16.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const rule = getMockData(); expect(() => { migration7160(rule, migrationContext); @@ -2475,6 +2638,53 @@ describe('handles errors during migrations', () => { ); }); }); + + describe('8.3.0 throws if migration fails', () => { + test('should show the proper exception on search source migration', () => { + encryptedSavedObjectsSetup.createMigration.mockImplementation(({ migration }) => migration); + const mockRule = getMockData(); + const rule = { + ...mockRule, + attributes: { + ...mockRule.attributes, + params: { + searchConfiguration: { + some: 'prop', + migrated: false, + }, + }, + }, + }; + + const versionToTest = '8.3.0'; + const migration830 = getMigrations( + encryptedSavedObjectsSetup, + { + [versionToTest]: () => { + throw new Error(`Can't migrate search source!`); + }, + }, + isPreconfigured + )[versionToTest]; + + expect(() => { + migration830(rule, migrationContext); + }).toThrowError(`Can't migrate search source!`); + expect(migrationContext.log.error).toHaveBeenCalledWith( + `encryptedSavedObject ${versionToTest} migration failed for alert ${rule.id} with error: Can't migrate search source!`, + { + migrations: { + alertDocument: { + ...rule, + attributes: { + ...rule.attributes, + }, + }, + }, + } + ); + }); + }); }); function getUpdatedAt(): string { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 69d88e196dcfd..b3f8d873d8ef0 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -7,6 +7,7 @@ import { isRuleType, ruleTypeMappings } from '@kbn/securitysolution-rules'; import { isString } from 'lodash/fp'; +import { gte } from 'semver'; import { LogMeta, SavedObjectMigrationMap, @@ -19,12 +20,16 @@ import { } from '@kbn/core/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import type { IsMigrationNeededPredicate } from '@kbn/encrypted-saved-objects-plugin/server'; -import { RawRule, RawRuleAction, RawRuleExecutionStatus } from '../types'; +import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; +import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; +import { isSerializedSearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations'; +import { RawRule, RawRuleAction, RawRuleExecutionStatus } from '../types'; import { getMappedParams } from '../rules_client/lib/mapped_params_utils'; const SIEM_APP_ID = 'securitySolution'; const SIEM_SERVER_APP_ID = 'siem'; +const MINIMUM_SS_MIGRATION_VERSION = '8.3.0'; export const LEGACY_LAST_MODIFIED_VERSION = 'pre-7.10.0'; export const FILEBEAT_7X_INDICATOR_PATH = 'threatintel.indicator'; @@ -59,6 +64,9 @@ export const isAnyActionSupportIncidents = (doc: SavedObjectUnsanitizedDoc): boolean => doc.attributes.alertTypeId === 'siem.signals'; +export const isEsQueryRuleType = (doc: SavedObjectUnsanitizedDoc) => + doc.attributes.alertTypeId === '.es-query'; + export const isDetectionEngineAADRuleType = (doc: SavedObjectUnsanitizedDoc): boolean => (Object.values(ruleTypeMappings) as string[]).includes(doc.attributes.alertTypeId); @@ -75,6 +83,7 @@ export const isSecuritySolutionLegacyNotification = ( export function getMigrations( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, + searchSourceMigrations: MigrateFunctionsObject, isPreconfigured: (connectorId: string) => boolean ): SavedObjectMigrationMap { const migrationWhenRBACWasIntroduced = createEsoMigration( @@ -155,22 +164,25 @@ export function getMigrations( const migrationRules830 = createEsoMigration( encryptedSavedObjects, (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(removeInternalTags) + pipeMigrations(addSearchType, removeInternalTags) ); - return { - '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), - '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), - '7.11.2': executeMigrationWithErrorHandling(migrationActions7112, '7.11.2'), - '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), - '7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'), - '7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'), - '7.16.0': executeMigrationWithErrorHandling(migrateRules716, '7.16.0'), - '8.0.0': executeMigrationWithErrorHandling(migrationRules800, '8.0.0'), - '8.0.1': executeMigrationWithErrorHandling(migrationRules801, '8.0.1'), - '8.2.0': executeMigrationWithErrorHandling(migrationRules820, '8.2.0'), - '8.3.0': executeMigrationWithErrorHandling(migrationRules830, '8.3.0'), - }; + return mergeSavedObjectMigrationMaps( + { + '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), + '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), + '7.11.2': executeMigrationWithErrorHandling(migrationActions7112, '7.11.2'), + '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), + '7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'), + '7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'), + '7.16.0': executeMigrationWithErrorHandling(migrateRules716, '7.16.0'), + '8.0.0': executeMigrationWithErrorHandling(migrationRules800, '8.0.0'), + '8.0.1': executeMigrationWithErrorHandling(migrationRules801, '8.0.1'), + '8.2.0': executeMigrationWithErrorHandling(migrationRules820, '8.2.0'), + '8.3.0': executeMigrationWithErrorHandling(migrationRules830, '8.3.0'), + }, + getSearchSourceMigrations(encryptedSavedObjects, searchSourceMigrations) + ); } function executeMigrationWithErrorHandling( @@ -697,6 +709,23 @@ function addSecuritySolutionAADRuleTypes( : doc; } +function addSearchType(doc: SavedObjectUnsanitizedDoc) { + const searchType = doc.attributes.params.searchType; + + return isEsQueryRuleType(doc) && !searchType + ? { + ...doc, + attributes: { + ...doc.attributes, + params: { + ...doc.attributes.params, + searchType: 'esQuery', + }, + }, + } + : doc; +} + function addSecuritySolutionAADRuleTypeTags( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc { @@ -902,3 +931,56 @@ function pipeMigrations(...migrations: AlertMigration[]): AlertMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); } + +function mapSearchSourceMigrationFunc( + migrateSerializedSearchSourceFields: MigrateFunction +): MigrateFunction { + return (doc) => { + const _doc = doc as { attributes: RawRule }; + + const serializedSearchSource = _doc.attributes.params.searchConfiguration; + + if (isSerializedSearchSource(serializedSearchSource)) { + return { + ..._doc, + attributes: { + ..._doc.attributes, + params: { + ..._doc.attributes.params, + searchConfiguration: migrateSerializedSearchSourceFields(serializedSearchSource), + }, + }, + }; + } + return _doc; + }; +} + +/** + * This creates a migration map that applies search source migrations to legacy es query rules. + * It doesn't modify existing migrations. The following migrations will occur at minimum version of 8.3+. + */ +function getSearchSourceMigrations( + encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, + searchSourceMigrations: MigrateFunctionsObject +) { + const filteredMigrations: SavedObjectMigrationMap = {}; + for (const versionKey in searchSourceMigrations) { + if (gte(versionKey, MINIMUM_SS_MIGRATION_VERSION)) { + const migrateSearchSource = mapSearchSourceMigrationFunc( + searchSourceMigrations[versionKey] + ) as unknown as AlertMigration; + + filteredMigrations[versionKey] = executeMigrationWithErrorHandling( + createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => + isEsQueryRuleType(doc), + pipeMigrations(migrateSearchSource) + ), + versionKey + ); + } + } + return filteredMigrations; +} diff --git a/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.test.ts b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.test.ts new file mode 100644 index 0000000000000..a6dc1f59b00e3 --- /dev/null +++ b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom } from 'rxjs'; +import { registerCloudDeploymentIdAnalyticsContext } from './register_cloud_deployment_id_analytics_context'; + +describe('registerCloudDeploymentIdAnalyticsContext', () => { + let analytics: { registerContextProvider: jest.Mock }; + beforeEach(() => { + analytics = { + registerContextProvider: jest.fn(), + }; + }); + + test('it does not register the context provider if cloudId not provided', () => { + registerCloudDeploymentIdAnalyticsContext(analytics); + expect(analytics.registerContextProvider).not.toHaveBeenCalled(); + }); + + test('it registers the context provider and emits the cloudId', async () => { + registerCloudDeploymentIdAnalyticsContext(analytics, 'cloud_id'); + expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1); + const [{ context$ }] = analytics.registerContextProvider.mock.calls[0]; + await expect(firstValueFrom(context$)).resolves.toEqual({ cloudId: 'cloud_id' }); + }); +}); diff --git a/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.ts b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.ts new file mode 100644 index 0000000000000..e8bdc6b37b50c --- /dev/null +++ b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AnalyticsClient } from '@kbn/analytics-client'; +import { of } from 'rxjs'; + +export function registerCloudDeploymentIdAnalyticsContext( + analytics: Pick, + cloudId?: string +) { + if (!cloudId) { + return; + } + analytics.registerContextProvider({ + name: 'Cloud Deployment ID', + context$: of({ cloudId }), + schema: { + cloudId: { + type: 'keyword', + _meta: { description: 'The Cloud Deployment ID' }, + }, + }, + }); +} diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 5e0294178a5da..cfd0d45667417 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -9,9 +9,8 @@ import { nextTick } from '@kbn/test-jest-helpers'; import { coreMock } from '@kbn/core/public/mocks'; import { homePluginMock } from '@kbn/home-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; -import { CloudPlugin, CloudConfigType, loadUserId } from './plugin'; -import { firstValueFrom, Observable, Subject } from 'rxjs'; -import { KibanaExecutionContext } from '@kbn/core/public'; +import { CloudPlugin, CloudConfigType } from './plugin'; +import { firstValueFrom } from 'rxjs'; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -20,17 +19,7 @@ describe('Cloud Plugin', () => { jest.clearAllMocks(); }); - const setupPlugin = async ({ - config = {}, - securityEnabled = true, - currentUserProps = {}, - currentContext$ = undefined, - }: { - config?: Partial; - securityEnabled?: boolean; - currentUserProps?: Record; - currentContext$?: Observable; - }) => { + const setupPlugin = async ({ config = {} }: { config?: Partial }) => { const initContext = coreMock.createPluginInitializerContext({ id: 'cloudId', base_url: 'https://cloud.elastic.co', @@ -49,21 +38,9 @@ describe('Cloud Plugin', () => { const plugin = new CloudPlugin(initContext); const coreSetup = coreMock.createSetup(); - const coreStart = coreMock.createStart(); - if (currentContext$) { - coreStart.executionContext.context$ = currentContext$; - } - - coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]); - - const securitySetup = securityMock.createSetup(); - - securitySetup.authc.getCurrentUser.mockResolvedValue( - securityMock.createMockAuthenticatedUser(currentUserProps) - ); + const setup = plugin.setup(coreSetup, {}); - const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {}); // Wait for FullStory dynamic import to resolve await new Promise((r) => setImmediate(r)); @@ -73,9 +50,6 @@ describe('Cloud Plugin', () => { test('register the shipper FullStory with correct args when enabled and org_id are set', async () => { const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, - currentUserProps: { - username: '1234', - }, }); expect(coreSetup.analytics.registerShipper).toHaveBeenCalled(); @@ -86,9 +60,67 @@ describe('Cloud Plugin', () => { }); }); + it('does not call initializeFullStory when enabled=false', async () => { + const { coreSetup } = await setupPlugin({ + config: { full_story: { enabled: false, org_id: 'foo' } }, + }); + expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); + }); + + it('does not call initializeFullStory when org_id is undefined', async () => { + const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } }); + expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); + }); + }); + + describe('setupTelemetryContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setupPlugin = async ({ + config = {}, + securityEnabled = true, + currentUserProps = {}, + }: { + config?: Partial; + securityEnabled?: boolean; + currentUserProps?: Record | Error; + }) => { + const initContext = coreMock.createPluginInitializerContext({ + base_url: 'https://cloud.elastic.co', + deployment_url: '/abc123', + profile_url: '/profile/alice', + organization_url: '/org/myOrg', + full_story: { + enabled: false, + }, + chat: { + enabled: false, + }, + ...config, + }); + + const plugin = new CloudPlugin(initContext); + + const coreSetup = coreMock.createSetup(); + const securitySetup = securityMock.createSetup(); + if (currentUserProps instanceof Error) { + securitySetup.authc.getCurrentUser.mockRejectedValue(currentUserProps); + } else { + securitySetup.authc.getCurrentUser.mockResolvedValue( + securityMock.createMockAuthenticatedUser(currentUserProps) + ); + } + + const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {}); + + return { initContext, plugin, setup, coreSetup }; + }; + test('register the context provider for the cloud user with hashed user ID when security is available', async () => { const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, + config: { id: 'cloudId' }, currentUserProps: { username: '1234', }, @@ -105,9 +137,9 @@ describe('Cloud Plugin', () => { }); }); - it('user hash includes org id', async () => { + it('user hash includes cloud id', async () => { const { coreSetup: coreSetup1 } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg1' }, + config: { id: 'esOrg1' }, currentUserProps: { username: '1234', }, @@ -137,146 +169,37 @@ describe('Cloud Plugin', () => { expect(hashId1).not.toEqual(hashId2); }); - it('emits the execution context provider everytime an app changes', async () => { - const currentContext$ = new Subject(); + test('user hash does not include cloudId when not provided', async () => { const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, + config: {}, currentUserProps: { username: '1234', }, - currentContext$, }); + expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( - ([{ name }]) => name === 'execution_context' + ([{ name }]) => name === 'cloud_user_id' )!; - let latestContext; - context$.subscribe((context) => { - latestContext = context; - }); - - // takes the app name - expect(latestContext).toBeUndefined(); - currentContext$.next({ - name: 'App1', - description: '123', - }); - - await new Promise((r) => setImmediate(r)); - - expect(latestContext).toEqual({ - pageName: 'App1', - applicationId: 'App1', - }); - - // context clear - currentContext$.next({}); - expect(latestContext).toEqual({ - pageName: '', - applicationId: 'unknown', - }); - - // different app - currentContext$.next({ - name: 'App2', - page: 'page2', - id: '123', - }); - expect(latestContext).toEqual({ - pageName: 'App2:page2', - applicationId: 'App2', - page: 'page2', - entityId: '123', - }); - - // Back to first app - currentContext$.next({ - name: 'App1', - page: 'page3', - id: '123', - }); - - expect(latestContext).toEqual({ - pageName: 'App1:page3', - applicationId: 'App1', - page: 'page3', - entityId: '123', + await expect(firstValueFrom(context$)).resolves.toEqual({ + userId: '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4', }); }); - it('does not register the cloud user id context provider when security is not available', async () => { + test('user hash is undefined when failed to fetch a user', async () => { const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - securityEnabled: false, - }); - - expect( - coreSetup.analytics.registerContextProvider.mock.calls.find( - ([{ name }]) => name === 'cloud_user_id' - ) - ).toBeUndefined(); - }); - - describe('with memory', () => { - beforeAll(() => { - // @ts-expect-error 2339 - window.performance.memory = { - get jsHeapSizeLimit() { - return 3; - }, - get totalJSHeapSize() { - return 2; - }, - get usedJSHeapSize() { - return 1; - }, - }; + currentUserProps: new Error('failed to fetch a user'), }); - afterAll(() => { - // @ts-expect-error 2339 - delete window.performance.memory; - }); - - it('reports an event when security is available', async () => { - const { initContext, coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - currentUserProps: { - username: '1234', - }, - }); - - expect(coreSetup.analytics.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { - kibana_version: initContext.env.packageInfo.version, - memory_js_heap_size_limit: 3, - memory_js_heap_size_total: 2, - memory_js_heap_size_used: 1, - }); - }); - }); - - it('reports an event when security is not available', async () => { - const { initContext, coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - securityEnabled: false, - }); - - expect(coreSetup.analytics.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { - kibana_version: initContext.env.packageInfo.version, - }); - }); + expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); - it('does not call initializeFullStory when enabled=false', async () => { - const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: false, org_id: 'foo' } }, - }); - expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); - }); + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + )!; - it('does not call initializeFullStory when org_id is undefined', async () => { - const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } }); - expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); + await expect(firstValueFrom(context$)).resolves.toEqual({ userId: undefined }); }); }); @@ -652,56 +575,4 @@ describe('Cloud Plugin', () => { expect(securityStart.navControlService.addUserMenuLinks).not.toHaveBeenCalled(); }); }); - - describe('loadFullStoryUserId', () => { - let consoleMock: jest.SpyInstance; - - beforeEach(() => { - consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {}); - }); - afterEach(() => { - consoleMock.mockRestore(); - }); - - it('returns principal ID when username specified', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockResolvedValue({ - username: '1234', - }), - }) - ).toEqual('1234'); - expect(consoleMock).not.toHaveBeenCalled(); - }); - - it('returns undefined if getCurrentUser throws', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockRejectedValue(new Error(`Oh no!`)), - }) - ).toBeUndefined(); - }); - - it('returns undefined if getCurrentUser returns undefined', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockResolvedValue(undefined), - }) - ).toBeUndefined(); - }); - - it('returns undefined and logs if username undefined', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockResolvedValue({ - username: undefined, - metadata: { foo: 'bar' }, - }), - }) - ).toBeUndefined(); - expect(consoleMock).toHaveBeenLastCalledWith( - `[cloud.analytics] username not specified. User metadata: {"foo":"bar"}` - ); - }); - }); }); diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 4ee3098c709cf..db6b2305495bf 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -13,21 +13,16 @@ import type { PluginInitializerContext, HttpStart, IBasePath, - ExecutionContextStart, AnalyticsServiceSetup, } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject, from, of, Subscription } from 'rxjs'; -import { exhaustMap, filter, map } from 'rxjs/operators'; -import { compact } from 'lodash'; +import { BehaviorSubject, catchError, from, map, of } from 'rxjs'; -import type { - AuthenticatedUser, - SecurityPluginSetup, - SecurityPluginStart, -} from '@kbn/security-plugin/public'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; +import { Sha256 } from '@kbn/core/public/utils'; +import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK, @@ -91,11 +86,6 @@ interface SetupFullStoryDeps { analytics: AnalyticsServiceSetup; basePath: IBasePath; } -interface SetupTelemetryContextDeps extends CloudSetupDependencies { - analytics: AnalyticsServiceSetup; - executionContextPromise: Promise; - cloudId?: string; -} interface SetupChatDeps extends Pick { http: CoreSetup['http']; @@ -104,7 +94,6 @@ interface SetupChatDeps extends Pick { export class CloudPlugin implements Plugin { private readonly config: CloudConfigType; private isCloudEnabled: boolean; - private appSubscription?: Subscription; private chatConfig$ = new BehaviorSubject({ enabled: false }); constructor(private readonly initializerContext: PluginInitializerContext) { @@ -113,19 +102,7 @@ export class CloudPlugin implements Plugin { } public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) { - const executionContextPromise = core.getStartServices().then(([coreStart]) => { - return coreStart.executionContext; - }); - - this.setupTelemetryContext({ - analytics: core.analytics, - security, - executionContextPromise, - cloudId: this.config.id, - }).catch((e) => { - // eslint-disable-next-line no-console - console.debug(`Error setting up TelemetryContext: ${e.toString()}`); - }); + this.setupTelemetryContext(core.analytics, security, this.config.id); this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) => // eslint-disable-next-line no-console @@ -213,9 +190,7 @@ export class CloudPlugin implements Plugin { }; } - public stop() { - this.appSubscription?.unsubscribe(); - } + public stop() {} /** * Determines if the current user should see links back to Cloud. @@ -272,48 +247,25 @@ export class CloudPlugin implements Plugin { * Set up the Analytics context providers. * @param analytics Core's Analytics service. The Setup contract. * @param security The security plugin. - * @param executionContextPromise Core's executionContext's start contract. - * @param esOrgId The Cloud Org ID. + * @param cloudId The Cloud Org ID. * @private */ - private async setupTelemetryContext({ - analytics, - security, - executionContextPromise, - cloudId, - }: SetupTelemetryContextDeps) { - // Some context providers can be moved to other places for better domain isolation. - // Let's use https://github.com/elastic/kibana/issues/125690 for that purpose. - analytics.registerContextProvider({ - name: 'kibana_version', - context$: of({ version: this.initializerContext.env.packageInfo.version }), - schema: { version: { type: 'keyword', _meta: { description: 'The version of Kibana' } } }, - }); + private setupTelemetryContext( + analytics: AnalyticsServiceSetup, + security?: Pick, + cloudId?: string + ) { + registerCloudDeploymentIdAnalyticsContext(analytics, cloudId); - analytics.registerContextProvider({ - name: 'cloud_org_id', - context$: of({ cloudId }), - schema: { - cloudId: { - type: 'keyword', - _meta: { description: 'The Cloud ID', optional: true }, - }, - }, - }); - - // This needs to be called synchronously to be sure that we populate the user ID soon enough to make sessions merging - // across domains work if (security) { analytics.registerContextProvider({ name: 'cloud_user_id', - context$: from(loadUserId({ getCurrentUser: security.authc.getCurrentUser })).pipe( - filter((userId): userId is string => Boolean(userId)), - exhaustMap(async (userId) => { - const { sha256 } = await import('js-sha256'); - // Join the cloud org id and the user to create a truly unique user id. - // The hashing here is to keep it at clear as possible in our source code that we do not send literal user IDs - return { userId: sha256(cloudId ? `${cloudId}:${userId}` : `${userId}`) }; - }) + context$: from(security.authc.getCurrentUser()).pipe( + map((user) => user.username), + // Join the cloud org id and the user to create a truly unique user id. + // The hashing here is to keep it at clear as possible in our source code that we do not send literal user IDs + map((userId) => ({ userId: sha256(cloudId ? `${cloudId}:${userId}` : `${userId}`) })), + catchError(() => of({ userId: undefined })) ), schema: { userId: { @@ -323,81 +275,6 @@ export class CloudPlugin implements Plugin { }, }); } - - const executionContext = await executionContextPromise; - analytics.registerContextProvider({ - name: 'execution_context', - context$: executionContext.context$.pipe( - // Update the current context every time it changes - map(({ name, page, id }) => ({ - pageName: `${compact([name, page]).join(':')}`, - applicationId: name ?? 'unknown', - page, - entityId: id, - })) - ), - schema: { - pageName: { - type: 'keyword', - _meta: { description: 'The name of the current page' }, - }, - page: { - type: 'keyword', - _meta: { description: 'The current page', optional: true }, - }, - applicationId: { - type: 'keyword', - _meta: { description: 'The id of the current application' }, - }, - entityId: { - type: 'keyword', - _meta: { - description: - 'The id of the current entity (dashboard, visualization, canvas, lens, etc)', - optional: true, - }, - }, - }, - }); - - analytics.registerEventType({ - eventType: 'Loaded Kibana', - schema: { - kibana_version: { - type: 'keyword', - _meta: { description: 'The version of Kibana', optional: true }, - }, - memory_js_heap_size_limit: { - type: 'long', - _meta: { description: 'The maximum size of the heap', optional: true }, - }, - memory_js_heap_size_total: { - type: 'long', - _meta: { description: 'The total size of the heap', optional: true }, - }, - memory_js_heap_size_used: { - type: 'long', - _meta: { description: 'The used size of the heap', optional: true }, - }, - }, - }); - - // Get performance information from the browser (non standard property - // @ts-expect-error 2339 - const memory = window.performance.memory; - let memoryInfo = {}; - if (memory) { - memoryInfo = { - memory_js_heap_size_limit: memory.jsHeapSizeLimit, - memory_js_heap_size_total: memory.totalJSHeapSize, - memory_js_heap_size_used: memory.usedJSHeapSize, - }; - } - - analytics.reportEvent('Loaded Kibana', { - kibana_version: this.initializerContext.env.packageInfo.version, - ...memoryInfo, - }); } private async setupChat({ http, security }: SetupChatDeps) { @@ -438,32 +315,6 @@ export class CloudPlugin implements Plugin { } } -/** @internal exported for testing */ -export const loadUserId = async ({ - getCurrentUser, -}: { - getCurrentUser: () => Promise; -}) => { - try { - const currentUser = await getCurrentUser().catch(() => undefined); - if (!currentUser) { - return undefined; - } - - // Log very defensively here so we can debug this easily if it breaks - if (!currentUser.username) { - // eslint-disable-next-line no-console - console.debug( - `[cloud.analytics] username not specified. User metadata: ${JSON.stringify( - currentUser.metadata - )}` - ); - } - - return currentUser.username; - } catch (e) { - // eslint-disable-next-line no-console - console.error(`[cloud.analytics] Error loading the current user: ${e.toString()}`, e); - return undefined; - } -}; +function sha256(str: string) { + return new Sha256().update(str, 'utf8').digest('hex'); +} diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 284d37804be21..2cbb41531ecf5 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -8,6 +8,7 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import { CloudConfigType } from './config'; import { registerCloudUsageCollector } from './collectors'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; @@ -35,7 +36,7 @@ export interface CloudSetup { export class CloudPlugin implements Plugin { private readonly logger: Logger; private readonly config: CloudConfigType; - private isDev: boolean; + private readonly isDev: boolean; constructor(private readonly context: PluginInitializerContext) { this.logger = this.context.logger.get(); @@ -46,6 +47,7 @@ export class CloudPlugin implements Plugin { public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup): CloudSetup { this.logger.debug('Setting up Cloud plugin'); const isCloudEnabled = getIsCloudEnabled(this.config.id); + registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id); registerCloudUsageCollector(usageCollection, { isCloudEnabled }); if (this.config.full_story.enabled) { diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.scss b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.scss index 6f274921d5ebf..6b0624fae2757 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.scss +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.scss @@ -3,6 +3,10 @@ padding: $euiSizeS; } +.dvSearchPanel__container { + align-items: baseline; +} + @include euiBreakpoint('xs', 's', 'm', 'l') { .dvSearchPanel__container { flex-direction: column; @@ -13,8 +17,4 @@ .dvSearchPanel__controls { padding: 0; } - // prevent margin -16 which scrunches the filter bar - .globalFilterGroup__wrapper-isVisible { - margin: 0 !important; - } } diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx index e7ac50c906660..7d218d98afa39 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx @@ -120,7 +120,6 @@ export const SearchPanel: FC = ({ return ( { // pick the button component out of the tree because // it's part of a popover and thus not covered by enzyme - ( - instance.find(QueryStringInput).prop('prepend') as ReactElement - ).props.children.props.onClick(); + instance.find('[data-test-subj="graphDatasourceButton"]').first().simulate('click'); expect(openSourceModal).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/graph/public/components/search_bar.tsx b/x-pack/plugins/graph/public/components/search_bar.tsx index 762a2e87d2a5a..046ed05977c79 100644 --- a/x-pack/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiToolTip } from '@elastic/eui'; import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; @@ -110,6 +110,47 @@ export function SearchBarComponent(props: SearchBarStateProps & SearchBarProps) }} > + + + { + confirmWipeWorkspace( + () => + openSourceModal({ overlays, savedObjects, uiSettings }, onIndexPatternSelected), + i18n.translate('xpack.graph.clearWorkspace.confirmText', { + defaultMessage: + 'If you change data sources, your current fields and vertices will be reset.', + }), + { + confirmButtonText: i18n.translate( + 'xpack.graph.clearWorkspace.confirmButtonLabel', + { + defaultMessage: 'Change data source', + } + ), + title: i18n.translate('xpack.graph.clearWorkspace.modalTitle', { + defaultMessage: 'Unsaved changes', + }), + } + ); + }} + > + {currentIndexPattern + ? currentIndexPattern.title + : // This branch will be shown if the user exits the + // initial picker modal + i18n.translate('xpack.graph.bar.pickSourceLabel', { + defaultMessage: 'Select a data source', + })} + + + - { - confirmWipeWorkspace( - () => - openSourceModal( - { overlays, savedObjects, uiSettings }, - onIndexPatternSelected - ), - i18n.translate('xpack.graph.clearWorkspace.confirmText', { - defaultMessage: - 'If you change data sources, your current fields and vertices will be reset.', - }), - { - confirmButtonText: i18n.translate( - 'xpack.graph.clearWorkspace.confirmButtonLabel', - { - defaultMessage: 'Change data source', - } - ), - title: i18n.translate('xpack.graph.clearWorkspace.modalTitle', { - defaultMessage: 'Unsaved changes', - }), - } - ); - }} - > - {currentIndexPattern - ? currentIndexPattern.title - : // This branch will be shown if the user exits the - // initial picker modal - i18n.translate('xpack.graph.bar.pickSourceLabel', { - defaultMessage: 'Select a data source', - })} - - - } onChange={setQuery} /> diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 8ed33fb304525..e5a55322a2f10 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -20,6 +20,7 @@ "share", "presentationUtil", "dataViewFieldEditor", + "dataViewEditor", "expressionGauge", "expressionHeatmap", "eventAnnotation", diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 99684a8b983c7..58ecce5592937 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -10,10 +10,6 @@ flex-direction: column; height: 100%; overflow: hidden; - - > .kbnTopNavMenu__wrapper { - border-bottom: $euiBorderThin; - } } .lnsApp__frame { diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index bfad8dcd3f0ee..6e8cc4315ad8b 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -380,6 +380,75 @@ describe('Lens App', () => { }); }); + describe('TopNavMenu#dataViewPickerProps', () => { + it('calls the nav component with the correct dataview picker props if no permissions are given', async () => { + const { instance, lensStore } = await mountWith({ preloadedState: {} }); + const document = { + savedObjectId: defaultSavedObjectId, + state: { + query: 'fake query', + filters: [{ query: { match_phrase: { src: 'test' } } }], + }, + references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], + } as unknown as Document; + + act(() => { + lensStore.dispatch( + setState({ + query: 'fake query' as unknown as Query, + persistedDoc: document, + }) + ); + }); + instance.update(); + const props = instance + .find('[data-test-subj="lnsApp_topNav"]') + .prop('dataViewPickerComponentProps') as TopNavMenuData[]; + expect(props).toEqual( + expect.objectContaining({ + currentDataViewId: 'mockip', + onChangeDataView: expect.any(Function), + onDataViewCreated: expect.any(Function), + onAddField: undefined, + }) + ); + }); + + it('calls the nav component with the correct dataview picker props if permissions are given', async () => { + const { instance, lensStore, services } = await mountWith({ preloadedState: {} }); + services.dataViewFieldEditor.userPermissions.editIndexPattern = () => true; + const document = { + savedObjectId: defaultSavedObjectId, + state: { + query: 'fake query', + filters: [{ query: { match_phrase: { src: 'test' } } }], + }, + references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], + } as unknown as Document; + + act(() => { + lensStore.dispatch( + setState({ + query: 'fake query' as unknown as Query, + persistedDoc: document, + }) + ); + }); + instance.update(); + const props = instance + .find('[data-test-subj="lnsApp_topNav"]') + .prop('dataViewPickerComponentProps') as TopNavMenuData[]; + expect(props).toEqual( + expect.objectContaining({ + currentDataViewId: 'mockip', + onChangeDataView: expect.any(Function), + onDataViewCreated: expect.any(Function), + onAddField: expect.any(Function), + }) + ); + }); + }); + describe('persistence', () => { it('passes query and indexPatterns to TopNavMenu', async () => { const { instance, lensStore, services } = await mountWith({ preloadedState: {} }); diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index e532c82b7b3be..4ae1b8860c878 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -7,7 +7,7 @@ import { isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useStore } from 'react-redux'; import { TopNavMenuData } from '@kbn/navigation-plugin/public'; import { downloadMultipleAs } from '@kbn/share-plugin/public'; @@ -16,6 +16,7 @@ import { exporters } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { trackUiEvent } from '../lens_ui_telemetry'; +import type { StateSetter } from '../types'; import { LensAppServices, LensTopNavActions, @@ -29,8 +30,15 @@ import { useLensDispatch, LensAppState, DispatchSetState, + updateDatasourceState, } from '../state_management'; -import { getIndexPatternsObjects, getIndexPatternsIds, getResolvedDateRange } from '../utils'; +import { + getIndexPatternsObjects, + getIndexPatternsIds, + getResolvedDateRange, + handleIndexPatternChange, + refreshIndexPatternsList, +} from '../utils'; import { combineQueryAndFilters, getLayerMetaInfo } from './show_underlying_data'; function getLensTopNavConfig(options: { @@ -222,6 +230,8 @@ export const LensTopNavMenu = ({ attributeService, discover, dashboardFeatureFlag, + dataViewFieldEditor, + dataViewEditor, dataViews, } = useKibana().services; @@ -232,7 +242,11 @@ export const LensTopNavMenu = ({ ); const [indexPatterns, setIndexPatterns] = useState([]); + const [currentIndexPattern, setCurrentIndexPattern] = useState(); const [rejectedIndexPatterns, setRejectedIndexPatterns] = useState([]); + const editPermission = dataViewFieldEditor.userPermissions.editIndexPattern(); + const closeFieldEditor = useRef<() => void | undefined>(); + const closeDataViewEditor = useRef<() => void | undefined>(); const { isSaveable, @@ -293,6 +307,20 @@ export const LensTopNavMenu = ({ dataViews, ]); + useEffect(() => { + if (indexPatterns.length > 0) { + setCurrentIndexPattern(indexPatterns[0]); + } + }, [indexPatterns]); + + useEffect(() => { + return () => { + // Make sure to close the editors when unmounting + closeFieldEditor.current?.(); + closeDataViewEditor.current?.(); + }; + }, []); + const { TopNavMenu } = navigation.ui; const { from, to } = data.query.timefilter.timefilter.getTime(); @@ -576,6 +604,123 @@ export const LensTopNavMenu = ({ }); }, [data.query.filterManager, data.query.queryString, dispatchSetState]); + const setDatasourceState: StateSetter = useMemo(() => { + return (updater) => { + dispatch( + updateDatasourceState({ + updater, + datasourceId: activeDatasourceId!, + clearStagedPreview: true, + }) + ); + }; + }, [activeDatasourceId, dispatch]); + + const refreshFieldList = useCallback(async () => { + if (currentIndexPattern && currentIndexPattern.id) { + refreshIndexPatternsList({ + activeDatasources: Object.keys(datasourceStates).reduce( + (acc, datasourceId) => ({ + ...acc, + [datasourceId]: datasourceMap[datasourceId], + }), + {} + ), + indexPatternId: currentIndexPattern.id, + setDatasourceState, + }); + } + // start a new session so all charts are refreshed + data.search.session.start(); + }, [ + currentIndexPattern, + data.search.session, + datasourceMap, + datasourceStates, + setDatasourceState, + ]); + + const editField = useMemo( + () => + editPermission + ? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => { + if (currentIndexPattern?.id) { + const indexPatternInstance = await data.dataViews.get(currentIndexPattern?.id); + closeFieldEditor.current = dataViewFieldEditor.openEditor({ + ctx: { + dataView: indexPatternInstance, + }, + fieldName, + onSave: async () => { + refreshFieldList(); + }, + }); + } + } + : undefined, + [editPermission, currentIndexPattern?.id, data.dataViews, dataViewFieldEditor, refreshFieldList] + ); + + const addField = useMemo( + () => (editPermission && editField ? () => editField(undefined, 'add') : undefined), + [editField, editPermission] + ); + + const createNewDataView = useCallback(() => { + const dataViewEditPermission = dataViewEditor.userPermissions.editDataView; + if (!dataViewEditPermission) { + return; + } + closeDataViewEditor.current = dataViewEditor.openEditor({ + onSave: async (dataView) => { + if (dataView.id) { + handleIndexPatternChange({ + activeDatasources: Object.keys(datasourceStates).reduce( + (acc, datasourceId) => ({ + ...acc, + [datasourceId]: datasourceMap[datasourceId], + }), + {} + ), + datasourceStates, + indexPatternId: dataView.id, + setDatasourceState, + }); + refreshFieldList(); + } + }, + }); + }, [dataViewEditor, datasourceMap, datasourceStates, refreshFieldList, setDatasourceState]); + + const dataViewPickerProps = { + trigger: { + label: currentIndexPattern?.title || '', + 'data-test-subj': 'lns-dataView-switch-link', + title: currentIndexPattern?.title || '', + }, + currentDataViewId: currentIndexPattern?.id, + onAddField: addField, + onDataViewCreated: createNewDataView, + onChangeDataView: (newIndexPatternId: string) => { + const currentDataView = indexPatterns.find( + (indexPattern) => indexPattern.id === newIndexPatternId + ); + setCurrentIndexPattern(currentDataView); + handleIndexPatternChange({ + activeDatasources: Object.keys(datasourceStates).reduce( + (acc, datasourceId) => ({ + ...acc, + [datasourceId]: datasourceMap[datasourceId], + }), + {} + ), + datasourceStates, + indexPatternId: newIndexPatternId, + setDatasourceState, + }); + }, + }; + return ( ip.isTimeBased()) || Boolean( @@ -607,6 +753,7 @@ export const LensTopNavMenu = ({ data-test-subj="lnsApp_topNav" screenTitle={'lens'} appName={'lens'} + displayStyle="detached" /> ); }; diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index f7d865e92853e..6ddd49a7e5df0 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -84,6 +84,8 @@ export async function getLensServices( notifications: coreStart.notifications, savedObjectsClient: coreStart.savedObjects.client, presentationUtil: startDependencies.presentationUtil, + dataViewEditor: startDependencies.dataViewEditor, + dataViewFieldEditor: startDependencies.dataViewFieldEditor, dashboard: startDependencies.dashboard, getOriginatingAppName: () => { return embeddableEditorIncomingState?.originatingApp diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 94507754b893f..abb6cfa6a06a6 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -30,6 +30,8 @@ import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public' import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { DashboardFeatureFlagConfig } from '@kbn/dashboard-plugin/public'; import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; +import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { VisualizeFieldContext, ACTION_VISUALIZE_LENS_FIELD } from '@kbn/ui-actions-plugin/public'; import { ACTION_CONVERT_TO_LENS } from '@kbn/visualizations-plugin/public'; import type { EmbeddableEditorState, EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public'; @@ -140,6 +142,8 @@ export interface LensAppServices { // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag: DashboardFeatureFlagConfig; + dataViewEditor: DataViewEditorStart; + dataViewFieldEditor: IndexPatternFieldEditorStart; } export interface LensTopNavTooltips { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx index 1baad07b2198c..ae087221fd49a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx @@ -7,8 +7,9 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; +import { EuiPopover, EuiPopoverTitle, EuiSelectableProps } from '@elastic/eui'; import { ToolbarButton, ToolbarButtonProps } from '@kbn/kibana-react-plugin/public'; +import { DataViewsList } from '@kbn/unified-search-plugin/public'; import { IndexPatternRef } from './types'; import { trackUiEvent } from '../lens_ui_telemetry'; @@ -67,50 +68,26 @@ export function ChangeIndexPattern({ isOpen={isPopoverOpen} closePopover={() => setPopoverIsOpen(false)} display="block" - panelPaddingSize="s" + panelPaddingSize="none" ownFocus >
- + {i18n.translate('xpack.lens.indexPattern.changeDataViewTitle', { defaultMessage: 'Data view', })} - - {...selectableProps} - searchable - singleSelection="always" - options={indexPatternRefs.map(({ title, id }) => ({ - key: id, - label: title, - value: id, - checked: id === indexPatternId ? 'on' : undefined, - }))} - onChange={(choices) => { - const choice = choices.find(({ checked }) => checked) as unknown as { - value: string; - }; + + { trackUiEvent('indexpattern_changed'); - onChangeIndexPattern(choice.value); + onChangeIndexPattern(newId); setPopoverIsOpen(false); }} - searchProps={{ - compressed: true, - ...(selectableProps ? selectableProps.searchProps : undefined), - }} - > - {(list, search) => ( - <> - {search} - {list} - - )} - + currentDataViewId={indexPatternId} + selectableProps={selectableProps} + />
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 512ef627c9116..9aaaf9c128a11 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { waitFor } from '@testing-library/react'; import ReactDOM from 'react-dom'; import { createMockedDragDropContext } from './mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; @@ -19,7 +18,6 @@ import { act } from 'react-dom/test-utils'; import { coreMock } from '@kbn/core/public/mocks'; import { IndexPatternPrivateState } from './types'; import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; -import { ChangeIndexPattern } from './change_indexpattern'; import { EuiProgress, EuiLoadingSpinner } from '@elastic/eui'; import { documentField } from './document_field'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; @@ -328,14 +326,6 @@ describe('IndexPattern Data Panel', () => { expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); }); - it('should call setState when the index pattern is switched', async () => { - const wrapper = shallowWithIntl(); - - wrapper.find(ChangeIndexPattern).prop('onChangeIndexPattern')('2'); - - expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('2'); - }); - describe('loading existence data', () => { function testProps() { const setState = jest.fn(); @@ -853,90 +843,5 @@ describe('IndexPattern Data Panel', () => { 'memory', ]); }); - describe('edit field list', () => { - beforeEach(() => { - props.indexPatternFieldEditor.userPermissions.editIndexPattern = () => true; - }); - it('should call field editor plugin on clicking add button', async () => { - const mockIndexPattern = {}; - (props.dataViews.get as jest.Mock).mockImplementation(() => - Promise.resolve(mockIndexPattern) - ); - const wrapper = mountWithIntl(); - act(() => { - const popoverTrigger = wrapper.find( - '[data-test-subj="lnsIndexPatternActions-popover"] button' - ); - popoverTrigger.simulate('click'); - }); - - wrapper.update(); - act(() => { - wrapper.find('[data-test-subj="indexPattern-add-field"]').first().simulate('click'); - }); - // wait for indx pattern to be loaded - await waitFor(() => { - expect(props.indexPatternFieldEditor.openEditor).toHaveBeenCalledWith( - expect.objectContaining({ - ctx: expect.objectContaining({ - dataView: mockIndexPattern, - }), - }) - ); - }); - }); - - it('should reload index pattern if callback gets called', async () => { - const mockIndexPattern = { - id: '1', - fields: [ - { - name: 'fieldOne', - aggregatable: true, - }, - ], - metaFields: [], - }; - (props.dataViews.get as jest.Mock).mockImplementation(() => - Promise.resolve(mockIndexPattern) - ); - const wrapper = mountWithIntl(); - - act(() => { - const popoverTrigger = wrapper.find( - '[data-test-subj="lnsIndexPatternActions-popover"] button' - ); - popoverTrigger.simulate('click'); - }); - - wrapper.update(); - act(() => { - wrapper.find('[data-test-subj="indexPattern-add-field"]').first().simulate('click'); - }); - - // wait for indx pattern to be loaded - await act(async () => await new Promise((r) => setTimeout(r, 0))); - - await (props.indexPatternFieldEditor.openEditor as jest.Mock).mock.calls[0][0].onSave(); - // wait for indx pattern to be loaded - await act(async () => await new Promise((r) => setTimeout(r, 0))); - expect(props.onUpdateIndexPattern).toHaveBeenCalledWith( - expect.objectContaining({ - fields: [ - expect.objectContaining({ - name: 'fieldOne', - }), - expect.anything(), - ], - }) - ); - }); - - it('should not render add button without permissions', () => { - props.indexPatternFieldEditor.userPermissions.editIndexPattern = () => false; - const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="indexPattern-add-field"]').exists()).toBe(false); - }); - }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index ab437b9328e7e..d4cdca9a4c7fa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -20,7 +20,6 @@ import { EuiFilterGroup, EuiFilterButton, EuiScreenReaderOnly, - EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { EsQueryConfig, Query, Filter } from '@kbn/es-query'; @@ -47,6 +46,8 @@ import { trackUiEvent } from '../lens_ui_telemetry'; import { loadIndexPatterns, syncExistingFields } from './loader'; import { fieldExists } from './pure_helpers'; import { Loader } from '../loader'; +import { LensFieldIcon } from './lens_field_icon'; +import { FieldGroups, FieldList } from './field_list'; export type Props = Omit, 'core'> & { data: DataPublicPluginStart; @@ -61,9 +62,6 @@ export type Props = Omit, 'co core: CoreStart; indexPatternFieldEditor: IndexPatternFieldEditorStart; }; -import { LensFieldIcon } from './lens_field_icon'; -import { ChangeIndexPattern } from './change_indexpattern'; -import { FieldGroups, FieldList } from './field_list'; function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { return fieldA.displayName.localeCompare(fieldB.displayName, undefined, { sensitivity: 'base' }); @@ -573,11 +571,6 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ [currentIndexPattern.id, dataViews, editPermission, indexPatternFieldEditor, refreshFieldList] ); - const addField = useMemo( - () => (editPermission && editField ? () => editField(undefined, 'add') : undefined), - [editField, editPermission] - ); - const fieldProps = useMemo( () => ({ core, @@ -603,8 +596,6 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ] ); - const [popoverOpen, setPopoverOpen] = useState(false); - return ( - - - - { - onChangeIndexPattern(newId); - clearLocalState(); - }} - /> - - {addField && ( - - { - setPopoverOpen(false); - }} - ownFocus - data-test-subj="lnsIndexPatternActions-popover" - button={ - { - setPopoverOpen(!popoverOpen); - }} - /> - } - > - { - setPopoverOpen(false); - addField(); - }} - > - {i18n.translate('xpack.lens.indexPatterns.addFieldButton', { - defaultMessage: 'Add field to data view', - })} - , - { - setPopoverOpen(false); - core.application.navigateToApp('management', { - path: `/kibana/indexPatterns/patterns/${currentIndexPattern.id}`, - }); - }} - > - {i18n.translate('xpack.lens.indexPatterns.manageFieldButton', { - defaultMessage: 'Manage data view fields', - })} - , - ]} - /> - - - )} - - { + handleChangeIndexPattern(indexPatternId, state, setState); + }, + + refreshIndexPatternsList: async ({ indexPatternId, setState }) => { + const newlyMappedIndexPattern = await loadIndexPatterns({ + indexPatternsService: dataViews, + cache: {}, + patterns: [indexPatternId], + }); + const indexPatternRefs = await dataViews.getIdsWithTitle(); + const indexPattern = newlyMappedIndexPattern[indexPatternId]; + setState((s) => { + return { + ...s, + indexPatterns: { + ...s.indexPatterns, + [indexPattern.id]: indexPattern, + }, + indexPatternRefs, + }; + }); + }, + // Reset the temporary invalid state when closing the editor, but don't // update the state if it's not needed updateStateOnCloseDimension: ({ state, layerId }) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 91b9de58bdaa1..dba57f2fcb03e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -11,6 +11,7 @@ import { IndexPatternLayerPanelProps, LayerPanel } from './layerpanel'; import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; import { ShallowWrapper } from 'enzyme'; import { EuiSelectable } from '@elastic/eui'; +import { DataViewsList } from '@kbn/unified-search-plugin/public'; import { ChangeIndexPattern } from './change_indexpattern'; import { getFieldByNameFactory } from './pure_helpers'; import { TermsIndexPatternColumn } from './operations'; @@ -212,7 +213,14 @@ describe('Layer Data Panel', () => { }); function getIndexPatternPickerList(instance: ShallowWrapper) { - return instance.find(ChangeIndexPattern).first().dive().find(EuiSelectable); + return instance + .find(ChangeIndexPattern) + .first() + .dive() + .find(DataViewsList) + .first() + .dive() + .find(EuiSelectable); } function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx index f8548321e49bd..efa1ef509b12d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx @@ -22,7 +22,6 @@ export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatter const layer = state.layers[layerId]; const indexPattern = state.indexPatterns[layer.indexPatternId]; - const notFoundTitleLabel = i18n.translate('xpack.lens.layerPanel.missingDataView', { defaultMessage: 'Data view not found', }); diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index c30b39476b1ab..cf25828d3322c 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -36,6 +36,7 @@ export function createMockDatasource(id: string): DatasourceMock { initialize: jest.fn((_state?) => Promise.resolve()), renderDataPanel: jest.fn(), renderLayerPanel: jest.fn(), + getCurrentIndexPatternId: jest.fn(), toExpression: jest.fn((_frame, _state) => null), insertLayer: jest.fn((_state, _newLayerId) => ({})), removeLayer: jest.fn((_state, _layerId) => {}), diff --git a/x-pack/plugins/lens/public/mocks/services_mock.tsx b/x-pack/plugins/lens/public/mocks/services_mock.tsx index f5e94d374481a..800ec3dee25b1 100644 --- a/x-pack/plugins/lens/public/mocks/services_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/services_mock.tsx @@ -10,6 +10,8 @@ import { Subject } from 'rxjs'; import { coreMock } from '@kbn/core/public/mocks'; import { navigationPluginMock } from '@kbn/navigation-plugin/public/mocks'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; +import { indexPatternFieldEditorPluginMock } from '@kbn/data-view-field-editor-plugin/public/mocks'; +import { indexPatternEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks'; @@ -155,5 +157,7 @@ export function makeDefaultServices( clear: jest.fn(), }, spaces: spacesPluginMock.createStartContract(), + dataViewFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(), + dataViewEditor: indexPatternEditorPluginMock.createStartContract(), }; } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index b39c14cd82454..876cb63b0333d 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -32,6 +32,7 @@ import type { EventAnnotationPluginSetup } from '@kbn/event-annotation-plugin/pu import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; import { EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public'; import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; +import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; import { AppNavLinkStatus } from '@kbn/core/public'; import { @@ -123,6 +124,7 @@ export interface LensPluginStartDependencies { savedObjectsTagging?: SavedObjectTaggingPluginStart; presentationUtil: PresentationUtilPluginStart; dataViewFieldEditor: IndexPatternFieldEditorStart; + dataViewEditor: DataViewEditorStart; inspector: InspectorStartContract; spaces: SpacesPluginStart; usageCollection?: UsageCollectionStart; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 8c6c6d9af22dc..a91240e7e6a3e 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -223,6 +223,7 @@ export interface Datasource { // Given the current state, which parts should be saved? getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] }; + getCurrentIndexPatternId: (state: T) => string; insertLayer: (state: T, newLayerId: string) => T; removeLayer: (state: T, layerId: string) => T; @@ -274,6 +275,14 @@ export interface Datasource { state: T; }) => T | undefined; + updateCurrentIndexPatternId?: (props: { + indexPatternId: string; + state: T; + setState: StateSetter; + }) => void; + + refreshIndexPatternsList?: (props: { indexPatternId: string; setState: StateSetter }) => void; + toExpression: (state: T, layerId: string) => ExpressionAstExpression | string | null; getDatasourceSuggestionsForField: ( diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index b5ada350b2aaa..2a2bd0a35efa1 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -19,6 +19,7 @@ import type { LensBrushEvent, LensFilterEvent, Visualization, + StateSetter, } from './types'; import type { DatasourceStates, VisualizationState } from './state_management'; @@ -63,6 +64,43 @@ export const getInitialDatasourceId = (datasourceMap: DatasourceMap, doc?: Docum return (doc && getActiveDatasourceIdFromDoc(doc)) || Object.keys(datasourceMap)[0] || null; }; +export function handleIndexPatternChange({ + activeDatasources, + datasourceStates, + indexPatternId, + setDatasourceState, +}: { + activeDatasources: Record; + datasourceStates: DatasourceStates; + indexPatternId: string; + setDatasourceState: StateSetter; +}): void { + Object.entries(activeDatasources).forEach(([id, datasource]) => { + datasource?.updateCurrentIndexPatternId?.({ + state: datasourceStates[id].state, + indexPatternId, + setState: setDatasourceState, + }); + }); +} + +export function refreshIndexPatternsList({ + activeDatasources, + indexPatternId, + setDatasourceState, +}: { + activeDatasources: Record; + indexPatternId: string; + setDatasourceState: StateSetter; +}): void { + Object.entries(activeDatasources).forEach(([id, datasource]) => { + datasource?.refreshIndexPatternsList?.({ + indexPatternId, + setState: setDatasourceState, + }); + }); +} + export function getIndexPatternsIds({ activeDatasources, datasourceStates, @@ -70,17 +108,21 @@ export function getIndexPatternsIds({ activeDatasources: Record; datasourceStates: DatasourceStates; }): string[] { + let currentIndexPatternId: string | undefined; const references: SavedObjectReference[] = []; Object.entries(activeDatasources).forEach(([id, datasource]) => { const { savedObjectReferences } = datasource.getPersistableState(datasourceStates[id].state); + const indexPatternId = datasource.getCurrentIndexPatternId(datasourceStates[id].state); + currentIndexPatternId = indexPatternId; references.push(...savedObjectReferences); }); - - const uniqueFilterableIndexPatternIds = uniq( - references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) - ); - - return uniqueFilterableIndexPatternIds; + const referencesIds = references + .filter(({ type }) => type === 'index-pattern') + .map(({ id }) => id); + if (currentIndexPatternId) { + referencesIds.unshift(currentIndexPatternId); + } + return uniq(referencesIds); } export async function getIndexPatternsObjects( diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 380d387249e17..e00581833f621 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -28,13 +28,14 @@ { "path": "../../../src/plugins/saved_objects/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json" }, - { "path": "../../../src/plugins/presentation_util/tsconfig.json" }, - { "path": "../../../src/plugins/field_formats/tsconfig.json" }, - { "path": "../../../src/plugins/chart_expressions/expression_xy/tsconfig.json" }, - { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json" }, - { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json" }, - { "path": "../../../src/plugins/event_annotation/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json"}, + { "path": "../../../src/plugins/presentation_util/tsconfig.json"}, + { "path": "../../../src/plugins/field_formats/tsconfig.json"}, + { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"}, + { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"}, + { "path": "../../../src/plugins/data_view_editor/tsconfig.json"}, + { "path": "../../../src/plugins/event_annotation/tsconfig.json"}, + { "path": "../../../src/plugins/chart_expressions/expression_xy/tsconfig.json"}, { "path": "../../../src/plugins/unified_search/tsconfig.json" } ] } diff --git a/x-pack/plugins/licensing/common/register_analytics_context_provider.test.ts b/x-pack/plugins/licensing/common/register_analytics_context_provider.test.ts new file mode 100644 index 0000000000000..7edccfd319c91 --- /dev/null +++ b/x-pack/plugins/licensing/common/register_analytics_context_provider.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom, ReplaySubject, Subject } from 'rxjs'; +import type { ILicense } from './types'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; + +describe('registerAnalyticsContextProvider', () => { + const analyticsClientMock = { + registerContextProvider: jest.fn(), + }; + + let license$: Subject; + + beforeEach(() => { + jest.clearAllMocks(); + license$ = new ReplaySubject(1); + registerAnalyticsContextProvider(analyticsClientMock, license$); + }); + + test('should register the analytics context provider', () => { + expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(1); + }); + + test('emits a context value the moment license emits', async () => { + license$.next({ + uid: 'uid', + status: 'active', + isActive: true, + type: 'basic', + signature: 'signature', + isAvailable: true, + toJSON: jest.fn(), + getUnavailableReason: jest.fn(), + hasAtLeast: jest.fn(), + check: jest.fn(), + getFeature: jest.fn(), + }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$) + ).resolves.toEqual({ + license_id: 'uid', + license_status: 'active', + license_type: 'basic', + }); + }); +}); diff --git a/x-pack/plugins/licensing/common/register_analytics_context_provider.ts b/x-pack/plugins/licensing/common/register_analytics_context_provider.ts new file mode 100644 index 0000000000000..60f3fbbb3e603 --- /dev/null +++ b/x-pack/plugins/licensing/common/register_analytics_context_provider.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Observable } from 'rxjs'; +import { map } from 'rxjs'; +import type { AnalyticsClient } from '@kbn/analytics-client'; +import type { ILicense } from './types'; + +export function registerAnalyticsContextProvider( + // Using `AnalyticsClient` from the package to be able to implement this method in the `common` dir. + analytics: Pick, + license$: Observable +) { + analytics.registerContextProvider({ + name: 'license info', + context$: license$.pipe( + map((license) => ({ + license_id: license.uid, + license_status: license.status, + license_type: license.type, + })) + ), + schema: { + license_id: { + type: 'keyword', + _meta: { description: 'The license ID', optional: true }, + }, + license_status: { + type: 'keyword', + _meta: { description: 'The license Status (active/invalid/expired)', optional: true }, + }, + license_type: { + type: 'keyword', + _meta: { + description: 'The license Type (basic/standard/gold/platinum/enterprise/trial)', + optional: true, + }, + }, + }, + }); +} diff --git a/x-pack/plugins/licensing/public/plugin.ts b/x-pack/plugins/licensing/public/plugin.ts index 9ef27e22657af..3953a29a08214 100644 --- a/x-pack/plugins/licensing/public/plugin.ts +++ b/x-pack/plugins/licensing/public/plugin.ts @@ -15,6 +15,7 @@ import { License } from '../common/license'; import { mountExpiredBanner } from './expired_banner'; import { FeatureUsageService } from './services'; import type { PublicLicenseJSON } from '../common/types'; +import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider'; export const licensingSessionStorageKey = 'xpack.licensing'; @@ -82,6 +83,8 @@ export class LicensingPlugin implements Plugin { if (license.isAvailable) { this.prevSignature = license.signature; diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 98dd1e7cbbb93..aaeeb4e058008 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -21,6 +21,7 @@ import { IClusterClient, } from '@kbn/core/server'; +import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider'; import { ILicense, PublicLicense, @@ -120,6 +121,8 @@ export class LicensingPlugin implements Plugin { cy.get('[data-test-subj="navigation-hostRisk"]').click(); waitForTableToLoad(); - cy.get('[data-test-subj="topHostScoreContributors"]') + cy.get('[data-test-subj="topRiskScoreContributors"]') .find(TABLE_ROWS) .within(() => { cy.get(TABLE_CELL).contains('Unusual Linux Username'); diff --git a/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts b/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts index 9a691c82be7b8..50c06141c7ba9 100644 --- a/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts +++ b/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const RULE_NAME = '[data-test-subj="topHostScoreContributors"] .euiTableCellContent'; +export const RULE_NAME = '[data-test-subj="topRiskScoreContributors"] .euiTableCellContent'; export const RISK_FLYOUT = '[data-test-subj="open-risk-information-flyout"] .euiFlyoutHeader'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 3c4d434b1ec3f..287281619cb08 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -94,8 +94,8 @@ export const setEnrichmentDates = (from?: string, to?: string) => { export const goToClosedAlerts = () => { cy.get(CLOSED_ALERTS_FILTER_BTN).click(); - cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); - cy.get(REFRESH_BUTTON).should('have.text', 'Refresh'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); + cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); }; @@ -105,13 +105,13 @@ export const goToManageAlertsDetectionRules = () => { export const goToOpenedAlerts = () => { cy.get(OPENED_ALERTS_FILTER_BTN).click({ force: true }); - cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); - cy.get(REFRESH_BUTTON).should('have.text', 'Refresh'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); + cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); }; export const refreshAlerts = () => { // ensure we've refetched fields the first time index is defined - cy.get(REFRESH_BUTTON).should('have.text', 'Refresh'); + cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); cy.get(REFRESH_BUTTON).first().click({ force: true }); }; @@ -127,8 +127,8 @@ export const openAlerts = () => { export const goToAcknowledgedAlerts = () => { cy.get(ACKNOWLEDGED_ALERTS_FILTER_BTN).click(); - cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); - cy.get(REFRESH_BUTTON).should('have.text', 'Refresh'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); + cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); }; @@ -154,7 +154,7 @@ export const investigateFirstAlertInTimeline = () => { }; export const waitForAlerts = () => { - cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); }; export const waitForAlertsPanelToBeLoaded = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/hosts/authentications.ts b/x-pack/plugins/security_solution/cypress/tasks/hosts/authentications.ts index 508e76851f7ff..cf6c6ae467092 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/hosts/authentications.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/hosts/authentications.ts @@ -10,5 +10,5 @@ import { REFRESH_BUTTON } from '../../screens/security_header'; export const waitForAuthenticationsToBeLoaded = () => { cy.get(AUTHENTICATIONS_TABLE).should('exist'); - cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/hosts/uncommon_processes.ts b/x-pack/plugins/security_solution/cypress/tasks/hosts/uncommon_processes.ts index 66f7f0cb9f3b8..67bec8904a849 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/hosts/uncommon_processes.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/hosts/uncommon_processes.ts @@ -10,5 +10,5 @@ import { REFRESH_BUTTON } from '../../screens/security_header'; export const waitForUncommonProcessesToBeLoaded = () => { cy.get(UNCOMMON_PROCESSES_TABLE).should('exist'); - cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_header.ts b/x-pack/plugins/security_solution/cypress/tasks/security_header.ts index 57cf72ed85a6e..a50851fa87c77 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_header.ts @@ -21,5 +21,7 @@ export const navigateFromHeaderTo = (page: string) => { }; export const refreshPage = () => { - cy.get(REFRESH_BUTTON).click({ force: true }).should('not.have.text', 'Updating'); + cy.get(REFRESH_BUTTON) + .click({ force: true }) + .should('not.have.attr', 'aria-label', 'Needs updating'); }; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index edfd46d167368..e9735b8c0b903 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -260,7 +260,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ { id: SecurityPageName.hostsRisk, title: i18n.translate('xpack.securitySolution.search.hosts.risk', { - defaultMessage: 'Hosts by risk', + defaultMessage: 'Host risk', }), path: `${HOSTS_PATH}/hostRisk`, experimentalKey: 'riskyHostsEnabled', @@ -355,7 +355,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ { id: SecurityPageName.usersRisk, title: i18n.translate('xpack.securitySolution.search.users.risk', { - defaultMessage: 'Users by risk', + defaultMessage: 'User risk', }), path: `${USERS_PATH}/userRisk`, experimentalKey: 'riskyUsersEnabled', diff --git a/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap index 51326d54a6161..4dd14f56997eb 100644 --- a/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/filters_global/__snapshots__/filters_global.test.tsx.snap @@ -7,7 +7,7 @@ exports[`rendering renders correctly 1`] = ` (({ children, show = return ( - + {children} diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index fb244c40d6e3d..0dd0bba916cc9 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -38,9 +38,10 @@ import { isUrlInvalid } from '../../utils/validators'; import * as i18n from './translations'; import { SecurityPageName } from '../../../app/types'; -import { getUsersDetailsUrl } from '../link_to/redirect_to_users'; +import { getTabsOnUsersDetailsUrl, getUsersDetailsUrl } from '../link_to/redirect_to_users'; import { LinkAnchor, GenericLinkButton, PortContainer, Comma, LinkButton } from './helpers'; import { HostsTableType } from '../../../hosts/store/model'; +import { UsersTableType } from '../../../users/store/model'; export { LinkButton, LinkAnchor } from './helpers'; @@ -52,10 +53,11 @@ const UserDetailsLinkComponent: React.FC<{ /** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */ Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; userName: string; + userTab?: UsersTableType; title?: string; isButton?: boolean; onClick?: (e: SyntheticEvent) => void; -}> = ({ children, Component, userName, isButton, onClick, title }) => { +}> = ({ children, Component, userName, isButton, onClick, title, userTab }) => { const encodedUserName = encodeURIComponent(userName); const { formatUrl, search } = useFormatUrl(SecurityPageName.users); @@ -65,17 +67,29 @@ const UserDetailsLinkComponent: React.FC<{ ev.preventDefault(); navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.users, - path: getUsersDetailsUrl(encodedUserName, search), + path: userTab + ? getTabsOnUsersDetailsUrl(encodedUserName, userTab, search) + : getUsersDetailsUrl(encodedUserName, search), }); }, - [encodedUserName, navigateToApp, search] + [encodedUserName, navigateToApp, search, userTab] + ); + + const href = useMemo( + () => + formatUrl( + userTab + ? getTabsOnUsersDetailsUrl(encodedUserName, userTab) + : getUsersDetailsUrl(encodedUserName) + ), + [formatUrl, encodedUserName, userTab] ); return isButton ? ( @@ -85,7 +99,7 @@ const UserDetailsLinkComponent: React.FC<{ {children ? children : userName} diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx index 2d38f72b338ee..1620a142c15cb 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx @@ -87,6 +87,7 @@ describe('QueryBar ', () => { dataTestSubj: undefined, dateRangeFrom: 'now/d', dateRangeTo: 'now/d', + displayStyle: undefined, filters: [], indexPatterns: [ { @@ -205,6 +206,7 @@ describe('QueryBar ', () => { showQueryBar: true, showQueryInput: true, showSaveQuery: true, + showSubmitButton: false, }); }); @@ -304,7 +306,7 @@ describe('QueryBar ', () => { }); describe('SavedQueryManagementComponent state', () => { - test('popover should hidden when "Save current query" button was clicked', async () => { + test('popover should remain open when "Save current query" button was clicked', async () => { const wrapper = await getWrapper( { /> ); const isSavedQueryPopoverOpen = () => - wrapper.find('EuiPopover[id="savedQueryPopover"]').prop('isOpen'); + wrapper.find('EuiPopover[data-test-subj="queryBarMenuPopover"]').prop('isOpen'); expect(isSavedQueryPopoverOpen()).toBeFalsy(); - wrapper - .find('button[data-test-subj="saved-query-management-popover-button"]') - .simulate('click'); + wrapper.find('button[data-test-subj="showQueryBarMenu"]').simulate('click'); await waitFor(() => { expect(isSavedQueryPopoverOpen()).toBeTruthy(); @@ -338,7 +338,7 @@ describe('QueryBar ', () => { wrapper.find('button[data-test-subj="saved-query-management-save-button"]').simulate('click'); await waitFor(() => { - expect(isSavedQueryPopoverOpen()).toBeFalsy(); + expect(isSavedQueryPopoverOpen()).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx index 15fd5927b7f75..fe8d50d6fab2e 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx @@ -17,7 +17,7 @@ import { SavedQueryTimeFilter, } from '@kbn/data-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; -import { SearchBar } from '@kbn/unified-search-plugin/public'; +import { SearchBar, SearchBarProps } from '@kbn/unified-search-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; export interface QueryBarComponentProps { @@ -36,6 +36,7 @@ export interface QueryBarComponentProps { refreshInterval?: number; savedQuery?: SavedQuery; onSavedQuery: (savedQuery: SavedQuery | undefined) => void; + displayStyle?: SearchBarProps['displayStyle']; } export const QueryBar = memo( @@ -55,6 +56,7 @@ export const QueryBar = memo( savedQuery, onSavedQuery, dataTestSubj, + displayStyle, }) => { const onQuerySubmit = useCallback( (payload: { dateRange: TimeRange; query?: Query }) => { @@ -102,12 +104,11 @@ export const QueryBar = memo( [filterManager] ); - const CustomButton = <>{null}; const indexPatterns = useMemo(() => [indexPattern], [indexPattern]); return ( ( timeHistory={new TimeHistory(new Storage(localStorage))} dataTestSubj={dataTestSubj} savedQuery={savedQuery} + displayStyle={displayStyle} /> ); } diff --git a/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/index.test.tsx new file mode 100644 index 0000000000000..e07ff93b98c31 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/index.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { RiskScoreOverTime, scoreFormatter } from '.'; +import { TestProviders } from '../../mock'; +import { LineSeries } from '@elastic/charts'; + +const mockLineSeries = LineSeries as jest.Mock; + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + return { + ...original, + LineSeries: jest.fn().mockImplementation(() => <>), + }; +}); + +describe('Risk Score Over Time', () => { + it('renders', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('RiskScoreOverTime')).toBeInTheDocument(); + }); + + it('renders loader when loading', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('RiskScoreOverTime-loading')).toBeInTheDocument(); + }); + + describe('scoreFormatter', () => { + it('renders score formatted', () => { + render( + + + + ); + + const tickFormat = mockLineSeries.mock.calls[0][0].tickFormat; + + expect(tickFormat).toBe(scoreFormatter); + }); + + it('renders a formatted score', () => { + expect(scoreFormatter(3.000001)).toEqual('3'); + expect(scoreFormatter(3.4999)).toEqual('3'); + expect(scoreFormatter(3.51111)).toEqual('4'); + expect(scoreFormatter(3.9999)).toEqual('4'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/index.tsx b/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/index.tsx new file mode 100644 index 0000000000000..a7a2dc676abc5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/index.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { + Chart, + LineSeries, + ScaleType, + Settings, + Axis, + Position, + AnnotationDomainType, + LineAnnotation, + TooltipValue, +} from '@elastic/charts'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart, EuiText, EuiPanel } from '@elastic/eui'; +import styled from 'styled-components'; +import { chartDefaultSettings, useTheme } from '../charts/common'; +import { useTimeZone } from '../../lib/kibana'; +import { histogramDateTimeFormatter } from '../utils'; +import { HeaderSection } from '../header_section'; +import { InspectButton, InspectButtonContainer } from '../inspect'; +import * as i18n from './translations'; +import { PreferenceFormattedDate } from '../formatted_date'; +import { RiskScore } from '../../../../common/search_strategy'; + +export interface RiskScoreOverTimeProps { + from: string; + to: string; + loading: boolean; + riskScore?: RiskScore[]; + queryId: string; + title: string; + toggleStatus: boolean; + toggleQuery?: (status: boolean) => void; +} + +const RISKY_THRESHOLD = 70; +const DEFAULT_CHART_HEIGHT = 250; + +const StyledEuiText = styled(EuiText)` + font-size: 9px; + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; + margin-right: ${({ theme }) => theme.eui.paddingSizes.xs}; +`; + +const LoadingChart = styled(EuiLoadingChart)` + display: block; + text-align: center; +`; + +export const scoreFormatter = (d: number) => Math.round(d).toString(); + +const RiskScoreOverTimeComponent: React.FC = ({ + from, + to, + riskScore, + loading, + queryId, + title, + toggleStatus, + toggleQuery, +}) => { + const timeZone = useTimeZone(); + + const dataTimeFormatter = useMemo(() => histogramDateTimeFormatter([from, to]), [from, to]); + const headerFormatter = useCallback( + (tooltip: TooltipValue) => , + [] + ); + + const theme = useTheme(); + + const graphData = useMemo( + () => + riskScore + ?.map((data) => ({ + x: data['@timestamp'], + y: data.risk_stats.risk_score, + })) + .reverse() ?? [], + [riskScore] + ); + + return ( + + + + + + + {toggleStatus && ( + + + + )} + + + {toggleStatus && ( + + +
+ {loading ? ( + + ) : ( + + + + + + + {i18n.RISKY} + + } + /> + + )} +
+
+
+ )} +
+
+ ); +}; + +RiskScoreOverTimeComponent.displayName = 'RiskScoreOverTimeComponent'; +export const RiskScoreOverTime = React.memo(RiskScoreOverTimeComponent); +RiskScoreOverTime.displayName = 'RiskScoreOverTime'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/translations.ts b/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/translations.ts similarity index 75% rename from x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/translations.ts rename to x-pack/plugins/security_solution/public/common/components/risk_score_over_time/translations.ts index 5e1b4ca7410a8..a3d32f5e5d59f 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/risk_score_over_time/translations.ts @@ -7,14 +7,7 @@ import { i18n } from '@kbn/i18n'; -export const HOST_RISK_SCORE_OVER_TIME = i18n.translate( - 'xpack.securitySolution.hosts.hostScoreOverTime.title', - { - defaultMessage: 'Host risk score over time', - } -); - -export const HOST_RISK_THRESHOLD = i18n.translate( +export const RISK_THRESHOLD = i18n.translate( 'xpack.securitySolution.hosts.hostScoreOverTime.riskyThresholdHeader', { defaultMessage: 'Risky threshold', diff --git a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx index d1d5c2fcebc12..d5e9fba36361a 100644 --- a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx @@ -11,7 +11,6 @@ import React, { memo, useEffect, useCallback, useMemo } from 'react'; import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { Subscription } from 'rxjs'; -import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import type { DataViewBase, Filter, Query } from '@kbn/es-query'; @@ -53,12 +52,6 @@ interface SiemSearchBarProps { hideQueryInput?: boolean; } -const SearchBarContainer = styled.div` - .globalQueryBar { - padding: 0px; - } -`; - export const SearchBarComponent = memo( ({ end, @@ -322,7 +315,7 @@ export const SearchBarComponent = memo( const indexPatterns = useMemo(() => [indexPattern], [indexPattern]); return ( - +
( showSaveQuery={true} dataTestSubj={dataTestSubj} /> - +
); }, (prevProps, nextProps) => diff --git a/x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/index.test.tsx new file mode 100644 index 0000000000000..4cc6812772b81 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/index.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { TopRiskScoreContributors } from '.'; +import { TestProviders } from '../../mock'; +import { RuleRisk } from '../../../../common/search_strategy'; + +jest.mock('../../containers/query_toggle'); +jest.mock('../../../risk_score/containers'); + +const testProps = { + riskScore: [], + setQuery: jest.fn(), + deleteQuery: jest.fn(), + hostName: 'test-host-name', + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', + loading: false, + toggleStatus: true, + queryId: 'test-query-id', +}; + +describe('Top Risk Score Contributors', () => { + it('renders', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('topRiskScoreContributors')).toBeInTheDocument(); + }); + + it('renders sorted items', () => { + const ruleRisk: RuleRisk[] = [ + { + rule_name: 'third', + rule_risk: 10, + rule_id: '3', + }, + { + rule_name: 'first', + rule_risk: 99, + rule_id: '1', + }, + { + rule_name: 'second', + rule_risk: 55, + rule_id: '2', + }, + ]; + + const { queryAllByRole } = render( + + + + ); + + expect(queryAllByRole('row')[1]).toHaveTextContent('first'); + expect(queryAllByRole('row')[2]).toHaveTextContent('second'); + expect(queryAllByRole('row')[3]).toHaveTextContent('third'); + }); + + describe('toggleStatus', () => { + test('toggleStatus=true, render components', () => { + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topRiskScoreContributors-table')).toBeTruthy(); + }); + + test('toggleStatus=false, do not render components', () => { + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topRiskScoreContributors-table')).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/index.tsx b/x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/index.tsx new file mode 100644 index 0000000000000..7ee2ae5e21413 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/index.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiInMemoryTable, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; + +import { HeaderSection } from '../header_section'; +import { InspectButton, InspectButtonContainer } from '../inspect'; +import * as i18n from './translations'; + +import { RuleRisk } from '../../../../common/search_strategy'; + +import { RuleLink } from '../../../detections/pages/detection_engine/rules/all/use_columns'; + +export interface TopRiskScoreContributorsProps { + loading: boolean; + rules?: RuleRisk[]; + queryId: string; + toggleStatus: boolean; + toggleQuery?: (status: boolean) => void; +} +interface TableItem { + rank: number; + name: string; + id: string; +} + +const columns: Array> = [ + { + name: i18n.RANK_TITLE, + field: 'rank', + width: '45px', + align: 'right', + }, + { + name: i18n.RULE_NAME_TITLE, + field: 'name', + sortable: true, + truncateText: true, + render: (value: TableItem['name'], { id }: TableItem) => + id ? : value, + }, +]; + +const PAGE_SIZE = 5; + +const TopRiskScoreContributorsComponent: React.FC = ({ + rules = [], + loading, + queryId, + toggleStatus, + toggleQuery, +}) => { + const items = useMemo(() => { + return rules + ?.sort((a, b) => b.rule_risk - a.rule_risk) + .map(({ rule_name: name, rule_id: id }, i) => ({ rank: i + 1, name, id })); + }, [rules]); + + const tablePagination = useMemo( + () => ({ + showPerPageOptions: false, + pageSize: PAGE_SIZE, + totalItemCount: items.length, + }), + [items.length] + ); + + return ( + + + + + + + {toggleStatus && ( + + + + )} + + + {toggleStatus && ( + + + + + + )} + + + ); +}; + +export const TopRiskScoreContributors = React.memo(TopRiskScoreContributorsComponent); +TopRiskScoreContributors.displayName = 'TopRiskScoreContributors'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/translations.ts b/x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/translations.ts rename to x-pack/plugins/security_solution/public/common/components/top_risk_score_contributors/translations.ts diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx index 2ce403a832906..5c8d2643eb445 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx @@ -46,23 +46,7 @@ interface QueryBarDefineRuleProps { const actionTimelineToHide: ActionTimelineToShow[] = ['duplicate', 'createFrom']; -const StyledEuiFormRow = styled(EuiFormRow)` - .kbnTypeahead__items { - max-height: 45vh !important; - } - .globalQueryBar { - padding: 4px 0px 0px 0px; - .kbnQueryBar { - & > div:first-child { - margin: 0px 0px 0px 4px; - } - &__wrap, - &__textarea { - z-index: 0; - } - } - } -`; +const StyledEuiFormRow = styled(EuiFormRow)``; // TODO need to add disabled in the SearchBar @@ -283,6 +267,7 @@ export const QueryBarDefineRule = ({ savedQuery={savedQuery} onSavedQuery={onSavedQuery} hideSavedQuery={false} + displayStyle="inPage" />
)} diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx deleted file mode 100644 index a96ffb577d90c..0000000000000 --- a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx +++ /dev/null @@ -1,53 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { render } from '@testing-library/react'; -import React from 'react'; -import { HostRiskScoreOverTime } from '.'; -import { TestProviders } from '../../../common/mock'; -import { useHostRiskScore } from '../../../risk_score/containers'; - -jest.mock('../../../risk_score/containers'); -const useHostRiskScoreMock = useHostRiskScore as jest.Mock; - -describe('Host Risk Flyout', () => { - it('renders', () => { - useHostRiskScoreMock.mockReturnValueOnce([false, { data: [], isModuleEnabled: true }]); - - const { queryByTestId } = render( - - - - ); - - expect(queryByTestId('hostRiskScoreOverTime')).toBeInTheDocument(); - }); - - it('renders loader when HostsRiskScore is laoding', () => { - useHostRiskScoreMock.mockReturnValueOnce([true, { data: [], isModuleEnabled: true }]); - - const { queryByTestId } = render( - - - - ); - - expect(queryByTestId('HostRiskScoreOverTime-loading')).toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx deleted file mode 100644 index 52a840e857fff..0000000000000 --- a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx +++ /dev/null @@ -1,210 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useCallback } from 'react'; -import { - Chart, - LineSeries, - ScaleType, - Settings, - Axis, - Position, - AnnotationDomainType, - LineAnnotation, - TooltipValue, -} from '@elastic/charts'; -import { euiThemeVars } from '@kbn/ui-theme'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart, EuiText, EuiPanel } from '@elastic/eui'; -import styled from 'styled-components'; -import { chartDefaultSettings, useTheme } from '../../../common/components/charts/common'; -import { useTimeZone } from '../../../common/lib/kibana'; -import { histogramDateTimeFormatter } from '../../../common/components/utils'; -import { HeaderSection } from '../../../common/components/header_section'; -import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; -import * as i18n from './translations'; -import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; -import { useQueryInspector } from '../../../common/components/page/manage_query'; -import { HostsComponentsQueryProps } from '../../pages/navigation/types'; -import { buildHostNamesFilter } from '../../../../common/search_strategy/security_solution/risk_score'; -import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; - -export interface HostRiskScoreOverTimeProps - extends Pick { - hostName: string; - from: string; - to: string; -} - -const RISKY_THRESHOLD = 70; -const DEFAULT_CHART_HEIGHT = 250; -const QUERY_ID = HostRiskScoreQueryId.HOST_RISK_SCORE_OVER_TIME; - -const StyledEuiText = styled(EuiText)` - font-size: 9px; - font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; - margin-right: ${({ theme }) => theme.eui.paddingSizes.xs}; -`; - -const LoadingChart = styled(EuiLoadingChart)` - display: block; - text-align: center; -`; - -const HostRiskScoreOverTimeComponent: React.FC = ({ - hostName, - from, - to, - setQuery, - deleteQuery, -}) => { - const timeZone = useTimeZone(); - - const dataTimeFormatter = useMemo(() => histogramDateTimeFormatter([from, to]), [from, to]); - const scoreFormatter = useCallback((d: number) => Math.round(d).toString(), []); - const headerFormatter = useCallback( - (tooltip: TooltipValue) => , - [] - ); - - const timerange = useMemo( - () => ({ - from, - to, - }), - [from, to] - ); - const theme = useTheme(); - - const [loading, { data, refetch, inspect }] = useHostRiskScore({ - filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, - onlyLatest: false, - timerange, - }); - - const graphData = useMemo( - () => - data - ?.map((hostRisk) => ({ - x: hostRisk['@timestamp'], - y: hostRisk.risk_stats.risk_score, - })) - .reverse() ?? [], - [data] - ); - - useQueryInspector({ - queryId: QUERY_ID, - loading, - refetch, - setQuery, - deleteQuery, - inspect, - }); - - return ( - - - - - - - - - - - - - - -
- {loading ? ( - - ) : ( - - - - - - - {i18n.RISKY} - - } - /> - - )} -
-
-
-
-
- ); -}; - -HostRiskScoreOverTimeComponent.displayName = 'HostRiskScoreOverTimeComponent'; -export const HostRiskScoreOverTime = React.memo(HostRiskScoreOverTimeComponent); -HostRiskScoreOverTime.displayName = 'HostRiskScoreOverTime'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx deleted file mode 100644 index 5ff8696ae5be3..0000000000000 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx +++ /dev/null @@ -1,150 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { render, fireEvent } from '@testing-library/react'; -import React from 'react'; -import { TopHostScoreContributors } from '.'; -import { TestProviders } from '../../../common/mock'; -import { useHostRiskScore } from '../../../risk_score/containers'; -import { useQueryToggle } from '../../../common/containers/query_toggle'; - -jest.mock('../../../common/containers/query_toggle'); -jest.mock('../../../risk_score/containers'); -const useHostRiskScoreMock = useHostRiskScore as jest.Mock; -const testProps = { - setQuery: jest.fn(), - deleteQuery: jest.fn(), - hostName: 'test-host-name', - from: '2020-07-07T08:20:18.966Z', - to: '2020-07-08T08:20:18.966Z', -}; -describe('Host Risk Flyout', () => { - const mockUseQueryToggle = useQueryToggle as jest.Mock; - const mockSetToggle = jest.fn(); - beforeEach(() => { - jest.clearAllMocks(); - mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); - }); - it('renders', () => { - useHostRiskScoreMock.mockReturnValueOnce([ - true, - { - data: [], - isModuleEnabled: true, - }, - ]); - - const { queryByTestId } = render( - - - - ); - - expect(queryByTestId('topHostScoreContributors')).toBeInTheDocument(); - }); - - it('renders sorted items', () => { - useHostRiskScoreMock.mockReturnValueOnce([ - true, - { - data: [ - { - risk_stats: { - rule_risks: [ - { - rule_name: 'third', - rule_risk: '10', - }, - { - rule_name: 'first', - rule_risk: '99', - }, - { - rule_name: 'second', - rule_risk: '55', - }, - ], - }, - }, - ], - isModuleEnabled: true, - }, - ]); - - const { queryAllByRole } = render( - - - - ); - - expect(queryAllByRole('row')[1]).toHaveTextContent('first'); - expect(queryAllByRole('row')[2]).toHaveTextContent('second'); - expect(queryAllByRole('row')[3]).toHaveTextContent('third'); - }); - - describe('toggleQuery', () => { - beforeEach(() => { - useHostRiskScoreMock.mockReturnValue([ - true, - { - data: [], - isModuleEnabled: true, - }, - ]); - }); - - test('toggleQuery updates toggleStatus', () => { - const { getByTestId } = render( - - - - ); - expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); - fireEvent.click(getByTestId('query-toggle-header')); - expect(mockSetToggle).toBeCalledWith(false); - expect(useHostRiskScoreMock.mock.calls[1][0].skip).toEqual(true); - }); - - test('toggleStatus=true, do not skip', () => { - render( - - - - ); - expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); - }); - - test('toggleStatus=true, render components', () => { - const { queryByTestId } = render( - - - - ); - expect(queryByTestId('topHostScoreContributors-table')).toBeTruthy(); - }); - - test('toggleStatus=false, do not render components', () => { - mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); - const { queryByTestId } = render( - - - - ); - expect(queryByTestId('topHostScoreContributors-table')).toBeFalsy(); - }); - - test('toggleStatus=false, skip', () => { - mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); - render( - - - - ); - expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(true); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx deleted file mode 100644 index ceb4394619fc5..0000000000000 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx +++ /dev/null @@ -1,176 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useEffect, useMemo, useState } from 'react'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiInMemoryTable, - EuiTableFieldDataColumnType, -} from '@elastic/eui'; - -import { Direction } from '@kbn/timelines-plugin/common'; -import { HeaderSection } from '../../../common/components/header_section'; -import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; -import * as i18n from './translations'; - -import { buildHostNamesFilter, RiskScoreFields } from '../../../../common/search_strategy'; - -import { useQueryInspector } from '../../../common/components/page/manage_query'; -import { HostsComponentsQueryProps } from '../../pages/navigation/types'; - -import { RuleLink } from '../../../detections/pages/detection_engine/rules/all/use_columns'; -import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; -import { useQueryToggle } from '../../../common/containers/query_toggle'; - -export interface TopHostScoreContributorsProps - extends Pick { - hostName: string; - from: string; - to: string; -} -interface TableItem { - rank: number; - name: string; - id: string; -} - -const columns: Array> = [ - { - name: i18n.RANK_TITLE, - field: 'rank', - width: '45px', - align: 'right', - }, - { - name: i18n.RULE_NAME_TITLE, - field: 'name', - sortable: true, - truncateText: true, - render: (value: TableItem['name'], { id }: TableItem) => - id ? : value, - }, -]; - -const PAGE_SIZE = 5; -const QUERY_ID = HostRiskScoreQueryId.TOP_HOST_SCORE_CONTRIBUTORS; - -const TopHostScoreContributorsComponent: React.FC = ({ - hostName, - from, - to, - setQuery, - deleteQuery, -}) => { - const timerange = useMemo( - () => ({ - from, - to, - }), - [from, to] - ); - - const sort = useMemo(() => ({ field: RiskScoreFields.timestamp, direction: Direction.desc }), []); - - const { toggleStatus, setToggleStatus } = useQueryToggle(QUERY_ID); - const [querySkip, setQuerySkip] = useState(!toggleStatus); - useEffect(() => { - setQuerySkip(!toggleStatus); - }, [toggleStatus]); - - const toggleQuery = useCallback( - (status: boolean) => { - setToggleStatus(status); - // toggle on = skipQuery false - setQuerySkip(!status); - }, - [setQuerySkip, setToggleStatus] - ); - - const [loading, { data, refetch, inspect }] = useHostRiskScore({ - filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, - timerange, - onlyLatest: false, - sort, - skip: querySkip, - pagination: { - querySize: 1, - cursorStart: 0, - }, - }); - - const items = useMemo(() => { - const rules = data && data.length > 0 ? data[0].risk_stats.rule_risks : []; - - return rules - .sort((a, b) => b.rule_risk - a.rule_risk) - .map(({ rule_name: name, rule_id: id }, i) => ({ rank: i + 1, name, id })); - }, [data]); - - const tablePagination = useMemo( - () => ({ - showPerPageOptions: false, - pageSize: PAGE_SIZE, - totalItemCount: items.length, - }), - [items.length] - ); - - useQueryInspector({ - queryId: QUERY_ID, - loading, - refetch, - setQuery, - deleteQuery, - inspect, - }); - - return ( - - - - - - - {toggleStatus && ( - - - - )} - - - {toggleStatus && ( - - - - - - )} - - - ); -}; - -export const TopHostScoreContributors = React.memo(TopHostScoreContributorsComponent); -TopHostScoreContributors.displayName = 'TopHostScoreContributors'; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.test.tsx new file mode 100644 index 0000000000000..bab6809afc6f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; + +import { useHostRiskScore } from '../../../risk_score/containers'; +import { HostRiskTabBody } from './host_risk_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Host query tab body', () => { + const mockUseUserRiskScore = useHostRiskScore as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + hostName: 'testUser', + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseUserRiskScore.mockReturnValue([ + false, + { + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + refetch: jest.fn(), + isModuleEnabled: true, + }, + ]); + }); + + it("doesn't skip when both toggleStatus are true", () => { + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: true, setToggleStatus: jest.fn() }); + + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); + }); + + it("doesn't skip when at least one toggleStatus is true", () => { + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: false, setToggleStatus: jest.fn() }); + + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); + }); + + it('does skip when both toggleStatus are false', () => { + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: false, setToggleStatus: jest.fn() }); + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: false, setToggleStatus: jest.fn() }); + + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx index cebcc0ee855ea..b23ebb7de9bef 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx @@ -6,19 +6,26 @@ */ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; -import { HostRiskScoreOverTime } from '../../components/host_score_over_time'; -import { TopHostScoreContributors } from '../../components/top_host_score_contributors'; + import { HostsComponentsQueryProps } from './types'; import * as i18n from '../translations'; import { useRiskyHostsDashboardButtonHref } from '../../../overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; import { HostRiskInformationButtonEmpty } from '../../components/host_risk_information'; +import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; +import { buildHostNamesFilter } from '../../../../common/search_strategy'; +import { useQueryInspector } from '../../../common/components/page/manage_query'; +import { RiskScoreOverTime } from '../../../common/components/risk_score_over_time'; +import { TopRiskScoreContributors } from '../../../common/components/top_risk_score_contributors'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const StyledEuiFlexGroup = styled(EuiFlexGroup)` margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; `; +const QUERY_ID = HostRiskScoreQueryId.HOST_DETAILS_RISK_SCORE; + const HostRiskTabBodyComponent: React.FC< Pick & { hostName: string; @@ -26,25 +33,74 @@ const HostRiskTabBodyComponent: React.FC< > = ({ hostName, startDate, endDate, setQuery, deleteQuery }) => { const { buttonHref } = useRiskyHostsDashboardButtonHref(startDate, endDate); + const timerange = useMemo( + () => ({ + from: startDate, + to: endDate, + }), + [startDate, endDate] + ); + + const { toggleStatus: overTimeToggleStatus, setToggleStatus: setOverTimeToggleStatus } = + useQueryToggle(`${QUERY_ID} overTime`); + const { toggleStatus: contributorsToggleStatus, setToggleStatus: setContributorsToggleStatus } = + useQueryToggle(`${QUERY_ID} contributors`); + + const [loading, { data, refetch, inspect }] = useHostRiskScore({ + filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, + onlyLatest: false, + skip: !overTimeToggleStatus && !contributorsToggleStatus, + timerange, + }); + + useQueryInspector({ + queryId: QUERY_ID, + loading, + refetch, + setQuery, + deleteQuery, + inspect, + }); + + const toggleContributorsQuery = useCallback( + (status: boolean) => { + setContributorsToggleStatus(status); + }, + [setContributorsToggleStatus] + ); + + const toggleOverTimeQuery = useCallback( + (status: boolean) => { + setOverTimeToggleStatus(status); + }, + [setOverTimeToggleStatus] + ); + + const rules = data && data.length > 0 ? data[data.length - 1].risk_stats.rule_risks : []; + return ( <> - + - diff --git a/x-pack/plugins/security_solution/public/hosts/pages/translations.ts b/x-pack/plugins/security_solution/public/hosts/pages/translations.ts index 8b92ef035405f..3e6b23a521026 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/translations.ts @@ -60,7 +60,7 @@ export const NAVIGATION_ALERTS_TITLE = i18n.translate( export const NAVIGATION_HOST_RISK_TITLE = i18n.translate( 'xpack.securitySolution.hosts.navigation.hostRisk', { - defaultMessage: 'Hosts by risk', + defaultMessage: 'Host risk', } ); @@ -97,3 +97,10 @@ export const VIEW_DASHBOARD_BUTTON = i18n.translate( defaultMessage: 'View source dashboard', } ); + +export const HOST_RISK_SCORE_OVER_TIME = i18n.translate( + 'xpack.securitySolution.hosts.navigaton.hostScoreOverTimeTitle', + { + defaultMessage: 'Host risk score over time', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx index 889c5005ec193..24da8b3b86a35 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/search_bar.tsx @@ -8,7 +8,6 @@ import React, { memo, useCallback, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; import { encode, RisonValue } from 'rison-node'; -import styled from 'styled-components'; import type { Query } from '@kbn/es-query'; import { TimeHistory } from '@kbn/data-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; @@ -19,12 +18,6 @@ import { useEndpointSelector } from '../hooks'; import * as selectors from '../../store/selectors'; import { clone } from '../../models/index_pattern'; -const AdminQueryBar = styled.div` - .globalQueryBar { - padding: 0; - } -`; - export const AdminSearchBar = memo(() => { const history = useHistory(); const { admin_query: _, ...queryParams } = useEndpointSelector(selectors.uiQueryParams); @@ -57,7 +50,7 @@ export const AdminSearchBar = memo(() => { return (
{searchBarIndexPatterns && searchBarIndexPatterns.length > 0 && ( - +
{ showQueryBar={true} showQueryInput={true} /> - +
)}
); diff --git a/x-pack/plugins/security_solution/public/network/pages/details/utils.ts b/x-pack/plugins/security_solution/public/network/pages/details/utils.ts index 98094665cbcd2..044c1d22a6348 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/network/pages/details/utils.ts @@ -42,6 +42,7 @@ export const getBreadcrumbs = ( }), }, ]; + if (params.detailName != null) { breadcrumb = [ ...breadcrumb, diff --git a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx index 8ddd081088686..368bf2589d5c7 100644 --- a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx @@ -103,7 +103,7 @@ export const useUserRiskScore = ({ const spaceId = useSpaceId(); const defaultIndex = spaceId ? getUserRiskIndex(spaceId, onlyLatest) : undefined; - const usersFeatureEnabled = useIsExperimentalFeatureEnabled('usersEnabled'); + const riskyUsersFeatureEnabled = useIsExperimentalFeatureEnabled('riskyUsersEnabled'); return useRiskScore({ timerange, onlyLatest, @@ -111,7 +111,7 @@ export const useUserRiskScore = ({ sort, skip, pagination, - featureEnabled: usersFeatureEnabled, + featureEnabled: riskyUsersFeatureEnabled, defaultIndex, }); }; diff --git a/x-pack/plugins/security_solution/public/risk_score/containers/index.ts b/x-pack/plugins/security_solution/public/risk_score/containers/index.ts index 089c88aa9be37..ffe964b974776 100644 --- a/x-pack/plugins/security_solution/public/risk_score/containers/index.ts +++ b/x-pack/plugins/security_solution/public/risk_score/containers/index.ts @@ -12,12 +12,12 @@ export * from './kpi'; export const enum UserRiskScoreQueryId { USERS_BY_RISK = 'UsersByRisk', + USER_DETAILS_RISK_SCORE = 'UserDetailsRiskScore', } export const enum HostRiskScoreQueryId { DEFAULT = 'HostRiskScore', - HOST_RISK_SCORE_OVER_TIME = 'HostRiskScoreOverTimeQuery', - TOP_HOST_SCORE_CONTRIBUTORS = 'TopHostScoreContributorsQuery', + HOST_DETAILS_RISK_SCORE = 'HostDetailsRiskScore', OVERVIEW_RISKY_HOSTS = 'OverviewRiskyHosts', HOSTS_BY_RISK = 'HostsByRisk', } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx index 6412567174c73..c62869c0f0746 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx @@ -275,6 +275,7 @@ export const QueryBarTimeline = memo( savedQuery={savedQuery} onSavedQuery={onSavedQuery} dataTestSubj={'timelineQueryInput'} + displayStyle="inPage" /> ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx index 17dd6491f2326..c5cc33c18c1c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx @@ -62,24 +62,13 @@ interface Props { const SearchOrFilterContainer = styled.div` ${({ theme }) => `margin-top: ${theme.eui.euiSizeXS};`} - user-select: none; - .globalQueryBar { - padding: 0px; - .kbnQueryBar { - div:first-child { - margin-right: 0px; - } - } - .globalFilterGroup__wrapper.globalFilterGroup__wrapper-isVisible { - height: auto !important; - } - } + user-select: none; // This should not be here, it makes the entire page inaccessible `; SearchOrFilterContainer.displayName = 'SearchOrFilterContainer'; const ModeFlexItem = styled(EuiFlexItem)` - user-select: none; + user-select: none; // Again, why? `; ModeFlexItem.displayName = 'ModeFlexItem'; diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/index.tsx b/x-pack/plugins/security_solution/public/users/components/kpi_users/index.tsx index 11d577a037ddb..768248cf9b6ac 100644 --- a/x-pack/plugins/security_solution/public/users/components/kpi_users/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/index.tsx @@ -6,17 +6,47 @@ */ import React from 'react'; -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import { UsersKpiProps } from './types'; import { HostsKpiAuthentications } from '../../../hosts/components/kpi_hosts/authentications'; import { TotalUsersKpi } from './total_users'; +import { useUserRiskScore } from '../../../risk_score/containers'; +import { CallOutSwitcher } from '../../../common/components/callouts'; +import * as i18n from './translations'; export const UsersKpiComponent = React.memo( ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => { + const [_, { isModuleEnabled }] = useUserRiskScore({}); + return ( <> + {isModuleEnabled === false && ( + <> + + {/* + TODO PENDING ON USER RISK DOCUMENTATION} + */} + {i18n.LEARN_MORE} {i18n.USER_RISK_DATA} + {/* */} + + + ), + }} + /> + + + )} ( } ); -UsersKpiComponent.displayName = 'HostsKpiComponent'; +UsersKpiComponent.displayName = 'UsersKpiComponent'; diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/translations.ts b/x-pack/plugins/security_solution/public/users/components/kpi_users/translations.ts new file mode 100644 index 0000000000000..8315b6dc21c19 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const ENABLE_USER_RISK_TEXT = i18n.translate( + 'xpack.securitySolution.kpiUser.enableUserRiskText', + { + defaultMessage: 'Enable user risk module to see more data', + } +); + +export const LEARN_MORE = i18n.translate('xpack.securitySolution.kpiUser.learnMore', { + defaultMessage: 'Learn more about', +}); + +export const USER_RISK_DATA = i18n.translate('xpack.securitySolution.kpiUser.userRiskData', { + defaultMessage: 'user risk data', +}); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.test.tsx new file mode 100644 index 0000000000000..764d732fa2898 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { UserRiskInformationButtonEmpty } from '.'; +import { TestProviders } from '../../../common/mock'; + +describe('User Risk Flyout', () => { + describe('UserRiskInformationButtonEmpty', () => { + it('renders', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('open-risk-information-flyout-trigger')).toBeInTheDocument(); + }); + }); + + it('opens and displays table with 5 rows', () => { + const NUMBER_OF_ROWS = 1 + 5; // 1 header row + 5 severity rows + const { getByTestId, queryByTestId, queryAllByRole } = render( + + + + ); + + fireEvent.click(getByTestId('open-risk-information-flyout-trigger')); + + expect(queryByTestId('risk-information-table')).toBeInTheDocument(); + expect(queryAllByRole('row')).toHaveLength(NUMBER_OF_ROWS); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.tsx new file mode 100644 index 0000000000000..6ae647544d965 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + useGeneratedHtmlId, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiText, + EuiTitle, + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutFooter, + EuiButton, + EuiSpacer, + EuiBasicTableColumn, + EuiButtonEmpty, +} from '@elastic/eui'; + +import React from 'react'; + +import * as i18n from './translations'; +import { useOnOpenCloseHandler } from '../../../helper_hooks'; +import { RiskScore } from '../../../common/components/severity/common'; +import { RiskSeverity } from '../../../../common/search_strategy'; + +const tableColumns: Array> = [ + { + field: 'classification', + name: i18n.INFORMATION_CLASSIFICATION_HEADER, + render: (riskScore?: RiskSeverity) => { + if (riskScore != null) { + return ; + } + }, + }, + { + field: 'range', + name: i18n.INFORMATION_RISK_HEADER, + }, +]; + +interface TableItem { + range?: string; + classification: RiskSeverity; +} + +const tableItems: TableItem[] = [ + { classification: RiskSeverity.critical, range: i18n.CRITICAL_RISK_DESCRIPTION }, + { classification: RiskSeverity.high, range: '70 - 90 ' }, + { classification: RiskSeverity.moderate, range: '40 - 70' }, + { classification: RiskSeverity.low, range: '20 - 40' }, + { classification: RiskSeverity.unknown, range: i18n.UNKNOWN_RISK_DESCRIPTION }, +]; + +export const USER_RISK_INFO_BUTTON_CLASS = 'UserRiskInformation__button'; + +export const UserRiskInformationButtonEmpty = () => { + const [isFlyoutVisible, handleOnOpen, handleOnClose] = useOnOpenCloseHandler(); + + return ( + <> + + {i18n.INFO_BUTTON_TEXT} + + {isFlyoutVisible && } + + ); +}; + +const UserRiskInformationFlyout = ({ handleOnClose }: { handleOnClose: () => void }) => { + const simpleFlyoutTitleId = useGeneratedHtmlId({ + prefix: 'UserRiskInformation', + }); + + return ( + + + +

{i18n.TITLE}

+
+
+ + +

{i18n.INTRODUCTION}

+

{i18n.EXPLANATION_MESSAGE}

+
+ + + {/* TODO PENDING ON USER RISK DOCUMENTATION + + + + + ), + }} + /> */} +
+ + + + + {i18n.CLOSE_BUTTON_LTEXT} + + + +
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_information/translations.ts b/x-pack/plugins/security_solution/public/users/components/user_risk_information/translations.ts new file mode 100644 index 0000000000000..dbf4ad96e486c --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_information/translations.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INFORMATION_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.informationAriaLabel', + { + defaultMessage: 'Information', + } +); + +export const INFORMATION_CLASSIFICATION_HEADER = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.classificationHeader', + { + defaultMessage: 'Classification', + } +); + +export const INFORMATION_RISK_HEADER = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.riskHeader', + { + defaultMessage: 'User risk score range', + } +); + +export const UNKNOWN_RISK_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.unknownRiskDescription', + { + defaultMessage: 'Less than 20', + } +); + +export const CRITICAL_RISK_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.criticalRiskDescription', + { + defaultMessage: '90 and above', + } +); + +export const TITLE = i18n.translate('xpack.securitySolution.users.userRiskInformation.title', { + defaultMessage: 'How is user risk calculated?', +}); + +export const INTRODUCTION = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.introduction', + { + defaultMessage: + 'The User Risk Score capability surfaces risky users from within your environment.', + } +); + +export const EXPLANATION_MESSAGE = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.explanation', + { + defaultMessage: + 'This feature utilizes a transform, with a scripted metric aggregation to calculate user risk scores based on detection rule alerts with an "open" status, within a 5 day time window. The transform runs hourly to keep the score updated as new detection rule alerts stream in.', + } +); + +export const CLOSE_BUTTON_LTEXT = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.closeBtn', + { + defaultMessage: 'Close', + } +); + +export const INFO_BUTTON_TEXT = i18n.translate( + 'xpack.securitySolution.users.userRiskInformation.buttonLabel', + { + defaultMessage: 'How is risk score calculated?', + } +); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx index c3b26aa1e44d3..3ea4d6a14c247 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/columns.tsx @@ -22,6 +22,7 @@ import * as i18n from './translations'; import { RiskScore } from '../../../common/components/severity/common'; import { RiskSeverity } from '../../../../common/search_strategy'; import { UserDetailsLink } from '../../../common/components/links'; +import { UsersTableType } from '../../store/model'; export const getUserRiskScoreColumns = ({ dispatchSeverityUpdate, @@ -55,7 +56,7 @@ export const getUserRiskScoreColumns = ({ ) : ( - + ) } /> diff --git a/x-pack/plugins/security_solution/public/users/pages/constants.ts b/x-pack/plugins/security_solution/public/users/pages/constants.ts index 44d3b0ba83e1f..e8b9e4a4118a1 100644 --- a/x-pack/plugins/security_solution/public/users/pages/constants.ts +++ b/x-pack/plugins/security_solution/public/users/pages/constants.ts @@ -12,4 +12,4 @@ export const usersDetailsPagePath = `${USERS_PATH}/:detailName`; export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.authentications}|${UsersTableType.anomalies}|${UsersTableType.risk}|${UsersTableType.events}|${UsersTableType.alerts})`; -export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.authentications}|${UsersTableType.anomalies}|${UsersTableType.events}|${UsersTableType.alerts})`; +export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.authentications}|${UsersTableType.anomalies}|${UsersTableType.events}|${UsersTableType.alerts}|${UsersTableType.risk})`; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx index d3c3a4607b39c..22b394f41bfaf 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx @@ -21,6 +21,7 @@ import { EventsQueryTabBody } from '../../../common/components/events_tab/events import { AlertsView } from '../../../common/components/alerts_viewer'; import { userNameExistsFilter } from './helpers'; import { AuthenticationsQueryTabBody } from '../navigation'; +import { UserRiskTabBody } from '../navigation/user_risk_tab_body'; export const UsersDetailsTabs = React.memo( ({ @@ -107,6 +108,9 @@ export const UsersDetailsTabs = React.memo( {...tabProps} /> + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx index ee070f749925e..9f12d8824f817 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx @@ -43,6 +43,12 @@ export const navTabsUsersDetails = ( href: getTabsOnUsersDetailsUrl(userName, UsersTableType.alerts), disabled: false, }, + [UsersTableType.risk]: { + id: UsersTableType.risk, + name: i18n.NAVIGATION_RISK_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.risk), + disabled: false, + }, }; return hasMlUserPermissions diff --git a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts index 4b85d0f59314f..26ed75997a85d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts @@ -27,6 +27,7 @@ const TabNameMappedToI18nKey: Record = { [UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE, [UsersTableType.events]: i18n.NAVIGATION_EVENTS_TITLE, [UsersTableType.alerts]: i18n.NAVIGATION_ALERTS_TITLE, + [UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE, }; export const getBreadcrumbs = ( diff --git a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx index 3097fdeb604f3..046b8b7088125 100644 --- a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx @@ -38,12 +38,6 @@ export const navTabsUsers = ( href: getTabsOnUsersUrl(UsersTableType.anomalies), disabled: false, }, - [UsersTableType.risk]: { - id: UsersTableType.risk, - name: i18n.NAVIGATION_RISK_TITLE, - href: getTabsOnUsersUrl(UsersTableType.risk), - disabled: false, - }, [UsersTableType.events]: { id: UsersTableType.events, name: i18n.NAVIGATION_EVENTS_TITLE, @@ -56,6 +50,12 @@ export const navTabsUsers = ( href: getTabsOnUsersUrl(UsersTableType.alerts), disabled: false, }, + [UsersTableType.risk]: { + id: UsersTableType.risk, + name: i18n.NAVIGATION_RISK_TITLE, + href: getTabsOnUsersUrl(UsersTableType.risk), + disabled: false, + }, }; if (!hasMlUserPermissions) { diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx index 6b5ec66f864bb..10c85be1b72f7 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx @@ -29,23 +29,22 @@ describe('All users query tab body', () => { endDate: '2019-06-25T06:31:59.345Z', type: UsersType.page, }; + beforeEach(() => { jest.clearAllMocks(); mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUserRiskScore.mockReturnValue([ false, { - authentications: [], - id: '123', inspect: { dsl: [], response: [], }, isInspected: false, totalCount: 0, - pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, - loadPage: jest.fn(), refetch: jest.fn(), + isModuleEnabled: true, }, ]); mockUseUserRiskScoreKpi.mockReturnValue({ @@ -59,6 +58,7 @@ describe('All users query tab body', () => { }, }); }); + it('toggleStatus=true, do not skip', () => { render( @@ -68,6 +68,7 @@ describe('All users query tab body', () => { expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); expect(mockUseUserRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); }); + it('toggleStatus=false, skip', () => { mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); render( diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.test.tsx new file mode 100644 index 0000000000000..539b6df2d8f0a --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { UsersType } from '../../store/model'; +import { useUserRiskScore } from '../../../risk_score/containers'; +import { UserRiskTabBody } from './user_risk_tab_body'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('User query tab body', () => { + const mockUseUserRiskScore = useUserRiskScore as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + userName: 'testUser', + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: UsersType.page, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseUserRiskScore.mockReturnValue([ + false, + { + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + refetch: jest.fn(), + isModuleEnabled: true, + }, + ]); + }); + + it("doesn't skip when both toggleStatus are true", () => { + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: true, setToggleStatus: jest.fn() }); + + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); + }); + + it("doesn't skip when at least one toggleStatus is true", () => { + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: false, setToggleStatus: jest.fn() }); + + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); + }); + + it('does skip when at both toggleStatus are false', () => { + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: false, setToggleStatus: jest.fn() }); + mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: false, setToggleStatus: jest.fn() }); + + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx new file mode 100644 index 0000000000000..ee37df16fd19c --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; + +import * as i18n from '../translations'; + +import { useQueryInspector } from '../../../common/components/page/manage_query'; +import { RiskScoreOverTime } from '../../../common/components/risk_score_over_time'; +import { TopRiskScoreContributors } from '../../../common/components/top_risk_score_contributors'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { UserRiskScoreQueryId, useUserRiskScore } from '../../../risk_score/containers'; +import { buildUserNamesFilter } from '../../../../common/search_strategy'; +import { UsersComponentsQueryProps } from './types'; +import { UserRiskInformationButtonEmpty } from '../../components/user_risk_information'; + +const QUERY_ID = UserRiskScoreQueryId.USER_DETAILS_RISK_SCORE; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; +`; + +const UserRiskTabBodyComponent: React.FC< + Pick & { + userName: string; + } +> = ({ userName, startDate, endDate, setQuery, deleteQuery }) => { + const timerange = useMemo( + () => ({ + from: startDate, + to: endDate, + }), + [startDate, endDate] + ); + + const { toggleStatus: overTimeToggleStatus, setToggleStatus: setOverTimeToggleStatus } = + useQueryToggle(`${QUERY_ID} overTime`); + const { toggleStatus: contributorsToggleStatus, setToggleStatus: setContributorsToggleStatus } = + useQueryToggle(`${QUERY_ID} contributors`); + + const [loading, { data, refetch, inspect }] = useUserRiskScore({ + filterQuery: userName ? buildUserNamesFilter([userName]) : undefined, + onlyLatest: false, + skip: !overTimeToggleStatus && !contributorsToggleStatus, + timerange, + }); + + useQueryInspector({ + queryId: QUERY_ID, + loading, + refetch, + setQuery, + deleteQuery, + inspect, + }); + + const toggleContributorsQuery = useCallback( + (status: boolean) => { + setContributorsToggleStatus(status); + }, + [setContributorsToggleStatus] + ); + + const toggleOverTimeQuery = useCallback( + (status: boolean) => { + setOverTimeToggleStatus(status); + }, + [setOverTimeToggleStatus] + ); + + const rules = data && data.length > 0 ? data[data.length - 1].risk_stats.rule_risks : []; + + return ( + <> + + + + + + + + + + + + {/* // TODO PENDING ON USER RISK DOCUMENTATION + + + {i18n.VIEW_DASHBOARD_BUTTON} + + */} + + + + + + ); +}; + +UserRiskTabBodyComponent.displayName = 'UserRiskTabBodyComponent'; + +export const UserRiskTabBody = React.memo(UserRiskTabBodyComponent); + +UserRiskTabBody.displayName = 'UserRiskTabBody'; diff --git a/x-pack/plugins/security_solution/public/users/pages/translations.ts b/x-pack/plugins/security_solution/public/users/pages/translations.ts index 41fec21c5bfb0..c36abbaab86ec 100644 --- a/x-pack/plugins/security_solution/public/users/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/users/pages/translations.ts @@ -35,7 +35,7 @@ export const NAVIGATION_ANOMALIES_TITLE = i18n.translate( export const NAVIGATION_RISK_TITLE = i18n.translate( 'xpack.securitySolution.users.navigation.riskTitle', { - defaultMessage: 'Users by risk', + defaultMessage: 'User risk', } ); @@ -52,3 +52,17 @@ export const NAVIGATION_ALERTS_TITLE = i18n.translate( defaultMessage: 'External alerts', } ); + +export const USER_RISK_SCORE_OVER_TIME = i18n.translate( + 'xpack.securitySolution.users.navigation.userScoreOverTimeTitle', + { + defaultMessage: 'User risk score over time', + } +); + +export const VIEW_DASHBOARD_BUTTON = i18n.translate( + 'xpack.securitySolution.hosts.navigaton.hostRisk.viewDashboardButtonLabel', + { + defaultMessage: 'View source dashboard', + } +); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx index e753fee71d44b..6747c60bb840c 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { injectI18n } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { getDisplayValueFromFilter } from '@kbn/data-plugin/public'; @@ -30,7 +31,12 @@ export const ReadOnlyFilterItems = ({ filters, indexPatterns }: ReadOnlyFilterIt const filterList = filters.map((filter, index) => { const filterValue = getDisplayValueFromFilter(filter, indexPatterns); return ( - + { + const response = await es.get<{ alert: RawRule }>( + { + index: '.kibana', + id: 'alert:776cb5c0-ad1e-11ec-ab9e-5f5932f4fad8', + }, + { meta: true } + ); + expect(response.statusCode).to.equal(200); + expect(response.body._source?.alert?.params.searchType).to.eql('esQuery'); + }); + it('8.3.0 removes internal tags in Security Solution rule', async () => { const response = await es.get<{ alert: RawRule }>( { diff --git a/x-pack/test/functional/apps/canvas/embeddables/lens.ts b/x-pack/test/functional/apps/canvas/embeddables/lens.ts index 5ecd3a3156909..748f17c720b53 100644 --- a/x-pack/test/functional/apps/canvas/embeddables/lens.ts +++ b/x-pack/test/functional/apps/canvas/embeddables/lens.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function canvasLensTest({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); - const PageObjects = getPageObjects(['canvas', 'common', 'header', 'lens']); + const PageObjects = getPageObjects(['canvas', 'common', 'header', 'lens', 'unifiedSearch']); const esArchiver = getService('esArchiver'); const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); @@ -68,6 +68,7 @@ export default function canvasLensTest({ getService, getPageObjects }: FtrProvid await PageObjects.canvas.deleteSelectedElement(); const originalEmbeddableCount = await PageObjects.canvas.getEmbeddableCount(); await PageObjects.canvas.createNewVis('lens'); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); await PageObjects.lens.goToTimeRange(); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', diff --git a/x-pack/test/functional/apps/dashboard/group1/drilldowns/explore_data_panel_action.ts b/x-pack/test/functional/apps/dashboard/group1/drilldowns/explore_data_panel_action.ts index c302d9a195397..1dafddbb8567b 100644 --- a/x-pack/test/functional/apps/dashboard/group1/drilldowns/explore_data_panel_action.ts +++ b/x-pack/test/functional/apps/dashboard/group1/drilldowns/explore_data_panel_action.ts @@ -67,7 +67,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.clickWhenNotDisabled(ACTION_TEST_SUBJ); await discover.waitForDiscoverAppOnScreen(); - const el = await testSubjects.find('indexPattern-switch-link'); + const el = await testSubjects.find('discover-dataView-switch-link'); const text = await el.getVisibleText(); expect(text).to.be('logstash-*'); diff --git a/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts index 47a895472d992..6b08a9455b644 100644 --- a/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/group1/feature_controls/dashboard_security.ts @@ -228,6 +228,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { true, false ); + const contextMenuPanelTitleButton = await testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await testSubjects.click('contextMenuPanelTitleButton'); + } await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); const queryString = await queryBar.getQueryString(); @@ -244,6 +250,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('allow saving currently loaded query as a copy', async () => { await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( 'ok2', 'description', diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 0a12de3fb44d6..1f4cfa15fa892 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -20,6 +20,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'security', 'share', 'spaceSelector', + 'header', ]); const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); @@ -152,13 +153,17 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('allow saving currently loaded query as a copy', async () => { await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( 'ok2', 'description', true, false ); + await PageObjects.header.waitUntilLoadingHasFinished(); await savedQueryManagementComponent.savedQueryExistOrFail('ok2'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); + await testSubjects.click('showQueryBarMenu'); await savedQueryManagementComponent.deleteSavedQuery('ok2'); }); }); diff --git a/x-pack/test/functional/apps/lens/group2/dashboard.ts b/x-pack/test/functional/apps/lens/group2/dashboard.ts index 9a8cc99b24315..787a0a6a6d99a 100644 --- a/x-pack/test/functional/apps/lens/group2/dashboard.ts +++ b/x-pack/test/functional/apps/lens/group2/dashboard.ts @@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'timePicker', 'lens', 'discover', + 'unifiedSearch', ]); const find = getService('find'); @@ -163,6 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickNewDashboard(); await dashboardAddPanel.clickCreateNewLink(); await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); await PageObjects.lens.goToTimeRange(); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', diff --git a/x-pack/test/functional/apps/lens/group2/show_underlying_data.ts b/x-pack/test/functional/apps/lens/group2/show_underlying_data.ts index 910a4a6880644..bd8f02c723102 100644 --- a/x-pack/test/functional/apps/lens/group2/show_underlying_data.ts +++ b/x-pack/test/functional/apps/lens/group2/show_underlying_data.ts @@ -87,7 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.enableFilter(); // turn off the KQL switch to change the language to lucene await testSubjects.click('indexPattern-filter-by-input > switchQueryLanguageButton'); - await testSubjects.click('languageToggle'); + await testSubjects.click('luceneLanguageMenuItem'); await testSubjects.click('indexPattern-filter-by-input > switchQueryLanguageButton'); // apparently setting a filter requires some time before and after typing to work properly await PageObjects.common.sleep(1000); diff --git a/x-pack/test/functional/apps/lens/group2/show_underlying_data_dashboard.ts b/x-pack/test/functional/apps/lens/group2/show_underlying_data_dashboard.ts index 9446b28c1e3ca..92e9b6fcdb58e 100644 --- a/x-pack/test/functional/apps/lens/group2/show_underlying_data_dashboard.ts +++ b/x-pack/test/functional/apps/lens/group2/show_underlying_data_dashboard.ts @@ -23,6 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardPanelActions = getService('dashboardPanelActions'); const filterBarService = getService('filterBar'); const queryBar = getService('queryBar'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const browser = getService('browser'); const retry = getService('retry'); @@ -58,8 +59,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should bring both dashboard context and visualization context to discover', async () => { await PageObjects.dashboard.switchToEditMode(); await dashboardPanelActions.clickEdit(); - + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.switchQueryLanguage('lucene'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); await queryBar.setQuery('host.keyword www.elastic.co'); await queryBar.submitQuery(); await filterBarService.addFilter('geo.src', 'is', 'AF'); @@ -67,8 +69,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.sleep(1000); await PageObjects.lens.saveAndReturn(); - + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.switchQueryLanguage('kql'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); await queryBar.setQuery('request.keyword : "/apm"'); await queryBar.submitQuery(); await filterBarService.addFilter( diff --git a/x-pack/test/functional/apps/lens/group3/disable_auto_apply.ts b/x-pack/test/functional/apps/lens/group3/disable_auto_apply.ts index e52b1cccda8a3..4ccc642dd9929 100644 --- a/x-pack/test/functional/apps/lens/group3/disable_auto_apply.ts +++ b/x-pack/test/functional/apps/lens/group3/disable_auto_apply.ts @@ -50,6 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.goToTimeRange(); await PageObjects.lens.disableAutoApply(); + await PageObjects.lens.closeSettingsMenu(); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', diff --git a/x-pack/test/functional/apps/lens/group3/lens_tagging.ts b/x-pack/test/functional/apps/lens/group3/lens_tagging.ts index d69b49403fc31..b246f84bb43ce 100644 --- a/x-pack/test/functional/apps/lens/group3/lens_tagging.ts +++ b/x-pack/test/functional/apps/lens/group3/lens_tagging.ts @@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visualize', 'lens', 'timePicker', + 'unifiedSearch', ]); const lensTag = 'extreme-lens-tag'; @@ -36,6 +37,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.preserveCrossAppState(); await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); }); after(async () => { diff --git a/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts index ad4a2acd475a0..94f46763acd31 100644 --- a/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/group1/feature_controls/maps_security.ts @@ -113,18 +113,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { false ); }); - - it('allow saving currently loaded query as a copy', async () => { - await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); - await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( - 'ok2', - 'description', - true, - false - ); - await savedQueryManagementComponent.savedQueryExistOrFail('ok2'); - await savedQueryManagementComponent.deleteSavedQuery('ok2'); - }); }); describe('global maps read-only privileges', () => { diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index 85b9a31e2d361..0bf6f6fad2a75 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -138,10 +138,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('allows saving via the saved query management component popover with no saved query loaded', async () => { await queryBar.setQuery('response:200'); + await queryBar.clickQuerySubmitButton(); + await testSubjects.click('showQueryBarMenu'); await savedQueryManagementComponent.saveNewQuery('foo', 'bar', true, false); + await PageObjects.header.waitUntilLoadingHasFinished(); await savedQueryManagementComponent.savedQueryExistOrFail('foo'); await savedQueryManagementComponent.closeSavedQueryManagementComponent(); - + await testSubjects.click('showQueryBarMenu'); await savedQueryManagementComponent.deleteSavedQuery('foo'); await savedQueryManagementComponent.savedQueryMissingOrFail('foo'); }); @@ -170,13 +173,17 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('allow saving currently loaded query as a copy', async () => { await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( 'ok2', 'description', true, false ); + await PageObjects.header.waitUntilLoadingHasFinished(); await savedQueryManagementComponent.savedQueryExistOrFail('ok2'); + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); + await testSubjects.click('showQueryBarMenu'); await savedQueryManagementComponent.deleteSavedQuery('ok2'); }); }); diff --git a/x-pack/test/functional/es_archives/alerts/data.json b/x-pack/test/functional/es_archives/alerts/data.json index 1c096d9df9930..3ce8cddcb284d 100644 --- a/x-pack/test/functional/es_archives/alerts/data.json +++ b/x-pack/test/functional/es_archives/alerts/data.json @@ -891,6 +891,57 @@ } } +{ + "type": "doc", + "value": { + "id": "alert:776cb5c0-ad1e-11ec-ab9e-5f5932f4fad8", + "index": ".kibana_1", + "source": { + "alert": { + "name": "123", + "alertTypeId": ".es-query", + "consumer": "alerts", + "params": { + "esQuery": "{\n \"query\":{\n \"match_all\" : {}\n }\n}", + "size": 100, + "timeWindowSize": 5, + "timeWindowUnit": "m", + "threshold": [ + 1000 + ], + "thresholdComparator": ">", + "index": [ + "kibana_sample_data_ecommerce" + ], + "timeField": "order_date" + }, + "schedule": { + "interval": "1m" + }, + "enabled": true, + "actions": [ + ], + "throttle": null, + "apiKeyOwner": null, + "createdBy" : "elastic", + "updatedBy" : "elastic", + "createdAt": "2022-03-26T16:04:50.698Z", + "muteAll": false, + "mutedInstanceIds": [], + "scheduledTaskId": "776cb5c0-ad1e-11ec-ab9e-5f5932f4fad8", + "tags": [] + }, + "type": "alert", + "updated_at": "2022-03-26T16:05:55.957Z", + "migrationVersion": { + "alert": "8.0.1" + }, + "references": [ + ] + } + } +} + { "type":"doc", "value":{ @@ -989,4 +1040,4 @@ ] } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index d38264150cfa5..7432c5e066a3d 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -28,6 +28,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont 'visualize', 'dashboard', 'timeToVisualize', + 'unifiedSearch', ]); return logWrapper('lensPage', log, { @@ -56,6 +57,8 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont fromTime = fromTime || PageObjects.timePicker.defaultStartTime; toTime = toTime || PageObjects.timePicker.defaultEndTime; await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + // give some time for the update button tooltip to close + await PageObjects.common.sleep(500); }, /** @@ -96,6 +99,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return retry.try(async () => { await testSubjects.click(`visListingTitleLink-${title}`); await this.isLensPageOrFail(); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); }); }, @@ -566,10 +570,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont // pressing Enter at this point may lead to auto-complete the queryInput with random stuff from the // dropdown which was not intended originally. // To close the Filter popover we need to move to the label input and then press Enter: - // solution is to press Tab 2 twice (first Tab will close the dropdown) instead of Enter to avoid + // solution is to press Tab 3 tims (first Tab will close the dropdown) instead of Enter to avoid // race condition with the dropdown await PageObjects.common.pressTabKey(); await PageObjects.common.pressTabKey(); + await PageObjects.common.pressTabKey(); // Now it is safe to press Enter as we're in the label input await PageObjects.common.pressEnterKey(); await PageObjects.common.sleep(1000); // give time for debounced components to rerender @@ -837,7 +842,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * Changes the index pattern in the data panel */ async switchDataPanelIndexPattern(name: string) { - await testSubjects.click('indexPattern-switch-link'); + await testSubjects.click('lns-dataView-switch-link'); await find.clickByCssSelector(`[title="${name}"]`); await PageObjects.header.waitUntilLoadingHasFinished(); }, @@ -855,7 +860,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * Returns the current index pattern of the data panel */ async getDataPanelIndexPattern() { - return await (await testSubjects.find('indexPattern-switch-link')).getAttribute('title'); + return await (await testSubjects.find('lns-dataView-switch-link')).getAttribute('title'); }, /** @@ -1128,6 +1133,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await PageObjects.dashboard.switchToEditMode(); } await dashboardAddPanel.clickCreateNewLink(); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); await this.goToTimeRange(); await this.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', @@ -1200,7 +1206,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, async clickAddField() { - await testSubjects.click('lnsIndexPatternActions'); + await testSubjects.click('lns-dataView-switch-link'); await testSubjects.existOrFail('indexPattern-add-field'); await testSubjects.click('indexPattern-add-field'); }, @@ -1371,9 +1377,9 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, async closeSettingsMenu() { - if (!(await this.settingsMenuOpen())) return; - - await testSubjects.click('lnsApp_settingsButton'); + if (await this.settingsMenuOpen()) { + await testSubjects.click('lnsApp_settingsButton'); + } }, async enableAutoApply() { diff --git a/x-pack/test/functional/services/ml/dashboard_embeddables.ts b/x-pack/test/functional/services/ml/dashboard_embeddables.ts index 1eb4e25a4edd5..74268f74d19a2 100644 --- a/x-pack/test/functional/services/ml/dashboard_embeddables.ts +++ b/x-pack/test/functional/services/ml/dashboard_embeddables.ts @@ -117,7 +117,9 @@ export function MachineLearningDashboardEmbeddablesProvider( async selectDiscoverIndexPattern(indexPattern: string) { await retry.tryForTime(2 * 1000, async () => { await PageObjects.discover.selectIndexPattern(indexPattern); - const indexPatternTitle = await testSubjects.getVisibleText('indexPattern-switch-link'); + const indexPatternTitle = await testSubjects.getVisibleText( + 'discover-dataView-switch-link' + ); expect(indexPatternTitle).to.be(indexPattern); }); }, diff --git a/x-pack/test/functional/services/transform/discover.ts b/x-pack/test/functional/services/transform/discover.ts index a98f7e5ae9890..d96ab079043a0 100644 --- a/x-pack/test/functional/services/transform/discover.ts +++ b/x-pack/test/functional/services/transform/discover.ts @@ -28,7 +28,7 @@ export function TransformDiscoverProvider({ getService }: FtrProviderContext) { async assertNoResults(expectedDestinationIndex: string) { // Discover should use the destination index pattern const actualIndexPatternSwitchLinkText = await ( - await testSubjects.find('indexPattern-switch-link') + await testSubjects.find('discover-dataView-switch-link') ).getVisibleText(); expect(actualIndexPatternSwitchLinkText).to.eql( expectedDestinationIndex, diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 3c75db1c4c366..bc28120400895 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -25,7 +25,7 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi const comboBox = getService('comboBox'); const retry = getService('retry'); const ml = getService('ml'); - const PageObjects = getPageObjects(['discover', 'timePicker']); + const PageObjects = getPageObjects(['discover', 'timePicker', 'unifiedSearch']); return { async clickNextButton() { @@ -911,6 +911,7 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi await testSubjects.click('transformWizardCardDiscover'); await PageObjects.discover.isDiscoverAppOnScreen(); }); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); }, async setDiscoverTimeRange(fromTime: string, toTime: string) {