diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f62d103124558..9c06ece1290ad 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -595,6 +595,7 @@ x-pack/plugins/security_solution/public/kubernetes @elastic/awp-platform # Cloud Security Posture /x-pack/plugins/cloud_security_posture/ @elastic/kibana-cloud-security-posture +/x-pack/plugins/security_solution/public/cloud_security_posture @elastic/kibana-cloud-security-posture # Design (at the bottom for specificity of SASS files) **/*.scss @elastic/kibana-design diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 919727dfe8625..c939ab2dcf690 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -504,7 +504,7 @@ the infrastructure monitoring use-case within Kibana. |{kib-repo}blob/{branch}/x-pack/plugins/lens/readme.md[lens] -|Visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. +|Lens is a visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. |{kib-repo}blob/{branch}/x-pack/plugins/license_api_guard/README.md[licenseApiGuard] diff --git a/package.json b/package.json index 501fd8dc9991d..9d83e2641eb9d 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "@hapi/inert": "^6.0.4", "@hapi/wreck": "^17.1.0", "@kbn/ace": "link:bazel-bin/packages/kbn-ace", + "@kbn/aiops-components": "link:bazel-bin/x-pack/packages/ml/aiops_components", "@kbn/aiops-utils": "link:bazel-bin/x-pack/packages/ml/aiops_utils", "@kbn/alerts": "link:bazel-bin/packages/kbn-alerts", "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics", @@ -299,11 +300,14 @@ "cytoscape-dagre": "^2.2.2", "d3": "3.5.17", "d3-array": "1.2.4", + "d3-brush": "^3.0.0", "d3-cloud": "1.2.5", "d3-interpolate": "^3.0.1", "d3-scale": "^2.2.2", + "d3-selection": "^3.0.0", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", + "d3-transition": "^3.0.1", "dedent": "^0.7.0", "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", @@ -615,11 +619,14 @@ "@types/cytoscape": "^3.14.0", "@types/d3": "^3.5.43", "@types/d3-array": "^1.2.7", + "@types/d3-brush": "^3.0.0", "@types/d3-interpolate": "^2.0.0", "@types/d3-scale": "^2.2.6", + "@types/d3-selection": "^3.0.0", "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^2.1.1", + "@types/d3-transition": "^3.0.1", "@types/dedent": "^0.7.0", "@types/deep-freeze-strict": "^1.1.0", "@types/delete-empty": "^2.0.0", @@ -664,6 +671,7 @@ "@types/json5": "^0.0.30", "@types/jsonwebtoken": "^8.5.6", "@types/kbn__ace": "link:bazel-bin/packages/kbn-ace/npm_module_types", + "@types/kbn__aiops-components": "link:bazel-bin/x-pack/packages/ml/aiops_components/npm_module_types", "@types/kbn__aiops-utils": "link:bazel-bin/x-pack/packages/ml/aiops_utils/npm_module_types", "@types/kbn__alerts": "link:bazel-bin/packages/kbn-alerts/npm_module_types", "@types/kbn__analytics": "link:bazel-bin/packages/kbn-analytics/npm_module_types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 5ea9e412221b2..dc5f7542525ac 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -176,6 +176,7 @@ filegroup( "//packages/shared-ux/page/kibana_no_data:build", "//packages/shared-ux/prompt/no_data_views:build", "//x-pack/packages/ml/agg_utils:build", + "//x-pack/packages/ml/aiops_components:build", "//x-pack/packages/ml/aiops_utils:build", "//x-pack/packages/ml/is_populated_object:build", "//x-pack/packages/ml/string_hash:build", @@ -338,6 +339,7 @@ filegroup( "//packages/shared-ux/page/kibana_no_data:build_types", "//packages/shared-ux/prompt/no_data_views:build_types", "//x-pack/packages/ml/agg_utils:build_types", + "//x-pack/packages/ml/aiops_components:build_types", "//x-pack/packages/ml/aiops_utils:build_types", "//x-pack/packages/ml/is_populated_object:build_types", "//x-pack/packages/ml/string_hash:build_types", diff --git a/packages/kbn-coloring/src/shared_components/coloring/palette_configuration.tsx b/packages/kbn-coloring/src/shared_components/coloring/palette_configuration.tsx index 5db80ba80446c..3bca5955182a9 100644 --- a/packages/kbn-coloring/src/shared_components/coloring/palette_configuration.tsx +++ b/packages/kbn-coloring/src/shared_components/coloring/palette_configuration.tsx @@ -110,6 +110,7 @@ export const CustomizablePalette = ({ {showRangeTypeSelector && ( {i18n.translate('coloring.dynamicColoring.rangeType.label', { @@ -131,6 +132,7 @@ export const CustomizablePalette = ({ display="rowCompressed" > )} diff --git a/packages/kbn-coloring/src/shared_components/coloring/palette_picker.tsx b/packages/kbn-coloring/src/shared_components/coloring/palette_picker.tsx index 941a9f20527a8..5cb2466d48421 100644 --- a/packages/kbn-coloring/src/shared_components/coloring/palette_picker.tsx +++ b/packages/kbn-coloring/src/shared_components/coloring/palette_picker.tsx @@ -98,6 +98,7 @@ export function PalettePicker({ } return ( = ( // context.core will always be available, but plugin contexts are typed as optional @@ -148,7 +148,7 @@ export interface IContextContainer { * @param provider - A {@link IContextProvider} to be called each time a new context is created. * @returns The {@link IContextContainer} for method chaining. */ - registerContext( + registerContext( pluginOpaqueId: PluginOpaqueId, contextName: ContextName, provider: IContextProvider @@ -195,7 +195,7 @@ export class ContextContainer implements IContextContainer { } public registerContext = < - Context extends RequestHandlerContext, + Context extends RequestHandlerContextBase, ContextName extends keyof Context >( source: symbol, @@ -243,7 +243,7 @@ export class ContextContainer implements IContextContainer { const builtContextPromises: Record> = {}; const builtContext = {} as HandlerContextType; - (builtContext as unknown as RequestHandlerContext).resolve = async (keys) => { + (builtContext as unknown as RequestHandlerContextBase).resolve = async (keys) => { const resolved = await Promise.all( keys.map(async (key) => { return [key, await builtContext[key]]; diff --git a/src/core/server/core_app/core_app.ts b/src/core/server/core_app/core_app.ts index b004e1352da9b..78e1e3548fdaa 100644 --- a/src/core/server/core_app/core_app.ts +++ b/src/core/server/core_app/core_app.ts @@ -19,6 +19,7 @@ import { HttpResources, HttpResourcesServiceToolkit } from '../http_resources'; import { InternalCorePreboot, InternalCoreSetup } from '../internal_types'; import { registerBundleRoutes } from './bundle_routes'; import { UiPlugins } from '../plugins'; +import type { InternalCoreAppRequestHandlerContext } from './internal_types'; /** @internal */ interface CommonRoutesParams { @@ -85,7 +86,7 @@ export class CoreApp { private registerDefaultRoutes(coreSetup: InternalCoreSetup, uiPlugins: UiPlugins) { const httpSetup = coreSetup.http; - const router = httpSetup.createRouter(''); + const router = httpSetup.createRouter(''); const resources = coreSetup.httpResources.createRegistrar(router); router.get({ path: '/', validate: false }, async (context, req, res) => { diff --git a/src/core/server/core_app/internal_types.ts b/src/core/server/core_app/internal_types.ts new file mode 100644 index 0000000000000..24b9bd4c709aa --- /dev/null +++ b/src/core/server/core_app/internal_types.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 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 { RequestHandlerContextBase } from '..'; +import type { IRouter } from '../http'; +import type { UiSettingsRequestHandlerContext } from '../ui_settings'; + +/** + * Request handler context used by core's coreApp routes. + * @internal + */ +export interface InternalCoreAppRequestHandlerContext extends RequestHandlerContextBase { + core: Promise<{ + uiSettings: UiSettingsRequestHandlerContext; + }>; +} + +/** + * Router bound to the {@link InternalCoreAppRequestHandlerContext}. + * Used by core's coreApp routes. + * @internal + */ +export type InternalCoreAppRouter = IRouter; diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index 3106053eb6afa..66c7f8c6d98d9 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -6,131 +6,63 @@ * Side Public License, v 1. */ -// eslint-disable-next-line max-classes-per-file -import { InternalCoreStart } from './internal_types'; -import { KibanaRequest } from './http/router'; -import { SavedObjectsClientContract } from './saved_objects/types'; +import type { InternalCoreStart } from './internal_types'; +import type { KibanaRequest } from './http'; import { - InternalSavedObjectsServiceStart, - ISavedObjectTypeRegistry, - SavedObjectsClientProviderOptions, + CoreSavedObjectsRouteHandlerContext, + SavedObjectsRequestHandlerContext, } from './saved_objects'; -import { InternalElasticsearchServiceStart, IScopedClusterClient } from './elasticsearch'; -import { InternalUiSettingsServiceStart, IUiSettingsClient } from './ui_settings'; -import { DeprecationsClient, InternalDeprecationsServiceStart } from './deprecations'; - -class CoreElasticsearchRouteHandlerContext { - #client?: IScopedClusterClient; - - constructor( - private readonly elasticsearchStart: InternalElasticsearchServiceStart, - private readonly request: KibanaRequest - ) {} - - public get client() { - if (this.#client == null) { - this.#client = this.elasticsearchStart.client.asScoped(this.request); - } - return this.#client; - } -} - -class CoreSavedObjectsRouteHandlerContext { - constructor( - private readonly savedObjectsStart: InternalSavedObjectsServiceStart, - private readonly request: KibanaRequest - ) {} - #scopedSavedObjectsClient?: SavedObjectsClientContract; - #typeRegistry?: ISavedObjectTypeRegistry; - - public get client() { - if (this.#scopedSavedObjectsClient == null) { - this.#scopedSavedObjectsClient = this.savedObjectsStart.getScopedClient(this.request); - } - return this.#scopedSavedObjectsClient; - } - - public get typeRegistry() { - if (this.#typeRegistry == null) { - this.#typeRegistry = this.savedObjectsStart.getTypeRegistry(); - } - return this.#typeRegistry; - } - - public getClient = (options?: SavedObjectsClientProviderOptions) => { - if (!options) return this.client; - return this.savedObjectsStart.getScopedClient(this.request, options); - }; - - public getExporter = (client: SavedObjectsClientContract) => { - return this.savedObjectsStart.createExporter(client); - }; - - public getImporter = (client: SavedObjectsClientContract) => { - return this.savedObjectsStart.createImporter(client); - }; -} - -class CoreUiSettingsRouteHandlerContext { - #client?: IUiSettingsClient; - constructor( - private readonly uiSettingsStart: InternalUiSettingsServiceStart, - private readonly savedObjectsRouterHandlerContext: CoreSavedObjectsRouteHandlerContext - ) {} - - public get client() { - if (this.#client == null) { - this.#client = this.uiSettingsStart.asScopedToClient( - this.savedObjectsRouterHandlerContext.client - ); - } - return this.#client; - } -} - -class CoreDeprecationsRouteHandlerContext { - #client?: DeprecationsClient; - constructor( - private readonly deprecationsStart: InternalDeprecationsServiceStart, - private readonly elasticsearchRouterHandlerContext: CoreElasticsearchRouteHandlerContext, - private readonly savedObjectsRouterHandlerContext: CoreSavedObjectsRouteHandlerContext - ) {} - - public get client() { - if (this.#client == null) { - this.#client = this.deprecationsStart.asScopedToClient( - this.elasticsearchRouterHandlerContext.client, - this.savedObjectsRouterHandlerContext.client - ); - } - return this.#client; - } +import { + CoreElasticsearchRouteHandlerContext, + ElasticsearchRequestHandlerContext, +} from './elasticsearch'; +import { CoreUiSettingsRouteHandlerContext, UiSettingsRequestHandlerContext } from './ui_settings'; +import { + CoreDeprecationsRouteHandlerContext, + DeprecationsRequestHandlerContext, +} from './deprecations'; + +/** + * The `core` context provided to route handler. + * + * Provides the following clients and services: + * - {@link SavedObjectsClient | savedObjects.client} - Saved Objects client + * which uses the credentials of the incoming request + * - {@link ISavedObjectTypeRegistry | savedObjects.typeRegistry} - Type registry containing + * all the registered types. + * - {@link IScopedClusterClient | elasticsearch.client} - Elasticsearch + * data client which uses the credentials of the incoming request + * - {@link IUiSettingsClient | uiSettings.client} - uiSettings client + * which uses the credentials of the incoming request + * @public + */ +export interface CoreRequestHandlerContext { + savedObjects: SavedObjectsRequestHandlerContext; + elasticsearch: ElasticsearchRequestHandlerContext; + uiSettings: UiSettingsRequestHandlerContext; + deprecations: DeprecationsRequestHandlerContext; } -export class CoreRouteHandlerContext { +/** + * The concrete implementation for Core's route handler context. + * + * @internal + */ +export class CoreRouteHandlerContext implements CoreRequestHandlerContext { readonly elasticsearch: CoreElasticsearchRouteHandlerContext; readonly savedObjects: CoreSavedObjectsRouteHandlerContext; readonly uiSettings: CoreUiSettingsRouteHandlerContext; readonly deprecations: CoreDeprecationsRouteHandlerContext; - constructor( - private readonly coreStart: InternalCoreStart, - private readonly request: KibanaRequest - ) { - this.elasticsearch = new CoreElasticsearchRouteHandlerContext( - this.coreStart.elasticsearch, - this.request - ); - this.savedObjects = new CoreSavedObjectsRouteHandlerContext( - this.coreStart.savedObjects, - this.request - ); + constructor(coreStart: InternalCoreStart, request: KibanaRequest) { + this.elasticsearch = new CoreElasticsearchRouteHandlerContext(coreStart.elasticsearch, request); + this.savedObjects = new CoreSavedObjectsRouteHandlerContext(coreStart.savedObjects, request); this.uiSettings = new CoreUiSettingsRouteHandlerContext( - this.coreStart.uiSettings, + coreStart.uiSettings, this.savedObjects ); this.deprecations = new CoreDeprecationsRouteHandlerContext( - this.coreStart.deprecations, + coreStart.deprecations, this.elasticsearch, this.savedObjects ); diff --git a/src/core/server/deprecations/deprecations_route_handler_context.ts b/src/core/server/deprecations/deprecations_route_handler_context.ts new file mode 100644 index 0000000000000..76e5d974056d4 --- /dev/null +++ b/src/core/server/deprecations/deprecations_route_handler_context.ts @@ -0,0 +1,43 @@ +/* + * 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 { CoreElasticsearchRouteHandlerContext } from '../elasticsearch'; +import type { CoreSavedObjectsRouteHandlerContext } from '../saved_objects'; +import type { DeprecationsClient, InternalDeprecationsServiceStart } from './deprecations_service'; + +/** + * Core's `deprecations` request handler context. + * @public + */ +export interface DeprecationsRequestHandlerContext { + client: DeprecationsClient; +} + +/** + * The {@link DeprecationsRequestHandlerContext} implementation. + * @internal + */ +export class CoreDeprecationsRouteHandlerContext implements DeprecationsRequestHandlerContext { + #client?: DeprecationsClient; + + constructor( + private readonly deprecationsStart: InternalDeprecationsServiceStart, + private readonly elasticsearchRouterHandlerContext: CoreElasticsearchRouteHandlerContext, + private readonly savedObjectsRouterHandlerContext: CoreSavedObjectsRouteHandlerContext + ) {} + + public get client() { + if (this.#client == null) { + this.#client = this.deprecationsStart.asScopedToClient( + this.elasticsearchRouterHandlerContext.client, + this.savedObjectsRouterHandlerContext.client + ); + } + return this.#client; + } +} diff --git a/src/core/server/deprecations/index.ts b/src/core/server/deprecations/index.ts index d9225750f04a1..9245179d3099a 100644 --- a/src/core/server/deprecations/index.ts +++ b/src/core/server/deprecations/index.ts @@ -25,3 +25,5 @@ export type { export { DeprecationsService } from './deprecations_service'; export { config } from './deprecation_config'; +export { CoreDeprecationsRouteHandlerContext } from './deprecations_route_handler_context'; +export type { DeprecationsRequestHandlerContext } from './deprecations_route_handler_context'; diff --git a/src/core/server/deprecations/internal_types.ts b/src/core/server/deprecations/internal_types.ts new file mode 100644 index 0000000000000..0f23360e94ba9 --- /dev/null +++ b/src/core/server/deprecations/internal_types.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 type { IRouter } from '../http'; +import type { RequestHandlerContextBase } from '..'; +import type { DeprecationsRequestHandlerContext } from './deprecations_route_handler_context'; + +/** + * Request handler context used by core's deprecations routes. + * @internal + */ +export interface InternalDeprecationRequestHandlerContext extends RequestHandlerContextBase { + core: Promise<{ deprecations: DeprecationsRequestHandlerContext }>; +} + +/** + * Router bound to the {@link InternalDeprecationRequestHandlerContext}. + * Used by core's deprecations routes. + * @internal + */ +export type InternalDeprecationRouter = IRouter; diff --git a/src/core/server/deprecations/routes/get.ts b/src/core/server/deprecations/routes/get.ts index 88965505488f9..a3ea08f04d706 100644 --- a/src/core/server/deprecations/routes/get.ts +++ b/src/core/server/deprecations/routes/get.ts @@ -5,10 +5,11 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { IRouter } from '../../http'; -import { DeprecationsGetResponse } from '../types'; -export const registerGetRoute = (router: IRouter) => { +import type { DeprecationsGetResponse } from '../types'; +import type { InternalDeprecationRouter } from '../internal_types'; + +export const registerGetRoute = (router: InternalDeprecationRouter) => { router.get( { path: '/', diff --git a/src/core/server/deprecations/routes/index.ts b/src/core/server/deprecations/routes/index.ts index 633950be61d12..5c73af882085c 100644 --- a/src/core/server/deprecations/routes/index.ts +++ b/src/core/server/deprecations/routes/index.ts @@ -7,9 +7,10 @@ */ import { InternalHttpServiceSetup } from '../../http'; +import type { InternalDeprecationRequestHandlerContext } from '../internal_types'; import { registerGetRoute } from './get'; export function registerRoutes({ http }: { http: InternalHttpServiceSetup }) { - const router = http.createRouter('/api/deprecations'); + const router = http.createRouter('/api/deprecations'); registerGetRoute(router); } diff --git a/src/core/server/elasticsearch/elasticsearch_route_handler_context.ts b/src/core/server/elasticsearch/elasticsearch_route_handler_context.ts new file mode 100644 index 0000000000000..536ce7f369b6e --- /dev/null +++ b/src/core/server/elasticsearch/elasticsearch_route_handler_context.ts @@ -0,0 +1,39 @@ +/* + * 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 { KibanaRequest } from '../http'; +import type { IScopedClusterClient } from './client'; +import type { InternalElasticsearchServiceStart } from './types'; + +/** + * Core's `elasticsearch` request handler context. + * @public + */ +export interface ElasticsearchRequestHandlerContext { + client: IScopedClusterClient; +} + +/** + * The {@link UiSettingsRequestHandlerContext} implementation. + * @internal + */ +export class CoreElasticsearchRouteHandlerContext implements ElasticsearchRequestHandlerContext { + #client?: IScopedClusterClient; + + constructor( + private readonly elasticsearchStart: InternalElasticsearchServiceStart, + private readonly request: KibanaRequest + ) {} + + public get client() { + if (this.#client == null) { + this.#client = this.elasticsearchStart.client.asScoped(this.request); + } + return this.#client; + } +} diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index f146ba937509e..5b7ef6e1290e9 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -54,3 +54,5 @@ export { isNotFoundFromUnsupportedServer, PRODUCT_RESPONSE_HEADER, } from './supported_server_response_check'; +export { CoreElasticsearchRouteHandlerContext } from './elasticsearch_route_handler_context'; +export type { ElasticsearchRequestHandlerContext } from './elasticsearch_route_handler_context'; diff --git a/src/core/server/execution_context/integration_tests/tracing.test.ts b/src/core/server/execution_context/integration_tests/tracing.test.ts index a733bf796f62e..40dd31e8116df 100644 --- a/src/core/server/execution_context/integration_tests/tracing.test.ts +++ b/src/core/server/execution_context/integration_tests/tracing.test.ts @@ -8,6 +8,7 @@ import { ExecutionContextContainer } from '@kbn/core-execution-context-browser-internal'; import * as kbnTestServer from '../../../test_helpers/kbn_server'; +import { RequestHandlerContext } from '../..'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -63,7 +64,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asInternalUser.ping({}, { meta: true }); @@ -86,7 +87,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asCurrentUser.ping({}, { meta: true }); @@ -109,7 +110,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asInternalUser.ping({}, { meta: true }); @@ -128,7 +129,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asCurrentUser.ping({}, { meta: true }); @@ -147,7 +148,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asInternalUser.ping( @@ -194,7 +195,7 @@ describe('trace', () => { const { http } = await rootExecutionContextDisabled.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asCurrentUser.ping({}, { meta: true }); @@ -217,7 +218,7 @@ describe('trace', () => { const { http, executionContext } = await rootExecutionContextDisabled.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { executionContext.set(parentContext); const esClient = (await context.core).elasticsearch.client; @@ -250,7 +251,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { executionContext.set(parentContext); return res.ok({ body: executionContext.get() }); @@ -265,7 +266,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { executionContext.set(parentContext); await delay(100); @@ -281,7 +282,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); let id = 42; router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { executionContext.set({ ...parentContext, id: String(id++) }); @@ -301,7 +302,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); let id = 2; router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { executionContext.set({ ...parentContext, id: String(id) }); @@ -329,7 +330,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); let id = 2; router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { executionContext.set(parentContext); @@ -364,7 +365,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, (context, req, res) => res.ok({ body: executionContext.get() }) ); @@ -382,7 +383,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asCurrentUser.ping({}, { meta: true }); @@ -416,7 +417,7 @@ describe('trace', () => { registerOnPreResponse, } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { return res.ok({ body: executionContext.get()?.toJSON() }); }); @@ -470,7 +471,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asCurrentUser.ping({}, { meta: true }); @@ -492,7 +493,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asInternalUser.ping({}, { meta: true }); @@ -514,7 +515,7 @@ describe('trace', () => { const { http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { const esClient = (await context.core).elasticsearch.client; const { headers } = await esClient.asCurrentUser.ping({}, { meta: true }); @@ -537,7 +538,7 @@ describe('trace', () => { const { http, executionContext } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { executionContext.set(parentContext); const esClient = (await context.core).elasticsearch.client; @@ -561,7 +562,7 @@ describe('trace', () => { const { http, executionContext } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); const ctx = { type: 'test-type', name: 'test-name', @@ -588,7 +589,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { return executionContext.withContext(parentContext, () => res.ok({ body: executionContext.get() }) @@ -604,7 +605,7 @@ describe('trace', () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); const nestedContext = { type: 'nested-type', name: 'nested-name', @@ -630,7 +631,7 @@ describe('trace', () => { const { http, executionContext } = await root.setup(); const { createRouter } = http; - const router = createRouter(''); + const router = createRouter(''); const newContext = { type: 'new-type', name: 'new-name', diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 5746bd1d6306e..ac2597a0d28ce 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -25,7 +25,7 @@ import { } from './router'; import { HttpServer } from './http_server'; import { Readable } from 'stream'; -import { RequestHandlerContext } from '..'; +import { RequestHandlerContextBase } from '..'; import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import moment from 'moment'; import { of } from 'rxjs'; @@ -466,7 +466,7 @@ test('not inline handler - KibanaRequest', async () => { const router = new Router('/foo', logger, enhanceWithContext); const handler = ( - context: RequestHandlerContext, + context: RequestHandlerContextBase, req: KibanaRequest, res: KibanaResponseFactory ) => { diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index d3e9fd7fe27f2..c0ddd0ac5b3f4 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -30,6 +30,7 @@ import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { ExternalUrlConfig } from './external_url'; import type { IAuthHeadersStorage } from './auth_headers_storage'; +import type { RequestHandlerContextBase } from '..'; type BasePathMocked = jest.Mocked; type AuthMocked = jest.Mocked; @@ -38,9 +39,9 @@ export type HttpServicePrebootMock = jest.Mocked; export type InternalHttpServicePrebootMock = jest.Mocked< Omit > & { basePath: BasePathMocked }; -export type HttpServiceSetupMock = jest.Mocked< - Omit -> & { +export type HttpServiceSetupMock< + ContextType extends RequestHandlerContextBase = RequestHandlerContextBase +> = jest.Mocked, 'basePath' | 'createRouter'>> & { basePath: BasePathMocked; createRouter: jest.MockedFunction<() => RouterMock>; }; @@ -166,10 +167,12 @@ const createInternalSetupContractMock = () => { return mock; }; -const createSetupContractMock = () => { +const createSetupContractMock = < + ContextType extends RequestHandlerContextBase = RequestHandlerContextBase +>() => { const internalMock = createInternalSetupContractMock(); - const mock: HttpServiceSetupMock = { + const mock: HttpServiceSetupMock = { createCookieSessionStorageFactory: internalMock.createCookieSessionStorageFactory, registerOnPreRouting: internalMock.registerOnPreRouting, registerOnPreAuth: jest.fn(), diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index f13a0fbaf294d..1014b89e7425e 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -16,11 +16,11 @@ import type { CoreContext, CoreService } from '@kbn/core-base-server-internal'; import type { PluginOpaqueId } from '@kbn/core-base-common'; import type { InternalExecutionContextSetup } from '@kbn/core-execution-context-server-internal'; -import type { RequestHandlerContext } from '..'; +import type { RequestHandlerContextBase } from '..'; import { InternalContextSetup, InternalContextPreboot } from '../context'; import { CspConfigType, cspConfig } from './csp'; -import { Router } from './router'; +import { Router, IRouter } from './router'; import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; @@ -114,8 +114,13 @@ export class HttpService server: prebootSetup.server, registerRouteHandlerContext: (pluginOpaqueId, contextName, provider) => prebootServerRequestHandlerContext.registerContext(pluginOpaqueId, contextName, provider), - registerRoutes: (path, registerCallback) => { - const router = new Router( + registerRoutes: < + DefaultRequestHandlerType extends RequestHandlerContextBase = RequestHandlerContextBase + >( + path: string, + registerCallback: (router: IRouter) => void + ) => { + const router = new Router( path, this.log, prebootServerRequestHandlerContext.createHandler.bind(null, this.coreContext.coreId) @@ -157,7 +162,7 @@ export class HttpService externalUrl: new ExternalUrlConfig(config.externalUrl), - createRouter: ( + createRouter: ( path: string, pluginId: PluginOpaqueId = this.coreContext.coreId ) => { @@ -168,7 +173,7 @@ export class HttpService }, registerRouteHandlerContext: < - Context extends RequestHandlerContext, + Context extends RequestHandlerContextBase, ContextName extends keyof Context >( pluginOpaqueId: PluginOpaqueId, diff --git a/src/core/server/http/router/error_wrapper.test.ts b/src/core/server/http/router/error_wrapper.test.ts index 948aff8f32fee..48dad17fcf934 100644 --- a/src/core/server/http/router/error_wrapper.test.ts +++ b/src/core/server/http/router/error_wrapper.test.ts @@ -9,7 +9,8 @@ import Boom from '@hapi/boom'; import { KibanaResponse, KibanaResponseFactory, kibanaResponseFactory } from './response'; import { wrapErrors } from './error_wrapper'; -import { KibanaRequest, RequestHandler, RequestHandlerContext } from '../..'; +import { RequestHandlerContextBase } from '../..'; +import { KibanaRequest, RequestHandler } from '..'; const createHandler = (handler: () => any): RequestHandler => @@ -18,7 +19,7 @@ const createHandler = }; describe('wrapErrors', () => { - let context: RequestHandlerContext; + let context: RequestHandlerContextBase; let request: KibanaRequest; let response: KibanaResponseFactory; diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index 59611e51d9a15..570f4f973dd53 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -24,7 +24,7 @@ import { } from './response'; import { RouteConfig, RouteConfigOptions, RouteMethod, validBodyOutput } from './route'; import { HapiResponseAdapter } from './response_adapter'; -import { RequestHandlerContext } from '../..'; +import { RequestHandlerContextBase } from '../..'; import { wrapErrors } from './error_wrapper'; import { RouteValidator } from './validator'; @@ -46,7 +46,7 @@ export interface RouterRoute { */ export type RouteRegistrar< Method extends RouteMethod, - Context extends RequestHandlerContext = RequestHandlerContext + Context extends RequestHandlerContextBase = RequestHandlerContextBase > = ( route: RouteConfig, handler: RequestHandler @@ -58,7 +58,7 @@ export type RouteRegistrar< * * @public */ -export interface IRouter { +export interface IRouter { /** * Resulted path */ @@ -118,7 +118,7 @@ export type ContextEnhancer< Q, B, Method extends RouteMethod, - Context extends RequestHandlerContext + Context extends RequestHandlerContextBase > = (handler: RequestHandler) => RequestHandlerEnhanced; function getRouteFullPath(routerPath: string, routePath: string) { @@ -202,7 +202,7 @@ function validOptions( /** * @internal */ -export class Router +export class Router implements IRouter { public routes: Array> = []; @@ -307,7 +307,7 @@ type WithoutHeadArgument = T extends (first: any, ...rest: infer Params) => i : never; type RequestHandlerEnhanced = WithoutHeadArgument< - RequestHandler + RequestHandler >; /** @@ -350,7 +350,7 @@ export type RequestHandler< P = unknown, Q = unknown, B = unknown, - Context extends RequestHandlerContext = RequestHandlerContext, + Context extends RequestHandlerContextBase = RequestHandlerContextBase, Method extends RouteMethod = any, ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory > = ( @@ -376,7 +376,7 @@ export type RequestHandlerWrapper = < P, Q, B, - Context extends RequestHandlerContext = RequestHandlerContext, + Context extends RequestHandlerContextBase = RequestHandlerContextBase, Method extends RouteMethod = any, ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory >( diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 55bef12fd0f8b..46b74deec2bc2 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -21,7 +21,7 @@ import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; import { IBasePath } from './base_path_service'; import { ExternalUrlConfig } from './external_url'; -import type { PluginOpaqueId, RequestHandlerContext } from '..'; +import type { PluginOpaqueId, RequestHandlerContextBase } from '..'; /** * An object that handles registration of http request context providers. @@ -36,7 +36,7 @@ export type RequestHandlerContextContainer = IContextContainer; * @public */ export type RequestHandlerContextProvider< - Context extends RequestHandlerContext, + Context extends RequestHandlerContextBase, ContextName extends keyof Context > = IContextProvider; @@ -117,7 +117,9 @@ export interface HttpAuth { * ``` * @public */ -export interface HttpServicePreboot { +export interface HttpServicePreboot< + DefaultRequestHandlerType extends RequestHandlerContextBase = RequestHandlerContextBase +> { /** * Provides ability to acquire `preboot` {@link IRouter} instance for a particular top-level path and register handler * functions for any number of nested routes. @@ -135,7 +137,10 @@ export interface HttpServicePreboot { * ``` * @public */ - registerRoutes(path: string, callback: (router: IRouter) => void): void; + registerRoutes( + path: string, + callback: (router: IRouter) => void + ): void; /** * Access or manipulate the Kibana base path @@ -162,7 +167,12 @@ export interface InternalHttpServicePreboot | 'server' | 'getServerInfo' > { - registerRoutes(path: string, callback: (router: IRouter) => void): void; + registerRoutes< + DefaultRequestHandlerType extends RequestHandlerContextBase = RequestHandlerContextBase + >( + path: string, + callback: (router: IRouter) => void + ): void; } /** @@ -237,7 +247,9 @@ export interface InternalHttpServicePreboot * ``` * @public */ -export interface HttpServiceSetup { +export interface HttpServiceSetup< + DefaultRequestHandlerType extends RequestHandlerContextBase = RequestHandlerContextBase +> { /** * Creates cookie based session storage factory {@link SessionStorageFactory} * @param cookieOptions {@link SessionStorageCookieOptions} - options to configure created cookie session storage. @@ -333,7 +345,7 @@ export interface HttpServiceSetup { * @public */ createRouter: < - Context extends RequestHandlerContext = RequestHandlerContext + Context extends DefaultRequestHandlerType = DefaultRequestHandlerType >() => IRouter; /** @@ -365,7 +377,7 @@ export interface HttpServiceSetup { * @public */ registerRouteHandlerContext: < - Context extends RequestHandlerContext, + Context extends DefaultRequestHandlerType, ContextName extends keyof Omit >( contextName: ContextName, @@ -384,7 +396,7 @@ export interface InternalHttpServiceSetup auth: HttpServerSetup['auth']; server: HttpServerSetup['server']; externalUrl: ExternalUrlConfig; - createRouter: ( + createRouter: ( path: string, plugin?: PluginOpaqueId ) => IRouter; @@ -392,7 +404,7 @@ export interface InternalHttpServiceSetup registerStaticDir: (path: string, dirPath: string) => void; authRequestHeaders: IAuthHeadersStorage; registerRouteHandlerContext: < - Context extends RequestHandlerContext, + Context extends RequestHandlerContextBase, ContextName extends keyof Omit >( pluginOpaqueId: PluginOpaqueId, diff --git a/src/core/server/http_resources/http_resources_service.test.ts b/src/core/server/http_resources/http_resources_service.test.ts index 91500737123c7..e626d496c8dec 100644 --- a/src/core/server/http_resources/http_resources_service.test.ts +++ b/src/core/server/http_resources/http_resources_service.test.ts @@ -8,7 +8,7 @@ import { getApmConfigMock } from './http_resources_service.test.mocks'; -import { IRouter, RouteConfig } from '../http'; +import { RouteConfig } from '../http'; import { mockCoreContext } from '@kbn/core-base-server-mocks'; import { coreMock } from '../mocks'; @@ -25,7 +25,7 @@ describe('HttpResources service', () => { let service: HttpResourcesService; let prebootDeps: PrebootDeps; let setupDeps: SetupDeps; - let router: jest.Mocked; + let router: ReturnType; const kibanaRequest = httpServerMock.createKibanaRequest(); const context = coreMock.createCustomRequestHandlerContext({}); const apmConfig = { mockApmConfig: true }; diff --git a/src/core/server/http_resources/http_resources_service.ts b/src/core/server/http_resources/http_resources_service.ts index 978ad8e72621b..2fa90b4346965 100644 --- a/src/core/server/http_resources/http_resources_service.ts +++ b/src/core/server/http_resources/http_resources_service.ts @@ -63,7 +63,10 @@ export class HttpResourcesService implements CoreService + ): HttpResources { return { register: ( route: RouteConfig, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 25dd89acab6e6..4198fe04789ae 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -46,29 +46,26 @@ import { ElasticsearchServiceSetup, configSchema as elasticsearchConfigSchema, ElasticsearchServiceStart, - IScopedClusterClient, ElasticsearchServicePreboot, } from './elasticsearch'; -import { HttpServicePreboot, HttpServiceSetup, HttpServiceStart } from './http'; +import type { + HttpServicePreboot, + HttpServiceSetup, + HttpServiceStart, + IRouter, + RequestHandler, +} from './http'; import { HttpResources } from './http_resources'; import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins'; -import { IUiSettingsClient, UiSettingsServiceSetup, UiSettingsServiceStart } from './ui_settings'; -import { SavedObjectsClientContract } from './saved_objects/types'; -import { - ISavedObjectTypeRegistry, - SavedObjectsServiceSetup, - SavedObjectsServiceStart, - ISavedObjectsExporter, - ISavedObjectsImporter, - SavedObjectsClientProviderOptions, -} from './saved_objects'; +import { UiSettingsServiceSetup, UiSettingsServiceStart } from './ui_settings'; +import { SavedObjectsServiceSetup, SavedObjectsServiceStart } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; import { CoreUsageDataStart, CoreUsageDataSetup } from './core_usage_data'; import { I18nServiceSetup } from './i18n'; -import { DeprecationsServiceSetup, DeprecationsClient } from './deprecations'; +import { DeprecationsServiceSetup } from './deprecations'; // Because of #79265 we need to explicitly import, then export these types for // scripts/telemetry_check.js to work as expected import { @@ -80,6 +77,9 @@ import { CoreServicesUsageData, } from './core_usage_data'; import { PrebootServicePreboot } from './preboot'; +import type { CoreRequestHandlerContext } from './core_route_handler_context'; +import type { PrebootCoreRequestHandlerContext } from './preboot_core_route_handler_context'; +import { KibanaResponseFactory, RouteMethod } from './http'; export type { PrebootServicePreboot } from './preboot'; @@ -151,6 +151,7 @@ export type { UnauthorizedErrorHandlerResult, UnauthorizedErrorHandlerToolkit, UnauthorizedErrorHandler, + ElasticsearchRequestHandlerContext, } from './elasticsearch'; export type { @@ -176,7 +177,6 @@ export type { HttpResponsePayload, HttpServerInfo, HttpServicePreboot, - HttpServiceSetup, HttpServiceStart, ErrorHttpResponseOptions, IKibanaSocket, @@ -199,7 +199,6 @@ export type { OnPreResponseExtensions, OnPreResponseInfo, RedirectResponseOptions, - RequestHandler, RequestHandlerWrapper, RequestHandlerContextContainer, RequestHandlerContextProvider, @@ -208,7 +207,6 @@ export type { ResponseHeaders, KibanaResponseFactory, RouteConfig, - IRouter, RouteRegistrar, RouteMethod, RouteConfigOptions, @@ -387,6 +385,7 @@ export type { SavedObjectsImportWarning, SavedObjectsValidationMap, SavedObjectsValidationSpec, + SavedObjectsRequestHandlerContext, } from './saved_objects'; export type { @@ -398,6 +397,7 @@ export type { UiSettingsServiceStart, UserProvidedValues, DeprecationSettings, + UiSettingsRequestHandlerContext, } from './ui_settings'; export type { @@ -421,6 +421,7 @@ export type { GetDeprecationsContext, DeprecationsServiceSetup, DeprecationsClient, + DeprecationsRequestHandlerContext, } from './deprecations'; export type { AppCategory } from '../types'; @@ -473,7 +474,12 @@ export type { AnalyticsServiceStart, } from '@kbn/core-analytics-server'; -/** @public **/ +export type { CoreRequestHandlerContext } from './core_route_handler_context'; + +/** + * Base, abstract type for request handler contexts. + * @public + **/ export interface RequestHandlerContextBase { /** * Await all the specified context parts and return them. @@ -491,7 +497,7 @@ export interface RequestHandlerContextBase { } /** - * Base context passed to a route handler. + * Base context passed to a route handler, containing the `core` context part. * * @public */ @@ -499,43 +505,21 @@ export interface RequestHandlerContext extends RequestHandlerContextBase { core: Promise; } -/** @public */ -export type CustomRequestHandlerContext = RequestHandlerContext & { - [Key in keyof T]: T[Key] extends Promise ? T[Key] : Promise; -}; +/** + * @internal + */ +export interface PrebootRequestHandlerContext extends RequestHandlerContextBase { + core: Promise; +} /** - * The `core` context provided to route handler. + * Mixin allowing plugins to define their own request handler contexts. * - * Provides the following clients and services: - * - {@link SavedObjectsClient | savedObjects.client} - Saved Objects client - * which uses the credentials of the incoming request - * - {@link ISavedObjectTypeRegistry | savedObjects.typeRegistry} - Type registry containing - * all the registered types. - * - {@link IScopedClusterClient | elasticsearch.client} - Elasticsearch - * data client which uses the credentials of the incoming request - * - {@link IUiSettingsClient | uiSettings.client} - uiSettings client - * which uses the credentials of the incoming request * @public */ -export interface CoreRequestHandlerContext { - savedObjects: { - client: SavedObjectsClientContract; - typeRegistry: ISavedObjectTypeRegistry; - getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; - getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; - getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; - }; - elasticsearch: { - client: IScopedClusterClient; - }; - uiSettings: { - client: IUiSettingsClient; - }; - deprecations: { - client: DeprecationsClient; - }; -} +export type CustomRequestHandlerContext = RequestHandlerContext & { + [Key in keyof T]: T[Key] extends Promise ? T[Key] : Promise; +}; /** * Context passed to the `setup` method of `preboot` plugins. @@ -547,7 +531,7 @@ export interface CorePreboot { /** {@link ElasticsearchServicePreboot} */ elasticsearch: ElasticsearchServicePreboot; /** {@link HttpServicePreboot} */ - http: HttpServicePreboot; + http: HttpServicePreboot; /** {@link PrebootServicePreboot} */ preboot: PrebootServicePreboot; } @@ -573,7 +557,7 @@ export interface CoreSetup & { /** {@link HttpResources} */ resources: HttpResources; }; @@ -662,3 +646,42 @@ export const config = { appenders: appendersSchema as Type, }, }; + +/** + * Public version of RequestHandler, default-scoped to {@link RequestHandlerContext} + * See [@link RequestHandler} + * @public + */ +type PublicRequestHandler< + P = unknown, + Q = unknown, + B = unknown, + Context extends RequestHandlerContext = RequestHandlerContext, + Method extends RouteMethod = any, + ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory +> = RequestHandler; + +export type { PublicRequestHandler as RequestHandler, RequestHandler as BaseRequestHandler }; + +/** + * Public version of IRouter, default-scoped to {@link RequestHandlerContext} + * See [@link IRouter} + * @public + */ +type PublicRouter = + IRouter; + +export type { PublicRouter as IRouter, IRouter as IBaseRouter }; + +/** + * Public version of RequestHandlerContext, default-scoped to {@link RequestHandlerContext} + * See [@link RequestHandlerContext} + * @public + */ +type PublicHttpServiceSetup = + HttpServiceSetup; + +export type { + PublicHttpServiceSetup as HttpServiceSetup, + HttpServiceSetup as BaseHttpServiceSetup, +}; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 0382d9f56ca1d..f224ff76418ab 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -23,6 +23,7 @@ import type { CoreStart, StartServicesAccessor, CorePreboot, + RequestHandlerContext, } from '.'; import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -139,7 +140,7 @@ function createCorePrebootMock() { const mock: CorePrebootMockType = { analytics: analyticsServiceMock.createAnalyticsServicePreboot(), elasticsearch: elasticsearchServiceMock.createPreboot(), - http: httpServiceMock.createPrebootContract(), + http: httpServiceMock.createPrebootContract() as CorePrebootMockType['http'], preboot: prebootServiceMock.createPrebootContract(), }; @@ -159,7 +160,7 @@ function createCoreSetupMock({ pluginStartContract?: any; } = {}) { const httpMock: jest.Mocked = { - ...httpServiceMock.createSetupContract(), + ...httpServiceMock.createSetupContract(), resources: httpResourcesMock.createRegistrar(), }; diff --git a/src/core/server/preboot_core_route_handler_context.ts b/src/core/server/preboot_core_route_handler_context.ts index 63378046e8050..ba3cc445f6958 100644 --- a/src/core/server/preboot_core_route_handler_context.ts +++ b/src/core/server/preboot_core_route_handler_context.ts @@ -10,12 +10,34 @@ import { InternalCorePreboot } from './internal_types'; import { IUiSettingsClient } from './ui_settings'; -class PrebootCoreUiSettingsRouteHandlerContext { +/** + * @public + */ +export interface PrebootUiSettingsRequestHandlerContext { + client: IUiSettingsClient; +} + +/** + * Implementation of {@link PrebootUiSettingsRequestHandlerContext} + * @internal + */ +class PrebootCoreUiSettingsRouteHandlerContext implements PrebootUiSettingsRequestHandlerContext { constructor(public readonly client: IUiSettingsClient) {} } -export class PrebootCoreRouteHandlerContext { - readonly uiSettings: PrebootCoreUiSettingsRouteHandlerContext; +/** + * @public + */ +export interface PrebootCoreRequestHandlerContext { + uiSettings: PrebootUiSettingsRequestHandlerContext; +} + +/** + * Implementation of {@link PrebootCoreRequestHandlerContext}. + * @internal + */ +export class PrebootCoreRouteHandlerContext implements PrebootCoreRequestHandlerContext { + readonly uiSettings: PrebootUiSettingsRequestHandlerContext; constructor(private readonly corePreboot: InternalCorePreboot) { this.uiSettings = new PrebootCoreUiSettingsRouteHandlerContext( diff --git a/src/core/server/rendering/bootstrap/register_bootstrap_route.ts b/src/core/server/rendering/bootstrap/register_bootstrap_route.ts index 06f4100a01576..c22f7f56d70f2 100644 --- a/src/core/server/rendering/bootstrap/register_bootstrap_route.ts +++ b/src/core/server/rendering/bootstrap/register_bootstrap_route.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import { IRouter } from '../../http'; +import type { InternalRenderingRouter } from '../internal_types'; import type { BootstrapRenderer } from './bootstrap_renderer'; export const registerBootstrapRoute = ({ router, renderer, }: { - router: IRouter; + router: InternalRenderingRouter; renderer: BootstrapRenderer; }) => { router.get( diff --git a/src/core/server/rendering/internal_types.ts b/src/core/server/rendering/internal_types.ts new file mode 100644 index 0000000000000..6ad3fd086f819 --- /dev/null +++ b/src/core/server/rendering/internal_types.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 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 { RequestHandlerContextBase } from '..'; +import type { IRouter } from '../http'; +import type { UiSettingsRequestHandlerContext } from '../ui_settings'; + +/** + * Request handler context used by core's rendering routes. + * @internal + */ +export interface InternalRenderingRequestHandlerContext extends RequestHandlerContextBase { + core: Promise<{ + uiSettings: UiSettingsRequestHandlerContext; + }>; +} + +/** + * Router bound to the {@link InternalRenderingRequestHandlerContext}. + * Used by core's rendering routes. + * @internal + */ +export type InternalRenderingRouter = IRouter; diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 2ce22b731b5e5..ac6ff9ee2f2b8 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -29,6 +29,7 @@ import { getSettingValue, getStylesheetPaths } from './render_utils'; import type { HttpAuth, KibanaRequest } from '../http'; import { IUiSettingsClient } from '../ui_settings'; import { filterUiPlugins } from './filter_ui_plugins'; +import type { InternalRenderingRequestHandlerContext } from './internal_types'; type RenderOptions = | (RenderingPrebootDeps & { status?: never; elasticsearch?: never }) @@ -42,7 +43,7 @@ export class RenderingService { http, uiPlugins, }: RenderingPrebootDeps): Promise { - http.registerRoutes('', (router) => { + http.registerRoutes('', (router) => { registerBootstrapRoute({ router, renderer: bootstrapRendererFactory({ @@ -66,7 +67,7 @@ export class RenderingService { uiPlugins, }: RenderingSetupDeps): Promise { registerBootstrapRoute({ - router: http.createRouter(''), + router: http.createRouter(''), renderer: bootstrapRendererFactory({ uiPlugins, serverBasePath: http.basePath.serverBasePath, diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 73a20c0402bbe..434096193ff9f 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -99,3 +99,5 @@ export type { SavedObjectsValidationMap, SavedObjectsValidationSpec } from './va export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry } from './saved_objects_type_registry'; export type { ISavedObjectTypeRegistry } from './saved_objects_type_registry'; +export { CoreSavedObjectsRouteHandlerContext } from './saved_objects_route_handler_context'; +export type { SavedObjectsRequestHandlerContext } from './saved_objects_route_handler_context'; diff --git a/src/core/server/saved_objects/internal_types.ts b/src/core/server/saved_objects/internal_types.ts new file mode 100644 index 0000000000000..6d3851fca120b --- /dev/null +++ b/src/core/server/saved_objects/internal_types.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 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 { RequestHandlerContextBase } from '..'; +import type { IRouter } from '../http'; +import type { ElasticsearchRequestHandlerContext } from '../elasticsearch'; +import type { SavedObjectsRequestHandlerContext } from './saved_objects_route_handler_context'; + +/** + * Request handler context used by core's savedObjects routes. + * @internal + */ +export interface InternalSavedObjectsRequestHandlerContext extends RequestHandlerContextBase { + core: Promise<{ + savedObjects: SavedObjectsRequestHandlerContext; + elasticsearch: ElasticsearchRequestHandlerContext; + }>; +} + +/** + * Router bound to the {@link InternalSavedObjectsRequestHandlerContext}. + * Used by core's savedObjects routes. + * @internal + */ +export type InternalSavedObjectRouter = IRouter; diff --git a/src/core/server/saved_objects/migrations/actions/es_errors.test.ts b/src/core/server/saved_objects/migrations/actions/es_errors.test.ts index b34366b7386d2..f0004851b1bf9 100644 --- a/src/core/server/saved_objects/migrations/actions/es_errors.test.ts +++ b/src/core/server/saved_objects/migrations/actions/es_errors.test.ts @@ -9,6 +9,7 @@ import { isClusterShardLimitExceeded, isIncompatibleMappingException, + isIndexNotFoundException, isWriteBlockException, } from './es_errors'; @@ -37,6 +38,9 @@ describe('isWriteBlockError', () => { }) ).toEqual(false); }); + it('returns false undefined', () => { + expect(isWriteBlockException(undefined)).toEqual(false); + }); }); describe('isIncompatibleMappingExceptionError', () => { @@ -57,6 +61,31 @@ describe('isIncompatibleMappingExceptionError', () => { }) ).toEqual(true); }); + it('returns false undefined', () => { + expect(isIncompatibleMappingException(undefined)).toEqual(false); + }); +}); + +describe('isIndexNotFoundException', () => { + it('returns true with index_not_found_exception errors', () => { + expect( + isIndexNotFoundException({ + type: 'index_not_found_exception', + reason: 'idk', + }) + ).toEqual(true); + }); + it('returns false for other errors', () => { + expect( + isIndexNotFoundException({ + type: 'validation_exception', + reason: 'idk', + }) + ).toEqual(false); + }); + it('returns false undefined', () => { + expect(isIndexNotFoundException(undefined)).toEqual(false); + }); }); describe('isClusterShardLimitExceeded', () => { @@ -77,4 +106,7 @@ describe('isClusterShardLimitExceeded', () => { }) ).toEqual(false); }); + it('returns false undefined', () => { + expect(isClusterShardLimitExceeded(undefined)).toEqual(false); + }); }); diff --git a/src/core/server/saved_objects/migrations/actions/es_errors.ts b/src/core/server/saved_objects/migrations/actions/es_errors.ts index 9f571d38ffd85..2bf432de032a6 100644 --- a/src/core/server/saved_objects/migrations/actions/es_errors.ts +++ b/src/core/server/saved_objects/migrations/actions/es_errors.ts @@ -7,25 +7,28 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -export const isWriteBlockException = ({ type, reason }: estypes.ErrorCause): boolean => { +export const isWriteBlockException = (errorCause?: estypes.ErrorCause): boolean => { return ( - type === 'cluster_block_exception' && - reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/.+ \(api\)\]/) !== null + errorCause?.type === 'cluster_block_exception' && + errorCause?.reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/.+ \(api\)\]/) !== null ); }; -export const isIncompatibleMappingException = ({ type }: estypes.ErrorCause): boolean => { - return type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; +export const isIncompatibleMappingException = (errorCause?: estypes.ErrorCause): boolean => { + return ( + errorCause?.type === 'strict_dynamic_mapping_exception' || + errorCause?.type === 'mapper_parsing_exception' + ); }; -export const isIndexNotFoundException = ({ type }: estypes.ErrorCause): boolean => { - return type === 'index_not_found_exception'; +export const isIndexNotFoundException = (errorCause?: estypes.ErrorCause): boolean => { + return errorCause?.type === 'index_not_found_exception'; }; -export const isClusterShardLimitExceeded = ({ type, reason }: estypes.ErrorCause): boolean => { +export const isClusterShardLimitExceeded = (errorCause?: estypes.ErrorCause): boolean => { return ( - type === 'validation_exception' && - reason.match( + errorCause?.type === 'validation_exception' && + errorCause?.reason.match( /this action would add .* shards, but this cluster currently has .* maximum normal shards open/ ) !== null ); diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index b72cd6715fa10..6a671c830ca69 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerBulkCreateRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerBulkCreateRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.post( { path: '/_bulk_create', diff --git a/src/core/server/saved_objects/routes/bulk_get.ts b/src/core/server/saved_objects/routes/bulk_get.ts index 87b6a604ac6c4..ba485c832ca65 100644 --- a/src/core/server/saved_objects/routes/bulk_get.ts +++ b/src/core/server/saved_objects/routes/bulk_get.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerBulkGetRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerBulkGetRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.post( { path: '/_bulk_get', diff --git a/src/core/server/saved_objects/routes/bulk_resolve.ts b/src/core/server/saved_objects/routes/bulk_resolve.ts index 5754ec180541c..e689e243ebc95 100644 --- a/src/core/server/saved_objects/routes/bulk_resolve.ts +++ b/src/core/server/saved_objects/routes/bulk_resolve.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerBulkResolveRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerBulkResolveRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.post( { path: '/_bulk_resolve', diff --git a/src/core/server/saved_objects/routes/bulk_update.ts b/src/core/server/saved_objects/routes/bulk_update.ts index 961b8349d1745..2edfa23ed8786 100644 --- a/src/core/server/saved_objects/routes/bulk_update.ts +++ b/src/core/server/saved_objects/routes/bulk_update.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerBulkUpdateRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerBulkUpdateRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.put( { path: '/_bulk_update', diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index eb3938db0a9c7..282cbafc0b2f5 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerCreateRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.post( { path: '/{type}/{id?}', diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index 5c239a55a6923..7410a237d39f7 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerDeleteRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.delete( { path: '/{type}/{id}', diff --git a/src/core/server/saved_objects/routes/deprecations/delete_unknown_types.ts b/src/core/server/saved_objects/routes/deprecations/delete_unknown_types.ts index a30da9723b916..5d36994928363 100644 --- a/src/core/server/saved_objects/routes/deprecations/delete_unknown_types.ts +++ b/src/core/server/saved_objects/routes/deprecations/delete_unknown_types.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { IRouter } from '../../../http'; import { catchAndReturnBoomErrors } from '../utils'; +import type { InternalSavedObjectRouter } from '../../internal_types'; import { deleteUnknownTypeObjects } from '../../deprecations'; interface RouteDependencies { @@ -16,7 +16,7 @@ interface RouteDependencies { } export const registerDeleteUnknownTypesRoute = ( - router: IRouter, + router: InternalSavedObjectRouter, { kibanaIndex, kibanaVersion }: RouteDependencies ) => { router.post( diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 16547970369c2..e011c71575080 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema'; import stringify from 'json-stable-stringify'; import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils'; -import { IRouter, KibanaRequest } from '../../http'; +import { KibanaRequest } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { @@ -18,6 +18,7 @@ import { SavedObjectsExportByObjectOptions, SavedObjectsExportError, } from '../export'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { validateTypes, validateObjects, catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { @@ -129,7 +130,7 @@ const validateOptions = ( }; export const registerExportRoute = ( - router: IRouter, + router: InternalSavedObjectRouter, { config, coreUsageData }: RouteDependencies ) => { const { maxImportExportSize } = config; diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 01ac9ae9025f4..7ebfef5be7c39 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerFindRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { const referenceSchema = schema.object({ type: schema.string(), id: schema.string(), diff --git a/src/core/server/saved_objects/routes/get.ts b/src/core/server/saved_objects/routes/get.ts index 2ea9a13bbce64..e7a2a779c4037 100644 --- a/src/core/server/saved_objects/routes/get.ts +++ b/src/core/server/saved_objects/routes/get.ts @@ -7,15 +7,18 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerGetRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerGetRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.get( { path: '/{type}/{id}', diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 545d01b454741..0c56acf9e2d68 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -9,10 +9,10 @@ import { Readable } from 'stream'; import { extname } from 'path'; import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; interface RouteDependencies { @@ -27,7 +27,7 @@ interface FileStream extends Readable { } export const registerImportRoute = ( - router: IRouter, + router: InternalSavedObjectRouter, { config, coreUsageData }: RouteDependencies ) => { const { maxImportPayloadBytes } = config; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index f76f802928190..34083e0d6ddf8 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -11,6 +11,7 @@ import { InternalHttpServiceSetup } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { IKibanaMigrator } from '../migrations'; +import type { InternalSavedObjectsRequestHandlerContext } from '../internal_types'; import { registerGetRoute } from './get'; import { registerResolveRoute } from './resolve'; import { registerCreateRoute } from './create'; @@ -46,7 +47,8 @@ export function registerRoutes({ kibanaVersion: string; kibanaIndex: string; }) { - const router = http.createRouter('/api/saved_objects/'); + const router = + http.createRouter('/api/saved_objects/'); registerGetRoute(router, { coreUsageData }); registerResolveRoute(router, { coreUsageData }); @@ -62,7 +64,7 @@ export function registerRoutes({ registerImportRoute(router, { config, coreUsageData }); registerResolveImportErrorsRoute(router, { config, coreUsageData }); - const legacyRouter = http.createRouter(''); + const legacyRouter = http.createRouter(''); registerLegacyImportRoute(legacyRouter, { maxImportPayloadBytes: config.maxImportPayloadBytes, coreUsageData, @@ -70,7 +72,9 @@ export function registerRoutes({ }); registerLegacyExportRoute(legacyRouter, { kibanaVersion, coreUsageData, logger }); - const internalRouter = http.createRouter('/internal/saved_objects/'); + const internalRouter = http.createRouter( + '/internal/saved_objects/' + ); registerMigrateRoute(internalRouter, migratorPromise); registerDeleteUnknownTypesRoute(internalRouter, { kibanaIndex, kibanaVersion }); diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts index eeec6bd6b0be0..c34ea099d0ec8 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts @@ -13,6 +13,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -28,7 +29,8 @@ describe('POST /api/saved_objects/_bulk_create', () => { savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: [] }); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsBulkCreate.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts index f389307d45c41..a82a5351e9949 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts @@ -13,6 +13,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -30,7 +31,8 @@ describe('POST /api/saved_objects/_bulk_get', () => { savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [], }); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsBulkGet.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_resolve.test.ts index bb5067a13ba3e..90f06368468be 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_resolve.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_resolve.test.ts @@ -13,6 +13,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -30,7 +31,8 @@ describe('POST /api/saved_objects/_bulk_resolve', () => { savedObjectsClient.bulkResolve.mockResolvedValue({ resolved_objects: [], }); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsBulkResolve.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts index 8c55686b2fd0f..d92041bce3c72 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts @@ -13,6 +13,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -27,7 +28,8 @@ describe('PUT /api/saved_objects/_bulk_update', () => { ({ server, httpSetup, handlerContext } = await setupServer()); savedObjectsClient = handlerContext.savedObjects.client; - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsBulkUpdate.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/create.test.ts b/src/core/server/saved_objects/routes/integration_tests/create.test.ts index 9d65843271cc0..e4f566c769234 100644 --- a/src/core/server/saved_objects/routes/integration_tests/create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/create.test.ts @@ -13,6 +13,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -37,7 +38,8 @@ describe('POST /api/saved_objects/{type}', () => { savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient.create.mockImplementation(() => Promise.resolve(clientResponse)); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsCreate.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index 84a2db60b587a..849b99b49fbc0 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -13,6 +13,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -28,7 +29,8 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { savedObjectsClient = handlerContext.savedObjects.getClient(); handlerContext.savedObjects.getClient = jest.fn().mockImplementation(() => savedObjectsClient); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsDelete.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/delete_unknown_types.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete_unknown_types.test.ts index 60d916744babc..87376de206810 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete_unknown_types.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete_unknown_types.test.ts @@ -12,6 +12,7 @@ import { elasticsearchServiceMock } from '../../../elasticsearch/elasticsearch_s import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; import { setupServer } from '../test_utils'; import { SavedObjectsType } from '../../..'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -37,7 +38,9 @@ describe('POST /internal/saved_objects/deprecations/_delete_unknown_types', () = handlerContext.elasticsearch.client.asCurrentUser = elasticsearchClient.asCurrentUser; handlerContext.elasticsearch.client.asInternalUser = elasticsearchClient.asInternalUser; - const router = httpSetup.createRouter('/internal/saved_objects/'); + const router = httpSetup.createRouter( + '/internal/saved_objects/' + ); registerDeleteUnknownTypesRoute(router, { kibanaVersion, kibanaIndex, diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 1227d2636c555..9bdd6f39abbd0 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -19,6 +19,7 @@ import { savedObjectsExporterMock } from '../../export/saved_objects_exporter.mo import { SavedObjectConfig } from '../../saved_objects_config'; import { registerExportRoute } from '../export'; import { setupServer, createExportableType } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; const allowedTypes = ['index-pattern', 'search']; @@ -41,7 +42,8 @@ describe('POST /api/saved_objects/_export', () => { ); exporter = handlerContext.savedObjects.getExporter(); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); handlerContext.savedObjects.getExporter = jest .fn() .mockImplementation(() => exporter as ReturnType); diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index c18044f8973b8..8758666bc487d 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -15,6 +15,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -38,7 +39,8 @@ describe('GET /api/saved_objects/_find', () => { savedObjectsClient.find.mockResolvedValue(clientResponse); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsFind.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/get.test.ts b/src/core/server/saved_objects/routes/integration_tests/get.test.ts index 98d9d9170e5eb..8b34c9f2ef958 100644 --- a/src/core/server/saved_objects/routes/integration_tests/get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/get.test.ts @@ -17,6 +17,7 @@ import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_da import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { createHttpServer, createCoreContext } from '../../../http/test_utils'; import { contextServiceMock, coreMock } from '../../../mocks'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; const coreId = Symbol('core'); @@ -41,11 +42,16 @@ describe('GET /api/saved_objects/{type}/{id}', () => { handlerContext = coreMock.createRequestHandlerContext(); savedObjectsClient = handlerContext.savedObjects.client; - httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { - return handlerContext; - }); + httpSetup.registerRouteHandlerContext( + coreId, + 'core', + (ctx, req, res) => { + return handlerContext; + } + ); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsGet.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 2f6f206366766..9e6e52977b2e1 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -17,6 +17,7 @@ import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_da import { SavedObjectConfig } from '../../saved_objects_config'; import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectsErrorHelpers, SavedObjectsImporter } from '../..'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -69,7 +70,9 @@ describe(`POST ${URL}`, () => { .fn() .mockImplementation(() => importer as jest.Mocked); - const router = httpSetup.createRouter('/internal/saved_objects/'); + const router = httpSetup.createRouter( + '/internal/saved_objects/' + ); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsImport.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts index f551abda188ef..78397ca00cb60 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts @@ -17,6 +17,7 @@ import { executionContextServiceMock } from '@kbn/core-execution-context-server- import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { createHttpServer, createCoreContext } from '../../../http/test_utils'; import { contextServiceMock, coreMock } from '../../../mocks'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; const coreId = Symbol('core'); @@ -41,11 +42,16 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => { handlerContext = coreMock.createRequestHandlerContext(); savedObjectsClient = handlerContext.savedObjects.client; - httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { - return handlerContext; - }); + httpSetup.registerRouteHandlerContext( + coreId, + 'core', + (ctx, req, res) => { + return handlerContext; + } + ); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsResolve.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index dfc69edaff420..101cf05032618 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -17,6 +17,7 @@ import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_da import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectConfig } from '../../saved_objects_config'; import { SavedObjectsImporter } from '../..'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -76,7 +77,8 @@ describe(`POST ${URL}`, () => { .fn() .mockImplementation(() => importer as jest.Mocked); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsResolveImportErrors.mockRejectedValue( new Error('Oh no!') // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail diff --git a/src/core/server/saved_objects/routes/integration_tests/update.test.ts b/src/core/server/saved_objects/routes/integration_tests/update.test.ts index 78e7b9c1e93d5..1a41d94b0febe 100644 --- a/src/core/server/saved_objects/routes/integration_tests/update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/update.test.ts @@ -13,6 +13,7 @@ import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { setupServer } from '../test_utils'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../internal_types'; type SetupServerReturn = Awaited>; @@ -38,7 +39,8 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { savedObjectsClient = handlerContext.savedObjects.client; savedObjectsClient.update.mockResolvedValue(clientResponse); - const router = httpSetup.createRouter('/api/saved_objects/'); + const router = + httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsUpdate.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); diff --git a/src/core/server/saved_objects/routes/legacy_import_export/export.ts b/src/core/server/saved_objects/routes/legacy_import_export/export.ts index 7141d74b71904..9073090de8fe7 100644 --- a/src/core/server/saved_objects/routes/legacy_import_export/export.ts +++ b/src/core/server/saved_objects/routes/legacy_import_export/export.ts @@ -8,12 +8,13 @@ import moment from 'moment'; import { schema } from '@kbn/config-schema'; +import type { Logger } from '@kbn/logging'; import { InternalCoreUsageDataSetup } from '../../../core_usage_data'; -import { IRouter, Logger } from '../../..'; +import type { InternalSavedObjectRouter } from '../../internal_types'; import { exportDashboards } from './lib'; export const registerLegacyExportRoute = ( - router: IRouter, + router: InternalSavedObjectRouter, { kibanaVersion, coreUsageData, diff --git a/src/core/server/saved_objects/routes/legacy_import_export/import.ts b/src/core/server/saved_objects/routes/legacy_import_export/import.ts index d98c14f9b620d..d4a13b2973964 100644 --- a/src/core/server/saved_objects/routes/legacy_import_export/import.ts +++ b/src/core/server/saved_objects/routes/legacy_import_export/import.ts @@ -7,12 +7,14 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter, Logger, SavedObject } from '../../..'; +import type { Logger } from '@kbn/logging'; +import type { SavedObject } from '../../..'; import { InternalCoreUsageDataSetup } from '../../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../../internal_types'; import { importDashboards } from './lib'; export const registerLegacyImportRoute = ( - router: IRouter, + router: InternalSavedObjectRouter, { maxImportPayloadBytes, coreUsageData, diff --git a/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/export.test.ts index 6ae4a74aee014..7d0a8607fb6ed 100644 --- a/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/export.test.ts @@ -38,6 +38,7 @@ import { coreUsageDataServiceMock } from '../../../../core_usage_data/core_usage import { registerLegacyExportRoute } from '../export'; import { setupServer } from '../../test_utils'; import { loggerMock } from '@kbn/logging-mocks'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../../internal_types'; type SetupServerReturn = Awaited>; let coreUsageStatsClient: jest.Mocked; @@ -49,7 +50,7 @@ describe('POST /api/dashboards/export', () => { beforeEach(async () => { ({ server, httpSetup } = await setupServer()); - const router = httpSetup.createRouter(''); + const router = httpSetup.createRouter(''); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementLegacyDashboardsExport.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail diff --git a/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/import.test.ts index 13d5638440547..37f82aa47ece5 100644 --- a/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/legacy_import_export/integration_tests/import.test.ts @@ -38,6 +38,7 @@ import { coreUsageDataServiceMock } from '../../../../core_usage_data/core_usage import { registerLegacyImportRoute } from '../import'; import { setupServer } from '../../test_utils'; import { loggerMock } from '@kbn/logging-mocks'; +import type { InternalSavedObjectsRequestHandlerContext } from '../../../internal_types'; type SetupServerReturn = Awaited>; let coreUsageStatsClient: jest.Mocked; @@ -49,7 +50,7 @@ describe('POST /api/dashboards/import', () => { beforeEach(async () => { ({ server, httpSetup } = await setupServer()); - const router = httpSetup.createRouter(''); + const router = httpSetup.createRouter(''); coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementLegacyDashboardsImport.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail diff --git a/src/core/server/saved_objects/routes/migrate.ts b/src/core/server/saved_objects/routes/migrate.ts index 404074124c92b..05579855cbfc3 100644 --- a/src/core/server/saved_objects/routes/migrate.ts +++ b/src/core/server/saved_objects/routes/migrate.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { IRouter } from '../../http'; import { IKibanaMigrator } from '../migrations'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; export const registerMigrateRoute = ( - router: IRouter, + router: InternalSavedObjectRouter, migratorPromise: Promise ) => { router.post( diff --git a/src/core/server/saved_objects/routes/resolve.ts b/src/core/server/saved_objects/routes/resolve.ts index ae09f6526baa3..b0a7153e8d226 100644 --- a/src/core/server/saved_objects/routes/resolve.ts +++ b/src/core/server/saved_objects/routes/resolve.ts @@ -7,14 +7,17 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import type { InternalSavedObjectRouter } from '../internal_types'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerResolveRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerResolveRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.get( { path: '/resolve/{type}/{id}', diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index bf536e906d7da..4bedec1715a4f 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -10,11 +10,12 @@ import { extname } from 'path'; import { Readable } from 'stream'; import { schema } from '@kbn/config-schema'; import { chain } from 'lodash'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; + interface RouteDependencies { config: SavedObjectConfig; coreUsageData: InternalCoreUsageDataSetup; @@ -27,7 +28,7 @@ interface FileStream extends Readable { } export const registerResolveImportErrorsRoute = ( - router: IRouter, + router: InternalSavedObjectRouter, { config, coreUsageData }: RouteDependencies ) => { const { maxImportPayloadBytes } = config; diff --git a/src/core/server/saved_objects/routes/test_utils.ts b/src/core/server/saved_objects/routes/test_utils.ts index 0d5e7588ad6ce..5783ecb9e5e27 100644 --- a/src/core/server/saved_objects/routes/test_utils.ts +++ b/src/core/server/saved_objects/routes/test_utils.ts @@ -11,6 +11,7 @@ import { ContextService } from '../../context'; import { createHttpServer, createCoreContext } from '../../http/test_utils'; import { contextServiceMock, coreMock } from '../../mocks'; import { SavedObjectsType } from '../types'; +import { InternalSavedObjectsRequestHandlerContext } from '../internal_types'; const defaultCoreId = Symbol('core'); @@ -26,9 +27,13 @@ export const setupServer = async (coreId: symbol = defaultCoreId) => { }); const handlerContext = coreMock.createRequestHandlerContext(); - httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { - return handlerContext; - }); + httpSetup.registerRouteHandlerContext( + coreId, + 'core', + (ctx, req, res) => { + return handlerContext; + } + ); return { server, diff --git a/src/core/server/saved_objects/routes/update.ts b/src/core/server/saved_objects/routes/update.ts index 5383ab76a1e4d..3d68c6a698a04 100644 --- a/src/core/server/saved_objects/routes/update.ts +++ b/src/core/server/saved_objects/routes/update.ts @@ -7,16 +7,19 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { InternalCoreUsageDataSetup } from '../../core_usage_data'; import type { SavedObjectsUpdateOptions } from '../service/saved_objects_client'; +import type { InternalSavedObjectRouter } from '../internal_types'; import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: InternalCoreUsageDataSetup; } -export const registerUpdateRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { +export const registerUpdateRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { router.put( { path: '/{type}/{id}', diff --git a/src/core/server/saved_objects/saved_objects_route_handler_context.ts b/src/core/server/saved_objects/saved_objects_route_handler_context.ts new file mode 100644 index 0000000000000..bda45de389c98 --- /dev/null +++ b/src/core/server/saved_objects/saved_objects_route_handler_context.ts @@ -0,0 +1,68 @@ +/* + * 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 { KibanaRequest } from '../http'; +import type { InternalSavedObjectsServiceStart } from './saved_objects_service'; +import type { ISavedObjectTypeRegistry } from './saved_objects_type_registry'; +import type { SavedObjectsClientContract } from './types'; +import type { SavedObjectsClientProviderOptions } from './service'; +import type { ISavedObjectsExporter } from './export'; +import type { ISavedObjectsImporter } from './import'; + +/** + * Core's `savedObjects` request handler context. + * @public + */ +export interface SavedObjectsRequestHandlerContext { + client: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; +} + +/** + * The {@link SavedObjectsRequestHandlerContext} implementation. + * @internal + */ +export class CoreSavedObjectsRouteHandlerContext implements SavedObjectsRequestHandlerContext { + constructor( + private readonly savedObjectsStart: InternalSavedObjectsServiceStart, + private readonly request: KibanaRequest + ) {} + + #scopedSavedObjectsClient?: SavedObjectsClientContract; + #typeRegistry?: ISavedObjectTypeRegistry; + + public get client() { + if (this.#scopedSavedObjectsClient == null) { + this.#scopedSavedObjectsClient = this.savedObjectsStart.getScopedClient(this.request); + } + return this.#scopedSavedObjectsClient; + } + + public get typeRegistry() { + if (this.#typeRegistry == null) { + this.#typeRegistry = this.savedObjectsStart.getTypeRegistry(); + } + return this.#typeRegistry; + } + + public getClient = (options?: SavedObjectsClientProviderOptions) => { + if (!options) return this.client; + return this.savedObjectsStart.getScopedClient(this.request, options); + }; + + public getExporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createExporter(client); + }; + + public getImporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createImporter(client); + }; +} diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 23586396bf62b..3f877d31eec3a 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -57,6 +57,7 @@ import { CoreRouteHandlerContext } from './core_route_handler_context'; import { PrebootCoreRouteHandlerContext } from './preboot_core_route_handler_context'; import { PrebootService } from './preboot'; import { DiscoveredPlugins } from './plugins'; +import type { RequestHandlerContext, PrebootRequestHandlerContext } from '.'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -208,9 +209,13 @@ export class Server { await this.plugins.preboot(corePreboot); - httpPreboot.registerRouteHandlerContext(coreId, 'core', (() => { - return new PrebootCoreRouteHandlerContext(corePreboot); - }) as any); + httpPreboot.registerRouteHandlerContext( + coreId, + 'core', + () => { + return new PrebootCoreRouteHandlerContext(corePreboot); + } + ); this.coreApp.preboot(corePreboot, uiPlugins); @@ -413,9 +418,13 @@ export class Server { } private registerCoreContext(coreSetup: InternalCoreSetup) { - coreSetup.http.registerRouteHandlerContext(coreId, 'core', async (context, req, res) => { - return new CoreRouteHandlerContext(this.coreStart!, req); - }); + coreSetup.http.registerRouteHandlerContext( + coreId, + 'core', + (context, req) => { + return new CoreRouteHandlerContext(this.coreStart!, req); + } + ); } public setupCoreConfig() { diff --git a/src/core/server/ui_settings/index.ts b/src/core/server/ui_settings/index.ts index d83d9c4358621..ee8cd7181fe8d 100644 --- a/src/core/server/ui_settings/index.ts +++ b/src/core/server/ui_settings/index.ts @@ -10,6 +10,8 @@ export type { UiSettingsClient, UiSettingsServiceOptions } from './ui_settings_c export { config } from './ui_settings_config'; export { UiSettingsService } from './ui_settings_service'; +export { CoreUiSettingsRouteHandlerContext } from './ui_settings_route_handler_context'; +export type { UiSettingsRequestHandlerContext } from './ui_settings_route_handler_context'; export type { UiSettingsServiceSetup, diff --git a/src/core/server/ui_settings/internal_types.ts b/src/core/server/ui_settings/internal_types.ts new file mode 100644 index 0000000000000..4292cca94ffbd --- /dev/null +++ b/src/core/server/ui_settings/internal_types.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 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 { RequestHandlerContextBase } from '..'; +import type { IRouter } from '../http'; +import type { UiSettingsRequestHandlerContext } from './ui_settings_route_handler_context'; + +/** + * Request handler context used by core's uiSetting routes. + * @internal + */ +export interface InternalUiSettingsRequestHandlerContext extends RequestHandlerContextBase { + core: Promise<{ + uiSettings: UiSettingsRequestHandlerContext; + }>; +} + +/** + * Router bound to the {@link InternalUiSettingsRequestHandlerContext}. + * Used by core's uiSetting routes. + * @internal + */ +export type InternalUiSettingsRouter = IRouter; diff --git a/src/core/server/ui_settings/routes/delete.ts b/src/core/server/ui_settings/routes/delete.ts index 87c6edf386428..705a5319c1cf5 100644 --- a/src/core/server/ui_settings/routes/delete.ts +++ b/src/core/server/ui_settings/routes/delete.ts @@ -8,8 +8,8 @@ import { schema } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; +import type { InternalUiSettingsRouter } from '../internal_types'; import { CannotOverrideError } from '../ui_settings_errors'; const validate = { @@ -18,7 +18,7 @@ const validate = { }), }; -export function registerDeleteRoute(router: IRouter) { +export function registerDeleteRoute(router: InternalUiSettingsRouter) { router.delete( { path: '/api/kibana/settings/{key}', validate }, async (context, request, response) => { diff --git a/src/core/server/ui_settings/routes/get.ts b/src/core/server/ui_settings/routes/get.ts index 0929330cf0238..c940c2e1fe71e 100644 --- a/src/core/server/ui_settings/routes/get.ts +++ b/src/core/server/ui_settings/routes/get.ts @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import { IRouter } from '../../http'; +import type { InternalUiSettingsRouter } from '../internal_types'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; -export function registerGetRoute(router: IRouter) { +export function registerGetRoute(router: InternalUiSettingsRouter) { router.get( { path: '/api/kibana/settings', validate: false }, async (context, request, response) => { diff --git a/src/core/server/ui_settings/routes/index.ts b/src/core/server/ui_settings/routes/index.ts index 0cf7233b8af19..22ca2ae38cde5 100644 --- a/src/core/server/ui_settings/routes/index.ts +++ b/src/core/server/ui_settings/routes/index.ts @@ -6,14 +6,13 @@ * Side Public License, v 1. */ -import { IRouter } from '../..'; - +import type { InternalUiSettingsRouter } from '../internal_types'; import { registerDeleteRoute } from './delete'; import { registerGetRoute } from './get'; import { registerSetManyRoute } from './set_many'; import { registerSetRoute } from './set'; -export function registerRoutes(router: IRouter) { +export function registerRoutes(router: InternalUiSettingsRouter) { registerGetRoute(router); registerDeleteRoute(router); registerSetRoute(router); diff --git a/src/core/server/ui_settings/routes/set.ts b/src/core/server/ui_settings/routes/set.ts index 91518fb6f3476..af62fda0144b6 100644 --- a/src/core/server/ui_settings/routes/set.ts +++ b/src/core/server/ui_settings/routes/set.ts @@ -8,8 +8,8 @@ import { schema, ValidationError } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; +import type { InternalUiSettingsRouter } from '../internal_types'; import { CannotOverrideError } from '../ui_settings_errors'; const validate = { @@ -21,7 +21,7 @@ const validate = { }), }; -export function registerSetRoute(router: IRouter) { +export function registerSetRoute(router: InternalUiSettingsRouter) { router.post( { path: '/api/kibana/settings/{key}', validate }, async (context, request, response) => { diff --git a/src/core/server/ui_settings/routes/set_many.ts b/src/core/server/ui_settings/routes/set_many.ts index f4f3f509bf920..fe0ee1a0a721f 100644 --- a/src/core/server/ui_settings/routes/set_many.ts +++ b/src/core/server/ui_settings/routes/set_many.ts @@ -8,8 +8,8 @@ import { schema, ValidationError } from '@kbn/config-schema'; -import { IRouter } from '../../http'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; +import type { InternalUiSettingsRouter } from '../internal_types'; import { CannotOverrideError } from '../ui_settings_errors'; const validate = { @@ -18,7 +18,7 @@ const validate = { }), }; -export function registerSetManyRoute(router: IRouter) { +export function registerSetManyRoute(router: InternalUiSettingsRouter) { router.post({ path: '/api/kibana/settings', validate }, async (context, request, response) => { try { const uiSettingsClient = (await context.core).uiSettings.client; diff --git a/src/core/server/ui_settings/ui_settings_route_handler_context.ts b/src/core/server/ui_settings/ui_settings_route_handler_context.ts new file mode 100644 index 0000000000000..a975f0faad6ef --- /dev/null +++ b/src/core/server/ui_settings/ui_settings_route_handler_context.ts @@ -0,0 +1,40 @@ +/* + * 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 { CoreSavedObjectsRouteHandlerContext } from '../saved_objects'; +import type { IUiSettingsClient, InternalUiSettingsServiceStart } from './types'; + +/** + * Core's `uiSettings` request handler context. + * @public + */ +export interface UiSettingsRequestHandlerContext { + client: IUiSettingsClient; +} + +/** + * The {@link UiSettingsRequestHandlerContext} implementation. + * @internal + */ +export class CoreUiSettingsRouteHandlerContext implements UiSettingsRequestHandlerContext { + #client?: IUiSettingsClient; + + constructor( + private readonly uiSettingsStart: InternalUiSettingsServiceStart, + private readonly savedObjectsRouterHandlerContext: CoreSavedObjectsRouteHandlerContext + ) {} + + public get client() { + if (this.#client == null) { + this.#client = this.uiSettingsStart.asScopedToClient( + this.savedObjectsRouterHandlerContext.client + ); + } + return this.#client; + } +} diff --git a/src/core/server/ui_settings/ui_settings_service.ts b/src/core/server/ui_settings/ui_settings_service.ts index d303060d55595..342514660e8d1 100644 --- a/src/core/server/ui_settings/ui_settings_service.ts +++ b/src/core/server/ui_settings/ui_settings_service.ts @@ -26,6 +26,7 @@ import { uiSettingsType } from './saved_objects'; import { registerRoutes } from './routes'; import { getCoreSettings } from './settings'; import { UiSettingsDefaultsClient } from './ui_settings_defaults_client'; +import type { InternalUiSettingsRequestHandlerContext } from './internal_types'; export interface SetupDeps { http: InternalHttpServiceSetup; @@ -70,7 +71,7 @@ export class UiSettingsService this.log.debug('Setting up ui settings service'); savedObjects.registerType(uiSettingsType); - registerRoutes(http.createRouter('')); + registerRoutes(http.createRouter('')); const config = await firstValueFrom(this.config$); this.overrides = config.overrides; diff --git a/src/plugins/console/public/application/containers/main/main.tsx b/src/plugins/console/public/application/containers/main/main.tsx index 5895b919f9842..2833bf1fbebc3 100644 --- a/src/plugins/console/public/application/containers/main/main.tsx +++ b/src/plugins/console/public/application/containers/main/main.tsx @@ -26,6 +26,7 @@ import { useDataInit } from '../../hooks'; import { getTopNavConfig } from './get_top_nav'; import type { SenseEditor } from '../../models/sense_editor'; +import { getResponseWithMostSevereStatusCode } from '../../../lib/utils'; export function Main() { const { @@ -62,7 +63,7 @@ export function Main() { ); } - const lastDatum = requestData?.[requestData.length - 1] ?? requestError; + const data = getResponseWithMostSevereStatusCode(requestData) ?? requestError; return (
@@ -95,13 +96,13 @@ export function Main() { { + if (requestData) { + return requestData + .slice() + .sort((a, b) => a.response.statusCode - b.response.statusCode) + .pop(); + } +}; diff --git a/src/plugins/console/public/lib/utils/utils.test.js b/src/plugins/console/public/lib/utils/utils.test.js index 738aa5b9bf5c3..8cdd93c3b6ed8 100644 --- a/src/plugins/console/public/lib/utils/utils.test.js +++ b/src/plugins/console/public/lib/utils/utils.test.js @@ -173,4 +173,24 @@ describe('Utils class', () => { ]); }); }); + + test('get response with most severe status code', () => { + expect( + utils.getResponseWithMostSevereStatusCode([ + { response: { statusCode: 500 } }, + { response: { statusCode: 400 } }, + { response: { statusCode: 200 } }, + ]) + ).toEqual({ response: { statusCode: 500 } }); + + expect( + utils.getResponseWithMostSevereStatusCode([ + { response: { statusCode: 0 } }, + { response: { statusCode: 100 } }, + { response: { statusCode: 201 } }, + ]) + ).toEqual({ response: { statusCode: 201 } }); + + expect(utils.getResponseWithMostSevereStatusCode(undefined)).toBe(undefined); + }); }); diff --git a/src/plugins/console/server/lib/proxy_request.test.ts b/src/plugins/console/server/lib/proxy_request.test.ts index 2bb5e481fbb26..98c63d9685c87 100644 --- a/src/plugins/console/server/lib/proxy_request.test.ts +++ b/src/plugins/console/server/lib/proxy_request.test.ts @@ -31,7 +31,7 @@ describe(`Console's send request`, () => { it('correctly implements timeout and abort mechanism', async () => { fakeRequest = { - abort: sinon.stub(), + destroy: sinon.stub(), on() {}, once() {}, } as any; @@ -47,7 +47,7 @@ describe(`Console's send request`, () => { fail('Should not reach here!'); } catch (e) { expect(e.message).toEqual('Client request timeout'); - expect((fakeRequest.abort as sinon.SinonStub).calledOnce).toBe(true); + expect((fakeRequest.destroy as sinon.SinonStub).calledOnce).toBe(true); } }); diff --git a/src/plugins/console/server/lib/proxy_request.ts b/src/plugins/console/server/lib/proxy_request.ts index c4fbfd315da4e..4a8839d1d8583 100644 --- a/src/plugins/console/server/lib/proxy_request.ts +++ b/src/plugins/console/server/lib/proxy_request.ts @@ -113,7 +113,10 @@ export const proxyRequest = ({ const timeoutPromise = new Promise((timeoutResolve, timeoutReject) => { setTimeout(() => { - if (!req.aborted && !req.socket) req.abort(); + // Destroy the stream on timeout and close the connection. + if (!req.destroyed) { + req.destroy(); + } if (!resolved) { timeoutReject(Boom.gatewayTimeout('Client request timeout')); } else { diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts index e47fc12eca446..5c13747b238c7 100644 --- a/src/plugins/data/common/search/aggs/agg_config.ts +++ b/src/plugins/data/common/search/aggs/agg_config.ts @@ -152,7 +152,7 @@ export class AggConfig { const isDeserialized = isType || isObject; if (!isDeserialized) { - val = aggParam.deserialize(val, this); + val = aggParam.deserialize(_.cloneDeep(val), this); } to[aggParam.name] = val; diff --git a/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts index 4412eeb78c428..98531c2fec879 100644 --- a/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts +++ b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts @@ -8,7 +8,12 @@ import { BehaviorSubject } from 'rxjs'; -import { MetricsServiceSetup, ServiceStatus, ServiceStatusLevels } from '@kbn/core/server'; +import { + MetricsServiceSetup, + RequestHandlerContext, + ServiceStatus, + ServiceStatusLevels, +} from '@kbn/core/server'; import { contextServiceMock, loggingSystemMock, @@ -42,7 +47,7 @@ describe('/api/stats', () => { }); metrics = metricsServiceMock.createSetupContract(); - const router = httpSetup.createRouter(''); + const router = httpSetup.createRouter(''); registerStatsRoute({ router, collectorSet: new CollectorSet({ diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 47890ec013046..c15d92ff57985 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -38,7 +38,7 @@ "xpack.main": "legacy/plugins/xpack_main", "xpack.maps": ["plugins/maps"], "xpack.aiops": [ - "packages/ml/aiops_utils", + "packages/ml/aiops_components", "plugins/aiops" ], "xpack.ml": ["plugins/ml"], diff --git a/x-pack/packages/ml/aiops_components/BUILD.bazel b/x-pack/packages/ml/aiops_components/BUILD.bazel new file mode 100644 index 0000000000000..f08ccff0d2893 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/BUILD.bazel @@ -0,0 +1,145 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "aiops_components" +PKG_REQUIRE_NAME = "@kbn/aiops-components" + +SOURCE_FILES = glob( + [ + "src/**/*.scss", + "src/**/*.ts", + "src/**/*.tsx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//d3-brush", + "@npm//d3-scale", + "@npm//d3-selection", + "@npm//d3-transition", + "@npm//react", + "@npm//@elastic/charts", + "@npm//@elastic/eui", + "//packages/kbn-i18n-react", + "//x-pack/packages/ml/aiops_utils", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@types/d3-brush", + "@npm//@types/d3-scale", + "@npm//@types/d3-selection", + "@npm//@types/d3-transition", + "@npm//@types/node", + "@npm//@types/jest", + "@npm//@types/react", + "@npm//@elastic/charts", + "@npm//@elastic/eui", + "//packages/kbn-i18n-react:npm_module_types", + "//x-pack/packages/ml/aiops_utils:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/x-pack/packages/ml/aiops_components/README.md b/x-pack/packages/ml/aiops_components/README.md new file mode 100644 index 0000000000000..36b36805d2872 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/README.md @@ -0,0 +1,5 @@ +# @kbn/aiops-components + +React components for AIOps related efforts. + +https://docs.elastic.dev/kibana-dev-docs/api/kbn-aiops-components diff --git a/x-pack/packages/ml/aiops_components/jest.config.js b/x-pack/packages/ml/aiops_components/jest.config.js new file mode 100644 index 0000000000000..cadc9733723e9 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/jest.config.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/packages/ml/aiops_components'], +}; diff --git a/x-pack/packages/ml/aiops_components/package.json b/x-pack/packages/ml/aiops_components/package.json new file mode 100644 index 0000000000000..f3cb901271998 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/package.json @@ -0,0 +1,11 @@ +{ + "name": "@kbn/aiops-components", + "description": "React components for AIOps related efforts.", + "author": "Machine Learning UI", + "homepage": "https://docs.elastic.dev/kibana-dev-docs/api/kbn-aiops-components", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush.scss b/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush.scss new file mode 100644 index 0000000000000..a97dec29ecd62 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush.scss @@ -0,0 +1,11 @@ +.aiops-dual-brush { + .handle { + fill: $euiColorDarkShade; + } + + .brush .selection { + stroke: none; + fill: $euiColorDarkShade !important; + opacity: .5 !important; + } +} diff --git a/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush.tsx b/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush.tsx new file mode 100644 index 0000000000000..bb44f4ac16a89 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush.tsx @@ -0,0 +1,264 @@ +/* + * 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, { useEffect, useRef } from 'react'; + +import * as d3Brush from 'd3-brush'; +import * as d3Scale from 'd3-scale'; +import * as d3Selection from 'd3-selection'; +import * as d3Transition from 'd3-transition'; + +import type { WindowParameters } from '@kbn/aiops-utils'; + +import './dual_brush.scss'; + +const { brush, brushSelection, brushX } = d3Brush; +const { scaleLinear } = d3Scale; +const { select: d3Select } = d3Selection; +// Import fix to apply correct types for the use of d3.select(this).transition() +d3Select.prototype.transition = d3Transition.transition; + +const d3 = { + brush, + brushSelection, + brushX, + scaleLinear, + select: d3Select, + transition: d3Transition, +}; + +const isBrushXSelection = (arg: unknown): arg is [number, number] => { + return ( + Array.isArray(arg) && + arg.length === 2 && + typeof arg[0] === 'number' && + typeof arg[1] === 'number' + ); +}; + +interface DualBrush { + id: string; + brush: d3Brush.BrushBehavior; + start: number; + end: number; +} + +const BRUSH_HEIGHT = 20; +const BRUSH_MARGIN = 4; +const BRUSH_HANDLE_SIZE = 4; +const BRUSH_HANDLE_ROUNDED_CORNER = 2; + +interface DualBrushProps { + windowParameters: WindowParameters; + min: number; + max: number; + onChange?: (windowParameters: WindowParameters) => void; + marginLeft: number; + width: number; +} + +export function DualBrush({ + windowParameters, + min, + max, + onChange, + marginLeft, + width, +}: DualBrushProps) { + const d3BrushContainer = useRef(null); + const brushes = useRef([]); + const widthRef = useRef(width); + + const { baselineMin, baselineMax, deviationMin, deviationMax } = windowParameters; + + useEffect(() => { + if (d3BrushContainer.current && width > 0) { + const gBrushes = d3.select(d3BrushContainer.current); + + function newBrush(id: string, start: number, end: number) { + brushes.current.push({ + id, + brush: d3.brushX().handleSize(BRUSH_HANDLE_SIZE).on('end', brushend), + start, + end, + }); + + function brushend(this: d3Selection.BaseType) { + const currentWidth = widthRef.current; + + const x = d3.scaleLinear().domain([min, max]).rangeRound([0, currentWidth]); + + const px2ts = (px: number) => Math.round(x.invert(px)); + const xMin = x(min) ?? 0; + const xMax = x(max) ?? 0; + const minExtentPx = Math.round((xMax - xMin) / 100); + + const baselineBrush = d3.select('#brush-baseline'); + const baselineSelection = d3.brushSelection(baselineBrush.node() as SVGGElement); + + const deviationBrush = d3.select('#brush-deviation'); + const deviationSelection = d3.brushSelection(deviationBrush.node() as SVGGElement); + + if (!isBrushXSelection(deviationSelection) || !isBrushXSelection(baselineSelection)) { + return; + } + + const baselineOverlay = baselineBrush.selectAll('.overlay'); + const deviationOverlay = deviationBrush.selectAll('.overlay'); + + let baselineWidth; + let deviationWidth; + baselineOverlay.each((d, i, n) => { + baselineWidth = d3.select(n[i]).attr('width'); + }); + deviationOverlay.each((d, i, n) => { + deviationWidth = d3.select(n[i]).attr('width'); + }); + + if (baselineWidth !== deviationWidth) { + return; + } + + const newWindowParameters = { + baselineMin: px2ts(baselineSelection[0]), + baselineMax: px2ts(baselineSelection[1]), + deviationMin: px2ts(deviationSelection[0]), + deviationMax: px2ts(deviationSelection[1]), + }; + + if ( + id === 'deviation' && + deviationSelection && + baselineSelection && + deviationSelection[0] - minExtentPx < baselineSelection[1] + ) { + const newDeviationMin = baselineSelection[1] + minExtentPx; + const newDeviationMax = Math.max(deviationSelection[1], newDeviationMin + minExtentPx); + + newWindowParameters.deviationMin = px2ts(newDeviationMin); + newWindowParameters.deviationMax = px2ts(newDeviationMax); + + d3.select(this) + .transition() + .duration(200) + // @ts-expect-error call doesn't allow the brush move function + .call(brushes.current[1].brush.move, [newDeviationMin, newDeviationMax]); + } else if ( + id === 'baseline' && + deviationSelection && + baselineSelection && + deviationSelection[0] < baselineSelection[1] + minExtentPx + ) { + const newBaselineMax = deviationSelection[0] - minExtentPx; + const newBaselineMin = Math.min(baselineSelection[0], newBaselineMax - minExtentPx); + + newWindowParameters.baselineMin = px2ts(newBaselineMin); + newWindowParameters.baselineMax = px2ts(newBaselineMax); + + d3.select(this) + .transition() + .duration(200) + // @ts-expect-error call doesn't allow the brush move function + .call(brushes.current[0].brush.move, [newBaselineMin, newBaselineMax]); + } + + brushes.current[0].start = newWindowParameters.baselineMin; + brushes.current[0].end = newWindowParameters.baselineMax; + brushes.current[1].start = newWindowParameters.deviationMin; + brushes.current[1].end = newWindowParameters.deviationMax; + + if (onChange) { + onChange(newWindowParameters); + } + drawBrushes(); + } + } + + function drawBrushes() { + const mlBrushSelection = gBrushes + .selectAll('.brush') + .data(brushes.current, (d) => (d as DualBrush).id); + + // Set up new brushes + mlBrushSelection + .enter() + .insert('g', '.brush') + .attr('class', 'brush') + .attr('id', (b: DualBrush) => { + return 'brush-' + b.id; + }) + .each((brushObject: DualBrush, i, n) => { + const x = d3.scaleLinear().domain([min, max]).rangeRound([0, widthRef.current]); + brushObject.brush(d3.select(n[i])); + const xStart = x(brushObject.start) ?? 0; + const xEnd = x(brushObject.end) ?? 0; + brushObject.brush.move(d3.select(n[i]), [xStart, xEnd]); + }); + + // disable drag-select to reset a brush's selection + mlBrushSelection + .attr('class', 'brush') + .selectAll('.overlay') + .attr('width', width) + .style('pointer-events', 'none'); + + mlBrushSelection + .selectAll('.handle') + .attr('rx', BRUSH_HANDLE_ROUNDED_CORNER) + .attr('ry', BRUSH_HANDLE_ROUNDED_CORNER); + + mlBrushSelection.exit().remove(); + } + + function updateBrushes() { + const mlBrushSelection = gBrushes + .selectAll('.brush') + .data(brushes.current, (d) => (d as DualBrush).id); + + mlBrushSelection.each(function (brushObject, i, n) { + const x = d3.scaleLinear().domain([min, max]).rangeRound([0, widthRef.current]); + brushObject.brush.extent([ + [0, BRUSH_MARGIN], + [width, BRUSH_HEIGHT - BRUSH_MARGIN], + ]); + brushObject.brush(d3.select(n[i] as SVGGElement)); + const xStart = x(brushObject.start) ?? 0; + const xEnd = x(brushObject.end) ?? 0; + brushObject.brush.move(d3.select(n[i] as SVGGElement), [xStart, xEnd]); + }); + } + + if (brushes.current.length !== 2) { + widthRef.current = width; + newBrush('baseline', baselineMin, baselineMax); + newBrush('deviation', deviationMin, deviationMax); + } else { + if (widthRef.current !== width) { + widthRef.current = width; + updateBrushes(); + } + } + + drawBrushes(); + } + }, [min, max, width, baselineMin, baselineMax, deviationMin, deviationMax, onChange]); + + return ( + <> + {width > 0 && ( + + + + )} + + ); +} diff --git a/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush_annotation.tsx b/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush_annotation.tsx new file mode 100644 index 0000000000000..54e2c204acfa9 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush_annotation.tsx @@ -0,0 +1,46 @@ +/* + * 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, { FC } from 'react'; + +import { RectAnnotation } from '@elastic/charts'; +import { useEuiTheme } from '@elastic/eui'; + +interface BrushAnnotationProps { + id: string; + min: number; + max: number; +} + +export const DualBrushAnnotation: FC = ({ id, min, max }) => { + const { euiTheme } = useEuiTheme(); + const { colors } = euiTheme; + + return ( + + ); +}; diff --git a/x-pack/packages/ml/aiops_components/src/dual_brush/index.ts b/x-pack/packages/ml/aiops_components/src/dual_brush/index.ts new file mode 100644 index 0000000000000..c72973a19871f --- /dev/null +++ b/x-pack/packages/ml/aiops_components/src/dual_brush/index.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DualBrushAnnotation } from './dual_brush_annotation'; +export { DualBrush } from './dual_brush'; diff --git a/x-pack/packages/ml/aiops_components/src/index.ts b/x-pack/packages/ml/aiops_components/src/index.ts new file mode 100644 index 0000000000000..d468ffcc4bba5 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/src/index.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DualBrush, DualBrushAnnotation } from './dual_brush'; +export { ProgressControls } from './progress_controls'; diff --git a/x-pack/packages/ml/aiops_components/src/progress_controls/index.ts b/x-pack/packages/ml/aiops_components/src/progress_controls/index.ts new file mode 100644 index 0000000000000..64cbbe174fd11 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/src/progress_controls/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ProgressControls } from './progress_controls'; diff --git a/x-pack/packages/ml/aiops_utils/src/components/progress_controls.tsx b/x-pack/packages/ml/aiops_components/src/progress_controls/progress_controls.tsx similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/components/progress_controls.tsx rename to x-pack/packages/ml/aiops_components/src/progress_controls/progress_controls.tsx diff --git a/x-pack/packages/ml/aiops_components/tsconfig.json b/x-pack/packages/ml/aiops_components/tsconfig.json new file mode 100644 index 0000000000000..ebe2e9eb5d0e4 --- /dev/null +++ b/x-pack/packages/ml/aiops_components/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "@types/d3-brush", + "@types/d3-scale", + "@types/d3-selection", + "@types/d3-transition", + "jest", + "node", + "react" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/x-pack/packages/ml/aiops_utils/BUILD.bazel b/x-pack/packages/ml/aiops_utils/BUILD.bazel index 755c152d62e34..f54af47470a3b 100644 --- a/x-pack/packages/ml/aiops_utils/BUILD.bazel +++ b/x-pack/packages/ml/aiops_utils/BUILD.bazel @@ -38,8 +38,6 @@ NPM_MODULE_EXTRA_FILES = [ # eg. "@npm//lodash" RUNTIME_DEPS = [ "@npm//react", - "@npm//@elastic/eui", - "//packages/kbn-i18n-react", "//packages/kbn-logging" ] @@ -56,8 +54,6 @@ TYPES_DEPS = [ "@npm//@types/node", "@npm//@types/jest", "@npm//@types/react", - "@npm//@elastic/eui", - "//packages/kbn-i18n-react:npm_module_types", "//packages/kbn-logging:npm_module_types" ] diff --git a/x-pack/packages/ml/aiops_utils/src/lib/accept_compression.test.ts b/x-pack/packages/ml/aiops_utils/src/accept_compression.test.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/accept_compression.test.ts rename to x-pack/packages/ml/aiops_utils/src/accept_compression.test.ts diff --git a/x-pack/packages/ml/aiops_utils/src/lib/accept_compression.ts b/x-pack/packages/ml/aiops_utils/src/accept_compression.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/accept_compression.ts rename to x-pack/packages/ml/aiops_utils/src/accept_compression.ts diff --git a/x-pack/packages/ml/aiops_utils/src/lib/fetch_stream.ts b/x-pack/packages/ml/aiops_utils/src/fetch_stream.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/fetch_stream.ts rename to x-pack/packages/ml/aiops_utils/src/fetch_stream.ts diff --git a/x-pack/packages/ml/aiops_utils/src/lib/get_window_parameters.ts b/x-pack/packages/ml/aiops_utils/src/get_window_parameters.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/get_window_parameters.ts rename to x-pack/packages/ml/aiops_utils/src/get_window_parameters.ts diff --git a/x-pack/packages/ml/aiops_utils/src/index.ts b/x-pack/packages/ml/aiops_utils/src/index.ts index 1ffbde324470f..a02ecc2d41958 100644 --- a/x-pack/packages/ml/aiops_utils/src/index.ts +++ b/x-pack/packages/ml/aiops_utils/src/index.ts @@ -5,12 +5,11 @@ * 2.0. */ -export { ProgressControls } from './components/progress_controls'; -export { getWindowParameters } from './lib/get_window_parameters'; -export type { WindowParameters } from './lib/get_window_parameters'; -export { streamFactory } from './lib/stream_factory'; -export { useFetchStream } from './lib/use_fetch_stream'; +export { getWindowParameters } from './get_window_parameters'; +export type { WindowParameters } from './get_window_parameters'; +export { streamFactory } from './stream_factory'; +export { useFetchStream } from './use_fetch_stream'; export type { UseFetchStreamCustomReducerParams, UseFetchStreamParamsDefault, -} from './lib/use_fetch_stream'; +} from './use_fetch_stream'; diff --git a/x-pack/packages/ml/aiops_utils/src/lib/stream_factory.test.ts b/x-pack/packages/ml/aiops_utils/src/stream_factory.test.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/stream_factory.test.ts rename to x-pack/packages/ml/aiops_utils/src/stream_factory.test.ts diff --git a/x-pack/packages/ml/aiops_utils/src/lib/stream_factory.ts b/x-pack/packages/ml/aiops_utils/src/stream_factory.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/stream_factory.ts rename to x-pack/packages/ml/aiops_utils/src/stream_factory.ts diff --git a/x-pack/packages/ml/aiops_utils/src/lib/string_reducer.ts b/x-pack/packages/ml/aiops_utils/src/string_reducer.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/string_reducer.ts rename to x-pack/packages/ml/aiops_utils/src/string_reducer.ts diff --git a/x-pack/packages/ml/aiops_utils/src/lib/use_fetch_stream.ts b/x-pack/packages/ml/aiops_utils/src/use_fetch_stream.ts similarity index 100% rename from x-pack/packages/ml/aiops_utils/src/lib/use_fetch_stream.ts rename to x-pack/packages/ml/aiops_utils/src/use_fetch_stream.ts diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx index b23a3a7b82a79..982f085b15415 100644 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx @@ -10,7 +10,8 @@ import React, { useEffect, FC } from 'react'; import { EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/public'; -import { useFetchStream, ProgressControls } from '@kbn/aiops-utils'; +import { ProgressControls } from '@kbn/aiops-components'; +import { useFetchStream } from '@kbn/aiops-utils'; import type { WindowParameters } from '@kbn/aiops-utils'; import { useKibana } from '@kbn/kibana-react-plugin/public'; diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index 98eb504ff7094..62892c143146f 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -48,7 +48,7 @@ To initialize the `CasesContext` you can use this code: // somewhere high on your plugin render tree {/* or something similar */} @@ -57,11 +57,11 @@ To initialize the `CasesContext` you can use this code: props: -| prop | type | description | -| --------------------- | --------------- | -------------------------------------------------------------- | -| PLUGIN_CASES_OWNER_ID | `string` | The owner string for your plugin. e.g: securitySolution | -| CASES_USER_CAN_CRUD | `boolean` | Defines if the user has access to cases to CRUD | -| CASES_FEATURES | `CasesFeatures` | `CasesFeatures` object defining the features to enable/disable | +| prop | type | description | +| --------------------- | ------------------- | -------------------------------------------------------------- | +| PLUGIN_CASES_OWNER_ID | `string` | The owner string for your plugin. e.g: securitySolution | +| CASES_PERMISSIONS | `CasesPermissions` | `CasesPermissions` object defining the user's permissions | +| CASES_FEATURES | `CasesFeatures` | `CasesFeatures` object defining the features to enable/disable | ### Cases UI client @@ -83,7 +83,10 @@ const { cases } = useKibana().services; // call in the return as you would any component cases.getCases({ basePath: '/investigate/cases', - userCanCrud: true, + permissions: { + all: true, + read: true, + }, owner: ['securitySolution'], features: { alerts: { sync: false }, metrics: ['alerts.count', 'lifespan'] } timelineIntegration: { @@ -206,7 +209,7 @@ Arguments: | Property | Description | | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| userCanCrud | `boolean;` user permissions to crud | +| permissions | `CasesPermissions` object defining the user's permissions | | owner | `string[];` owner ids of the cases | | basePath | `string;` path to mount the Cases router on top of | | useFetchAlertData | `(alertIds: string[]) => [boolean, Record];` fetch alerts | @@ -236,7 +239,7 @@ Arguments: | Property | Description | | --------------- | ---------------------------------------------------------------------------------- | -| userCanCrud | `boolean;` user permissions to crud | +| permissions | `CasesPermissions` object defining the user's permissions | | owner | `string[];` owner ids of the cases | | alertData? | `Omit;` alert data to post to case | | hiddenStatuses? | `CaseStatuses[];` array of hidden statuses | @@ -253,7 +256,7 @@ Arguments: | Property | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------ | -| userCanCrud | `boolean;` user permissions to crud | +| permissions | `CasesPermissions` object defining the user's permissions | | owner | `string[];` owner ids of the cases | | onClose | `() => void;` callback when create case is canceled | | onSuccess | `(theCase: Case) => Promise;` callback passing newly created case after pushCaseToExternalService is called | @@ -267,11 +270,11 @@ UI component: Arguments: -| Property | Description | -| -------------- | ------------------------------------------- | -| userCanCrud | `boolean;` user permissions to crud | -| owner | `string[];` owner ids of the cases | -| maxCasesToShow | `number;` number of cases to show in widget | +| Property | Description | +| -------------- | ---------------------------------------------------------- | +| permissions | `CasesPermissions` object defining the user's permissions | +| owner | `string[];` owner ids of the cases | +| maxCasesToShow | `number;` number of cases to show in widget | UI component: ![Recent Cases Component][recent-cases-img] @@ -289,7 +292,7 @@ Arguments: | Property | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------ | -| userCanCrud | `boolean;` user permissions to crud | +| permissions | `CasesPermissions` object defining the user's permissions | | onClose | `() => void;` callback when create case is canceled | | onSuccess | `(theCase: Case) => Promise;` callback passing newly created case after pushCaseToExternalService is called | | afterCaseCreated? | `(theCase: Case) => Promise;` callback passing newly created case before pushCaseToExternalService is called | diff --git a/x-pack/plugins/cases/public/client/helpers/capabilities.ts b/x-pack/plugins/cases/public/client/helpers/capabilities.ts new file mode 100644 index 0000000000000..652678d0add28 --- /dev/null +++ b/x-pack/plugins/cases/public/client/helpers/capabilities.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +export interface CasesPermissions { + all: boolean; + read: boolean; +} + +export const getUICapabilities = ( + featureCapabilities: Partial>> +): CasesPermissions => { + const read = !!featureCapabilities?.read_cases; + const all = !!featureCapabilities?.crud_cases; + + return { + all, + read, + }; +}; diff --git a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx index a40c92643ec34..37145c59b94ad 100644 --- a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx @@ -22,12 +22,12 @@ const AllCasesSelectorModalLazy: React.FC = lazy( export const getAllCasesSelectorModalLazy = ({ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, hiddenStatuses, onRowClick, onClose, }: GetAllCasesSelectorModalPropsInternal) => ( - + }> = lazy(() => import('../../component export const getCasesLazy = ({ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, basePath, onComponentInitialized, actionsNavigation, @@ -34,7 +34,7 @@ export const getCasesLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, basePath, features, releasePhase, diff --git a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx index 699b2ea4c515c..48367bb7672c2 100644 --- a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx @@ -22,7 +22,7 @@ const CasesProviderLazy: React.FC<{ value: GetCasesContextPropsInternal }> = laz const CasesProviderLazyWrapper = ({ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, features, children, releasePhase, @@ -33,7 +33,7 @@ const CasesProviderLazyWrapper = ({ value={{ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, features, releasePhase, }} diff --git a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx index a6eb07a09aaf4..5149b71d19dd4 100644 --- a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx @@ -22,14 +22,14 @@ export const CreateCaseFlyoutLazy: React.FC = lazy( export const getCreateCaseFlyoutLazy = ({ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, features, afterCaseCreated, onClose, onSuccess, attachments, }: GetCreateCaseFlyoutPropsInternal) => ( - + }> = lazy( export const getRecentCasesLazy = ({ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, maxCasesToShow, }: GetRecentCasesPropsInternal) => ( - + }> diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx index 0f6a1e0035e5c..3fb2ccf2ddc6b 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx @@ -23,7 +23,7 @@ describe('hooks', () => { expect(result.current).toEqual({ actions: { crud: true, read: true }, - generalCases: { crud: true, read: true }, + generalCases: { all: true, read: true }, visualize: { crud: true, read: true }, dashboard: { crud: true, read: true }, }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index a53a1e9ea452f..b144fcf9e24d2 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { AuthenticatedUser } from '@kbn/security-plugin/common/model'; import { NavigateToAppOptions } from '@kbn/core/public'; +import { CasesPermissions, getUICapabilities } from '../../../client/helpers/capabilities'; import { convertToCamelCase } from '../../../api/utils'; import { FEATURE_ID, @@ -166,7 +167,7 @@ interface Capabilities { } interface UseApplicationCapabilities { actions: Capabilities; - generalCases: Capabilities; + generalCases: CasesPermissions; visualize: Capabilities; dashboard: Capabilities; } @@ -179,13 +180,14 @@ interface UseApplicationCapabilities { export const useApplicationCapabilities = (): UseApplicationCapabilities => { const capabilities = useKibana().services?.application?.capabilities; const casesCapabilities = capabilities[FEATURE_ID]; + const permissions = getUICapabilities(casesCapabilities); return useMemo( () => ({ actions: { crud: !!capabilities.actions?.save, read: !!capabilities.actions?.show }, generalCases: { - crud: !!casesCapabilities?.crud_cases, - read: !!casesCapabilities?.read_cases, + all: permissions.all, + read: permissions.read, }, visualize: { crud: !!capabilities.visualize?.save, read: !!capabilities.visualize?.show }, dashboard: { @@ -200,8 +202,8 @@ export const useApplicationCapabilities = (): UseApplicationCapabilities => { capabilities.dashboard?.show, capabilities.visualize?.save, capabilities.visualize?.show, - casesCapabilities?.crud_cases, - casesCapabilities?.read_cases, + permissions.all, + permissions.read, ] ); }; diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 07ea1694b38e8..14ce43fc580af 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -23,13 +23,14 @@ import { import { FieldHook } from '../shared_imports'; import { StartServices } from '../../types'; import { ReleasePhase } from '../../components/types'; +import { CasesPermissions } from '../../client/helpers/capabilities'; import { AttachmentTypeRegistry } from '../../client/attachment_framework/registry'; import { ExternalReferenceAttachmentType } from '../../client/attachment_framework/types'; import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; interface TestProviderProps { children: React.ReactNode; - userCanCrud?: boolean; + permissions?: CasesPermissions; features?: CasesFeatures; owner?: string[]; releasePhase?: ReleasePhase; @@ -45,7 +46,7 @@ const TestProvidersComponent: React.FC = ({ children, features, owner = [SECURITY_SOLUTION_OWNER], - userCanCrud = true, + permissions = allCasesPermissions(), releasePhase = 'ga', externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(), }) => { @@ -63,7 +64,7 @@ const TestProvidersComponent: React.FC = ({ ({ eui: euiDarkVars, darkMode: true })}> {children} @@ -92,10 +93,24 @@ export const testQueryClient = new QueryClient({ }, }); +export const buildCasesPermissions = (overrides: Partial = {}) => { + const read = overrides.read ?? true; + const all = overrides.all ?? true; + + return { + all, + read, + }; +}; + +export const allCasesPermissions = () => buildCasesPermissions(); +export const noCasesPermissions = () => buildCasesPermissions({ read: false, all: false }); +export const readCasesPermissions = () => buildCasesPermissions({ all: false }); + export const createAppMockRenderer = ({ features, owner = [SECURITY_SOLUTION_OWNER], - userCanCrud = true, + permissions = allCasesPermissions(), releasePhase = 'ga', externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(), }: Omit = {}): AppMockRenderer => { @@ -118,7 +133,7 @@ export const createAppMockRenderer = ({ externalReferenceAttachmentTypeRegistry, features, owner, - userCanCrud, + permissions, releasePhase, }} > diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index b9a253adc76f5..18e02ff098314 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -29,7 +29,6 @@ const createAttachments = jest.fn(); const addCommentProps: AddCommentProps = { id: 'newComment', caseId: '1234', - userCanCrud: true, onCommentSaving, onCommentPosted, showLoading: false, @@ -120,8 +119,8 @@ describe('AddComment ', () => { isLoading: true, })); const wrapper = mount( - - + + ); diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index f3630d16cda79..235917a504e2e 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -47,7 +47,6 @@ export interface AddCommentRefObject { export interface AddCommentProps { id: string; caseId: string; - userCanCrud?: boolean; onCommentSaving?: () => void; onCommentPosted: (newCase: Case) => void; showLoading?: boolean; @@ -57,20 +56,12 @@ export interface AddCommentProps { export const AddComment = React.memo( forwardRef( ( - { - id, - caseId, - userCanCrud, - onCommentPosted, - onCommentSaving, - showLoading = true, - statusActionButton, - }, + { id, caseId, onCommentPosted, onCommentSaving, showLoading = true, statusActionButton }, ref ) => { const editorRef = useRef(null); const [focusOnContext, setFocusOnContext] = useState(false); - const { owner } = useCasesContext(); + const { permissions, owner } = useCasesContext(); const { isLoading, createAttachments } = useCreateAttachments(); const { form } = useForm({ @@ -156,7 +147,7 @@ export const AddComment = React.memo( return ( {isLoading && showLoading && } - {userCanCrud && ( + {permissions.all && (
{ handleIsLoading: jest.fn(), isLoadingCases: [], isSelectorView: false, - userCanCrud: true, }; let appMockRenderer: AppMockRenderer; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 4dc2223537d6d..887a30212833e 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -61,7 +61,7 @@ export interface AllCasesListProps { export const AllCasesList = React.memo( ({ hiddenStatuses = [], isSelectorView = false, onRowClick, doRefresh }) => { - const { owner, userCanCrud } = useCasesContext(); + const { owner, permissions } = useCasesContext(); const availableSolutions = useAvailableCasesOwners(); const [refresh, setRefresh] = useState(0); @@ -185,14 +185,13 @@ export const AllCasesList = React.memo( [deselectCases, setFilterOptions, refreshCases, setQueryParams] ); - const showActions = userCanCrud && !isSelectorView; + const showActions = permissions.all && !isSelectorView; const columns = useCasesColumns({ filterStatus: filterOptions.status ?? StatusAll, handleIsLoading, refreshCases, isSelectorView, - userCanCrud, connectors, onRowClick, showSolutionColumn: !hasOwner && availableSolutions.length > 1, @@ -271,7 +270,6 @@ export const AllCasesList = React.memo( sorting={sorting} tableRef={tableRef} tableRowProps={tableRowProps} - userCanCrud={userCanCrud} /> ); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 5d161caff5a17..3476be64c09ff 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -43,6 +43,7 @@ import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useCasesFeatures } from '../cases_context/use_cases_features'; import { severities } from '../severity/config'; import { useUpdateCase } from '../../containers/use_update_case'; +import { useCasesContext } from '../cases_context/use_cases_context'; export type CasesColumns = | EuiTableActionsColumnType @@ -61,7 +62,6 @@ export interface GetCasesColumn { handleIsLoading: (a: boolean) => void; refreshCases?: (a?: boolean) => void; isSelectorView: boolean; - userCanCrud: boolean; connectors?: ActionConnector[]; onRowClick?: (theCase: Case) => void; @@ -72,7 +72,6 @@ export const useCasesColumns = ({ handleIsLoading, refreshCases, isSelectorView, - userCanCrud, connectors = [], onRowClick, showSolutionColumn, @@ -88,6 +87,7 @@ export const useCasesColumns = ({ } = useDeleteCases(); const { isAlertsEnabled } = useCasesFeatures(); + const { permissions } = useCasesContext(); const [deleteThisCase, setDeleteThisCase] = useState({ id: '', @@ -319,7 +319,7 @@ export const useCasesColumns = ({ return ( handleDispatchUpdate({ updateKey: 'status', @@ -372,7 +372,7 @@ export const useCasesColumns = ({ }, ] : []), - ...(userCanCrud && !isSelectorView + ...(permissions.all && !isSelectorView ? [ { name: ( diff --git a/x-pack/plugins/cases/public/components/all_cases/header.tsx b/x-pack/plugins/cases/public/components/all_cases/header.tsx index 9a02594a790fa..aeb8a25340811 100644 --- a/x-pack/plugins/cases/public/components/all_cases/header.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/header.tsx @@ -11,23 +11,32 @@ import { HeaderPage } from '../header_page'; import * as i18n from './translations'; import { ErrorMessage } from '../use_push_to_service/callout/types'; import { NavButtons } from './nav_buttons'; +import { useCasesContext } from '../cases_context/use_cases_context'; interface OwnProps { actionsErrors: ErrorMessage[]; - userCanCrud: boolean; } type Props = OwnProps; -export const CasesTableHeader: FunctionComponent = ({ actionsErrors, userCanCrud }) => ( - - - {userCanCrud ? ( - - - - ) : null} - - -); +export const CasesTableHeader: FunctionComponent = ({ actionsErrors }) => { + const { permissions } = useCasesContext(); + + return ( + + + {permissions.all ? ( + + + + ) : null} + + + ); +}; CasesTableHeader.displayName = 'CasesTableHeader'; diff --git a/x-pack/plugins/cases/public/components/all_cases/index.tsx b/x-pack/plugins/cases/public/components/all_cases/index.tsx index 465806135a096..af467a7293239 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.tsx @@ -8,14 +8,12 @@ import React, { useMemo } from 'react'; import { CasesDeepLinkId } from '../../common/navigation'; import { useGetActionLicense } from '../../containers/use_get_action_license'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesBreadcrumbs } from '../use_breadcrumbs'; import { getActionLicenseError } from '../use_push_to_service/helpers'; import { AllCasesList } from './all_cases_list'; import { CasesTableHeader } from './header'; export const AllCases: React.FC = () => { - const { userCanCrud } = useCasesContext(); useCasesBreadcrumbs(CasesDeepLinkId.cases); const { data: actionLicense = null } = useGetActionLicense(); @@ -23,7 +21,7 @@ export const AllCases: React.FC = () => { return ( <> - + ); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index cfa2aeb8482e1..c390e4358ea76 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -61,7 +61,10 @@ describe('use cases add to existing case modal hook', () => { value={{ externalReferenceAttachmentTypeRegistry, owner: ['test'], - userCanCrud: true, + permissions: { + all: true, + read: true, + }, appId: 'test', appTitle: 'jest', basePath: '/jest', diff --git a/x-pack/plugins/cases/public/components/all_cases/table.tsx b/x-pack/plugins/cases/public/components/all_cases/table.tsx index 6d76e009403b8..86f5ea1ad195c 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table.tsx @@ -22,6 +22,7 @@ import { LinkButton } from '../links'; import { Cases, Case, FilterOptions } from '../../../common/ui/types'; import * as i18n from './translations'; import { useCreateCaseNavigation } from '../../common/navigation'; +import { useCasesContext } from '../cases_context/use_cases_context'; interface CasesTableProps { columns: EuiBasicTableProps['columns']; @@ -42,7 +43,6 @@ interface CasesTableProps { sorting: EuiBasicTableProps['sorting']; tableRef: MutableRefObject; tableRowProps: EuiBasicTableProps['rowProps']; - userCanCrud: boolean; } const Div = styled.div` @@ -68,8 +68,8 @@ export const CasesTable: FunctionComponent = ({ sorting, tableRef, tableRowProps, - userCanCrud, }) => { + const { permissions } = useCasesContext(); const { getCreateCaseUrl, navigateToCreateCase } = useCreateCaseNavigation(); const navigateToCreateCaseClick = useCallback( (ev) => { @@ -109,11 +109,11 @@ export const CasesTable: FunctionComponent = ({ {i18n.NO_CASES}} titleSize="xs" - body={userCanCrud ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY} + body={permissions.all ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY} actions={ - userCanCrud && ( + permissions.all && ( = ({ externalReferenceAttachmentTypeRegistry, owner: [APP_OWNER], useFetchAlertData: () => [false, {}], - userCanCrud: userCapabilities.generalCases.crud, + permissions: userCapabilities.generalCases, basePath: '/', features: { alerts: { enabled: false } }, releasePhase: 'experimental', diff --git a/x-pack/plugins/cases/public/components/app/routes.test.tsx b/x-pack/plugins/cases/public/components/app/routes.test.tsx index 8523b00317713..d8191aa339e6a 100644 --- a/x-pack/plugins/cases/public/components/app/routes.test.tsx +++ b/x-pack/plugins/cases/public/components/app/routes.test.tsx @@ -10,8 +10,9 @@ import React from 'react'; import { MemoryRouterProps } from 'react-router'; import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { TestProviders } from '../../common/mock'; +import { readCasesPermissions, TestProviders } from '../../common/mock'; import { CasesRoutes } from './routes'; +import { CasesPermissions } from '../../client/helpers/capabilities'; jest.mock('../all_cases', () => ({ AllCases: () =>
{'All cases'}
, @@ -29,10 +30,10 @@ const getCaseViewPaths = () => ['/cases/test-id', '/cases/test-id/comment-id']; const renderWithRouter = ( initialEntries: MemoryRouterProps['initialEntries'] = ['/cases'], - userCanCrud = true + permissions?: CasesPermissions ) => { return render( - + [false, {}]} /> @@ -48,8 +49,8 @@ describe('Cases routes', () => { }); // User has read only privileges - it('user can navigate to the all cases page with userCanCrud = false', () => { - renderWithRouter(['/cases'], false); + it('user can navigate to the all cases page with only read permissions', () => { + renderWithRouter(['/cases'], readCasesPermissions()); expect(screen.getByText('All cases')).toBeInTheDocument(); }); }); @@ -68,9 +69,9 @@ describe('Cases routes', () => { ); it.each(getCaseViewPaths())( - 'user can navigate to the cases view page with userCanCrud = false and path: %s', + 'user can navigate to the cases view page with read permissions and path: %s', async (path: string) => { - renderWithRouter([path], false); + renderWithRouter([path], readCasesPermissions()); await waitFor(() => { expect(screen.getByTestId('case-view-loading')).toBeInTheDocument(); }); @@ -84,8 +85,8 @@ describe('Cases routes', () => { expect(screen.getByText('Create case')).toBeInTheDocument(); }); - it('shows the no privileges page if userCanCrud = false', () => { - renderWithRouter(['/cases/create'], false); + it('shows the no privileges page if user is read only', () => { + renderWithRouter(['/cases/create'], readCasesPermissions()); expect(screen.getByText('Privileges required')).toBeInTheDocument(); }); }); @@ -96,8 +97,8 @@ describe('Cases routes', () => { expect(screen.getByText('Configure cases')).toBeInTheDocument(); }); - it('shows the no privileges page if userCanCrud = false', () => { - renderWithRouter(['/cases/configure'], false); + it('shows the no privileges page if user is read only', () => { + renderWithRouter(['/cases/configure'], readCasesPermissions()); expect(screen.getByText('Privileges required')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/app/routes.tsx b/x-pack/plugins/cases/public/components/app/routes.tsx index 06c92b7d199a4..fae77ef94fb0e 100644 --- a/x-pack/plugins/cases/public/components/app/routes.tsx +++ b/x-pack/plugins/cases/public/components/app/routes.tsx @@ -40,7 +40,7 @@ const CasesRoutesComponent: React.FC = ({ refreshRef, timelineIntegration, }) => { - const { basePath, userCanCrud } = useCasesContext(); + const { basePath, permissions } = useCasesContext(); const { navigateToAllCases } = useAllCasesNavigation(); const { navigateToCaseView } = useCaseViewNavigation(); useReadonlyHeader(); @@ -58,7 +58,7 @@ const CasesRoutesComponent: React.FC = ({ - {userCanCrud ? ( + {permissions.all ? ( = ({ - {userCanCrud ? ( + {permissions.all ? ( ) : ( diff --git a/x-pack/plugins/cases/public/components/app/use_readonly_header.test.tsx b/x-pack/plugins/cases/public/components/app/use_readonly_header.test.tsx index 31c9b46ad7ea5..39e7fa39b461f 100644 --- a/x-pack/plugins/cases/public/components/app/use_readonly_header.test.tsx +++ b/x-pack/plugins/cases/public/components/app/use_readonly_header.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; import { useKibana } from '../../common/lib/kibana'; -import { TestProviders } from '../../common/mock'; +import { readCasesPermissions, TestProviders } from '../../common/mock'; import { useReadonlyHeader } from './use_readonly_header'; const useKibanaMock = useKibana as jest.Mocked; @@ -33,7 +33,9 @@ describe('CaseContainerComponent', () => { it('displays the readonly glasses badge read permissions but not write', () => { renderHook(() => useReadonlyHeader(), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => ( + {children} + ), }); expect(mockedSetBadge).toBeCalledTimes(1); diff --git a/x-pack/plugins/cases/public/components/app/use_readonly_header.ts b/x-pack/plugins/cases/public/components/app/use_readonly_header.ts index eb522dffe4c7b..08aa05ab98ffe 100644 --- a/x-pack/plugins/cases/public/components/app/use_readonly_header.ts +++ b/x-pack/plugins/cases/public/components/app/use_readonly_header.ts @@ -15,19 +15,19 @@ import { useCasesContext } from '../cases_context/use_cases_context'; * This component places a read-only icon badge in the header if user only has read permissions */ export function useReadonlyHeader() { - const { userCanCrud } = useCasesContext(); + const { permissions } = useCasesContext(); const chrome = useKibana().services.chrome; // if the user is read only then display the glasses badge in the global navigation header const setBadge = useCallback(() => { - if (!userCanCrud) { + if (!permissions.all && permissions.read) { chrome.setBadge({ text: i18n.READ_ONLY_BADGE_TEXT, tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, iconType: 'glasses', }); } - }, [chrome, userCanCrud]); + }, [chrome, permissions]); useEffect(() => { setBadge(); diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx index 1142f7f27ccf3..f9c92c8222ccf 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx @@ -42,7 +42,6 @@ describe('CaseActionBar', () => { isLoading: false, onUpdateField, currentExternalIncident: null, - userCanCrud: true, metricsFeatures: [], }; diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index 5af9835605de9..dbffb0c338248 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -29,6 +29,7 @@ import { useCasesFeatures } from '../cases_context/use_cases_features'; import { FormattedRelativePreferenceDate } from '../formatted_date'; import { getStatusDate, getStatusTitle } from './helpers'; import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page'; +import { useCasesContext } from '../cases_context/use_cases_context'; const MyDescriptionList = styled(EuiDescriptionList)` ${({ theme }) => css` @@ -45,16 +46,15 @@ const MyDescriptionList = styled(EuiDescriptionList)` export interface CaseActionBarProps { caseData: Case; - userCanCrud: boolean; isLoading: boolean; onUpdateField: (args: OnUpdateFields) => void; } const CaseActionBarComponent: React.FC = ({ caseData, - userCanCrud, isLoading, onUpdateField, }) => { + const { permissions } = useCasesContext(); const { isSyncAlertsEnabled, metricsFeatures } = useCasesFeatures(); const date = useMemo(() => getStatusDate(caseData), [caseData]); const title = useMemo(() => getStatusTitle(caseData.status), [caseData.status]); @@ -107,7 +107,7 @@ const CaseActionBarComponent: React.FC = ({ @@ -134,7 +134,7 @@ const CaseActionBarComponent: React.FC = ({ responsive={false} justifyContent="spaceBetween" > - {userCanCrud && isSyncAlertsEnabled && ( + {permissions.all && isSyncAlertsEnabled && ( = ({ - {userCanCrud && ( + {permissions.all && ( ( showAlertDetails, useFetchAlertData, }) => { - const { userCanCrud, features } = useCasesContext(); + const { features } = useCasesContext(); const { navigateToCaseView } = useCaseViewNavigation(); const { urlParams } = useUrlParams(); const refreshCaseViewPage = useRefreshCaseViewPage(); @@ -171,7 +171,6 @@ export const CaseViewPage = React.memo( data-test-subj="case-view-title" titleNode={ ( > diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index 03fb726e1125c..ccf0bd9b475b3 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -39,7 +39,7 @@ export const CaseViewActivity = ({ showAlertDetails?: (alertId: string, index: string) => void; useFetchAlertData: UseFetchAlertData; }) => { - const { userCanCrud } = useCasesContext(); + const { permissions } = useCasesContext(); const { getCaseViewUrl } = useCaseViewNavigation(); const { data: userActionsData, isLoading: isLoadingUserActions } = useGetCaseUserActions( @@ -133,7 +133,7 @@ export const CaseViewActivity = ({ onShowAlertDetails={onShowAlertDetails} onUpdateField={onUpdateField} statusActionButton={ - userCanCrud ? ( + permissions.all ? ( @@ -150,7 +149,7 @@ export const CaseViewActivity = ({ ) : null} ) : null} diff --git a/x-pack/plugins/cases/public/components/case_view/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/index.test.tsx index 4990174b08dd7..b9213a8eb887f 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.test.tsx @@ -189,7 +189,6 @@ describe('CaseView', () => { onComponentInitialized: jest.fn(), showAlertDetails: jest.fn(), useFetchAlertData: jest.fn().mockReturnValue([false, alertsHit[0]]), - userCanCrud: true, }} /> ); diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index ebcdebc012709..de63eefb79cc5 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect, useReducer, Dispatch } from 'react'; import { merge } from 'lodash'; +import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; import { DEFAULT_FEATURES } from '../../../common/constants'; import { DEFAULT_BASE_PATH } from '../../common/navigation'; import { useApplication } from './use_application'; @@ -27,7 +28,10 @@ export interface CasesContextValue { owner: string[]; appId: string; appTitle: string; - userCanCrud: boolean; + permissions: { + all: boolean; + read: boolean; + }; basePath: string; features: CasesFeaturesAllRequired; releasePhase: ReleasePhase; @@ -37,7 +41,7 @@ export interface CasesContextValue { export interface CasesContextProps extends Pick< CasesContextValue, - 'owner' | 'userCanCrud' | 'externalReferenceAttachmentTypeRegistry' + 'owner' | 'permissions' | 'externalReferenceAttachmentTypeRegistry' > { basePath?: string; features?: CasesFeatures; @@ -56,7 +60,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ value: { externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, basePath = DEFAULT_BASE_PATH, features = {}, releasePhase = 'ga', @@ -67,7 +71,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ const [value, setValue] = useState(() => ({ externalReferenceAttachmentTypeRegistry, owner, - userCanCrud, + permissions, basePath, /** * The empty object at the beginning avoids the mutation @@ -83,7 +87,14 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ })); /** - * `userCanCrud` prop may change by the parent plugin. + * Only update the context if the nested permissions fields changed, this avoids a rerender when the object's reference + * changes. + */ + useDeepCompareEffect(() => { + setValue((prev) => ({ ...prev, permissions })); + }, [permissions]); + + /** * `appId` and `appTitle` are dynamically retrieved from kibana context. * We need to update the state if any of these values change, the rest of props are never updated. */ @@ -93,10 +104,9 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ ...prev, appId, appTitle, - userCanCrud, })); } - }, [appTitle, appId, userCanCrud]); + }, [appTitle, appId]); return isCasesContextValue(value) ? ( @@ -108,7 +118,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ CasesProvider.displayName = 'CasesProvider'; function isCasesContextValue(value: CasesContextStateValue): value is CasesContextValue { - return value.appId != null && value.appTitle != null && value.userCanCrud != null; + return value.appId != null && value.appTitle != null && value.permissions != null; } // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index 890b8683ae6a5..b6c46cbd91731 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -10,7 +10,7 @@ import { ReactWrapper, mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; import { ConfigureCases } from '.'; -import { TestProviders } from '../../common/mock'; +import { noCasesPermissions, TestProviders } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; @@ -191,7 +191,7 @@ describe('ConfigureCases', () => { test('it disables correctly when the user cannot crud', () => { const newWrapper = mount(, { wrappingComponent: TestProviders, - wrappingComponentProps: { userCanCrud: false }, + wrappingComponentProps: { permissions: noCasesPermissions() }, }); expect(newWrapper.find('button[data-test-subj="dropdown-connectors"]').prop('disabled')).toBe( diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index e7542ba39f382..bf75dd5a828cd 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -51,7 +51,7 @@ const FormWrapper = styled.div` `; export const ConfigureCases: React.FC = React.memo(() => { - const { userCanCrud } = useCasesContext(); + const { permissions } = useCasesContext(); const { triggersActionsUi } = useKibana().services; useCasesBreadcrumbs(CasesDeepLinkId.casesConfigure); @@ -225,7 +225,7 @@ export const ConfigureCases: React.FC = React.memo(() => { @@ -233,13 +233,13 @@ export const ConfigureCases: React.FC = React.memo(() => { {ConnectorAddFlyout} diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx index f701a3e647add..d553295eaef98 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx @@ -13,6 +13,7 @@ import React from 'react'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesAddToNewCaseFlyout } from './use_cases_add_to_new_case_flyout'; +import { allCasesPermissions } from '../../../common/mock'; import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry'; jest.mock('../../../common/use_cases_toast'); @@ -30,7 +31,7 @@ describe('use cases add to new case flyout hook', () => { value={{ externalReferenceAttachmentTypeRegistry, owner: ['test'], - userCanCrud: true, + permissions: allCasesPermissions(), appId: 'test', appTitle: 'jest', basePath: '/jest', diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 6309ce0ebd832..ee8c34faff078 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -11,7 +11,12 @@ import { render, waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { EditConnector, EditConnectorProps } from '.'; -import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; +import { + AppMockRenderer, + createAppMockRenderer, + readCasesPermissions, + TestProviders, +} from '../../common/mock'; import { basicCase, basicPush, caseUserActions, connectorsMock } from '../../containers/mock'; import { CaseConnector } from '../../containers/configure/types'; @@ -36,7 +41,6 @@ const getDefaultProps = (): EditConnectorProps => { isValidConnector: true, onSubmit, userActions: caseUserActions, - userCanCrud: true, }; }; @@ -201,11 +205,9 @@ describe('EditConnector ', () => { }); it('does not allow the connector to be edited when the user does not have write permissions', async () => { - const defaultProps = getDefaultProps(); - const props = { ...defaultProps, userCanCrud: false }; const wrapper = mount( - - + + ); await waitFor(() => diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index 29ab523764b47..0c691460ba007 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -32,6 +32,7 @@ import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { usePushToService } from '../use_push_to_service'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { useApplicationCapabilities } from '../../common/lib/kibana'; +import { useCasesContext } from '../cases_context/use_cases_context'; export interface EditConnectorProps { caseData: Case; @@ -48,7 +49,6 @@ export interface EditConnectorProps { onSuccess: () => void ) => void; userActions: CaseUserActions[]; - userCanCrud?: boolean; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -119,8 +119,8 @@ export const EditConnector = React.memo( isValidConnector, onSubmit, userActions, - userCanCrud = true, }: EditConnectorProps) => { + const { permissions } = useCasesContext(); const caseFields = caseData.connector.fields; const selectedConnector = caseData.connector.id; @@ -273,7 +273,6 @@ export const EditConnector = React.memo( connectors, hasDataToPush, onEditClick, - userCanCrud, isValidConnector, }); @@ -289,7 +288,7 @@ export const EditConnector = React.memo(

{i18n.CONNECTORS}

{isLoading && } - {!isLoading && !editConnector && userCanCrud && actionsReadCapabilities && ( + {!isLoading && !editConnector && permissions.all && actionsReadCapabilities && ( {pushButton} diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap index a4103a3e61fe5..41cc919b50200 100644 --- a/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap +++ b/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap @@ -41,7 +41,10 @@ exports[`EditableTitle renders 1`] = ` "owner": Array [ "securitySolution", ], - "userCanCrud": true, + "permissions": Object { + "all": true, + "read": true, + }, } } > @@ -49,7 +52,6 @@ exports[`EditableTitle renders 1`] = ` isLoading={false} onSubmit={[MockFunction]} title="Test title" - userCanCrud={true} /> diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap index 3848a6db31098..fb8c6d854317a 100644 --- a/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap @@ -41,7 +41,10 @@ exports[`HeaderPage it renders 1`] = ` "owner": Array [ "securitySolution", ], - "userCanCrud": true, + "permissions": Object { + "all": true, + "read": true, + }, } } > diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx index 111cc4940ac59..f36996c013471 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx @@ -9,7 +9,12 @@ import { shallow } from 'enzyme'; import React from 'react'; import '../../common/mock/match_media'; -import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; +import { + AppMockRenderer, + createAppMockRenderer, + readCasesPermissions, + TestProviders, +} from '../../common/mock'; import { EditableTitle, EditableTitleProps } from './editable_title'; import { useMountAppended } from '../../utils/use_mount_appended'; @@ -20,7 +25,6 @@ describe('EditableTitle', () => { title: 'Test title', onSubmit: submitTitle, isLoading: false, - userCanCrud: true, }; beforeEach(() => { @@ -39,8 +43,8 @@ describe('EditableTitle', () => { it('does not show the edit icon when the user does not have edit permissions', () => { const wrapper = mount( - - + + ); diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx index 0b142ca40a548..c92e1122c53e8 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx @@ -37,19 +37,13 @@ const MySpinner = styled(EuiLoadingSpinner)` `; export interface EditableTitleProps { - userCanCrud: boolean; isLoading: boolean; title: string; onSubmit: (title: string) => void; } -const EditableTitleComponent: React.FC = ({ - userCanCrud = false, - onSubmit, - isLoading, - title, -}) => { - const { releasePhase } = useCasesContext(); +const EditableTitleComponent: React.FC = ({ onSubmit, isLoading, title }) => { + const { releasePhase, permissions } = useCasesContext(); const [editMode, setEditMode] = useState(false); const [errors, setErrors] = useState([]); const [newTitle, setNewTitle] = useState(title); @@ -124,7 +118,7 @@ const EditableTitleComponent: React.FC = ({ ) : ( {isLoading && <MySpinner data-test-subj="editable-title-loading" />} - {!isLoading && userCanCrud && ( + {!isLoading && permissions.all && ( <MyEuiButtonIcon aria-label={i18n.EDIT_TITLE_ARIA(title as string)} iconType="pencil" diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx index 1fd4a28ddc1b9..eb968834d765e 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { TestProviders } from '../../../common/mock'; +import { readCasesPermissions, TestProviders } from '../../../common/mock'; import { NoCases } from '.'; jest.mock('../../../common/navigation/hooks'); @@ -27,7 +27,7 @@ describe('NoCases', () => { it('displays a message without a link to create a case when the user does not have write permissions', () => { const wrapper = mount( - <TestProviders userCanCrud={false}> + <TestProviders permissions={readCasesPermissions()}> <NoCases /> </TestProviders> ); diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx index dce685248c4c2..d39dfdbb2c50b 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx @@ -13,7 +13,7 @@ import { useCasesContext } from '../../cases_context/use_cases_context'; import { useCreateCaseNavigation } from '../../../common/navigation'; const NoCasesComponent = () => { - const { userCanCrud } = useCasesContext(); + const { permissions } = useCasesContext(); const { getCreateCaseUrl, navigateToCreateCase } = useCreateCaseNavigation(); const navigateToCreateCaseClick = useCallback( @@ -24,7 +24,7 @@ const NoCasesComponent = () => { [navigateToCreateCase] ); - return userCanCrud ? ( + return permissions.all ? ( <> <span>{i18n.NO_CASES}</span> <LinkAnchor diff --git a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx index f795f3ae851ad..46633503dd734 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import { TagList, TagListProps } from '.'; import { getFormMock } from '../__mock__/form'; -import { TestProviders } from '../../common/mock'; +import { readCasesPermissions, TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; import { useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/hooks/use_form'; import { useGetTags } from '../../containers/use_get_tags'; @@ -33,7 +33,6 @@ jest.mock('@elastic/eui', () => { }); const onSubmit = jest.fn(); const defaultProps: TagListProps = { - userCanCrud: true, isLoading: false, onSubmit, tags: [], @@ -109,10 +108,9 @@ describe('TagList ', () => { }); it('does not render when the user does not have write permissions', () => { - const props = { ...defaultProps, userCanCrud: false }; const wrapper = mount( - <TestProviders> - <TagList {...props} /> + <TestProviders permissions={readCasesPermissions()}> + <TagList {...defaultProps} /> </TestProviders> ); expect(wrapper.find(`[data-test-subj="tag-list-edit"]`).exists()).toBeFalsy(); diff --git a/x-pack/plugins/cases/public/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/tag_list/index.tsx index 2e2f9e783c011..74fb2efcb4fad 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.tsx @@ -24,11 +24,11 @@ import { schema } from './schema'; import { useGetTags } from '../../containers/use_get_tags'; import { Tags } from './tags'; +import { useCasesContext } from '../cases_context/use_cases_context'; const CommonUseField = getUseField({ component: Field }); export interface TagListProps { - userCanCrud?: boolean; isLoading: boolean; onSubmit: (a: string[]) => void; tags: string[]; @@ -55,144 +55,143 @@ const ColumnFlexGroup = styled(EuiFlexGroup)` `} `; -export const TagList = React.memo( - ({ userCanCrud = true, isLoading, onSubmit, tags }: TagListProps) => { - const initialState = { tags }; - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const { submit } = form; - const [isEditTags, setIsEditTags] = useState(false); +export const TagList = React.memo(({ isLoading, onSubmit, tags }: TagListProps) => { + const { permissions } = useCasesContext(); + const initialState = { tags }; + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + const { submit } = form; + const [isEditTags, setIsEditTags] = useState(false); - const onSubmitTags = useCallback(async () => { - const { isValid, data: newData } = await submit(); - if (isValid && newData.tags) { - onSubmit(newData.tags); - form.reset({ defaultValue: newData }); - setIsEditTags(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onSubmit, submit]); + const onSubmitTags = useCallback(async () => { + const { isValid, data: newData } = await submit(); + if (isValid && newData.tags) { + onSubmit(newData.tags); + form.reset({ defaultValue: newData }); + setIsEditTags(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onSubmit, submit]); - const { data: tagOptions = [] } = useGetTags(); - const [options, setOptions] = useState( - tagOptions.map((label) => ({ - label, - })) - ); + const { data: tagOptions = [] } = useGetTags(); + const [options, setOptions] = useState( + tagOptions.map((label) => ({ + label, + })) + ); - useEffect( - () => - setOptions( - tagOptions.map((label) => ({ - label, - })) - ), - [tagOptions] - ); - return ( - <EuiText data-test-subj="case-view-tag-list"> - <EuiFlexGroup - alignItems="center" - gutterSize="xs" - justifyContent="spaceBetween" - responsive={false} - > - <EuiFlexItem grow={false}> - <h4>{i18n.TAGS}</h4> + useEffect( + () => + setOptions( + tagOptions.map((label) => ({ + label, + })) + ), + [tagOptions] + ); + return ( + <EuiText data-test-subj="case-view-tag-list"> + <EuiFlexGroup + alignItems="center" + gutterSize="xs" + justifyContent="spaceBetween" + responsive={false} + > + <EuiFlexItem grow={false}> + <h4>{i18n.TAGS}</h4> + </EuiFlexItem> + {isLoading && <EuiLoadingSpinner data-test-subj="tag-list-loading" />} + {!isLoading && permissions.all && ( + <EuiFlexItem data-test-subj="tag-list-edit" grow={false}> + <EuiButtonIcon + data-test-subj="tag-list-edit-button" + aria-label={i18n.EDIT_TAGS_ARIA} + iconType={'pencil'} + onClick={setIsEditTags.bind(null, true)} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + <EuiHorizontalRule margin="xs" /> + <MyFlexGroup gutterSize="none" data-test-subj="case-tags"> + {tags.length === 0 && !isEditTags && <p data-test-subj="no-tags">{i18n.NO_TAGS}</p>} + {!isEditTags && ( + <EuiFlexItem> + <Tags tags={tags} color="hollow" /> </EuiFlexItem> - {isLoading && <EuiLoadingSpinner data-test-subj="tag-list-loading" />} - {!isLoading && userCanCrud && ( - <EuiFlexItem data-test-subj="tag-list-edit" grow={false}> - <EuiButtonIcon - data-test-subj="tag-list-edit-button" - aria-label={i18n.EDIT_TAGS_ARIA} - iconType={'pencil'} - onClick={setIsEditTags.bind(null, true)} - /> + )} + {isEditTags && ( + <ColumnFlexGroup data-test-subj="edit-tags" direction="column"> + <EuiFlexItem> + <Form form={form}> + <CommonUseField + path="tags" + componentProps={{ + idAria: 'caseTags', + 'data-test-subj': 'caseTags', + euiFieldProps: { + fullWidth: true, + placeholder: '', + options, + noSuggestions: false, + }, + }} + /> + <FormDataProvider pathsToWatch="tags"> + {({ tags: anotherTags }) => { + const current: string[] = options.map((opt) => opt.label); + const newOptions = anotherTags.reduce((acc: string[], item: string) => { + if (!acc.includes(item)) { + return [...acc, item]; + } + return acc; + }, current); + if (!isEqual(current, newOptions)) { + setOptions( + newOptions.map((label: string) => ({ + label, + })) + ); + } + return null; + }} + </FormDataProvider> + </Form> </EuiFlexItem> - )} - </EuiFlexGroup> - <EuiHorizontalRule margin="xs" /> - <MyFlexGroup gutterSize="none" data-test-subj="case-tags"> - {tags.length === 0 && !isEditTags && <p data-test-subj="no-tags">{i18n.NO_TAGS}</p>} - {!isEditTags && ( <EuiFlexItem> - <Tags tags={tags} color="hollow" /> + <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}> + <EuiFlexItem grow={false}> + <EuiButton + color="success" + data-test-subj="edit-tags-submit" + fill + iconType="save" + onClick={onSubmitTags} + size="s" + > + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + data-test-subj="edit-tags-cancel" + iconType="cross" + onClick={setIsEditTags.bind(null, false)} + size="s" + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> - )} - {isEditTags && ( - <ColumnFlexGroup data-test-subj="edit-tags" direction="column"> - <EuiFlexItem> - <Form form={form}> - <CommonUseField - path="tags" - componentProps={{ - idAria: 'caseTags', - 'data-test-subj': 'caseTags', - euiFieldProps: { - fullWidth: true, - placeholder: '', - options, - noSuggestions: false, - }, - }} - /> - <FormDataProvider pathsToWatch="tags"> - {({ tags: anotherTags }) => { - const current: string[] = options.map((opt) => opt.label); - const newOptions = anotherTags.reduce((acc: string[], item: string) => { - if (!acc.includes(item)) { - return [...acc, item]; - } - return acc; - }, current); - if (!isEqual(current, newOptions)) { - setOptions( - newOptions.map((label: string) => ({ - label, - })) - ); - } - return null; - }} - </FormDataProvider> - </Form> - </EuiFlexItem> - <EuiFlexItem> - <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}> - <EuiFlexItem grow={false}> - <EuiButton - color="success" - data-test-subj="edit-tags-submit" - fill - iconType="save" - onClick={onSubmitTags} - size="s" - > - {i18n.SAVE} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - data-test-subj="edit-tags-cancel" - iconType="cross" - onClick={setIsEditTags.bind(null, false)} - size="s" - > - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </ColumnFlexGroup> - )} - </MyFlexGroup> - </EuiText> - ); - } -); + </ColumnFlexGroup> + )} + </MyFlexGroup> + </EuiText> + ); +}); TagList.displayName = 'TagList'; diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx index 15cfefd57ac57..c00ebc7b48045 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx @@ -11,7 +11,7 @@ import { render, screen } from '@testing-library/react'; import '../../common/mock/match_media'; import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; -import { TestProviders } from '../../common/mock'; +import { readCasesPermissions, TestProviders } from '../../common/mock'; import { CaseStatuses, ConnectorTypes } from '../../../common/api'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { basicPush, actionLicenses, connectorsMock } from '../../containers/mock'; @@ -70,7 +70,6 @@ describe('usePushToService', () => { hasDataToPush: true, onEditClick, isValidConnector: true, - userCanCrud: true, }; beforeEach(() => { @@ -281,8 +280,6 @@ describe('usePushToService', () => { }); describe('user does not have write permissions', () => { - const noWriteProps = { ...defaultArgs, userCanCrud: false }; - it('does not display a message when user does not have a premium license', async () => { useFetchActionLicenseMock.mockImplementation(() => ({ isLoading: false, @@ -293,9 +290,11 @@ describe('usePushToService', () => { })); await act(async () => { const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( - () => usePushToService(noWriteProps), + () => usePushToService(defaultArgs), { - wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + wrapper: ({ children }) => ( + <TestProviders permissions={readCasesPermissions()}> {children}</TestProviders> + ), } ); await waitForNextUpdate(); @@ -313,9 +312,11 @@ describe('usePushToService', () => { })); await act(async () => { const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( - () => usePushToService(noWriteProps), + () => usePushToService(defaultArgs), { - wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + wrapper: ({ children }) => ( + <TestProviders permissions={readCasesPermissions()}> {children}</TestProviders> + ), } ); await waitForNextUpdate(); @@ -328,7 +329,7 @@ describe('usePushToService', () => { const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( () => usePushToService({ - ...noWriteProps, + ...defaultArgs, connectors: [], connector: { id: 'none', @@ -338,7 +339,9 @@ describe('usePushToService', () => { }, }), { - wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + wrapper: ({ children }) => ( + <TestProviders permissions={readCasesPermissions()}> {children}</TestProviders> + ), } ); await waitForNextUpdate(); @@ -351,7 +354,7 @@ describe('usePushToService', () => { const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( () => usePushToService({ - ...noWriteProps, + ...defaultArgs, connector: { id: 'none', name: 'none', @@ -360,7 +363,9 @@ describe('usePushToService', () => { }, }), { - wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + wrapper: ({ children }) => ( + <TestProviders permissions={readCasesPermissions()}> {children}</TestProviders> + ), } ); await waitForNextUpdate(); @@ -373,7 +378,7 @@ describe('usePushToService', () => { const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( () => usePushToService({ - ...noWriteProps, + ...defaultArgs, connector: { id: 'not-exist', name: 'not-exist', @@ -383,7 +388,9 @@ describe('usePushToService', () => { isValidConnector: false, }), { - wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + wrapper: ({ children }) => ( + <TestProviders permissions={readCasesPermissions()}> {children}</TestProviders> + ), } ); await waitForNextUpdate(); @@ -396,7 +403,7 @@ describe('usePushToService', () => { const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( () => usePushToService({ - ...noWriteProps, + ...defaultArgs, connectors: [], connector: { id: 'not-exist', @@ -407,7 +414,9 @@ describe('usePushToService', () => { isValidConnector: false, }), { - wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + wrapper: ({ children }) => ( + <TestProviders permissions={readCasesPermissions()}> {children}</TestProviders> + ), } ); await waitForNextUpdate(); @@ -420,11 +429,13 @@ describe('usePushToService', () => { const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>( () => usePushToService({ - ...noWriteProps, + ...defaultArgs, caseStatus: CaseStatuses.closed, }), { - wrapper: ({ children }) => <TestProviders> {children}</TestProviders>, + wrapper: ({ children }) => ( + <TestProviders permissions={readCasesPermissions()}> {children}</TestProviders> + ), } ); await waitForNextUpdate(); diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx index b2c4e79a35596..253170fdd955c 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx @@ -23,6 +23,7 @@ import { CaseServices } from '../../containers/use_get_case_user_actions'; import { ErrorMessage } from './callout/types'; import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page'; import { useGetActionLicense } from '../../containers/use_get_action_license'; +import { useCasesContext } from '../cases_context/use_cases_context'; export interface UsePushToService { caseId: string; @@ -33,7 +34,6 @@ export interface UsePushToService { hasDataToPush: boolean; isValidConnector: boolean; onEditClick: () => void; - userCanCrud: boolean; } export interface ReturnUsePushToService { @@ -50,8 +50,8 @@ export const usePushToService = ({ hasDataToPush, isValidConnector, onEditClick, - userCanCrud, }: UsePushToService): ReturnUsePushToService => { + const { permissions } = useCasesContext(); const { isLoading, pushCaseToExternalService } = usePostPushToService(); const { isLoading: loadingLicense, data: actionLicense = null } = useGetActionLicense(); @@ -76,7 +76,7 @@ export const usePushToService = ({ // these message require that the user do some sort of write action as a result of the message, readonly users won't // be able to perform such an action so let's not display the error to the user in that situation - if (!userCanCrud) { + if (!permissions.all) { return errors; } @@ -114,7 +114,7 @@ export const usePushToService = ({ return errors; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, userCanCrud]); + }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, permissions.all]); const pushToServiceButton = useMemo( () => ( @@ -126,7 +126,7 @@ export const usePushToService = ({ isLoading || loadingLicense || errorsMsg.length > 0 || - !userCanCrud || + !permissions.all || !isValidConnector || !hasDataToPush } @@ -146,29 +146,26 @@ export const usePushToService = ({ hasDataToPush, isLoading, loadingLicense, - userCanCrud, + permissions.all, isValidConnector, ] ); - const objToReturn = useMemo( - () => ({ - pushButton: - errorsMsg.length > 0 || !hasDataToPush ? ( - <EuiToolTip - position="top" - title={ - errorsMsg.length > 0 ? errorsMsg[0].title : i18n.PUSH_LOCKED_TITLE(connector.name) - } - content={ - <p>{errorsMsg.length > 0 ? errorsMsg[0].description : i18n.PUSH_LOCKED_DESC}</p> - } - > - {pushToServiceButton} - </EuiToolTip> - ) : ( - <>{pushToServiceButton}</> - ), + const objToReturn = useMemo(() => { + const hidePushButton = errorsMsg.length > 0 || !hasDataToPush || !permissions.all; + + return { + pushButton: hidePushButton ? ( + <EuiToolTip + position="top" + title={errorsMsg.length > 0 ? errorsMsg[0].title : i18n.PUSH_LOCKED_TITLE(connector.name)} + content={<p>{errorsMsg.length > 0 ? errorsMsg[0].description : i18n.PUSH_LOCKED_DESC}</p>} + > + {pushToServiceButton} + </EuiToolTip> + ) : ( + <>{pushToServiceButton}</> + ), pushCallouts: errorsMsg.length > 0 ? ( <CaseCallOut @@ -178,17 +175,17 @@ export const usePushToService = ({ onEditClick={onEditClick} /> ) : null, - }), - [ - connector.name, - connectors.length, - errorsMsg, - hasDataToPush, - hasLicenseError, - onEditClick, - pushToServiceButton, - ] - ); + }; + }, [ + connector.name, + connectors.length, + errorsMsg, + hasDataToPush, + hasLicenseError, + onEditClick, + pushToServiceButton, + permissions.all, + ]); return objToReturn; }; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx index 589efe48cd188..0dfd5876cea6b 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -42,7 +42,6 @@ const getCreateCommentUserAction = ({ caseData, externalReferenceAttachmentTypeRegistry, comment, - userCanCrud, commentRefs, manageMarkdownEditIds, selectedOutlineCommentId, @@ -68,7 +67,6 @@ const getCreateCommentUserAction = ({ case CommentType.user: const userBuilder = createUserAttachmentUserActionBuilder({ comment, - userCanCrud, outlined: comment.id === selectedOutlineCommentId, isEdit: manageMarkdownEditIds.includes(comment.id), commentRefs, @@ -116,7 +114,6 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({ caseData, externalReferenceAttachmentTypeRegistry, userAction, - userCanCrud, commentRefs, manageMarkdownEditIds, selectedOutlineCommentId, @@ -152,7 +149,6 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({ userAction: commentUserAction, externalReferenceAttachmentTypeRegistry, comment, - userCanCrud, commentRefs, manageMarkdownEditIds, selectedOutlineCommentId, diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx index 6c4c96a95bc46..398f58da97b9c 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx @@ -20,7 +20,6 @@ import { UserActionBuilderArgs, UserActionBuilder } from '../types'; type BuilderArgs = Pick< UserActionBuilderArgs, - | 'userCanCrud' | 'handleManageMarkdownEditId' | 'handleSaveComment' | 'handleManageQuote' @@ -35,7 +34,6 @@ type BuilderArgs = Pick< export const createUserAttachmentUserActionBuilder = ({ comment, - userCanCrud, outlined, isEdit, isLoading, @@ -95,7 +93,6 @@ export const createUserAttachmentUserActionBuilder = ({ onEdit={handleManageMarkdownEditId.bind(null, comment.id)} onQuote={handleManageQuote.bind(null, comment.comment)} onDelete={handleDeleteComment.bind(null, comment.id)} - userCanCrud={userCanCrud} /> ), }, diff --git a/x-pack/plugins/cases/public/components/user_actions/content_toolbar.test.tsx b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.test.tsx index 74f5205578a1d..bba8303149ae9 100644 --- a/x-pack/plugins/cases/public/components/user_actions/content_toolbar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { UserActionContentToolbar, UserActionContentToolbarProps } from './content_toolbar'; +import { TestProviders } from '../../common/mock'; jest.mock('../../common/navigation/hooks'); jest.mock('../../common/lib/kibana'); @@ -17,7 +18,6 @@ const props: UserActionContentToolbarProps = { id: '1', editLabel: 'edit', quoteLabel: 'quote', - userCanCrud: true, isLoading: false, onEdit: jest.fn(), onQuote: jest.fn(), @@ -27,7 +27,11 @@ describe('UserActionContentToolbar ', () => { let wrapper: ReactWrapper; beforeAll(() => { - wrapper = mount(<UserActionContentToolbar {...props} />); + wrapper = mount( + <TestProviders> + <UserActionContentToolbar {...props} /> + </TestProviders> + ); }); it('it renders', async () => { diff --git a/x-pack/plugins/cases/public/components/user_actions/content_toolbar.tsx b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.tsx index bea47933dccd6..e23a4efa2f0a2 100644 --- a/x-pack/plugins/cases/public/components/user_actions/content_toolbar.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.tsx @@ -22,7 +22,6 @@ export interface UserActionContentToolbarProps { onEdit: (id: string) => void; onQuote: (id: string) => void; onDelete?: (id: string) => void; - userCanCrud: boolean; } const UserActionContentToolbarComponent = ({ @@ -36,7 +35,6 @@ const UserActionContentToolbarComponent = ({ onEdit, onQuote, onDelete, - userCanCrud, }: UserActionContentToolbarProps) => ( <EuiFlexGroup responsive={false} alignItems="center"> <EuiFlexItem grow={false}> @@ -53,7 +51,6 @@ const UserActionContentToolbarComponent = ({ onEdit={onEdit} onQuote={onQuote} onDelete={onDelete} - userCanCrud={userCanCrud} commentMarkdown={commentMarkdown} /> </EuiFlexItem> diff --git a/x-pack/plugins/cases/public/components/user_actions/description.tsx b/x-pack/plugins/cases/public/components/user_actions/description.tsx index eae2bd3d1258e..ef8f26bd0e87b 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.tsx @@ -27,7 +27,6 @@ type GetDescriptionUserActionArgs = Pick< | 'caseData' | 'commentRefs' | 'manageMarkdownEditIds' - | 'userCanCrud' | 'handleManageMarkdownEditId' | 'handleManageQuote' > & @@ -38,7 +37,6 @@ export const getDescriptionUserAction = ({ commentRefs, manageMarkdownEditIds, isLoadingDescription, - userCanCrud, onUpdateField, handleManageMarkdownEditId, handleManageQuote, @@ -85,7 +83,6 @@ export const getDescriptionUserAction = ({ isLoading={isLoadingDescription} onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} onQuote={handleManageQuote.bind(null, caseData.description)} - userCanCrud={userCanCrud} /> ), }; diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index b6122ebec4016..9a971552f5ec3 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -44,7 +44,6 @@ const defaultProps = { selectedAlertPatterns: ['some-test-pattern'], statusActionButton: null, updateCase, - userCanCrud: true, useFetchAlertData: (): [boolean, Record<string, unknown>] => [ false, { 'some-id': { _id: 'some-id' } }, diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx index c8d038bb2b0f5..1c456f90a71e7 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -91,7 +91,6 @@ export const UserActions = React.memo( onUpdateField, statusActionButton, useFetchAlertData, - userCanCrud, }: UserActionTreeProps) => { const { detailName: caseId, commentId } = useCaseViewParams(); const [initLoading, setInitLoading] = useState(true); @@ -123,7 +122,6 @@ export const UserActions = React.memo( <AddComment id={NEW_COMMENT_ID} caseId={caseId} - userCanCrud={userCanCrud} ref={(element) => (commentRefs.current[NEW_COMMENT_ID] = element)} onCommentPosted={handleUpdate} onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_COMMENT_ID)} @@ -131,14 +129,7 @@ export const UserActions = React.memo( statusActionButton={statusActionButton} /> ), - [ - caseId, - userCanCrud, - handleUpdate, - handleManageMarkdownEditId, - statusActionButton, - commentRefs, - ] + [caseId, handleUpdate, handleManageMarkdownEditId, statusActionButton, commentRefs] ); useEffect(() => { @@ -157,7 +148,6 @@ export const UserActions = React.memo( commentRefs, manageMarkdownEditIds, isLoadingDescription, - userCanCrud, onUpdateField, handleManageMarkdownEditId, handleManageQuote, @@ -167,7 +157,6 @@ export const UserActions = React.memo( commentRefs, manageMarkdownEditIds, isLoadingDescription, - userCanCrud, onUpdateField, handleManageMarkdownEditId, handleManageQuote, @@ -195,7 +184,6 @@ export const UserActions = React.memo( caseServices, comments: caseData.comments, index, - userCanCrud, commentRefs, manageMarkdownEditIds, selectedOutlineCommentId, @@ -222,7 +210,6 @@ export const UserActions = React.memo( descriptionCommentListObj, caseData, caseServices, - userCanCrud, commentRefs, manageMarkdownEditIds, selectedOutlineCommentId, @@ -241,7 +228,9 @@ export const UserActions = React.memo( ] ); - const bottomActions = userCanCrud + const { permissions } = useCasesContext(); + + const bottomActions = permissions.all ? [ { username: ( diff --git a/x-pack/plugins/cases/public/components/user_actions/mock.ts b/x-pack/plugins/cases/public/components/user_actions/mock.ts index 7d6bbd8b58c59..777241aca71eb 100644 --- a/x-pack/plugins/cases/public/components/user_actions/mock.ts +++ b/x-pack/plugins/cases/public/components/user_actions/mock.ts @@ -67,7 +67,6 @@ export const getMockBuilderArgs = (): UserActionBuilderArgs => { caseServices, index: 0, alertData, - userCanCrud: true, commentRefs, manageMarkdownEditIds: [], selectedOutlineCommentId: '', diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions.test.tsx index 0084301183e68..e9319169b953c 100644 --- a/x-pack/plugins/cases/public/components/user_actions/property_actions.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/property_actions.test.tsx @@ -10,6 +10,7 @@ import { mount, ReactWrapper } from 'enzyme'; import { UserActionPropertyActions } from './property_actions'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { TestProviders } from '../../common/mock'; jest.mock('../../common/lib/kibana'); @@ -24,14 +25,17 @@ const props = { isLoading: false, onEdit, onQuote, - userCanCrud: true, }; describe('UserActionPropertyActions ', () => { let wrapper: ReactWrapper; beforeAll(() => { - wrapper = mount(<UserActionPropertyActions {...props} />); + wrapper = mount( + <TestProviders> + <UserActionPropertyActions {...props} /> + </TestProviders> + ); }); beforeEach(() => { @@ -65,7 +69,11 @@ describe('UserActionPropertyActions ', () => { }); it('shows the spinner when loading', async () => { - wrapper = mount(<UserActionPropertyActions {...props} isLoading={true} />); + wrapper = mount( + <TestProviders> + <UserActionPropertyActions {...props} isLoading={true} /> + </TestProviders> + ); expect( wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() ).toBeTruthy(); @@ -82,14 +90,22 @@ describe('UserActionPropertyActions ', () => { deleteConfirmlabel: 'confirm delete me', }; it('shows the delete button', () => { - const renderResult = render(<UserActionPropertyActions {...deleteProps} />); + const renderResult = render( + <TestProviders> + <UserActionPropertyActions {...deleteProps} /> + </TestProviders> + ); userEvent.click(renderResult.getByTestId('property-actions-ellipses')); expect(renderResult.getByTestId('property-actions-trash')).toBeTruthy(); }); it('shows a confirm dialog when the delete button is clicked', () => { - const renderResult = render(<UserActionPropertyActions {...deleteProps} />); + const renderResult = render( + <TestProviders> + <UserActionPropertyActions {...deleteProps} /> + </TestProviders> + ); userEvent.click(renderResult.getByTestId('property-actions-ellipses')); userEvent.click(renderResult.getByTestId('property-actions-trash')); @@ -98,7 +114,11 @@ describe('UserActionPropertyActions ', () => { }); it('closes the confirm dialog when the cancel button is clicked', () => { - const renderResult = render(<UserActionPropertyActions {...deleteProps} />); + const renderResult = render( + <TestProviders> + <UserActionPropertyActions {...deleteProps} /> + </TestProviders> + ); userEvent.click(renderResult.getByTestId('property-actions-ellipses')); userEvent.click(renderResult.getByTestId('property-actions-trash')); @@ -109,7 +129,11 @@ describe('UserActionPropertyActions ', () => { }); it('calls onDelete when the confirm is pressed', () => { - const renderResult = render(<UserActionPropertyActions {...deleteProps} />); + const renderResult = render( + <TestProviders> + <UserActionPropertyActions {...deleteProps} /> + </TestProviders> + ); userEvent.click(renderResult.getByTestId('property-actions-ellipses')); userEvent.click(renderResult.getByTestId('property-actions-trash')); diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions.tsx index 898ef87a0b2b9..502e69a9d1903 100644 --- a/x-pack/plugins/cases/public/components/user_actions/property_actions.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/property_actions.tsx @@ -11,6 +11,7 @@ import { EuiConfirmModal, EuiLoadingSpinner } from '@elastic/eui'; import { PropertyActions } from '../property_actions'; import { useLensOpenVisualization } from '../markdown_editor/plugins/lens/use_lens_open_visualization'; import { CANCEL_BUTTON, CONFIRM_BUTTON } from './translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; interface UserActionPropertyActionsProps { id: string; @@ -22,7 +23,6 @@ interface UserActionPropertyActionsProps { onEdit: (id: string) => void; onDelete?: (id: string) => void; onQuote: (id: string) => void; - userCanCrud: boolean; commentMarkdown: string; } @@ -36,9 +36,9 @@ const UserActionPropertyActionsComponent = ({ onEdit, onDelete, onQuote, - userCanCrud, commentMarkdown, }: UserActionPropertyActionsProps) => { + const { permissions } = useCasesContext(); const { canUseEditor, actionConfig } = useLensOpenVisualization({ comment: commentMarkdown }); const onEditClick = useCallback(() => onEdit(id), [id, onEdit]); const onQuoteClick = useCallback(() => onQuote(id), [id, onQuote]); @@ -62,7 +62,7 @@ const UserActionPropertyActionsComponent = ({ const propertyActions = useMemo( () => [ - userCanCrud + permissions.all ? [ { iconType: 'pencil', @@ -88,7 +88,7 @@ const UserActionPropertyActionsComponent = ({ canUseEditor && actionConfig ? [actionConfig] : [], ].flat(), [ - userCanCrud, + permissions.all, editLabel, onEditClick, deleteLabel, diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts index 2fdcc64be851e..a834328075a09 100644 --- a/x-pack/plugins/cases/public/components/user_actions/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -30,7 +30,6 @@ export interface UserActionTreeProps { onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; statusActionButton: JSX.Element | null; useFetchAlertData: UseFetchAlertData; - userCanCrud: boolean; } type UnsupportedUserActionTypes = typeof UNSUPPORTED_ACTION_TYPES[number]; @@ -43,7 +42,6 @@ export interface UserActionBuilderArgs { caseServices: CaseServices; comments: Comment[]; index: number; - userCanCrud: boolean; commentRefs: React.MutableRefObject< Record<string, AddCommentRefObject | UserActionMarkdownRefObject | null | undefined> >; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx new file mode 100644 index 0000000000000..f4b72f9e9b53b --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.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 React from 'react'; +import { debounce } from 'lodash'; +import { render, fireEvent, waitFor } from '@testing-library/react'; + +import { useUpdateTags } from '../hooks'; + +import { TagOptions } from './tag_options'; + +jest.mock('lodash', () => ({ + debounce: jest.fn(), +})); + +jest.mock('../hooks', () => ({ + useUpdateTags: jest.fn().mockReturnValue({ + bulkUpdateTags: jest.fn(), + }), +})); + +describe('TagOptions', () => { + const mockBulkUpdateTags = useUpdateTags().bulkUpdateTags as jest.Mock; + const onTagsUpdated = jest.fn(); + let isTagHovered: boolean; + + beforeEach(() => { + onTagsUpdated.mockReset(); + mockBulkUpdateTags.mockReset(); + mockBulkUpdateTags.mockResolvedValue({}); + isTagHovered = true; + (debounce as jest.Mock).mockImplementationOnce((fn) => (newName: string) => { + fn(newName); + onTagsUpdated(); + }); + }); + + const renderComponent = () => { + return render( + <div> + <TagOptions tagName={'agent'} isTagHovered={isTagHovered} onTagsUpdated={onTagsUpdated} /> + </div> + ); + }; + + it('should make menu button visible when tag is hovered', async () => { + isTagHovered = false; + const result = renderComponent(); + expect(result.container.querySelector('[aria-label="Tag Options"]')).toBeNull(); + + isTagHovered = true; + await waitFor(() => { + expect(result.container.querySelector('[aria-label="Tag Options"]')).toBeDefined(); + }); + }); + + it('should delete tag when button is clicked', () => { + const result = renderComponent(); + + fireEvent.click(result.getByRole('button')); + + fireEvent.click(result.getByText('Delete tag')); + + expect(mockBulkUpdateTags).toHaveBeenCalledWith('tags:agent', [], ['agent'], expect.anything()); + }); + + it('should rename tag when name input is changed', async () => { + const result = renderComponent(); + + fireEvent.click(result.getByRole('button')); + + const nameInput = result.getByDisplayValue('agent'); + fireEvent.input(nameInput, { + target: { value: 'newName' }, + }); + + expect(onTagsUpdated).toHaveBeenCalled(); + expect(mockBulkUpdateTags).toHaveBeenCalledWith( + 'tags:agent', + ['newName'], + ['agent'], + expect.anything() + ); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx new file mode 100644 index 0000000000000..62d25d83938b1 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx @@ -0,0 +1,119 @@ +/* + * 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, useState, useEffect } from 'react'; +import { debounce } from 'lodash'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiWrappingPopover, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +import { useUpdateTags } from '../hooks'; + +interface Props { + tagName: string; + isTagHovered: boolean; + onTagsUpdated: () => void; +} + +export const TagOptions: React.FC<Props> = ({ tagName, isTagHovered, onTagsUpdated }: Props) => { + const [tagOptionsVisible, setTagOptionsVisible] = useState<boolean>(false); + const [tagOptionsButton, setTagOptionsButton] = useState<HTMLElement>(); + + const [tagMenuButtonVisible, setTagMenuButtonVisible] = useState<boolean>(isTagHovered); + + useEffect(() => { + setTagMenuButtonVisible(isTagHovered || tagOptionsVisible); + }, [isTagHovered, tagOptionsVisible]); + + const [updatedName, setUpdatedName] = useState<string | undefined>(tagName); + + const closePopover = () => setTagOptionsVisible(false); + + const updateTagsHook = useUpdateTags(); + + const TAGS_QUERY = 'tags:{name}'; + + const debouncedSendRenameTag = useMemo( + () => + debounce((newName: string) => { + const kuery = TAGS_QUERY.replace('{name}', tagName); + updateTagsHook.bulkUpdateTags(kuery, [newName], [tagName], () => onTagsUpdated()); + }, 1000), + [onTagsUpdated, tagName, updateTagsHook] + ); + + return ( + <> + {tagMenuButtonVisible && ( + <EuiButtonIcon + iconType="boxesHorizontal" + aria-label={i18n.translate('xpack.fleet.tagOptions.tagOptionsToggleButtonLabel', { + defaultMessage: 'Tag Options', + })} + color="text" + onClick={(event: any) => { + setTagOptionsButton(event.target); + setTagOptionsVisible(!tagOptionsVisible); + }} + /> + )} + {tagOptionsVisible && ( + <EuiWrappingPopover + isOpen={true} + button={tagOptionsButton!} + closePopover={closePopover} + anchorPosition="downCenter" + > + <EuiFlexGroup direction="column" alignItems="flexStart" gutterSize="xs"> + <EuiFlexItem> + <EuiFieldText + placeholder={i18n.translate('xpack.fleet.tagOptions.nameTextFieldPlaceholder', { + defaultMessage: 'Enter new name for tag', + })} + value={updatedName} + required + onChange={(e: any) => { + const newName = e.target.value; + setUpdatedName(newName); + if (!newName) { + return; + } + debouncedSendRenameTag(newName); + }} + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiButtonEmpty + size="s" + color="danger" + onClick={() => { + const kuery = TAGS_QUERY.replace('{name}', tagName); + updateTagsHook.bulkUpdateTags(kuery, [], [tagName], () => onTagsUpdated()); + closePopover(); + }} + > + <EuiIcon type="trash" />{' '} + <FormattedMessage + id="xpack.fleet.tagOptions.deleteText" + defaultMessage="Delete tag" + /> + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + </EuiWrappingPopover> + )} + </> + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx index 03ba43d34ce43..4d4da36b9cb5f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx @@ -30,6 +30,7 @@ describe('TagsAddRemove', () => { beforeEach(() => { onTagsUpdated.mockReset(); mockUpdateTags.mockReset(); + mockBulkUpdateTags.mockReset(); allTags = ['tag1', 'tag2']; selectedTags = ['tag1']; }); @@ -47,23 +48,27 @@ describe('TagsAddRemove', () => { ); }; - it('should add selected tag when previously unselected', () => { + it('should add selected tag when previously unselected', async () => { + mockUpdateTags.mockImplementation(() => { + selectedTags = ['tag1', 'tag2']; + }); const result = renderComponent('agent1'); - const getTag = (name: string) => result.getByText(name).closest('li')!; + const getTag = (name: string) => result.getByText(name); fireEvent.click(getTag('tag2')); - expect(getTag('tag2').getAttribute('aria-checked')).toEqual('true'); expect(mockUpdateTags).toHaveBeenCalledWith('agent1', ['tag1', 'tag2'], expect.anything()); }); - it('should remove selected tag when previously selected', () => { + it('should remove selected tag when previously selected', async () => { + mockUpdateTags.mockImplementation(() => { + selectedTags = []; + }); const result = renderComponent('agent1'); - const getTag = (name: string) => result.getByText(name).closest('li')!; + const getTag = (name: string) => result.getByText(name); fireEvent.click(getTag('tag1')); - expect(getTag('tag1').getAttribute('aria-checked')).toEqual('false'); expect(mockUpdateTags).toHaveBeenCalledWith('agent1', [], expect.anything()); }); @@ -80,23 +85,27 @@ describe('TagsAddRemove', () => { expect(mockUpdateTags).toHaveBeenCalledWith('agent1', ['tag1', 'newTag'], expect.anything()); }); - it('should add selected tag when previously unselected - bulk selection', () => { + it('should add selected tag when previously unselected - bulk selection', async () => { + mockBulkUpdateTags.mockImplementation(() => { + selectedTags = ['tag1', 'tag2']; + }); const result = renderComponent(undefined, ''); - const getTag = (name: string) => result.getByText(name).closest('li')!; + const getTag = (name: string) => result.getByText(name); fireEvent.click(getTag('tag2')); - expect(getTag('tag2').getAttribute('aria-checked')).toEqual('true'); expect(mockBulkUpdateTags).toHaveBeenCalledWith('', ['tag2'], [], expect.anything()); }); - it('should remove selected tag when previously selected - bulk selection', () => { + it('should remove selected tag when previously selected - bulk selection', async () => { + mockBulkUpdateTags.mockImplementation(() => { + selectedTags = []; + }); const result = renderComponent(undefined, ['agent1', 'agent2']); - const getTag = (name: string) => result.getByText(name).closest('li')!; + const getTag = (name: string) => result.getByText(name); fireEvent.click(getTag('tag1')); - expect(getTag('tag1').getAttribute('aria-checked')).toEqual('false'); expect(mockBulkUpdateTags).toHaveBeenCalledWith( ['agent1', 'agent2'], [], @@ -117,4 +126,16 @@ describe('TagsAddRemove', () => { expect(mockBulkUpdateTags).toHaveBeenCalledWith('query', ['newTag'], [], expect.anything()); }); + + it('should make tag options button visible on mouse enter', async () => { + const result = renderComponent('agent1'); + + fireEvent.mouseEnter(result.getByText('tag1').closest('.euiFlexGroup')!); + + expect(result.getByRole('button').getAttribute('aria-label')).toEqual('Tag Options'); + + fireEvent.mouseLeave(result.getByText('tag1').closest('.euiFlexGroup')!); + + expect(result.queryByRole('button')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx index eb1e935af9119..32e3a10d68551 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx @@ -8,11 +8,22 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'; import { difference } from 'lodash'; import type { EuiSelectableOption } from '@elastic/eui'; -import { EuiButtonEmpty, EuiIcon, EuiSelectable, EuiWrappingPopover } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHighlight, + EuiIcon, + EuiSelectable, + EuiWrappingPopover, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; import { useUpdateTags } from '../hooks'; +import { TagOptions } from './tag_options'; + interface Props { agentId?: string; agents?: string[] | string; @@ -29,19 +40,13 @@ export const TagsAddRemove: React.FC<Props> = ({ selectedTags, button, onTagsUpdated, -}: { - agentId?: string; - agents?: string[] | string; - allTags: string[]; - selectedTags: string[]; - button: HTMLElement; - onTagsUpdated: () => void; -}) => { +}: Props) => { const labelsFromTags = useCallback( (tags: string[]) => tags.map((tag: string) => ({ label: tag, checked: selectedTags.includes(tag) ? 'on' : undefined, + onFocusBadge: false, })), [selectedTags] ); @@ -49,6 +54,7 @@ export const TagsAddRemove: React.FC<Props> = ({ const [labels, setLabels] = useState<Array<EuiSelectableOption<any>>>(labelsFromTags(allTags)); const [searchValue, setSearchValue] = useState<string | undefined>(undefined); const [isPopoverOpen, setIsPopoverOpen] = useState(true); + const [isTagHovered, setIsTagHovered] = useState<{ [tagName: string]: boolean }>({}); const closePopover = () => setIsPopoverOpen(false); const updateTagsHook = useUpdateTags(); @@ -70,67 +76,88 @@ export const TagsAddRemove: React.FC<Props> = ({ } }; - const setOptions = (newOptions: Array<EuiSelectableOption<any>>) => { - setLabels(newOptions); - - const existingCheckedTags = labels - .filter((option) => option.checked === 'on') - .map((option) => option.label); - const newCheckedTags = newOptions - .filter((option) => option.checked === 'on') - .map((option) => option.label); - const tagsToAdd = difference(newCheckedTags, existingCheckedTags); - const tagsToRemove = difference(existingCheckedTags, newCheckedTags); - - updateTags(tagsToAdd, tagsToRemove); - }; - - return ( - <EuiWrappingPopover - isOpen={isPopoverOpen} - button={button!} - closePopover={closePopover} - anchorPosition="leftUp" - > - <EuiSelectable - aria-label="Add / remove tags" - searchable - searchProps={{ - 'data-test-subj': 'addRemoveTags', - onChange: (value: string) => { - setSearchValue(value); - }, - }} - options={labels} - onChange={(newOptions) => setOptions(newOptions)} - noMatchesMessage={ - <EuiButtonEmpty - color="text" + const renderOption = (option: EuiSelectableOption<any>, search: string) => { + return ( + <EuiFlexGroup + onMouseEnter={() => setIsTagHovered({ ...isTagHovered, [option.label]: true })} + onMouseLeave={() => setIsTagHovered({ ...isTagHovered, [option.label]: false })} + > + <EuiFlexItem> + <EuiHighlight + search={search} onClick={() => { - if (!searchValue) { - return; - } - updateTags([searchValue], []); + const tagsToAdd = option.checked === 'on' ? [] : [option.label]; + const tagsToRemove = option.checked === 'on' ? [option.label] : []; + updateTags(tagsToAdd, tagsToRemove); }} > - <EuiIcon type="plus" />{' '} - <FormattedMessage - id="xpack.fleet.tagsAddRemove.createText" - defaultMessage='Create a new tag "{name}"' - values={{ - name: searchValue, - }} - /> - </EuiButtonEmpty> - } + {option.label} + </EuiHighlight> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <TagOptions + tagName={option.label} + isTagHovered={isTagHovered[option.label]} + onTagsUpdated={onTagsUpdated} + /> + </EuiFlexItem> + </EuiFlexGroup> + ); + }; + + return ( + <> + <EuiWrappingPopover + isOpen={isPopoverOpen} + button={button!} + closePopover={closePopover} + anchorPosition="leftUp" > - {(list, search) => ( - <Fragment> - {search} - {list} - </Fragment> - )} - </EuiSelectable> - </EuiWrappingPopover> + <EuiSelectable + aria-label={i18n.translate('xpack.fleet.tagsAddRemove.selectableTagsLabel', { + defaultMessage: 'Add / remove tags', + })} + searchable + searchProps={{ + 'data-test-subj': 'addRemoveTags', + placeholder: i18n.translate('xpack.fleet.tagsAddRemove.findOrCreatePlaceholder', { + defaultMessage: 'Find or create label...', + }), + onChange: (value: string) => { + setSearchValue(value); + }, + }} + options={labels} + renderOption={renderOption} + noMatchesMessage={ + <EuiButtonEmpty + color="text" + onClick={() => { + if (!searchValue) { + return; + } + updateTags([searchValue], []); + }} + > + <EuiIcon type="plus" />{' '} + <FormattedMessage + id="xpack.fleet.tagsAddRemove.createText" + defaultMessage='Create a new tag "{name}"' + values={{ + name: searchValue, + }} + /> + </EuiButtonEmpty> + } + > + {(list, search) => ( + <Fragment> + {search} + {list} + </Fragment> + )} + </EuiSelectable> + </EuiWrappingPopover> + </> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/server/services/license.ts b/x-pack/plugins/index_lifecycle_management/server/services/license.ts index e51ecac56327e..4b3b1331ae444 100644 --- a/x-pack/plugins/index_lifecycle_management/server/services/license.ts +++ b/x-pack/plugins/index_lifecycle_management/server/services/license.ts @@ -57,12 +57,12 @@ export class License { }); } - guardApiRoute(handler: RequestHandler) { + guardApiRoute<P, Q, B>(handler: RequestHandler<P, Q, B>) { const license = this; return function licenseCheck( ctx: RequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest<P, Q, B>, response: KibanaResponseFactory ) { const licenseStatus = license.getStatus(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index 7d96db4027bad..28537e7934555 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -190,6 +190,7 @@ export function TableDimensionEditor( </EuiFormRow> {!column.isTransposed && ( <EuiFormRow + fullWidth label={i18n.translate('xpack.lens.table.columnVisibilityLabel', { defaultMessage: 'Hide column', })} @@ -266,6 +267,7 @@ export function TableDimensionEditor( })} > <EuiFieldText + fullWidth compressed data-test-subj="lnsDatatable_summaryrow_label" value={summaryLabel ?? fallbackSummaryLabel} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx index 52a6ec68c294a..46ed51f35d9b0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -64,7 +64,7 @@ export function BucketNestingEditor({ defaultMessage: 'Group by this field first', }); return ( - <EuiFormRow label={useAsTopLevelAggCopy} display="columnCompressedSwitch"> + <EuiFormRow label={useAsTopLevelAggCopy} display="columnCompressedSwitch" fullWidth> <EuiSwitch compressed label={useAsTopLevelAggCopy} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index a79388d456b73..a770497d52bb2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -19,7 +19,7 @@ import { import ReactDOM from 'react-dom'; import type { IndexPatternDimensionEditorProps } from './dimension_panel'; import type { OperationSupportMatrix } from './operation_support'; -import type { GenericIndexPatternColumn } from '../indexpattern'; +import { deleteColumn, GenericIndexPatternColumn } from '../indexpattern'; import { operationDefinitionMap, getOperationDisplay, @@ -31,12 +31,13 @@ import { FieldBasedIndexPatternColumn, canTransition, DEFAULT_TIME_SCALE, + adjustColumnReferencesForChangedColumn, } from '../operations'; import { mergeLayer } from '../state_helpers'; import { hasField } from '../pure_utils'; import { fieldIsInvalid } from '../utils'; import { BucketNestingEditor } from './bucket_nesting_editor'; -import type { IndexPattern, IndexPatternLayer } from '../types'; +import type { IndexPattern, IndexPatternField, IndexPatternLayer } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { FormatSelector } from './format_selector'; import { ReferenceEditor } from './reference_editor'; @@ -60,8 +61,8 @@ import { FieldInput } from './field_input'; import { NameInput } from '../../shared_components'; import { ParamEditorProps } from '../operations/definitions'; import { WrappingHelpPopover } from '../help_popover'; - -const operationPanels = getOperationDisplay(); +import { FieldChoice } from './field_select'; +import { isColumn } from '../operations/definitions/helpers'; export interface DimensionEditorProps extends IndexPatternDimensionEditorProps { selectedColumn?: GenericIndexPatternColumn; @@ -70,6 +71,8 @@ export interface DimensionEditorProps extends IndexPatternDimensionEditorProps { currentIndexPattern: IndexPattern; } +const operationDisplay = getOperationDisplay(); + export function DimensionEditor(props: DimensionEditorProps) { const { selectedColumn, @@ -114,15 +117,47 @@ export function DimensionEditor(props: DimensionEditorProps) { ); const setStateWrapper = ( - setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer), + setter: + | IndexPatternLayer + | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + | GenericIndexPatternColumn, options: { forceRender?: boolean } = {} ) => { - const hypotheticalLayer = typeof setter === 'function' ? setter(state.layers[layerId]) : setter; - const isDimensionComplete = Boolean(hypotheticalLayer.columns[columnId]); + const layer = state.layers[layerId]; + let hypotethicalLayer: IndexPatternLayer; + if (isColumn(setter)) { + hypotethicalLayer = { + ...layer, + columns: { + ...layer.columns, + [columnId]: setter, + }, + }; + } else { + hypotethicalLayer = typeof setter === 'function' ? setter(state.layers[layerId]) : setter; + } + const isDimensionComplete = Boolean(hypotethicalLayer.columns[columnId]); + setState( (prevState) => { - const layer = typeof setter === 'function' ? setter(prevState.layers[layerId]) : setter; - return mergeLayer({ state: prevState, layerId, newLayer: layer }); + let outputLayer: IndexPatternLayer; + const prevLayer = prevState.layers[layerId]; + if (isColumn(setter)) { + outputLayer = { + ...prevLayer, + columns: { + ...prevLayer.columns, + [columnId]: setter, + }, + }; + } else { + outputLayer = typeof setter === 'function' ? setter(prevState.layers[layerId]) : setter; + } + return mergeLayer({ + state: prevState, + layerId, + newLayer: adjustColumnReferencesForChangedColumn(outputLayer, columnId), + }); }, { isDimensionComplete, @@ -189,7 +224,10 @@ export function DimensionEditor(props: DimensionEditorProps) { // Note: it forced a rerender at this point to avoid UI glitches in async updates (another hack upstream) // TODO: revisit this once we get rid of updateDatasourceAsync upstream const moveDefinetelyToStaticValueAndUpdate = ( - setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + setter: + | IndexPatternLayer + | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + | GenericIndexPatternColumn ) => { if (temporaryStaticValue) { setTemporaryState('none'); @@ -206,6 +244,9 @@ export function DimensionEditor(props: DimensionEditorProps) { } ); } + if (isColumn(setter)) { + throw new Error('static value should only be updated by the whole layer'); + } }; const ParamEditor = getParamEditor( @@ -290,23 +331,23 @@ export function DimensionEditor(props: DimensionEditorProps) { color = 'subdued'; } - let label: EuiListGroupItemProps['label'] = operationPanels[operationType].displayName; + let label: EuiListGroupItemProps['label'] = operationDisplay[operationType].displayName; if (isActive && disabledStatus) { label = ( <EuiToolTip content={disabledStatus} display="block" position="left"> <EuiText color="danger" size="s"> - <strong>{operationPanels[operationType].displayName}</strong> + <strong>{operationDisplay[operationType].displayName}</strong> </EuiText> </EuiToolTip> ); } else if (disabledStatus) { label = ( <EuiToolTip content={disabledStatus} display="block" position="left"> - <span>{operationPanels[operationType].displayName}</span> + <span>{operationDisplay[operationType].displayName}</span> </EuiToolTip> ); } else if (isActive) { - label = <strong>{operationPanels[operationType].displayName}</strong>; + label = <strong>{operationDisplay[operationType].displayName}</strong>; } return { @@ -438,6 +479,7 @@ export function DimensionEditor(props: DimensionEditorProps) { if (temporaryQuickFunction) { setTemporaryState('none'); } + const newLayer = replaceColumn({ layer: props.state.layers[props.layerId], indexPattern: currentIndexPattern, @@ -475,11 +517,16 @@ export function DimensionEditor(props: DimensionEditorProps) { const FieldInputComponent = selectedOperationDefinition?.renderFieldInput || FieldInput; - const paramEditorProps: ParamEditorProps<GenericIndexPatternColumn> = { + const paramEditorProps: ParamEditorProps< + GenericIndexPatternColumn, + | IndexPatternLayer + | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + | GenericIndexPatternColumn + > = { layer: state.layers[layerId], layerId, activeData: props.activeData, - updateLayer: (setter) => { + paramEditorUpdater: (setter) => { if (temporaryQuickFunction) { setTemporaryState('none'); } @@ -494,6 +541,8 @@ export function DimensionEditor(props: DimensionEditorProps) { isFullscreen, setIsCloseable, paramEditorCustomProps, + ReferenceEditor, + existingFields: state.existingFields, ...services, }; @@ -523,21 +572,75 @@ export function DimensionEditor(props: DimensionEditorProps) { <> {selectedColumn.references.map((referenceId, index) => { const validation = selectedOperationDefinition.requiredReferences[index]; - + const layer = state.layers[layerId]; return ( <ReferenceEditor + operationDefinitionMap={operationDefinitionMap} key={index} - layer={state.layers[layerId]} + layer={layer} layerId={layerId} activeData={props.activeData} columnId={referenceId} - updateLayer={( + column={layer.columns[referenceId]} + incompleteColumn={ + layer.incompleteColumns ? layer.incompleteColumns[referenceId] : undefined + } + onDeleteColumn={() => { + updateLayer( + deleteColumn({ + layer, + columnId: referenceId, + indexPattern: currentIndexPattern, + }) + ); + }} + onChooseFunction={(operationType: string, field?: IndexPatternField) => { + updateLayer( + insertOrReplaceColumn({ + layer, + columnId: referenceId, + op: operationType, + indexPattern: currentIndexPattern, + field, + visualizationGroups: dimensionGroups, + }) + ); + }} + onChooseField={(choice: FieldChoice) => { + trackUiEvent('indexpattern_dimension_field_changed'); + updateLayer( + insertOrReplaceColumn({ + layer, + columnId: referenceId, + indexPattern: currentIndexPattern, + op: choice.operationType, + field: currentIndexPattern.getFieldByName(choice.field), + visualizationGroups: dimensionGroups, + }) + ); + }} + paramEditorUpdater={( setter: | IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + | GenericIndexPatternColumn ) => { - updateLayer( - typeof setter === 'function' ? setter(state.layers[layerId]) : setter + let newLayer: IndexPatternLayer; + if (typeof setter === 'function') { + newLayer = setter(layer); + } else if (isColumn(setter)) { + newLayer = { + ...layer, + columns: { + ...layer.columns, + [referenceId]: setter, + }, + }; + } else { + newLayer = setter; + } + return updateLayer( + adjustColumnReferencesForChangedColumn(newLayer, referenceId) ); }} validation={validation} @@ -548,9 +651,8 @@ export function DimensionEditor(props: DimensionEditorProps) { labelAppend={selectedOperationDefinition?.getHelpMessage?.({ data: props.data, uiSettings: props.uiSettings, - currentColumn: state.layers[layerId].columns[columnId], + currentColumn: layer.columns[columnId], })} - dimensionGroups={dimensionGroups} isFullscreen={isFullscreen} toggleFullscreen={toggleFullscreen} setIsCloseable={setIsCloseable} @@ -600,19 +702,23 @@ export function DimensionEditor(props: DimensionEditorProps) { const customParamEditor = ParamEditor ? ( <> <ParamEditor + existingFields={state.existingFields} layer={state.layers[layerId]} - layerId={layerId} activeData={props.activeData} - updateLayer={temporaryStaticValue ? moveDefinetelyToStaticValueAndUpdate : setStateWrapper} + paramEditorUpdater={ + temporaryStaticValue ? moveDefinetelyToStaticValueAndUpdate : setStateWrapper + } columnId={columnId} currentColumn={state.layers[layerId].columns[columnId]} + operationDefinitionMap={operationDefinitionMap} + layerId={layerId} + paramEditorCustomProps={paramEditorCustomProps} dateRange={dateRange} + isFullscreen={isFullscreen} indexPattern={currentIndexPattern} - operationDefinitionMap={operationDefinitionMap} toggleFullscreen={toggleFullscreen} - isFullscreen={isFullscreen} setIsCloseable={setIsCloseable} - paramEditorCustomProps={paramEditorCustomProps} + ReferenceEditor={ReferenceEditor} {...services} /> </> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 7fc76300a73ec..9606cbbf21592 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -49,11 +49,16 @@ import { DimensionEditor } from './dimension_editor'; import { AdvancedOptions } from './advanced_options'; import { layerTypes } from '../../../common'; +jest.mock('./reference_editor', () => ({ + ReferenceEditor: () => null, +})); jest.mock('../loader'); jest.mock('../query_input', () => ({ QueryInput: () => null, })); + jest.mock('../operations'); + jest.mock('lodash', () => { const original = jest.requireActual('lodash'); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index 744033a2428fa..7546ff86b8b6e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -36,7 +36,7 @@ interface GetDropPropsArgs { type DropProps = { dropTypes: DropType[]; nextLabel?: string } | undefined; -const operationLabels = getOperationDisplay(); +const operationDisplay = getOperationDisplay(); export function getNewOperation( field: IndexPatternField | undefined | false, @@ -133,7 +133,7 @@ function getDropPropsForField({ const newOperation = getNewOperation(source.field, target.filterOperations, targetColumn); if (isTheSameIndexPattern && newOperation) { - const nextLabel = operationLabels[newOperation].displayName; + const nextLabel = operationDisplay[newOperation].displayName; if (!targetColumn) { return { dropTypes: ['field_add'], nextLabel }; @@ -227,7 +227,7 @@ function getDropPropsFromIncompatibleGroup( return { dropTypes, - nextLabel: operationLabels[newOperationForSource].displayName, + nextLabel: operationDisplay[newOperationForSource].displayName, }; } } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index afaa24d3d34b1..16e70f5657db0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -62,7 +62,6 @@ export function FieldSelect({ fields, (field) => currentIndexPattern.getFieldByName(field)?.type === 'document' ); - const containsData = (field: string) => currentIndexPattern.getFieldByName(field)?.type === 'document' || fieldExists(existingFields, currentIndexPattern.title, field); @@ -150,9 +149,11 @@ export function FieldSelect({ (selectedOperationType && selectedField ? [ { - label: fieldIsInvalid - ? selectedField - : currentIndexPattern.getFieldByName(selectedField)?.displayName ?? selectedField, + label: + (selectedOperationType && + selectedField && + currentIndexPattern.getFieldByName(selectedField)?.displayName) ?? + selectedField, value: { type: 'field', field: selectedField }, }, ] diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx index 857d0cfb9c1d2..6304f1ff64f91 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx @@ -21,25 +21,41 @@ import { ReferenceEditor, ReferenceEditorProps } from './reference_editor'; import { insertOrReplaceColumn, LastValueIndexPatternColumn, + operationDefinitionMap, TermsIndexPatternColumn, } from '../operations'; import { FieldSelect } from './field_select'; +import { IndexPatternLayer } from '../types'; jest.mock('../operations'); describe('reference editor', () => { let wrapper: ReactWrapper | ShallowWrapper; - let updateLayer: jest.Mock<ReferenceEditorProps['updateLayer']>; - + let paramEditorUpdater: jest.Mock<ReferenceEditorProps['paramEditorUpdater']>; + + const layer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Top values of dest', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'dest', + params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'desc' }, + } as TermsIndexPatternColumn, + }, + }; function getDefaultArgs() { return { - layer: { - indexPatternId: '1', - columns: {}, - columnOrder: [], - }, + layer, + column: layer.columns.ref, + onChooseField: jest.fn(), + onChooseFunction: jest.fn(), + onDeleteColumn: jest.fn(), columnId: 'ref', - updateLayer, + paramEditorUpdater, selectionStyle: 'full' as const, currentIndexPattern: createMockedIndexPattern(), existingFields: { @@ -63,11 +79,12 @@ describe('reference editor', () => { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', + operationDefinitionMap, }; } beforeEach(() => { - updateLayer = jest.fn().mockImplementation((newLayer) => { + paramEditorUpdater = jest.fn().mockImplementation((newLayer) => { if (wrapper instanceof ReactWrapper) { wrapper.setProps({ layer: newLayer }); } @@ -90,6 +107,7 @@ describe('reference editor', () => { input: ['field'], validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number', }} + column={undefined} /> ); @@ -115,27 +133,67 @@ describe('reference editor', () => { ); }); - it('should indicate functions and fields that are incompatible with the current', () => { + it('should indicate fields that are incompatible with the current', () => { + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + }, + } as IndexPatternLayer; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Top values of dest', - dataType: 'string', - isBucketed: true, - operationType: 'terms', - sourceField: 'dest', - params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'desc' }, - } as TermsIndexPatternColumn, - }, + layer={newLayer} + column={newLayer.columns.ref} + validation={{ + input: ['field'], + validateMetadata: (meta: OperationMetadata) => !meta.isBucketed, }} + /> + ); + + const fields = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + const findFieldDataTestSubj = (l: string) => { + return fields![0].options!.find(({ label }) => label === l)!['data-test-subj']; + }; + expect(findFieldDataTestSubj('timestampLabel')).toContain('Incompatible'); + expect(findFieldDataTestSubj('source')).toContain('Incompatible'); + expect(findFieldDataTestSubj('memory')).toContain('lns-fieldOption-memory'); + }); + + it('should indicate functions that are incompatible with the current', () => { + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Unique count of dest', + dataType: 'string', + isBucketed: false, + operationType: 'unique_count', + sourceField: 'dest', + }, + }, + } as IndexPatternLayer; + wrapper = mount( + <ReferenceEditor + {...getDefaultArgs()} + layer={newLayer} + column={newLayer.columns.ref} validation={{ input: ['field'], - validateMetadata: (meta: OperationMetadata) => meta.isBucketed, + validateMetadata: (meta: OperationMetadata) => !meta.isBucketed, }} /> ); @@ -144,36 +202,31 @@ describe('reference editor', () => { .find(EuiComboBox) .filter('[data-test-subj="indexPattern-reference-function"]') .prop('options'); - expect(functions.find(({ label }) => label === 'Date histogram')!['data-test-subj']).toContain( + + expect(functions.find(({ label }) => label === 'Average')!['data-test-subj']).toContain( 'incompatible' ); - - const fields = wrapper - .find(EuiComboBox) - .filter('[data-test-subj="indexPattern-dimension-field"]') - .prop('options'); - expect( - fields![0].options!.find(({ label }) => label === 'timestampLabel')!['data-test-subj'] - ).toContain('Incompatible'); }); it('should not update when selecting the same operation', () => { + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + }, + } as IndexPatternLayer; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'average', - sourceField: 'bytes', - }, - }, - }} + layer={newLayer} + column={newLayer.columns.ref} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number', @@ -193,26 +246,30 @@ describe('reference editor', () => { }); it('should keep the field when replacing an existing reference with a compatible function', () => { + const onChooseFunction = jest.fn(); + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + }, + } as IndexPatternLayer; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'average', - sourceField: 'bytes', - }, - }, - }} + layer={newLayer} + column={newLayer.columns.ref} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number', }} + onChooseFunction={onChooseFunction} /> ); @@ -225,31 +282,35 @@ describe('reference editor', () => { comboBox.prop('onChange')!([option]); }); - expect(insertOrReplaceColumn).toHaveBeenCalledWith( + expect(onChooseFunction).toHaveBeenCalledWith( + 'max', expect.objectContaining({ - op: 'max', - field: expect.objectContaining({ name: 'bytes' }), + name: 'bytes', }) ); }); it('should transition to another function with incompatible field', () => { + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Unique count of dest', + dataType: 'string', + isBucketed: false, + operationType: 'unique_count', + sourceField: 'dest', + }, + }, + } as IndexPatternLayer; + const onChooseFunction = jest.fn(); wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'average', - sourceField: 'bytes', - }, - }, - }} + onChooseFunction={onChooseFunction} + column={newLayer.columns.ref} + layer={newLayer} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => true, @@ -260,39 +321,36 @@ describe('reference editor', () => { const comboBox = wrapper .find(EuiComboBox) .filter('[data-test-subj="indexPattern-reference-function"]'); - const option = comboBox.prop('options')!.find(({ label }) => label === 'Date histogram')!; + const option = comboBox.prop('options')!.find(({ label }) => label === 'Average')!; act(() => { comboBox.prop('onChange')!([option]); }); - expect(insertOrReplaceColumn).toHaveBeenCalledWith( - expect.objectContaining({ - op: 'date_histogram', - field: undefined, - }) - ); + expect(onChooseFunction).toHaveBeenCalledWith('average', undefined); }); it("should show the sub-function as invalid if there's no field compatible with it", () => { // This may happen for saved objects after changing the type of a field + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + }, + } as IndexPatternLayer; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} currentIndexPattern={createMockedIndexPatternWithoutType('number')} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'average', - sourceField: 'bytes', - }, - }, - }} + column={newLayer.columns.ref} + layer={newLayer} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => true, @@ -321,6 +379,8 @@ describe('reference editor', () => { wrapper = mount( <ReferenceEditor {...getDefaultArgs()} + column={undefined} + currentIndexPattern={createMockedIndexPatternWithoutType('number')} validation={{ input: ['field', 'fullReference', 'managedReference'], validateMetadata: (meta: OperationMetadata) => true, @@ -331,8 +391,8 @@ describe('reference editor', () => { const subFunctionSelect = wrapper .find('[data-test-subj="indexPattern-reference-function"]') .first(); - expect(subFunctionSelect.prop('isInvalid')).toEqual(true); + expect(subFunctionSelect.prop('selectedOptions')).not.toEqual( expect.arrayContaining([expect.objectContaining({ value: 'math' })]) ); @@ -345,6 +405,7 @@ describe('reference editor', () => { wrapper = mount( <ReferenceEditor {...getDefaultArgs()} + column={undefined} selectionStyle={'field' as const} validation={{ input: ['field'], @@ -360,25 +421,28 @@ describe('reference editor', () => { }); it('should pass the incomplete operation info to FieldSelect', () => { + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + }, + incompleteColumns: { + ref: { operationType: 'max' }, + }, + } as IndexPatternLayer; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'average', - sourceField: 'bytes', - }, - }, - incompleteColumns: { - ref: { operationType: 'max' }, - }, - }} + incompleteColumn={newLayer.incompleteColumns?.ref} + column={newLayer.columns.ref} + layer={newLayer} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => true, @@ -395,25 +459,28 @@ describe('reference editor', () => { }); it('should pass the incomplete field info to FieldSelect', () => { + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + }, + incompleteColumns: { + ref: { sourceField: 'timestamp' }, + }, + } as IndexPatternLayer; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Average of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'average', - sourceField: 'bytes', - }, - }, - incompleteColumns: { - ref: { sourceField: 'timestamp' }, - }, - }} + layer={newLayer} + incompleteColumn={newLayer.incompleteColumns?.ref} + column={newLayer.columns.ref} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => true, @@ -432,6 +499,7 @@ describe('reference editor', () => { wrapper = mount( <ReferenceEditor {...getDefaultArgs()} + column={undefined} selectionStyle="field" validation={{ input: ['field'], @@ -449,22 +517,24 @@ describe('reference editor', () => { }); it('should show the FieldSelect as invalid if the selected field is missing', () => { + const newLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Average of missing', + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'missing', + }, + }, + } as IndexPatternLayer; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Average of missing', - dataType: 'number', - isBucketed: false, - operationType: 'average', - sourceField: 'missing', - }, - }, - }} + layer={newLayer} + column={newLayer.columns.ref} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => true, @@ -481,25 +551,27 @@ describe('reference editor', () => { }); it('should show the ParamEditor for functions that offer one', () => { + const lastValueLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Last value of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'last_value', + sourceField: 'bytes', + params: { + sortField: 'timestamp', + }, + } as LastValueIndexPatternColumn, + }, + }; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Last value of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'last_value', - sourceField: 'bytes', - params: { - sortField: 'timestamp', - }, - } as LastValueIndexPatternColumn, - }, - }} + column={lastValueLayer.columns.ref} + layer={lastValueLayer} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => true, @@ -513,28 +585,31 @@ describe('reference editor', () => { }); it('should hide the ParamEditor for incomplete functions', () => { + const lastValueLayer = { + indexPatternId: '1', + columnOrder: ['ref'], + columns: { + ref: { + label: 'Last value of bytes', + dataType: 'number', + isBucketed: false, + operationType: 'last_value', + sourceField: 'bytes', + params: { + sortField: 'timestamp', + }, + } as LastValueIndexPatternColumn, + }, + incompleteColumns: { + ref: { operationType: 'max' }, + }, + }; wrapper = mount( <ReferenceEditor {...getDefaultArgs()} - layer={{ - indexPatternId: '1', - columnOrder: ['ref'], - columns: { - ref: { - label: 'Last value of bytes', - dataType: 'number', - isBucketed: false, - operationType: 'last_value', - sourceField: 'bytes', - params: { - sortField: 'timestamp', - }, - } as LastValueIndexPatternColumn, - }, - incompleteColumns: { - ref: { operationType: 'max' }, - }, - }} + incompleteColumn={lastValueLayer.incompleteColumns.ref} + column={lastValueLayer.columns.ref} + layer={lastValueLayer} validation={{ input: ['field'], validateMetadata: (meta: OperationMetadata) => true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx index 4082580cb456a..53e87ab6620a2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.tsx @@ -8,13 +8,7 @@ import './dimension_editor.scss'; import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiFormRow, - EuiFormRowProps, - EuiSpacer, - EuiComboBox, - EuiComboBoxOptionOption, -} from '@elastic/eui'; +import { EuiFormRowProps, EuiSpacer, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from '@kbn/core/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -22,24 +16,58 @@ import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DateRange } from '../../../common'; import type { OperationSupportMatrix } from './operation_support'; -import type { OperationType } from '../indexpattern'; +import type { GenericIndexPatternColumn, OperationType } from '../indexpattern'; import { - operationDefinitionMap, getOperationDisplay, - insertOrReplaceColumn, - deleteColumn, isOperationAllowedAsReference, FieldBasedIndexPatternColumn, RequiredReference, + IncompleteColumn, + GenericOperationDefinition, } from '../operations'; -import { FieldSelect } from './field_select'; +import { FieldChoice, FieldSelect } from './field_select'; import { hasField } from '../pure_utils'; -import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../types'; +import type { + IndexPattern, + IndexPatternField, + IndexPatternLayer, + IndexPatternPrivateState, +} from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import type { ParamEditorCustomProps, VisualizationDimensionGroupConfig } from '../../types'; +import type { ParamEditorCustomProps } from '../../types'; import type { IndexPatternDimensionEditorProps } from './dimension_panel'; +import { FormRow } from '../operations/definitions/shared_components'; + +const operationDisplay = getOperationDisplay(); + +const getFunctionOptions = ( + operationSupportMatrix: OperationSupportMatrix & { + operationTypes: Set<OperationType>; + }, + operationDefinitionMap: Record<string, GenericOperationDefinition>, + column?: GenericIndexPatternColumn +): Array<EuiComboBoxOptionOption<OperationType>> => { + return Array.from(operationSupportMatrix.operationTypes).map((operationType) => { + const def = operationDefinitionMap[operationType]; + const label = operationDisplay[operationType].displayName; + const isCompatible = + !column || + (column && + hasField(column) && + def.input === 'field' && + operationSupportMatrix.fieldByOperation[operationType]?.has(column.sourceField)) || + (column && !hasField(column) && def.input !== 'field'); -const operationPanels = getOperationDisplay(); + return { + label, + value: operationType, + className: 'lnsIndexPatternDimensionEditor__operation', + 'data-test-subj': `lns-indexPatternDimension-${operationType}${ + isCompatible ? '' : ' incompatible' + }`, + }; + }); +}; export interface ReferenceEditorProps { layer: IndexPatternLayer; @@ -48,18 +76,29 @@ export interface ReferenceEditorProps { selectionStyle: 'full' | 'field' | 'hidden'; validation: RequiredReference; columnId: string; - updateLayer: ( - setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) - ) => void; + column?: GenericIndexPatternColumn; + incompleteColumn?: IncompleteColumn; currentIndexPattern: IndexPattern; - + functionLabel?: string; + fieldLabel?: string; + operationDefinitionMap: Record<string, GenericOperationDefinition>; + isInline?: boolean; existingFields: IndexPatternPrivateState['existingFields']; dateRange: DateRange; labelAppend?: EuiFormRowProps['labelAppend']; - dimensionGroups: VisualizationDimensionGroupConfig[]; isFullscreen: boolean; toggleFullscreen: () => void; setIsCloseable: (isCloseable: boolean) => void; + paramEditorCustomProps?: ParamEditorCustomProps; + paramEditorUpdater: ( + setter: + | IndexPatternLayer + | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + | GenericIndexPatternColumn + ) => void; + onChooseField: (choice: FieldChoice) => void; + onDeleteColumn: () => void; + onChooseFunction: (operationType: string, field?: IndexPatternField) => void; // Services uiSettings: IUiSettingsClient; @@ -69,39 +108,28 @@ export interface ReferenceEditorProps { data: DataPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart; dataViews: DataViewsPublicPluginStart; - paramEditorCustomProps?: ParamEditorCustomProps; } -export function ReferenceEditor(props: ReferenceEditorProps) { +export const ReferenceEditor = (props: ReferenceEditorProps) => { const { - layer, - layerId, - activeData, - columnId, - updateLayer, currentIndexPattern, existingFields, validation, selectionStyle, - dateRange, labelAppend, - dimensionGroups, - isFullscreen, - toggleFullscreen, - setIsCloseable, - paramEditorCustomProps, - ...services + column, + incompleteColumn, + functionLabel, + onChooseField, + onDeleteColumn, + onChooseFunction, + fieldLabel, + operationDefinitionMap, + isInline, } = props; - const column = layer.columns[columnId]; const selectedOperationDefinition = column && operationDefinitionMap[column.operationType]; - const ParamEditor = selectedOperationDefinition?.paramEditor; - - const incompleteInfo = layer.incompleteColumns ? layer.incompleteColumns[columnId] : undefined; - const incompleteOperation = incompleteInfo?.operationType; - const incompleteField = incompleteInfo?.sourceField ?? null; - // Basically the operation support matrix, but different validation const operationSupportMatrix: OperationSupportMatrix & { operationTypes: Set<OperationType>; @@ -111,7 +139,7 @@ export function ReferenceEditor(props: ReferenceEditorProps) { const operationByField: Partial<Record<string, Set<OperationType>>> = {}; const fieldByOperation: Partial<Record<OperationType, Set<string>>> = {}; Object.values(operationDefinitionMap) - .filter(({ hidden }) => !hidden) + .filter(({ hidden, allowAsReference }) => !hidden && allowAsReference) .sort((op1, op2) => { return op1.displayName.localeCompare(op2.displayName); }) @@ -152,230 +180,163 @@ export function ReferenceEditor(props: ReferenceEditorProps) { operationByField, fieldByOperation, }; - }, [currentIndexPattern, validation]); - - const functionOptions: Array<EuiComboBoxOptionOption<OperationType>> = Array.from( - operationSupportMatrix.operationTypes - ).map((operationType) => { - const def = operationDefinitionMap[operationType]; - const label = operationPanels[operationType].displayName; - const isCompatible = - !column || - (column && - hasField(column) && - def.input === 'field' && - operationSupportMatrix.fieldByOperation[operationType]?.has(column.sourceField)) || - (column && !hasField(column) && def.input !== 'field'); - - return { - label, - value: operationType, - className: 'lnsIndexPatternDimensionEditor__operation', - 'data-test-subj': `lns-indexPatternDimension-${operationType}${ - isCompatible ? '' : ' incompatible' - }`, - }; - }); - - function onChooseFunction(operationType: OperationType) { - if (column?.operationType === operationType) { - return; - } - const possibleFieldNames = operationSupportMatrix.fieldByOperation[operationType]; - if (column && 'sourceField' in column && possibleFieldNames?.has(column.sourceField)) { - // Reuse the current field if possible - updateLayer( - insertOrReplaceColumn({ - layer, - columnId, - op: operationType, - indexPattern: currentIndexPattern, - field: currentIndexPattern.getFieldByName(column.sourceField), - visualizationGroups: dimensionGroups, - }) - ); - } else { - // If reusing the field is impossible, we generally can't choose for the user. - // The one exception is if the field is the only possible field, like Count of Records. - const possibleField = - possibleFieldNames?.size === 1 - ? currentIndexPattern.getFieldByName(possibleFieldNames.values().next().value) - : undefined; - - updateLayer( - insertOrReplaceColumn({ - layer, - columnId, - op: operationType, - indexPattern: currentIndexPattern, - field: possibleField, - visualizationGroups: dimensionGroups, - }) - ); - } - trackUiEvent(`indexpattern_dimension_operation_${operationType}`); - return; - } + }, [currentIndexPattern, validation, operationDefinitionMap]); if (selectionStyle === 'hidden') { return null; } + const incompleteOperation = incompleteColumn?.operationType; + const incompleteField = incompleteColumn?.sourceField ?? null; + + const functionOptions = getFunctionOptions( + operationSupportMatrix, + operationDefinitionMap, + column + ); + const selectedOption = incompleteOperation - ? [functionOptions.find(({ value }) => value === incompleteOperation)!] + ? [functionOptions?.find(({ value }) => value === incompleteOperation)!] : column - ? [functionOptions.find(({ value }) => value === column.operationType)!] + ? [functionOptions?.find(({ value }) => value === column.operationType)!] : []; - // If the operationType is incomplete, the user needs to select a field- so - // the function is marked as valid. - const showOperationInvalid = !column && !Boolean(incompleteOperation); - // The field is invalid if the operation has been updated without a field, - // or if we are in a field-only mode but empty state - const showFieldInvalid = Boolean(incompleteOperation) || (selectionStyle === 'field' && !column); - // Check if the field still exists to protect from changes - const showFieldMissingInvalid = !currentIndexPattern.getFieldByName( - incompleteField ?? (column as FieldBasedIndexPatternColumn)?.sourceField - ); - // what about a field changing type and becoming invalid? // Let's say this change makes the indexpattern without any number field but the operation was set to a numeric operation. // At this point the ComboBox will crash. // Therefore check if the selectedOption is in functionOptions and in case fill it in as disabled option const showSelectionFunctionInvalid = Boolean(selectedOption.length && selectedOption[0] == null); if (showSelectionFunctionInvalid) { - const selectedOperationType = incompleteOperation || column.operationType; + const selectedOperationType = incompleteOperation || column?.operationType; const brokenFunctionOption = { - label: operationPanels[selectedOperationType].displayName, + label: selectedOperationType && operationDisplay[selectedOperationType].displayName, value: selectedOperationType, className: 'lnsIndexPatternDimensionEditor__operation', 'data-test-subj': `lns-indexPatternDimension-${selectedOperationType} incompatible`, - }; - functionOptions.push(brokenFunctionOption); + } as EuiComboBoxOptionOption<string>; + functionOptions?.push(brokenFunctionOption); selectedOption[0] = brokenFunctionOption; } + // If the operationType is incomplete, the user needs to select a field- so + // the function is marked as valid. + const showOperationInvalid = !column && !Boolean(incompleteOperation); + // The field is invalid if the operation has been updated without a field, + // or if we are in a field-only mode but empty state + const showFieldInvalid = Boolean(incompleteOperation) || (selectionStyle === 'field' && !column); + // Check if the field still exists to protect from changes + const showFieldMissingInvalid = !currentIndexPattern.getFieldByName( + incompleteField ?? (column as FieldBasedIndexPatternColumn)?.sourceField + ); + + const ParamEditor = selectedOperationDefinition?.paramEditor; + return ( - <div id={columnId}> - <div> - {selectionStyle !== 'field' ? ( - <> - <EuiFormRow - data-test-subj="indexPattern-subFunction-selection-row" - label={i18n.translate('xpack.lens.indexPattern.chooseSubFunction', { + <div> + {selectionStyle !== 'field' ? ( + <> + <FormRow + isInline={isInline} + data-test-subj="indexPattern-subFunction-selection-row" + label={ + functionLabel || + i18n.translate('xpack.lens.indexPattern.chooseSubFunction', { defaultMessage: 'Choose a sub-function', - })} + }) + } + fullWidth + isInvalid={showOperationInvalid || showSelectionFunctionInvalid} + > + <EuiComboBox fullWidth + compressed + isClearable={false} + data-test-subj="indexPattern-reference-function" + placeholder={ + functionLabel || + i18n.translate('xpack.lens.indexPattern.referenceFunctionPlaceholder', { + defaultMessage: 'Sub-function', + }) + } + options={functionOptions} isInvalid={showOperationInvalid || showSelectionFunctionInvalid} - > - <EuiComboBox - fullWidth - compressed - isClearable={false} - data-test-subj="indexPattern-reference-function" - placeholder={i18n.translate( - 'xpack.lens.indexPattern.referenceFunctionPlaceholder', - { - defaultMessage: 'Sub-function', - } - )} - options={functionOptions} - isInvalid={showOperationInvalid || showSelectionFunctionInvalid} - selectedOptions={selectedOption} - singleSelection={{ asPlainText: true }} - onChange={(choices) => { - if (choices.length === 0) { - updateLayer( - deleteColumn({ - layer, - columnId, - indexPattern: currentIndexPattern, - }) - ); - return; - } + selectedOptions={selectedOption} + singleSelection={{ asPlainText: true }} + onChange={(choices: Array<EuiComboBoxOptionOption<string>>) => { + if (choices.length === 0) { + return onDeleteColumn(); + } - trackUiEvent('indexpattern_dimension_field_changed'); + const operationType = choices[0].value!; + if (column?.operationType === operationType) { + return; + } + const possibleFieldNames = operationSupportMatrix.fieldByOperation[operationType]; - onChooseFunction(choices[0].value!); - }} - /> - </EuiFormRow> - <EuiSpacer size="s" /> - </> - ) : null} + const field = + column && 'sourceField' in column && possibleFieldNames?.has(column.sourceField) + ? currentIndexPattern.getFieldByName(column.sourceField) + : possibleFieldNames?.size === 1 + ? currentIndexPattern.getFieldByName(possibleFieldNames.values().next().value) + : undefined; - {!column || selectedOperationDefinition.input === 'field' ? ( - <EuiFormRow - data-test-subj="indexPattern-reference-field-selection-row" - label={i18n.translate('xpack.lens.indexPattern.chooseField', { - defaultMessage: 'Field', - })} - fullWidth - isInvalid={showFieldInvalid || showFieldMissingInvalid} - labelAppend={labelAppend} - > - <FieldSelect - fieldIsInvalid={showFieldInvalid || showFieldMissingInvalid} - currentIndexPattern={currentIndexPattern} - existingFields={existingFields} - operationByField={operationSupportMatrix.operationByField} - selectedOperationType={ - // Allows operation to be selected before creating a valid column - column ? column.operationType : incompleteOperation - } - selectedField={ - // Allows field to be selected - incompleteField ?? (column as FieldBasedIndexPatternColumn)?.sourceField - } - incompleteOperation={incompleteOperation} - markAllFieldsCompatible={selectionStyle === 'field'} - onDeleteColumn={() => { - updateLayer( - deleteColumn({ - layer, - columnId, - indexPattern: currentIndexPattern, - }) - ); - }} - onChoose={(choice) => { - updateLayer( - insertOrReplaceColumn({ - layer, - columnId, - indexPattern: currentIndexPattern, - op: choice.operationType, - field: currentIndexPattern.getFieldByName(choice.field), - visualizationGroups: dimensionGroups, - }) - ); + onChooseFunction(operationType, field); + trackUiEvent(`indexpattern_dimension_operation_${operationType}`); + return; }} /> - </EuiFormRow> - ) : null} + </FormRow> + <EuiSpacer size="s" /> + </> + ) : null} - {column && !incompleteInfo && ParamEditor && ( - <> - <ParamEditor - updateLayer={updateLayer} - currentColumn={column} - layer={layer} - layerId={layerId} - activeData={activeData} - columnId={columnId} - indexPattern={currentIndexPattern} - dateRange={dateRange} - operationDefinitionMap={operationDefinitionMap} - isFullscreen={isFullscreen} - toggleFullscreen={toggleFullscreen} - setIsCloseable={setIsCloseable} - paramEditorCustomProps={paramEditorCustomProps} - {...services} - /> - </> - )} - </div> + {!column || selectedOperationDefinition?.input === 'field' ? ( + <FormRow + isInline={isInline} + data-test-subj="indexPattern-reference-field-selection-row" + label={ + fieldLabel || + i18n.translate('xpack.lens.indexPattern.chooseField', { + defaultMessage: 'Field', + }) + } + fullWidth + isInvalid={showFieldInvalid || showFieldMissingInvalid} + labelAppend={labelAppend} + > + <FieldSelect + fieldIsInvalid={showFieldInvalid || showFieldMissingInvalid} + currentIndexPattern={currentIndexPattern} + existingFields={existingFields} + operationByField={operationSupportMatrix.operationByField} + selectedOperationType={ + // Allows operation to be selected before creating a valid column + column ? column.operationType : incompleteOperation + } + selectedField={ + // Allows field to be selected + incompleteField ?? (column as FieldBasedIndexPatternColumn)?.sourceField + } + incompleteOperation={incompleteOperation} + markAllFieldsCompatible={selectionStyle === 'field'} + onDeleteColumn={onDeleteColumn} + onChoose={onChooseField} + /> + </FormRow> + ) : null} + + {column && !incompleteColumn && ParamEditor && ( + <> + <EuiSpacer size="s" /> + <ParamEditor + {...props} + isReferenced={true} + operationDefinitionMap={operationDefinitionMap} + currentColumn={column} + indexPattern={props.currentIndexPattern} + /> + </> + )} </div> ); -} +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx index 20e5690f2f534..332f9664973af 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx @@ -101,6 +101,7 @@ export function TimeScaling({ <EuiFlexGroup gutterSize="s" alignItems="center"> <EuiFlexItem> <EuiSelect + fullWidth compressed options={Object.entries(unitSuffixesLong).map(([unit, text]) => ({ value: unit, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx index 6900df51ccbba..b74e26cb24895 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx @@ -163,7 +163,7 @@ export function TimeShift({ } isInvalid={Boolean(isLocalValueInvalid || localValueTooSmall || localValueNotMultiple)} > - <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}> <EuiFlexItem> <EuiComboBox fullWidth diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index a37976f6d8069..3734d93b6071e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -42,6 +42,9 @@ import { DatatableColumn } from '@kbn/expressions-plugin'; jest.mock('./loader'); jest.mock('../id_generator'); jest.mock('./operations'); +jest.mock('./dimension_panel/reference_editor', () => ({ + ReferenceEditor: () => null, +})); const fieldsOne = [ { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts index d6429fb67e9a1..709bf87e2e6f0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts @@ -16,6 +16,7 @@ jest.spyOn(actualHelpers, 'copyColumn'); jest.spyOn(actualHelpers, 'insertOrReplaceColumn'); jest.spyOn(actualHelpers, 'insertNewColumn'); jest.spyOn(actualHelpers, 'replaceColumn'); +jest.spyOn(actualHelpers, 'adjustColumnReferencesForChangedColumn'); jest.spyOn(actualHelpers, 'getErrorMessages'); jest.spyOn(actualHelpers, 'getColumnOrder'); @@ -50,6 +51,7 @@ export const { isOperationAllowedAsReference, canTransition, isColumnValidAsReference, + adjustColumnReferencesForChangedColumn, getManagedColumnsFrom, } = actualHelpers; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index cf96bcd11b788..c46b6954f7480 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -173,7 +173,7 @@ Example: Smooth a line of measurements: function MovingAverageParamEditor({ layer, - updateLayer, + paramEditorUpdater, currentColumn, columnId, }: ParamEditorProps<MovingAverageIndexPatternColumn>) { @@ -183,7 +183,7 @@ function MovingAverageParamEditor({ () => { if (!isValidNumber(inputValue, true, undefined, 1)) return; const inputNumber = parseInt(inputValue, 10); - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -207,6 +207,7 @@ function MovingAverageParamEditor({ isInvalid={!isValidNumber(inputValue)} > <EuiFieldNumber + fullWidth compressed value={inputValue} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value)} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index b8f6aa433c5f7..7b3ccf8da067b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -65,11 +65,17 @@ export interface CardinalityIndexPatternColumn extends FieldBasedIndexPatternCol }; } -export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternColumn, 'field'> = { +export const cardinalityOperation: OperationDefinition< + CardinalityIndexPatternColumn, + 'field', + {}, + true +> = { type: OPERATION_TYPE, displayName: i18n.translate('xpack.lens.indexPattern.cardinality', { defaultMessage: 'Unique count', }), + allowAsReference: true, input: 'field', getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { if ( @@ -123,7 +129,7 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo layer, columnId, currentColumn, - updateLayer, + paramEditorUpdater, }: ParamEditorProps<CardinalityIndexPatternColumn>) => { return [ { @@ -141,7 +147,7 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo }} checked={Boolean(currentColumn.params?.emptyAsNull)} onChange={() => { - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index 104b85651a876..014ff0f726cc7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -40,7 +40,7 @@ export type CountIndexPatternColumn = FieldBasedIndexPatternColumn & { }; }; -export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field'> = { +export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field', {}, true> = { type: 'count', priority: 2, displayName: i18n.translate('xpack.lens.indexPattern.count', { @@ -52,6 +52,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), getDisallowedPreviousShiftMessage(layer, columnId), ]), + allowAsReference: true, onFieldChange: (oldColumn, field) => { return { ...oldColumn, @@ -112,7 +113,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field layer, columnId, currentColumn, - updateLayer, + paramEditorUpdater, }: ParamEditorProps<CountIndexPatternColumn>) => { return [ { @@ -130,7 +131,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field }} checked={Boolean(currentColumn.params?.emptyAsNull)} onChange={() => { - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index d801387c30b29..73cb0a37ad563 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -106,6 +106,14 @@ const defaultOptions = { isFullscreen: false, toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), + existingFields: { + my_index_pattern: { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, }; describe('date_histogram', () => { @@ -310,7 +318,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -346,7 +354,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={secondLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={secondLayer.columns.col2 as DateHistogramIndexPatternColumn} indexPattern={indexPattern2} @@ -382,7 +390,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={thirdLayer} - updateLayer={jest.fn()} + paramEditorUpdater={jest.fn()} columnId="col1" currentColumn={thirdLayer.columns.col1 as DateHistogramIndexPatternColumn} indexPattern={indexPattern1} @@ -418,7 +426,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={thirdLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={thirdLayer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -459,7 +467,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={thirdLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={thirdLayer.columns.col1 as DateHistogramIndexPatternColumn} indexPattern={{ ...indexPattern1, timeFieldName: undefined }} @@ -502,7 +510,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={thirdLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={thirdLayer.columns.col1 as DateHistogramIndexPatternColumn} indexPattern={{ ...indexPattern1, timeFieldName: undefined }} @@ -544,7 +552,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={thirdLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={thirdLayer.columns.col1 as DateHistogramIndexPatternColumn} indexPattern={{ ...indexPattern1, timeFieldName: 'other_timestamp' }} @@ -559,7 +567,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -581,7 +589,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={testLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -598,7 +606,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={testLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -615,7 +623,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={testLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -631,7 +639,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -655,7 +663,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={testLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={testLayer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -707,7 +715,7 @@ describe('date_histogram', () => { {...defaultOptions} layer={layer} indexPattern={indexPattern} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as DateHistogramIndexPatternColumn} /> @@ -741,7 +749,7 @@ describe('date_histogram', () => { <InlineOptions {...defaultOptions} layer={thirdLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={thirdLayer.columns.col1 as DateHistogramIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 3bbd329a39396..5ee246f09c2e5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -171,7 +171,7 @@ export const dateHistogramOperation: OperationDefinition< layer, columnId, currentColumn, - updateLayer, + paramEditorUpdater, dateRange, data, indexPattern, @@ -200,7 +200,7 @@ export const dateHistogramOperation: OperationDefinition< // updateColumnParam will be called async // store the checked value before the event pooling clears it const value = ev.target.checked; - updateLayer((newLayer) => + paramEditorUpdater((newLayer) => updateColumnParam({ layer: newLayer, columnId, @@ -209,7 +209,7 @@ export const dateHistogramOperation: OperationDefinition< }) ); }, - [columnId, updateLayer] + [columnId, paramEditorUpdater] ); const setInterval = useCallback( @@ -221,11 +221,11 @@ export const dateHistogramOperation: OperationDefinition< ? autoInterval : `${isCalendarInterval ? '1' : newInterval.value}${newInterval.unit || 'd'}`; - updateLayer((newLayer) => + paramEditorUpdater((newLayer) => updateColumnParam({ layer: newLayer, columnId, paramName: 'interval', value }) ); }, - [columnId, updateLayer] + [columnId, paramEditorUpdater] ); const options = (intervalOptions || []) @@ -323,7 +323,7 @@ export const dateHistogramOperation: OperationDefinition< const newValue = opts.length ? opts[0].key! : ''; setIntervalInput(newValue); if (newValue === autoInterval && currentColumn.params.ignoreTimeRange) { - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -397,7 +397,7 @@ export const dateHistogramOperation: OperationDefinition< }); setIntervalInput(newFixedInterval); } - updateLayer(newLayer); + paramEditorUpdater(newLayer); }} compressed /> @@ -410,7 +410,7 @@ export const dateHistogramOperation: OperationDefinition< checked={Boolean(currentColumn.params.includeEmptyRows)} data-test-subj="indexPattern-include-empty-rows" onChange={() => { - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index 1bfa10be4107b..ef900ee1d7f8b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -36,6 +36,14 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', + existingFields: { + my_index_pattern: { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, }; // mocking random id generator function @@ -304,7 +312,7 @@ describe('filters', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as FiltersIndexPatternColumn} /> @@ -357,7 +365,7 @@ describe('filters', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as FiltersIndexPatternColumn} /> @@ -382,7 +390,7 @@ describe('filters', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as FiltersIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index d3eea6e223401..68798bd11aee5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -145,11 +145,11 @@ export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn, 'n }).toAst(); }, - paramEditor: ({ layer, columnId, currentColumn, indexPattern, updateLayer, data }) => { + paramEditor: ({ layer, columnId, currentColumn, indexPattern, paramEditorUpdater }) => { const filters = currentColumn.params.filters; const setFilters = (newFilters: Filter[]) => - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -159,7 +159,7 @@ export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn, 'n ); return ( - <EuiFormRow> + <EuiFormRow fullWidth> <FilterList filters={filters} setFilters={setFilters} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx index e912e345756f2..fcec1168a7474 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx @@ -85,7 +85,7 @@ const MemoizedFormulaEditor = React.memo(FormulaEditor); export function FormulaEditor({ layer, - updateLayer, + paramEditorUpdater, currentColumn, columnId, indexPattern, @@ -153,7 +153,7 @@ export function FormulaEditor({ setIsCloseable(true); // If the text is not synced, update the column. if (text !== currentColumn.params.formula) { - updateLayer( + paramEditorUpdater( (prevLayer) => insertOrReplaceFormulaColumn( columnId, @@ -183,7 +183,7 @@ export function FormulaEditor({ monaco.editor.setModelMarkers(editorModel.current, 'LENS', []); if (currentColumn.params.formula) { // Only submit if valid - updateLayer( + paramEditorUpdater( insertOrReplaceFormulaColumn( columnId, { @@ -232,7 +232,7 @@ export function FormulaEditor({ if (previousFormulaWasBroken || previousFormulaWasOkButNoData) { // If the formula is already broken, show the latest error message in the workspace if (currentColumn.params.formula !== text) { - updateLayer( + paramEditorUpdater( insertOrReplaceFormulaColumn( columnId, { @@ -314,7 +314,7 @@ export function FormulaEditor({ } ); - updateLayer(newLayer); + paramEditorUpdater(newLayer); const managedColumns = getManagedColumnsFrom(columnId, newLayer.columns); const markers: monaco.editor.IMarkerData[] = managedColumns diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx index c464ce0da027c..4ca172df112e5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx @@ -12,7 +12,7 @@ import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn, } from './column_types'; -import { IndexPattern, IndexPatternField } from '../../types'; +import { IndexPattern, IndexPatternField, IndexPatternLayer } from '../../types'; import { hasField } from '../../pure_utils'; export function getInvalidFieldMessage( @@ -128,6 +128,15 @@ export function isColumnOfType<C extends GenericIndexPatternColumn>( return column.operationType === type; } +export const isColumn = ( + setter: + | GenericIndexPatternColumn + | IndexPatternLayer + | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) +): setter is GenericIndexPatternColumn => { + return 'operationType' in setter; +}; + export function isColumnFormatted( column: GenericIndexPatternColumn ): column is FormattedIndexPatternColumn { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index cdf2b0249529e..91a83d22f4a29 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -64,6 +64,7 @@ import { DateRange, LayerType } from '../../../../common'; import { rangeOperation } from './ranges'; import { IndexPatternDimensionEditorProps, OperationSupportMatrix } from '../../dimension_panel'; import type { OriginalColumn } from '../../to_expression'; +import { ReferenceEditorProps } from '../../dimension_panel/reference_editor'; export type { IncompleteColumn, @@ -160,12 +161,14 @@ export { staticValueOperation } from './static_value'; /** * Properties passed to the operation-specific part of the popover editor */ -export interface ParamEditorProps<C> { +export interface ParamEditorProps< + C, + U = IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) +> { currentColumn: C; layer: IndexPatternLayer; - updateLayer: ( - setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) - ) => void; + paramEditorUpdater: (setter: U) => void; + ReferenceEditor?: (props: ReferenceEditorProps) => JSX.Element | null; toggleFullscreen: () => void; setIsCloseable: (isCloseable: boolean) => void; isFullscreen: boolean; @@ -183,6 +186,8 @@ export interface ParamEditorProps<C> { activeData?: IndexPatternDimensionEditorProps['activeData']; operationDefinitionMap: Record<string, GenericOperationDefinition>; paramEditorCustomProps?: ParamEditorCustomProps; + existingFields: Record<string, Record<string, boolean>>; + isReferenced?: boolean; } export interface FieldInputProps<C> { @@ -227,7 +232,11 @@ export interface AdvancedOption { helpPopup?: string | null; } -interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn, P = {}> { +interface BaseOperationDefinitionProps< + C extends BaseIndexPatternColumn, + AR extends boolean, + P = {} +> { type: C['operationType']; /** * The priority of the operation. If multiple operations are possible in @@ -258,7 +267,10 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn, P = {}> /** * React component for operation specific settings shown in the flyout editor */ - paramEditor?: React.ComponentType<ParamEditorProps<C>>; + allowAsReference?: AR; + paramEditor?: React.ComponentType< + AR extends true ? ParamEditorProps<C, GenericIndexPatternColumn> : ParamEditorProps<C> + >; getAdvancedOptions?: (params: ParamEditorProps<C>) => AdvancedOption[] | undefined; /** * Returns true if the `column` can also be used on `newIndexPattern`. @@ -498,7 +510,8 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn, P = {} indexPattern: IndexPattern, layer: IndexPatternLayer, uiSettings: IUiSettingsClient, - orderedColumnIds: string[] + orderedColumnIds: string[], + operationDefinitionMap?: Record<string, GenericOperationDefinition> ) => ExpressionAstFunction; /** * Validate that the operation has the right preconditions in the state. For example: @@ -646,8 +659,9 @@ interface OperationDefinitionMap<C extends BaseIndexPatternColumn, P = {}> { export type OperationDefinition< C extends BaseIndexPatternColumn, Input extends keyof OperationDefinitionMap<C>, - P = {} -> = BaseOperationDefinitionProps<C> & OperationDefinitionMap<C, P>[Input]; + P = {}, + AR extends boolean = false +> = BaseOperationDefinitionProps<C, AR> & OperationDefinitionMap<C, P>[Input]; /** * A union type of all available operation types. The operation type is a unique id of an operation. diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx index 242bdeaa677cb..f2248fcdf36c9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -40,6 +40,14 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', + existingFields: { + my_index_pattern: { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, }; describe('last_value', () => { @@ -642,6 +650,13 @@ describe('last_value', () => { return this.showArrayValuesSwitch.prop('disabled'); } + public get arrayValuesSwitchNotExisiting() { + return ( + this._instance.find('[data-test-subj="lns-indexPattern-lastValue-showArrayValues"]') + .length === 0 + ); + } + changeSortFieldOptions(options: Array<{ label: string; value: string }>) { this.sortField.find(EuiComboBox).prop('onChange')!([ { label: 'datefield2', value: 'datefield2' }, @@ -659,7 +674,7 @@ describe('last_value', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col2 as LastValueIndexPatternColumn} /> @@ -676,7 +691,7 @@ describe('last_value', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as LastValueIndexPatternColumn} /> @@ -685,16 +700,10 @@ describe('last_value', () => { new Harness(instance).changeSortFieldOptions([{ label: 'datefield2', value: 'datefield2' }]); expect(updateLayerSpy).toHaveBeenCalledWith({ - ...layer, - columns: { - ...layer.columns, - col2: { - ...layer.columns.col2, - params: { - ...(layer.columns.col2 as LastValueIndexPatternColumn).params, - sortField: 'datefield2', - }, - }, + ...layer.columns.col2, + params: { + ...(layer.columns.col2 as LastValueIndexPatternColumn).params, + sortField: 'datefield2', }, }); }); @@ -707,7 +716,7 @@ describe('last_value', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as LastValueIndexPatternColumn} /> @@ -718,27 +727,29 @@ describe('last_value', () => { harness.toggleShowArrayValues(); expect(updateLayerSpy).toHaveBeenCalledWith({ - ...layer, - columns: { - ...layer.columns, - col2: { - ...layer.columns.col2, - params: { - ...(layer.columns.col2 as LastValueIndexPatternColumn).params, - showArrayValues: true, - }, - }, + ...layer.columns.col2, + params: { + ...(layer.columns.col2 as LastValueIndexPatternColumn).params, + showArrayValues: true, }, }); // have to do this manually, but it happens automatically in the app - const newLayer = updateLayerSpy.mock.calls[0][0]; + const newColumn = updateLayerSpy.mock.calls[0][0]; + const newLayer = { + ...layer, + columns: { + ...layer.columns, + col2: newColumn, + }, + }; instance.setProps({ layer: newLayer, currentColumn: newLayer.columns.col2 }); expect(harness.showingTopValuesWarning).toBeTruthy(); }); it('should not warn user when top-values not in use', () => { + // todo: move to dimension editor const updateLayerSpy = jest.fn(); const localLayer = { ...layer, @@ -754,7 +765,7 @@ describe('last_value', () => { <InlineOptions {...defaultProps} layer={localLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as LastValueIndexPatternColumn} /> @@ -764,7 +775,15 @@ describe('last_value', () => { harness.toggleShowArrayValues(); // have to do this manually, but it happens automatically in the app - const newLayer = updateLayerSpy.mock.calls[0][0]; + const newColumn = updateLayerSpy.mock.calls[0][0]; + const newLayer = { + ...localLayer, + columns: { + ...localLayer.columns, + col2: newColumn, + }, + }; + instance.setProps({ layer: newLayer, currentColumn: newLayer.columns.col2 }); expect(harness.showingTopValuesWarning).toBeFalsy(); @@ -778,7 +797,7 @@ describe('last_value', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as LastValueIndexPatternColumn} /> @@ -786,6 +805,21 @@ describe('last_value', () => { expect(new Harness(instance).showArrayValuesSwitchDisabled).toBeTruthy(); }); + it('should not display an array for the last value if the column is referenced', () => { + const updateLayerSpy = jest.fn(); + const instance = shallow( + <InlineOptions + {...defaultProps} + isReferenced={true} + layer={layer} + paramEditorUpdater={updateLayerSpy} + columnId="col1" + currentColumn={layer.columns.col2 as LastValueIndexPatternColumn} + /> + ); + + expect(new Harness(instance).arrayValuesSwitchNotExisiting).toBeTruthy(); + }); }); }); @@ -829,6 +863,7 @@ describe('last_value', () => { 'Field notExisting was not found', ]); }); + it('shows error message if the sortField does not exist in index pattern', () => { errorLayer = { ...errorLayer, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx index 40b4d53154ba7..0af5ed4428ef7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx @@ -20,7 +20,6 @@ import { buildExpressionFunction } from '@kbn/expressions-plugin/public'; import { OperationDefinition } from '.'; import { FieldBasedIndexPatternColumn, ValueFormatConfig } from './column_types'; import { IndexPatternField, IndexPattern } from '../../types'; -import { adjustColumnReferencesForChangedColumn, updateColumnParam } from '../layer_helpers'; import { DataType } from '../../../types'; import { getFormatFromPreviousColumn, @@ -31,6 +30,7 @@ import { import { adjustTimeScaleLabelSuffix } from '../time_scale_utils'; import { getDisallowedPreviousShiftMessage } from '../../time_shift_utils'; import { isScriptedField } from './terms/helpers'; +import { FormRow } from './shared_components/form_row'; function ofName(name: string, timeShift: string | undefined) { return adjustTimeScaleLabelSuffix( @@ -122,7 +122,8 @@ function getExistsFilter(field: string) { export const lastValueOperation: OperationDefinition< LastValueIndexPatternColumn, 'field', - Partial<LastValueIndexPatternColumn['params']> + Partial<LastValueIndexPatternColumn['params']>, + true > = { type: 'last_value', displayName: i18n.translate('xpack.lens.indexPattern.lastValue', { @@ -257,8 +258,22 @@ export const lastValueOperation: OperationDefinition< supportedTypes.has(newField.type) ); }, + allowAsReference: true, + paramEditor: ({ + layer, + paramEditorUpdater, + currentColumn, + indexPattern, + isReferenced, + paramEditorCustomProps, + }) => { + const { labels, isInline } = paramEditorCustomProps || {}; + const sortByFieldLabel = + labels?.[0] || + i18n.translate('xpack.lens.indexPattern.lastValue.sortField', { + defaultMessage: 'Sort by date field', + }); - paramEditor: ({ layer, updateLayer, columnId, currentColumn, indexPattern }) => { const dateFields = getDateFields(indexPattern); const isSortFieldInvalid = !!getInvalidSortFieldMessage( currentColumn.params.sortField, @@ -270,27 +285,20 @@ export const lastValueOperation: OperationDefinition< ); const setShowArrayValues = (use: boolean) => { - let updatedLayer = updateColumnParam({ - layer, - columnId, - paramName: 'showArrayValues', - value: use, - }); - - updatedLayer = { - ...updatedLayer, - columns: adjustColumnReferencesForChangedColumn(updatedLayer, columnId), - }; - - updateLayer(updatedLayer); + return paramEditorUpdater({ + ...currentColumn, + params: { + ...currentColumn.params, + showArrayValues: use, + }, + } as LastValueIndexPatternColumn); }; return ( <> - <EuiFormRow - label={i18n.translate('xpack.lens.indexPattern.lastValue.sortField', { - defaultMessage: 'Sort by date field', - })} + <FormRow + isInline={isInline} + label={sortByFieldLabel} display="rowCompressed" fullWidth error={i18n.translate('xpack.lens.indexPattern.sortField.invalid', { @@ -302,14 +310,13 @@ export const lastValueOperation: OperationDefinition< placeholder={i18n.translate('xpack.lens.indexPattern.lastValue.sortFieldPlaceholder', { defaultMessage: 'Sort field', })} + fullWidth compressed isClearable={false} data-test-subj="lns-indexPattern-lastValue-sortField" isInvalid={isSortFieldInvalid} singleSelection={{ asPlainText: true }} - aria-label={i18n.translate('xpack.lens.indexPattern.lastValue.sortField', { - defaultMessage: 'Sort by date field', - })} + aria-label={sortByFieldLabel} options={dateFields?.map((field: IndexPatternField) => { return { value: field.name, @@ -320,14 +327,13 @@ export const lastValueOperation: OperationDefinition< if (choices.length === 0) { return; } - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'sortField', - value: choices[0].value, - }) - ); + return paramEditorUpdater({ + ...currentColumn, + params: { + ...currentColumn.params, + sortField: choices[0].value, + }, + } as LastValueIndexPatternColumn); }} selectedOptions={ (currentColumn.params?.sortField @@ -342,41 +348,43 @@ export const lastValueOperation: OperationDefinition< : []) as unknown as EuiComboBoxOptionOption[] } /> - </EuiFormRow> - <EuiFormRow - error={i18n.translate( - 'xpack.lens.indexPattern.lastValue.showArrayValuesWithTopValuesWarning', - { - defaultMessage: - 'When you show array values, you are unable to use this field to rank Top values.', - } - )} - isInvalid={currentColumn.params.showArrayValues && usingTopValues} - display="rowCompressed" - fullWidth - data-test-subj="lns-indexPattern-lastValue-showArrayValues" - > - <EuiToolTip - content={i18n.translate( - 'xpack.lens.indexPattern.lastValue.showArrayValuesExplanation', + </FormRow> + {!isReferenced && ( + <EuiFormRow + error={i18n.translate( + 'xpack.lens.indexPattern.lastValue.showArrayValuesWithTopValuesWarning', { defaultMessage: - 'Displays all values associated with this field in each last document.', + 'When you show array values, you are unable to use this field to rank top values.', } )} - position="left" + isInvalid={currentColumn.params.showArrayValues && usingTopValues} + display="rowCompressed" + fullWidth + data-test-subj="lns-indexPattern-lastValue-showArrayValues" > - <EuiSwitch - label={i18n.translate('xpack.lens.indexPattern.lastValue.showArrayValues', { - defaultMessage: 'Show array values', - })} - compressed={true} - checked={Boolean(currentColumn.params.showArrayValues)} - disabled={isScriptedField(currentColumn.sourceField, indexPattern)} - onChange={() => setShowArrayValues(!currentColumn.params.showArrayValues)} - /> - </EuiToolTip> - </EuiFormRow> + <EuiToolTip + content={i18n.translate( + 'xpack.lens.indexPattern.lastValue.showArrayValuesExplanation', + { + defaultMessage: + 'Displays all values associated with this field in each last document.', + } + )} + position="left" + > + <EuiSwitch + label={i18n.translate('xpack.lens.indexPattern.lastValue.showArrayValues', { + defaultMessage: 'Show array values', + })} + compressed={true} + checked={Boolean(currentColumn.params.showArrayValues)} + disabled={isScriptedField(currentColumn.sourceField, indexPattern)} + onChange={() => setShowArrayValues(!currentColumn.params.showArrayValues)} + /> + </EuiToolTip> + </EuiFormRow> + )} </> ); }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 10c4310e820b2..b58ffdf8c8a1d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -81,6 +81,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({ return { type, + allowAsReference: true, priority, displayName, description, @@ -142,7 +143,12 @@ function buildMetricOperation<T extends MetricColumn<string>>({ sourceField: field.name, }; }, - getAdvancedOptions: ({ layer, columnId, currentColumn, updateLayer }: ParamEditorProps<T>) => { + getAdvancedOptions: ({ + layer, + columnId, + currentColumn, + paramEditorUpdater, + }: ParamEditorProps<T>) => { if (!hideZeroOption) return []; return [ { @@ -160,7 +166,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({ }} checked={Boolean(currentColumn.params?.emptyAsNull)} onChange={() => { - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -221,7 +227,7 @@ Example: Get the {metric} of price for orders from the UK: }), }, shiftable: true, - } as OperationDefinition<T, 'field'>; + } as OperationDefinition<T, 'field', {}, true>; } export type SumIndexPatternColumn = MetricColumn<'sum'>; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx index 08afcc447eec6..a359ae0b89820 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx @@ -56,6 +56,14 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', + existingFields: { + my_index_pattern: { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, }; describe('percentile', () => { @@ -715,7 +723,7 @@ describe('percentile', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as PercentileIndexPatternColumn} /> @@ -732,7 +740,7 @@ describe('percentile', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as PercentileIndexPatternColumn} /> @@ -752,17 +760,11 @@ describe('percentile', () => { instance.update(); expect(updateLayerSpy).toHaveBeenCalledWith({ - ...layer, - columns: { - ...layer.columns, - col2: { - ...layer.columns.col2, - params: { - percentile: 27, - }, - label: '27th percentile of a', - }, + ...layer.columns.col2, + params: { + percentile: 27, }, + label: '27th percentile of a', }); }); @@ -772,7 +774,7 @@ describe('percentile', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as PercentileIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index a313b03d34e1b..c7e028dfaea7e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFormRow, EuiRange, EuiRangeProps } from '@elastic/eui'; +import { EuiFieldNumber, EuiRange } from '@elastic/eui'; import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { AggFunctionsMapping, METRIC_TYPES } from '@kbn/data-plugin/public'; @@ -29,6 +29,7 @@ import { FieldBasedIndexPatternColumn } from './column_types'; import { adjustTimeScaleLabelSuffix } from '../time_scale_utils'; import { useDebouncedValue } from '../../../shared_components'; import { getDisallowedPreviousShiftMessage } from '../../time_shift_utils'; +import { FormRow } from './shared_components'; export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColumn { operationType: 'percentile'; @@ -64,9 +65,11 @@ const supportedFieldTypes = ['number', 'histogram']; export const percentileOperation: OperationDefinition< PercentileIndexPatternColumn, 'field', - { percentile: number } + { percentile: number }, + true > = { type: 'percentile', + allowAsReference: true, displayName: i18n.translate('xpack.lens.indexPattern.percentile', { defaultMessage: 'Percentile', }), @@ -268,12 +271,17 @@ export const percentileOperation: OperationDefinition< getDisallowedPreviousShiftMessage(layer, columnId), ]), paramEditor: function PercentileParamEditor({ - layer, - updateLayer, + paramEditorUpdater, currentColumn, - columnId, indexPattern, + paramEditorCustomProps, }) { + const { labels, isInline } = paramEditorCustomProps || {}; + const percentileLabel = + labels?.[0] || + i18n.translate('xpack.lens.indexPattern.percentile.percentileValue', { + defaultMessage: 'Percentile', + }); const onChange = useCallback( (value) => { if ( @@ -282,29 +290,23 @@ export const percentileOperation: OperationDefinition< ) { return; } - updateLayer({ - ...layer, - columns: { - ...layer.columns, - [columnId]: { - ...currentColumn, - label: currentColumn.customLabel - ? currentColumn.label - : ofName( - indexPattern.getFieldByName(currentColumn.sourceField)?.displayName || - currentColumn.sourceField, - Number(value), - currentColumn.timeShift - ), - params: { - ...currentColumn.params, - percentile: Number(value), - }, - } as PercentileIndexPatternColumn, + paramEditorUpdater({ + ...currentColumn, + label: currentColumn.customLabel + ? currentColumn.label + : ofName( + indexPattern.getFieldByName(currentColumn.sourceField)?.displayName || + currentColumn.sourceField, + Number(value), + currentColumn.timeShift + ), + params: { + ...currentColumn.params, + percentile: Number(value), }, - }); + } as PercentileIndexPatternColumn); }, - [updateLayer, layer, columnId, currentColumn, indexPattern] + [paramEditorUpdater, currentColumn, indexPattern] ); const { inputValue, handleInputChange: handleInputChangeWithoutValidation } = useDebouncedValue< string | undefined @@ -314,16 +316,15 @@ export const percentileOperation: OperationDefinition< }); const inputValueIsValid = isValidNumber(inputValue, true, 99, 1); - const handleInputChange: EuiRangeProps['onChange'] = useCallback( + const handleInputChange = useCallback( (e) => handleInputChangeWithoutValidation(String(e.currentTarget.value)), [handleInputChangeWithoutValidation] ); return ( - <EuiFormRow - label={i18n.translate('xpack.lens.indexPattern.percentile.percentileValue', { - defaultMessage: 'Percentile', - })} + <FormRow + isInline={isInline} + label={percentileLabel} data-test-subj="lns-indexPattern-percentile-form" display="rowCompressed" fullWidth @@ -335,20 +336,33 @@ export const percentileOperation: OperationDefinition< }) } > - <EuiRange - data-test-subj="lns-indexPattern-percentile-input" - compressed - value={inputValue ?? ''} - min={1} - max={99} - step={1} - onChange={handleInputChange} - showInput - aria-label={i18n.translate('xpack.lens.indexPattern.percentile.percentileValue', { - defaultMessage: 'Percentile', - })} - /> - </EuiFormRow> + {isInline ? ( + <EuiFieldNumber + fullWidth + data-test-subj="lns-indexPattern-percentile-input" + compressed + value={inputValue ?? ''} + min={1} + max={99} + step={1} + onChange={handleInputChange} + aria-label={percentileLabel} + /> + ) : ( + <EuiRange + fullWidth + data-test-subj="lns-indexPattern-percentile-input" + compressed + value={inputValue ?? ''} + min={1} + max={99} + step={1} + onChange={handleInputChange} + showInput + aria-label={percentileLabel} + /> + )} + </FormRow> ); }, documentation: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.test.tsx index a7dbeedee633b..62c0bbd45be6c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.test.tsx @@ -50,6 +50,14 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', + existingFields: { + my_index_pattern: { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, }; describe('percentile ranks', () => { @@ -274,7 +282,7 @@ describe('percentile ranks', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as PercentileRanksIndexPatternColumn} /> @@ -291,7 +299,7 @@ describe('percentile ranks', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as PercentileRanksIndexPatternColumn} /> @@ -310,17 +318,11 @@ describe('percentile ranks', () => { instance.update(); expect(updateLayerSpy).toHaveBeenCalledWith({ - ...layer, - columns: { - ...layer.columns, - col2: { - ...layer.columns.col2, - params: { - value: 103, - }, - label: 'Percentile rank (103) of a', - }, + ...layer.columns.col2, + params: { + value: 103, }, + label: 'Percentile rank (103) of a', }); }); @@ -330,7 +332,7 @@ describe('percentile ranks', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as PercentileRanksIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.tsx index f153b2aca669b..61a7a33cdbd73 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile_ranks.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFormRow, EuiFieldNumberProps, EuiFieldNumber } from '@elastic/eui'; +import { EuiFieldNumberProps, EuiFieldNumber } from '@elastic/eui'; import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { AggFunctionsMapping } from '@kbn/data-plugin/public'; @@ -24,6 +24,7 @@ import { FieldBasedIndexPatternColumn } from './column_types'; import { adjustTimeScaleLabelSuffix } from '../time_scale_utils'; import { useDebouncedValue } from '../../../shared_components'; import { getDisallowedPreviousShiftMessage } from '../../time_shift_utils'; +import { FormRow } from './shared_components'; export interface PercentileRanksIndexPatternColumn extends FieldBasedIndexPatternColumn { operationType: 'percentile_rank'; @@ -52,9 +53,11 @@ const supportedFieldTypes = ['number', 'histogram']; export const percentileRanksOperation: OperationDefinition< PercentileRanksIndexPatternColumn, 'field', - { value: number } + { value: number }, + true > = { type: 'percentile_rank', + allowAsReference: true, displayName: i18n.translate('xpack.lens.indexPattern.percentileRank', { defaultMessage: 'Percentile rank', }), @@ -143,40 +146,39 @@ export const percentileRanksOperation: OperationDefinition< getDisallowedPreviousShiftMessage(layer, columnId), ]), paramEditor: function PercentileParamEditor({ - layer, - updateLayer, + paramEditorUpdater, currentColumn, - columnId, indexPattern, + paramEditorCustomProps, }) { + const { labels, isInline } = paramEditorCustomProps || {}; + const percentileRanksLabel = + labels?.[0] || + i18n.translate('xpack.lens.indexPattern.percentile.percentileRanksValue', { + defaultMessage: 'Percentile ranks value', + }); const onChange = useCallback( (value) => { if (!isValidNumber(value) || Number(value) === currentColumn.params.value) { return; } - updateLayer({ - ...layer, - columns: { - ...layer.columns, - [columnId]: { - ...currentColumn, - label: currentColumn.customLabel - ? currentColumn.label - : ofName( - indexPattern.getFieldByName(currentColumn.sourceField)?.displayName || - currentColumn.sourceField, - Number(value), - currentColumn.timeShift - ), - params: { - ...currentColumn.params, - value: Number(value), - }, - } as PercentileRanksIndexPatternColumn, + paramEditorUpdater({ + ...currentColumn, + label: currentColumn.customLabel + ? currentColumn.label + : ofName( + indexPattern.getFieldByName(currentColumn.sourceField)?.displayName || + currentColumn.sourceField, + Number(value), + currentColumn.timeShift + ), + params: { + ...currentColumn.params, + value: Number(value), }, - }); + } as PercentileRanksIndexPatternColumn); }, - [updateLayer, layer, columnId, currentColumn, indexPattern] + [paramEditorUpdater, currentColumn, indexPattern] ); const { inputValue, handleInputChange: handleInputChangeWithoutValidation } = useDebouncedValue< string | undefined @@ -197,10 +199,9 @@ export const percentileRanksOperation: OperationDefinition< ); return ( - <EuiFormRow - label={i18n.translate('xpack.lens.indexPattern.percentile.percentileRanksValue', { - defaultMessage: 'Percentile ranks value', - })} + <FormRow + isInline={isInline} + label={percentileRanksLabel} data-test-subj="lns-indexPattern-percentile_ranks-form" display="rowCompressed" fullWidth @@ -213,16 +214,15 @@ export const percentileRanksOperation: OperationDefinition< } > <EuiFieldNumber + fullWidth data-test-subj="lns-indexPattern-percentile_ranks-input" compressed value={inputValue ?? ''} onChange={handleInputChange} step="any" - aria-label={i18n.translate('xpack.lens.indexPattern.percentile.percentileRanksValue', { - defaultMessage: 'Percentile ranks value', - })} + aria-label={percentileRanksLabel} /> - </EuiFormRow> + </FormRow> ); }, documentation: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx index f0611f5ec194e..60d5a76a3085e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx @@ -246,6 +246,7 @@ export const AdvancedRangeEditor = ({ return ( <EuiFormRow + fullWidth label={i18n.translate('xpack.lens.indexPattern.ranges.customRanges', { defaultMessage: 'Ranges', })} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx index 30f883083072b..9548d9473e656 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx @@ -83,6 +83,14 @@ const defaultOptions = { storage: {} as IStorageWrapper, uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, + existingFields: { + my_index_pattern: { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, dateRange: { fromDate: 'now-1y', toDate: 'now', @@ -374,7 +382,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -390,7 +398,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -433,7 +441,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -503,7 +511,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -519,7 +527,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -539,7 +547,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={ { @@ -565,7 +573,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -620,7 +628,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -675,7 +683,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -722,7 +730,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -772,7 +780,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -810,7 +818,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -842,7 +850,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} indexPattern={{ @@ -872,7 +880,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} indexPattern={{ @@ -896,7 +904,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> @@ -916,7 +924,7 @@ describe('ranges', () => { <InlineOptions {...defaultOptions} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as RangeIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx index 11754e1f90005..06db3221bde34 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx @@ -180,7 +180,7 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field layer, columnId, currentColumn, - updateLayer, + paramEditorUpdater, indexPattern, uiSettings, data, @@ -208,7 +208,7 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field // Used to change one param at the time const setParam: UpdateParamsFnType = (paramName, value) => { - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -226,7 +226,7 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field newMode === MODES.Range ? { id: 'range', params: { template: 'arrow_right', replaceInfinity: true } } : undefined; - updateLayer({ + paramEditorUpdater({ ...layer, columns: { ...layer.columns, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/form_row.scss b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/form_row.scss new file mode 100644 index 0000000000000..accbb4060e797 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/form_row.scss @@ -0,0 +1,3 @@ +.lnsIndexPatternDimensionEditor__labelCustomRank { + min-width: 96px; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/form_row.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/form_row.tsx new file mode 100644 index 0000000000000..3961ff1b8a0de --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/form_row.tsx @@ -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 React from 'react'; +import { EuiFormLabel, EuiFormRow, EuiFormRowProps } from '@elastic/eui'; +import './form_row.scss'; + +type FormRowProps = EuiFormRowProps & { isInline?: boolean }; + +export const FormRow = ({ children, label, isInline, ...props }: FormRowProps) => { + return !isInline ? ( + <EuiFormRow {...props} label={label}> + {children} + </EuiFormRow> + ) : ( + <div data-test-subj={props['data-test-subj']}> + {React.cloneElement(children, { + prepend: ( + <EuiFormLabel className="lnsIndexPatternDimensionEditor__labelCustomRank"> + {label} + </EuiFormLabel> + ), + })} + </div> + ); +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/index.tsx index b2f409c9dcbd0..47cc121be095b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/index.tsx @@ -7,3 +7,4 @@ export * from './label_input'; export * from './buckets'; +export * from './form_row'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx index df96b02ba2c95..ea870e68f563b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx @@ -49,6 +49,14 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', + existingFields: { + my_index_pattern: { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, }; describe('static_value', () => { @@ -340,7 +348,7 @@ describe('static_value', () => { <ParamEditor {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as StaticValueIndexPatternColumn} /> @@ -371,7 +379,7 @@ describe('static_value', () => { <ParamEditor {...defaultProps} layer={zeroLayer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={zeroLayer.columns.col2 as StaticValueIndexPatternColumn} /> @@ -387,7 +395,7 @@ describe('static_value', () => { <ParamEditor {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as StaticValueIndexPatternColumn} /> @@ -428,7 +436,7 @@ describe('static_value', () => { <ParamEditor {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col2" currentColumn={layer.columns.col2 as StaticValueIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx index 5642c06c6b642..bb9b36a3d097b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx @@ -153,7 +153,7 @@ export const staticValueOperation: OperationDefinition< }, paramEditor: function StaticValueEditor({ - updateLayer, + paramEditorUpdater, currentColumn, columnId, activeData, @@ -168,7 +168,7 @@ export const staticValueOperation: OperationDefinition< } // Because of upstream specific UX flows, we need fresh layer state here // so need to use the updater pattern - updateLayer((newLayer) => { + paramEditorUpdater((newLayer) => { const newColumn = newLayer.columns[columnId] as StaticValueIndexPatternColumn; return { ...newLayer, @@ -186,7 +186,7 @@ export const staticValueOperation: OperationDefinition< }; }); }, - [columnId, updateLayer, currentColumn?.params?.value] + [columnId, paramEditorUpdater, currentColumn?.params?.value] ); // Pick the data from the current activeData (to be used when the current operation is not static_value) @@ -216,9 +216,10 @@ export const staticValueOperation: OperationDefinition< return ( <div className="lnsIndexPatternDimensionEditor__section lnsIndexPatternDimensionEditor__section--padded lnsIndexPatternDimensionEditor__section--shaded"> - <EuiFormLabel>{paramEditorCustomProps?.label || defaultLabel}</EuiFormLabel> + <EuiFormLabel>{paramEditorCustomProps?.labels?.[0] || defaultLabel}</EuiFormLabel> <EuiSpacer size="s" /> <EuiFieldNumber + fullWidth data-test-subj="lns-indexPattern-static_value-input" compressed value={inputValue ?? ''} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 62aed475df42a..31effec454efd 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -21,12 +21,17 @@ import { import { uniq } from 'lodash'; import { AggFunctionsMapping } from '@kbn/data-plugin/public'; import { buildExpressionFunction } from '@kbn/expressions-plugin/public'; +import { DOCUMENT_FIELD_NAME } from '../../../../../common'; import { insertOrReplaceColumn, updateColumnParam, updateDefaultLabels } from '../../layer_helpers'; -import type { DataType } from '../../../../types'; +import type { DataType, OperationMetadata } from '../../../../types'; import { OperationDefinition } from '..'; -import { FieldBasedIndexPatternColumn } from '../column_types'; +import { + FieldBasedIndexPatternColumn, + GenericIndexPatternColumn, + IncompleteColumn, +} from '../column_types'; import { ValuesInput } from './values_input'; -import { getInvalidFieldMessage } from '../helpers'; +import { getInvalidFieldMessage, isColumn } from '../helpers'; import { FieldInputs, getInputFieldErrorMessage, MAX_MULTI_FIELDS_SIZE } from './field_inputs'; import { FieldInput as FieldInputBase, @@ -226,7 +231,15 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field }, }; }, - toEsAggsFn: (column, columnId, _indexPattern, layer, uiSettings, orderedColumnIds) => { + toEsAggsFn: ( + column, + columnId, + _indexPattern, + layer, + uiSettings, + orderedColumnIds, + operationDefinitionMap + ) => { if (column.params?.orderBy.type === 'rare') { return buildExpressionFunction<AggFunctionsMapping['aggRareTerms']>('aggRareTerms', { id: columnId, @@ -236,7 +249,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field max_doc_count: column.params.orderBy.maxDocCount, }).toAst(); } - let orderBy = '_key'; + let orderBy: string = '_key'; if (column.params?.orderBy.type === 'column') { const orderColumn = layer.columns[column.params.orderBy.columnId]; @@ -254,6 +267,29 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field ? Math.max(1000, column.params.size * 1.5 + 10) : undefined; + const orderAggColumn = column.params.orderAgg; + let orderAgg; + if (orderAggColumn) { + orderBy = 'custom'; + const def = operationDefinitionMap?.[orderAggColumn?.operationType]; + if (def && 'toEsAggsFn' in def) { + orderAgg = [ + { + type: 'expression' as const, + chain: [ + def.toEsAggsFn( + orderAggColumn, + `${columnId}-orderAgg`, + _indexPattern, + layer, + uiSettings, + orderedColumnIds + ), + ], + }, + ]; + } + } if (column.params?.secondaryFields?.length) { return buildExpressionFunction<AggFunctionsMapping['aggMultiTerms']>('aggMultiTerms', { id: columnId, @@ -262,6 +298,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field fields: [column.sourceField, ...column.params.secondaryFields], orderBy, order: column.params.orderDirection, + orderAgg, size: column.params.size, shardSize, otherBucket: Boolean(column.params.otherBucket), @@ -270,6 +307,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field }), }).toAst(); } + return buildExpressionFunction<AggFunctionsMapping['aggTerms']>('aggTerms', { id: columnId, enabled: true, @@ -277,6 +315,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field field: column.sourceField, orderBy, order: column.params.orderDirection, + orderAgg, size: column.params.size, shardSize, otherBucket: Boolean(column.params.otherBucket), @@ -498,7 +537,22 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field </EuiFormRow> ); }, - paramEditor: function ParamEditor({ layer, updateLayer, currentColumn, columnId, indexPattern }) { + paramEditor: function ParamEditor({ + layer, + paramEditorUpdater, + currentColumn, + columnId, + indexPattern, + existingFields, + operationDefinitionMap, + ReferenceEditor, + paramEditorCustomProps, + ...rest + }) { + const [incompleteColumn, setIncompleteColumn] = useState<IncompleteColumn | undefined>( + undefined + ); + const hasRestrictions = indexPattern.hasRestrictions; const SEPARATOR = '$$$'; @@ -516,6 +570,9 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field if (value === 'rare') { return { type: 'rare', maxDocCount: DEFAULT_MAX_DOC_COUNT }; } + if (value === 'custom') { + return { type: 'custom' }; + } const parts = value.split(SEPARATOR); return { type: 'column', @@ -548,6 +605,12 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field }), }); } + orderOptions.push({ + value: toValue({ type: 'custom' }), + text: i18n.translate('xpack.lens.indexPattern.terms.orderCustomMetric', { + defaultMessage: 'Custom', + }), + }); const secondaryFieldsCount = currentColumn.params.secondaryFields ? currentColumn.params.secondaryFields.length @@ -559,7 +622,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field value={currentColumn.params.size} disabled={currentColumn.params.orderBy.type === 'rare'} onChange={(value) => { - updateLayer({ + paramEditorUpdater({ ...layer, columns: { ...layer.columns, @@ -590,7 +653,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field })} maxValue={MAXIMUM_MAX_DOC_COUNT} onChange={(value) => { - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -626,12 +689,13 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field > <EuiSelect compressed + fullWidth data-test-subj="indexPattern-terms-orderBy" options={orderOptions} value={toValue(currentColumn.params.orderBy)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => { const newOrderByValue = fromValue(e.target.value); - const updatedLayer = updateDefaultLabels( + let updatedLayer = updateDefaultLabels( updateColumnParam({ layer, columnId, @@ -640,8 +704,33 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field }), indexPattern ); - - updateLayer( + if (newOrderByValue.type === 'custom') { + const initialOperation = ( + operationDefinitionMap.count as OperationDefinition< + GenericIndexPatternColumn, + 'field' + > + ).buildColumn({ + layer, + indexPattern, + field: indexPattern.getFieldByName(DOCUMENT_FIELD_NAME)!, + }); + updatedLayer = updateColumnParam({ + layer: updatedLayer, + columnId, + paramName: 'orderAgg', + value: initialOperation, + }); + } else { + updatedLayer = updateColumnParam({ + layer: updatedLayer, + columnId, + paramName: 'orderAgg', + value: undefined, + }); + } + setIncompleteColumn(undefined); + paramEditorUpdater( updateColumnParam({ layer: updatedLayer, columnId, @@ -655,6 +744,113 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field })} /> </EuiFormRow> + {currentColumn.params.orderAgg && ReferenceEditor && ( + <> + <EuiSpacer size="s" /> + <ReferenceEditor + operationDefinitionMap={operationDefinitionMap} + functionLabel={i18n.translate('xpack.lens.indexPattern.terms.orderAgg.rankFunction', { + defaultMessage: 'Rank function', + })} + fieldLabel={i18n.translate('xpack.lens.indexPattern.terms.orderAgg.rankField', { + defaultMessage: 'Rank field', + })} + isInline={true} + paramEditorCustomProps={{ + ...paramEditorCustomProps, + isInline: true, + labels: getLabelForRankFunctions(currentColumn.params.orderAgg.operationType), + }} + layer={layer} + selectionStyle="full" + columnId={`${columnId}-orderAgg`} + currentIndexPattern={indexPattern} + paramEditorUpdater={(setter) => { + if (!isColumn(setter)) { + throw new Error('Setter should always be a column when ran here.'); + } + paramEditorUpdater( + updateColumnParam({ + layer, + columnId, + paramName: 'orderAgg', + value: setter, + }) + ); + }} + column={currentColumn.params.orderAgg} + incompleteColumn={incompleteColumn} + existingFields={existingFields} + onDeleteColumn={() => { + throw new Error('Should not be called'); + }} + onChooseField={(choice) => { + const field = choice.field && indexPattern.getFieldByName(choice.field); + if (field) { + const hypotethicalColumn = ( + operationDefinitionMap[choice.operationType] as OperationDefinition< + GenericIndexPatternColumn, + 'field' + > + ).buildColumn({ + previousColumn: currentColumn.params.orderAgg, + layer, + indexPattern, + field, + }); + setIncompleteColumn(undefined); + paramEditorUpdater( + updateColumnParam({ + layer, + columnId, + paramName: 'orderAgg', + value: hypotethicalColumn, + }) + ); + } else { + setIncompleteColumn({ + sourceField: choice.field, + operationType: choice.operationType, + }); + } + }} + onChooseFunction={(operationType: string, field?: IndexPatternField) => { + if (field) { + const hypotethicalColumn = ( + operationDefinitionMap[operationType] as OperationDefinition< + GenericIndexPatternColumn, + 'field' + > + ).buildColumn({ + previousColumn: currentColumn.params.orderAgg, + layer, + indexPattern, + field, + }); + setIncompleteColumn(undefined); + + paramEditorUpdater( + updateColumnParam({ + layer, + columnId, + paramName: 'orderAgg', + value: hypotethicalColumn, + }) + ); + } else { + setIncompleteColumn({ operationType }); + } + }} + validation={{ + input: ['field', 'managedReference'], + validateMetadata: (meta: OperationMetadata) => + meta.dataType === 'number' && !meta.isBucketed, + }} + {...rest} + /> + <EuiSpacer size="m" /> + </> + )} <EuiFormRow label={i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { defaultMessage: 'Rank direction', @@ -698,7 +894,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field idPrefix, '' ) as TermsIndexPatternColumn['params']['orderDirection']; - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -729,7 +925,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field checked={Boolean(currentColumn.params.otherBucket)} disabled={currentColumn.params.orderBy.type === 'rare'} onChange={(e: EuiSwitchEvent) => - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -753,7 +949,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field data-test-subj="indexPattern-terms-missing-bucket" checked={Boolean(currentColumn.params.missingBucket)} onChange={(e: EuiSwitchEvent) => - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -791,7 +987,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field currentColumn.params.accuracyMode && currentColumn.params.orderBy.type !== 'rare' )} onChange={(e: EuiSwitchEvent) => - updateLayer( + paramEditorUpdater( updateColumnParam({ layer, columnId, @@ -808,3 +1004,21 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field ); }, }; +function getLabelForRankFunctions(operationType: string) { + switch (operationType) { + case 'last_value': + return [ + i18n.translate('xpack.lens.indexPattern.terms.lastValue.sortRankBy', { + defaultMessage: 'Sort rank by', + }), + ]; + case 'percentile_rank': + return [ + i18n.translate('xpack.lens.indexPattern.terms.percentile.', { + defaultMessage: 'Percentile ranks', + }), + ]; + default: + return; + } +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index 99c20bbd8bca6..33c8fcd1af665 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -22,12 +22,18 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { createMockedIndexPattern } from '../../../mocks'; import { ValuesInput } from './values_input'; import type { TermsIndexPatternColumn } from '.'; -import { GenericOperationDefinition, termsOperation, LastValueIndexPatternColumn } from '..'; +import { + GenericOperationDefinition, + termsOperation, + LastValueIndexPatternColumn, + operationDefinitionMap, +} from '..'; import { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../../../types'; import { FrameDatasourceAPI } from '../../../../types'; import { DateHistogramIndexPatternColumn } from '../date_histogram'; import { getOperationSupportMatrix } from '../../../dimension_panel/operation_support'; import { FieldSelect } from '../../../dimension_panel/field_select'; +import { ReferenceEditor } from '../../../dimension_panel/reference_editor'; // mocking random id generator function jest.mock('@elastic/eui', () => { @@ -65,11 +71,20 @@ const defaultProps = { http: {} as HttpSetup, indexPattern: createMockedIndexPattern(), // need to provide the terms operation as some helpers use operation specific features - operationDefinitionMap: { terms: termsOperation as unknown as GenericOperationDefinition }, + operationDefinitionMap, isFullscreen: false, toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', + ReferenceEditor, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, }; describe('terms', () => { @@ -136,7 +151,8 @@ describe('terms', () => { {} as IndexPattern, layer, uiSettingsMock, - [] + [], + operationDefinitionMap ); expect(esAggsFn).toEqual( expect.objectContaining({ @@ -229,6 +245,59 @@ describe('terms', () => { ); }); + it('should pass orderAgg correctly', () => { + const termsColumn = layer.columns.col1 as TermsIndexPatternColumn; + const esAggsFn = termsOperation.toEsAggsFn( + { + ...termsColumn, + params: { + ...termsColumn.params, + orderAgg: { + label: 'Maximum of price', + dataType: 'number', + operationType: 'max', + sourceField: 'price', + isBucketed: false, + scale: 'ratio', + }, + orderBy: { + type: 'custom', + }, + }, + }, + 'col1', + {} as IndexPattern, + layer, + uiSettingsMock, + [], + operationDefinitionMap + ); + expect(esAggsFn).toEqual( + expect.objectContaining({ + arguments: expect.objectContaining({ + orderAgg: [ + { + chain: [ + { + arguments: { + enabled: [true], + field: ['price'], + id: ['col1-orderAgg'], + schema: ['metric'], + }, + function: 'aggMax', + type: 'function', + }, + ], + type: 'expression', + }, + ], + orderBy: ['custom'], + }), + }) + ); + }); + it('should default percentile rank with non integer value to alphabetical sort', () => { const newLayer = { ...layer, @@ -1801,7 +1870,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1824,7 +1893,7 @@ describe('terms', () => { ...createMockedIndexPattern(), hasRestrictions: true, }} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1839,7 +1908,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1858,7 +1927,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={ { @@ -1885,7 +1954,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={{ ...(layer.columns.col1 as TermsIndexPatternColumn), @@ -1916,7 +1985,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={() => {}} + paramEditorUpdater={() => {}} columnId="col1" currentColumn={ { @@ -1965,7 +2034,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={{ ...(layer.columns.col1 as TermsIndexPatternColumn), @@ -1992,7 +2061,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={ { @@ -2020,7 +2089,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -2056,7 +2125,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -2070,6 +2139,7 @@ describe('terms', () => { 'column$$$col2', 'alphabetical', 'rare', + 'custom', ]); }); @@ -2079,7 +2149,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={ { ...layer.columns.col1, sourceField: 'memory' } as TermsIndexPatternColumn @@ -2094,6 +2164,7 @@ describe('terms', () => { expect(select.prop('options')!.map(({ value }) => value)).toEqual([ 'column$$$col2', 'alphabetical', + 'custom', ]); }); @@ -2103,7 +2174,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -2143,7 +2214,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -2160,7 +2231,7 @@ describe('terms', () => { <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -2183,13 +2254,210 @@ describe('terms', () => { }); }); + it('should render reference editor when order is set to custom metric', () => { + const updateLayerSpy = jest.fn(); + const currentLayer = { + ...layer, + columns: { + ...layer.columns, + col1: { + ...layer.columns.col1, + params: { + ...(layer.columns.col1 as TermsIndexPatternColumn).params, + type: 'custom', + orderDirection: 'desc', + orderAgg: { + label: 'Median of bytes', + dataType: 'number', + operationType: 'median', + isBucketed: false, + scale: 'ratio', + sourceField: 'bytes', + }, + }, + }, + }, + }; + const instance = shallow( + <InlineOptions + {...defaultProps} + layer={currentLayer} + paramEditorUpdater={updateLayerSpy} + columnId="col1" + currentColumn={currentLayer.columns.col1 as TermsIndexPatternColumn} + /> + ); + + expect(instance.find(`ReferenceEditor`)).toHaveLength(1); + + instance + .find(EuiSelect) + .find('[data-test-subj="indexPattern-terms-orderBy"]') + .simulate('change', { + target: { + value: 'column$$$col2', + }, + }); + + expect(updateLayerSpy).toHaveBeenCalledWith({ + ...currentLayer, + columns: { + ...currentLayer.columns, + col1: { + ...currentLayer.columns.col1, + params: { + ...(currentLayer.columns.col1 as TermsIndexPatternColumn).params, + orderAgg: undefined, + orderBy: { + columnId: 'col2', + type: 'column', + }, + }, + }, + }, + }); + }); + + it('should update column when changing the operation for orderAgg', () => { + const updateLayerSpy = jest.fn(); + const currentLayer = { + ...layer, + columns: { + ...layer.columns, + col1: { + ...layer.columns.col1, + params: { + ...(layer.columns.col1 as TermsIndexPatternColumn).params, + type: 'custom', + orderDirection: 'desc', + orderAgg: { + label: 'Median of bytes', + dataType: 'number', + operationType: 'median', + isBucketed: false, + scale: 'ratio', + sourceField: 'bytes', + }, + }, + }, + }, + }; + const instance = mount( + <InlineOptions + {...defaultProps} + layer={currentLayer} + paramEditorUpdater={updateLayerSpy} + columnId="col1" + currentColumn={currentLayer.columns.col1 as TermsIndexPatternColumn} + /> + ); + const refEditor = instance.find(`ReferenceEditor`); + expect(refEditor).toHaveLength(1); + + const functionComboBox = refEditor + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-reference-function"]'); + const option = functionComboBox.prop('options')!.find(({ label }) => label === 'Average')!; + + act(() => { + functionComboBox.prop('onChange')!([option]); + }); + + expect(updateLayerSpy).toHaveBeenCalledWith({ + ...currentLayer, + columns: { + ...currentLayer.columns, + col1: { + ...currentLayer.columns.col1, + params: { + ...(currentLayer.columns.col1 as TermsIndexPatternColumn).params, + orderAgg: expect.objectContaining({ + dataType: 'number', + isBucketed: false, + label: 'Average of bytes', + operationType: 'average', + sourceField: 'bytes', + }), + }, + }, + }, + }); + }); + + it('should update column when changing the field for orderAgg', () => { + const updateLayerSpy = jest.fn(); + const currentLayer = { + ...layer, + columns: { + ...layer.columns, + col1: { + ...layer.columns.col1, + params: { + ...(layer.columns.col1 as TermsIndexPatternColumn).params, + type: 'custom', + orderDirection: 'desc', + orderAgg: { + label: 'Median of bytes', + dataType: 'number', + operationType: 'median', + isBucketed: false, + scale: 'ratio', + sourceField: 'bytes', + }, + }, + }, + }, + }; + const instance = mount( + <InlineOptions + {...defaultProps} + layer={currentLayer} + paramEditorUpdater={updateLayerSpy} + columnId="col1" + currentColumn={currentLayer.columns.col1 as TermsIndexPatternColumn} + /> + ); + const refEditor = instance.find(`ReferenceEditor`); + expect(refEditor).toHaveLength(1); + + const comboBoxes = refEditor.find(EuiComboBox); + + const fieldComboBox = comboBoxes.filter('[data-test-subj="indexPattern-dimension-field"]'); + + const option = fieldComboBox + .prop('options')[0] + .options!.find(({ label }) => label === 'memory')!; + act(() => { + fieldComboBox.prop('onChange')!([option]); + }); + expect(updateLayerSpy).toHaveBeenCalledWith({ + ...currentLayer, + columns: { + ...currentLayer.columns, + col1: { + ...currentLayer.columns.col1, + params: { + ...(currentLayer.columns.col1 as TermsIndexPatternColumn).params, + orderAgg: expect.objectContaining({ + dataType: 'number', + isBucketed: false, + label: 'Median of memory', + operationType: 'median', + sourceField: 'memory', + }), + }, + }, + }, + }); + }); + it('should render current size value', () => { const updateLayerSpy = jest.fn(); const instance = mount( <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -2198,13 +2466,64 @@ describe('terms', () => { expect(instance.find(EuiFieldNumber).prop('value')).toEqual('3'); }); + it('should not update the column when the change creates incomplete column', () => { + const updateLayerSpy = jest.fn(); + const currentLayer = { + ...layer, + columns: { + ...layer.columns, + col1: { + ...layer.columns.col1, + params: { + ...(layer.columns.col1 as TermsIndexPatternColumn).params, + type: 'custom', + orderDirection: 'desc', + orderAgg: { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: '___records___', + }, + }, + }, + }, + }; + const instance = mount( + <InlineOptions + {...defaultProps} + layer={currentLayer} + paramEditorUpdater={updateLayerSpy} + columnId="col1" + currentColumn={currentLayer.columns.col1 as TermsIndexPatternColumn} + /> + ); + const refEditor = instance.find(`ReferenceEditor`); + expect(refEditor).toHaveLength(1); + + const comboBoxes = refEditor.find(EuiComboBox); + + const functionComboBox = comboBoxes.filter( + '[data-test-subj="indexPattern-reference-function"]' + ); + const fieldComboBox = comboBoxes.filter('[data-test-subj="indexPattern-dimension-field"]'); + const option = functionComboBox.prop('options')!.find(({ label }) => label === 'Average')!; + act(() => { + functionComboBox.prop('onChange')!([option]); + }); + + expect(fieldComboBox.prop('isInvalid')).toBeTruthy(); + expect(updateLayerSpy).not.toHaveBeenCalled(); + }); + it('should update state with the size value', () => { const updateLayerSpy = jest.fn(); const instance = mount( <InlineOptions {...defaultProps} layer={layer} - updateLayer={updateLayerSpy} + paramEditorUpdater={updateLayerSpy} columnId="col1" currentColumn={layer.columns.col1 as TermsIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts index a292faabec742..35a9c7e02147c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/types.ts @@ -18,7 +18,9 @@ export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { orderBy: | { type: 'alphabetical'; fallback?: boolean } | { type: 'rare'; maxDocCount: number } - | { type: 'column'; columnId: string }; + | { type: 'column'; columnId: string } + | { type: 'custom' }; + orderAgg?: FieldBasedIndexPatternColumn; orderDirection: 'asc' | 'desc'; otherBucket?: boolean; missingBucket?: boolean; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx index 3130f2b8a5265..b244bdd54aad3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx @@ -77,6 +77,7 @@ export const ValuesInput = ({ } > <EuiFieldNumber + fullWidth min={minValue} max={maxValue} step={1} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index ab9319957afca..c89ec6ae02199 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -41,6 +41,9 @@ import { CoreStart } from '@kbn/core/public'; jest.mock('.'); jest.mock('../../id_generator'); +jest.mock('../dimension_panel/reference_editor', () => ({ + ReferenceEditor: () => null, +})); const indexPatternFields = [ { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 434370943fbc1..a1edd6132d22a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -503,17 +503,14 @@ export function replaceColumn({ tempLayer = { ...tempLayer, + columnOrder: getColumnOrder(tempLayer), columns: { ...tempLayer.columns, [columnId]: column, }, }; return updateDefaultLabels( - { - ...tempLayer, - columnOrder: getColumnOrder(tempLayer), - columns: adjustColumnReferencesForChangedColumn(tempLayer, columnId), - }, + adjustColumnReferencesForChangedColumn(tempLayer, columnId), indexPattern ); } else if ( @@ -573,11 +570,14 @@ export function replaceColumn({ } return updateDefaultLabels( - { - ...tempLayer, - columnOrder: getColumnOrder(newLayer), - columns: adjustColumnReferencesForChangedColumn(newLayer, columnId), - }, + adjustColumnReferencesForChangedColumn( + { + ...tempLayer, + columnOrder: getColumnOrder(newLayer), + columns: newLayer.columns, + }, + columnId + ), indexPattern ); } @@ -592,13 +592,18 @@ export function replaceColumn({ indexPattern ); - const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } }; + const newLayer = { + ...tempLayer, + columns: { ...tempLayer.columns, [columnId]: newColumn }, + }; return updateDefaultLabels( - { - ...tempLayer, - columnOrder: getColumnOrder(newLayer), - columns: adjustColumnReferencesForChangedColumn(newLayer, columnId), - }, + adjustColumnReferencesForChangedColumn( + { + ...newLayer, + columnOrder: getColumnOrder(newLayer), + }, + columnId + ), indexPattern ); } @@ -650,11 +655,13 @@ export function replaceColumn({ } const newLayer = { ...tempLayer, columns: { ...tempLayer.columns, [columnId]: newColumn } }; return updateDefaultLabels( - { - ...tempLayer, - columnOrder: getColumnOrder(newLayer), - columns: adjustColumnReferencesForChangedColumn(newLayer, columnId), - }, + adjustColumnReferencesForChangedColumn( + { + ...newLayer, + columnOrder: getColumnOrder(newLayer), + }, + columnId + ), indexPattern ); } else if ( @@ -677,11 +684,13 @@ export function replaceColumn({ { ...layer, columns: { ...layer.columns, [columnId]: newColumn } }, columnId ); - return { - ...newLayer, - columnOrder: getColumnOrder(newLayer), - columns: adjustColumnReferencesForChangedColumn(newLayer, columnId), - }; + return adjustColumnReferencesForChangedColumn( + { + ...newLayer, + columnOrder: getColumnOrder(newLayer), + }, + columnId + ); } else { throw new Error('nothing changed'); } @@ -836,11 +845,13 @@ function applyReferenceTransition({ }, }, }; - layer = { - ...layer, - columnOrder: getColumnOrder(newLayer), - columns: adjustColumnReferencesForChangedColumn(newLayer, newId), - }; + layer = adjustColumnReferencesForChangedColumn( + { + ...newLayer, + columnOrder: getColumnOrder(newLayer), + }, + newId + ); return newId; } @@ -977,11 +988,13 @@ function applyReferenceTransition({ }, }; return updateDefaultLabels( - { - ...layer, - columnOrder: getColumnOrder(layer), - columns: adjustColumnReferencesForChangedColumn(layer, columnId), - }, + adjustColumnReferencesForChangedColumn( + { + ...layer, + columnOrder: getColumnOrder(layer), + }, + columnId + ), indexPattern ); } @@ -1044,11 +1057,13 @@ function addBucket( columns: { ...layer.columns, [addedColumnId]: column }, columnOrder: updatedColumnOrder, }; - return { - ...tempLayer, - columns: adjustColumnReferencesForChangedColumn(tempLayer, addedColumnId), - columnOrder: getColumnOrder(tempLayer), - }; + return adjustColumnReferencesForChangedColumn( + { + ...tempLayer, + columnOrder: getColumnOrder(tempLayer), + }, + addedColumnId + ); } export function reorderByGroups( @@ -1108,11 +1123,13 @@ function addMetric( [addedColumnId]: column, }, }; - return { - ...tempLayer, - columnOrder: getColumnOrder(tempLayer), - columns: adjustColumnReferencesForChangedColumn(tempLayer, addedColumnId), - }; + return adjustColumnReferencesForChangedColumn( + { + ...tempLayer, + columnOrder: getColumnOrder(tempLayer), + }, + addedColumnId + ); } export function getMetricOperationTypes(field: IndexPatternField) { @@ -1146,7 +1163,7 @@ export function updateColumnLabel<C extends GenericIndexPatternColumn>({ }; } -export function updateColumnParam<C extends GenericIndexPatternColumn>({ +export function updateColumnParam({ layer, columnId, paramName, @@ -1157,15 +1174,15 @@ export function updateColumnParam<C extends GenericIndexPatternColumn>({ paramName: string; value: unknown; }): IndexPatternLayer { - const oldColumn = layer.columns[columnId]; + const currentColumn = layer.columns[columnId]; return { ...layer, columns: { ...layer.columns, [columnId]: { - ...oldColumn, + ...currentColumn, params: { - ...('params' in oldColumn ? oldColumn.params : {}), + ...('params' in currentColumn ? currentColumn.params : {}), [paramName]: value, }, }, @@ -1210,7 +1227,10 @@ export function adjustColumnReferencesForChangedColumn( : currentColumn; } }); - return newColumns; + return { + ...layer, + columns: newColumns, + }; } export function deleteColumn({ @@ -1238,13 +1258,13 @@ export function deleteColumn({ const hypotheticalColumns = { ...layer.columns }; delete hypotheticalColumns[columnId]; - let newLayer = { - ...layer, - columns: adjustColumnReferencesForChangedColumn( - { ...layer, columns: hypotheticalColumns }, - columnId - ), - }; + let newLayer = adjustColumnReferencesForChangedColumn( + { + ...layer, + columns: hypotheticalColumns, + }, + columnId + ); extraDeletions.forEach((id) => { newLayer = deleteColumn({ layer: newLayer, columnId: id, indexPattern }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts index 82093b31a09d9..396bf78f82db6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts @@ -39,7 +39,7 @@ export function getOperations(): OperationType[] { /** * Returns a list of the display names of all operations with any guaranteed order. */ -export function getOperationDisplay() { +export const getOperationDisplay = memoize(() => { const display = {} as Record< OperationType, { @@ -54,7 +54,7 @@ export function getOperationDisplay() { }; }); return display; -} +}); export function getSortScoreByPriority( a: GenericOperationDefinition, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 6cfab965f36a9..8e59f299c6917 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -130,7 +130,8 @@ function getExpressionForLayer( indexPattern, layer, uiSettings, - orderedColumnIds + orderedColumnIds, + operationDefinitionMap ); if (wrapInFilter) { aggAst = buildExpressionFunction<AggFunctionsMapping['aggFilteredMetric']>( diff --git a/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx b/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx index 936179c993d4c..f5558850d6af0 100644 --- a/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx +++ b/x-pack/plugins/lens/public/shared_components/collapse_setting.tsx @@ -46,6 +46,7 @@ export function CollapseSetting({ fullWidth > <EuiSelect + fullWidth compressed data-test-subj="indexPattern-terms-orderBy" options={options} diff --git a/x-pack/plugins/lens/public/shared_components/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx index a048439856ac5..efd1caba7e4da 100644 --- a/x-pack/plugins/lens/public/shared_components/palette_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx @@ -44,6 +44,7 @@ export function PalettePicker({ > <> <EuiColorPalettePicker + fullWidth data-test-subj="lns-palettePicker" compressed palettes={palettesToShow} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 770f4bee7eecd..2305e7215ccfc 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -430,7 +430,10 @@ export type DatasourceDimensionProps<T> = SharedDimensionProps & { invalid?: boolean; invalidMessage?: string; }; -export type ParamEditorCustomProps = Record<string, unknown> & { label?: string }; +export type ParamEditorCustomProps = Record<string, unknown> & { + labels?: string[]; + isInline?: boolean; +}; // The only way a visualization has to restrict the query building export type DatasourceDimensionEditorProps<T = unknown> = DatasourceDimensionProps<T> & { // Not a StateSetter because we have this unique use case of determining valid columns diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx index 193a91523ecc8..42717a3894303 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx @@ -458,9 +458,11 @@ export const getReferenceConfiguration = ({ enableDimensionEditor: true, supportStaticValue: true, paramEditorCustomProps: { - label: i18n.translate('xpack.lens.indexPattern.staticValue.label', { - defaultMessage: 'Reference line value', - }), + labels: [ + i18n.translate('xpack.lens.indexPattern.staticValue.label', { + defaultMessage: 'Reference line value', + }), + ], }, supportFieldFormat: false, dataTestSubj, diff --git a/x-pack/plugins/lens/readme.md b/x-pack/plugins/lens/readme.md index 84cea6feead06..340931efdcd52 100644 --- a/x-pack/plugins/lens/readme.md +++ b/x-pack/plugins/lens/readme.md @@ -1,12 +1,118 @@ -# Lens +Lens is a visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. -Visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. - -## Embedding +# Lens Embedding It's possible to embed Lens visualizations in other apps using `EmbeddableComponent` and `navigateToPrefilledEditor` exposed via contract. For more information check out the example in `x-pack/examples/embedded_lens_example`. +### Embedding guidance + +When adding visualizations to a solution page, there are multiple ways to approach this with pros and cons: + +* #### **Use a dashboard** + If the app page you are planning to build strongly resembles a regular dashboard, it might not even be necessary to write code - configuring a dashboard might be a better choice. The Presentation team is currently working on making it possible to embed dashboard into the solution navigation, which allows you to offer visualization and filter functionality without writing custom code. If possible this option should be chosen because of the low maintenance and development effort as well as the high flexibility for a user to clone the preset dashboard and start customizing it in various ways. + + Pros: + * No need to write and maintain custom code + * "Open in Lens" comes for free + * Ability for the user to customize/add/remove dashboard panels comes for free + + Cons: + * Limited data processing/visualization options - if the dashboard doesn't support it, it can't be used +* #### **Use Lens embeddable** + Using the Lens embeddable is an easy way to handle the rendering of charts. It allows you to specify data fetching and presentational properties of the final chart in a declarative way (the "Lens attributes") - everything is handled within the component (including re-fetching data on changing inputs). By using the `navigateToPrefilledEditor` method which takes the same configuration as the embeddable component, adding an "Open in Lens editor" button to your application comes at almost no additional cost. Such a button is always recommended as it allows a user to drill down further and explore the data on their own, using the current chart as a starting point. This approach is already widely deployed and should be the default choice for new visualizations. + + Pros: + * No need to manage searches and rendering logic on your own + * "Open in Lens" comes for free + + Cons: + * Each panel does its own data fetching and rendering (can lead to performance problems for high number of embeddables on a single page, e.g. more than 20) + * Limited data processing options - if the Lens UI doesn't support it, it can't be used + * Limited visualization options - if Lens can't do it, it's not possible +* #### **Using custom data fetching and rendering** + In case the disadvantages of using the Lens embeddable heavily affect your use case, it sometimes makes sense to roll your own data fetching and rendering by using the underlying APIs of search service and `elastic-charts` directly. This allows a high degree of flexibility when it comes to data processing, efficiently querying data for multiple charts in a single query and adjusting small details in how charts are rendered. However, do not choose these option lightly as maintenance as well as initial development effort will most likely be much higher than by using the Lens embeddable directly. In this case, almost always an "Open in Lens" button can still be offered to the user to drill down and further explore the data by generating a Lens configuration which is similar to the displayed visualization given the possibilities of Lens. Keep in mind that for the "Open in Lens" flow, the most important property isn't perfect fidelity of the chart but retaining the mental context of the user when switching so they don't have to start over. It's also possible to mix this approach with Lens embeddables on a single page. **Note**: In this situation, please let the VisEditors team know what features you are missing / why you chose not to use Lens. + + Pros: + * Full flexibility in data fetching optimization and chart rendering + + Cons: + * "Open in Lens" requires additional logic + * High maintenance and development effort + +## Getting started + +The `EmbeddableComponent` react component is exposed on the Lens plugin contract. In order to use it, +* Make sure you have a data view created for the data you plan to work with +* Add `lens` to `requiredPlugins` in your plugins `kibana.json` +* In the mount callback of your app, get `lens.EmbeddableComponent` from the start contract and pass it into your apps react tree +* In the place where you want to render a visualization, add the component to the tree: +```tsx +<div> + // my app + <EmbeddableComponent + id="" + style={{ height: 500 }} + timeRange={{ from: 'now - 15m', to: 'now' }} + attributes={attributes} + /> +</div> +``` + +You can see a working example of this in the `x-pack/examples/embedded_lens_example` directory. + +The `attributes` variable contains the configuration for the Lens visualization. The details are explained in the section below. It's difficult to set up this object manually, in order to quickly get to a functioning starting point, start your Kibana server with example plugins via +``` +yarn start --run-examples +``` + +This will add an `Open in Playground` action to the menu bar in the Lens editor. With this option, try to configure the chart configuration directly in the editor, then open it in the playground to see the attributes object to copy. This works for any possible Lens visualization. + +![Go to playground](./to_playground.gif "Go to playground") + +## Lens attributes explained + +The Lens attributes object contains multiple sections concerned with different aspects of the visualizations. + +On a high level there are references, datasource state, visualization state and filters: + +### References + +References (`references`) are regular saved object references forming a graph of saved objects which depend on each other. For the Lens case, these references are always data views (called `type: "index-pattern"`) in code, referencing data views which are used in the current Lens visualization. Often there is just a single data view in use, but it's possible to use multiple data views for multiple layers in a Lens xy chart. The `id` of a reference needs to be the saved object id of the referenced data view (see the "Handling data views" section below). The `name` of the reference is comprised out of multiple parts used to map the data view to the correct layer : `indexpattern-datasource-layer-<id of the layer>`. Even if multiple layers are using the same data view, there has to be one reference per layer (all pointing to the same data view id). + +### Datasource state + +The data source state (`state.datasourceStates.indexPattern.layers`) contains the configuration state of the data fetching and processing part of Lens. It's not specific to a certain representation (xy, pie, gauge, ...), but instead it defines a data table per layer made out of columns with various properties. This data table is passed over to the visualization state which maps it to various dimensions of the specific visualization. Layer and columns have unique ids which are shared amongst visualization and datasource - it's important to make sure they are always in sync. The keys of the `state.datasourceStates.indexPattern.layers` object are the layer ids. Lens editor chooses uuids for these, but when programmatically generating Lens attributes, any string can be used for them. The `layers[<layer id>].columns` object is constructed in a similar way (keys represent the column ids). The `operationType` property defines the type of the column, other properties depend on the specific operation. Types for individual parts of the datasource state are provided (check the `lens/public` export, e.g. there's the `MaxIndexPatternColumn` for a column of operation type `max`) + +### Visualization state + +The visualization state (`state.visualization`) depends on the chosen visualization type (`visualizationType`). Layer ids and accessor properties in this state have to correspond to the layer ids and column ids of the datasource state. Types for individual visualizations are exported as standalone interfaces (e.g. `XYState` or `HeatmapVisualizationState`). + +### Filters + +Filters and query `state.filters`/`state.query` define the visualization-global filters and query applied to all layers of the visualization. The query is rendered in the top level search bar in the editor while filters are rendered as filter pills. Filters and query state defined this way is used for dashboards and Discover in the same way. + +### Callbacks + +The `EmbeddableComponent` also takes a set of callbacks to react to user interactions with the embedded Lens visualization to integrate the visualization with the surrounding app: `onLoad`, `onBrushEnd`, `onFilter`, `onTableRowClick`. A common pattern is to keep state in the solution app which is updated within these callbacks - re-rendering the surrounding application will change the Lens attributes passed to the component which will re-render the visualization (including re-fetching data if necessary). + +## Handling data views + +Currently it's necessary to have a data view saved object to use the Lens embeddable. Use the data view service to find an existing data view for a given index pattern or create a new one if it doesn't exist yet: +```ts +let dataView = (await dataViews.find('my-pattern-*', 1))[0]; +if (!dataView) { + dataView = await dataViews.createAndSave({ + title: 'my-pattern-*', + timeFieldName: '@timestamp' + }); +} +const dataViewIdForLens = dataView.id; +``` + +# Lens Development + +The following sections are concerned with developing the Lens plugin itself. ## Testing Run all tests from the `x-pack` root directory diff --git a/x-pack/plugins/lens/to_playground.gif b/x-pack/plugins/lens/to_playground.gif new file mode 100644 index 0000000000000..56ce6f3975e53 Binary files /dev/null and b/x-pack/plugins/lens/to_playground.gif differ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx index 05b2b5859d275..b394b085a7c75 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -11,6 +11,7 @@ import { fireEvent } from '@testing-library/dom'; import { AddToCaseAction } from './add_to_case_action'; import * as useCaseHook from '../hooks/use_add_to_case'; import * as datePicker from '../components/date_range_picker'; +import * as useGetUserCasesPermissionsModule from '../../../../hooks/use_get_user_cases_permissions'; import moment from 'moment'; describe('AddToCaseAction', function () { @@ -81,6 +82,10 @@ describe('AddToCaseAction', function () { }); it('should be able to click add to case button', async function () { + const mockUseGetUserCasesPermissions = jest + .spyOn(useGetUserCasesPermissionsModule, 'useGetUserCasesPermissions') + .mockImplementation(() => ({ crud: false, read: false })); + const initSeries = { data: [ { @@ -106,8 +111,13 @@ describe('AddToCaseAction', function () { expect(core?.cases?.ui.getAllCasesSelectorModal).toHaveBeenCalledWith( expect.objectContaining({ owner: ['observability'], - userCanCrud: true, + permissions: { + all: false, + read: false, + }, }) ); + + mockUseGetUserCasesPermissions.mockRestore(); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx index 17cde17d8b7f9..118451b302948 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx @@ -15,6 +15,7 @@ import { GetAllCasesSelectorModalProps, } from '@kbn/cases-plugin/public'; import { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions'; import { ObservabilityAppServices } from '../../../../application/types'; import { useAddToCase } from '../hooks/use_add_to_case'; import { observabilityFeatureId, observabilityAppId } from '../../../../../common'; @@ -38,6 +39,8 @@ export function AddToCaseAction({ timeRange, }: AddToCaseProps) { const kServices = useKibana<ObservabilityAppServices>().services; + const userPermissions = useGetUserCasesPermissions(); + const casesPermissions = { all: userPermissions.crud, read: userPermissions.read }; const { cases, @@ -74,8 +77,8 @@ export function AddToCaseAction({ }); const getAllCasesSelectorModalProps: GetAllCasesSelectorModalProps = { + permissions: casesPermissions, onRowClick: onCaseClicked, - userCanCrud: true, owner: [owner], onClose: () => { setIsCasesOpen(false); diff --git a/x-pack/plugins/observability/public/hooks/use_get_user_cases_permissions.tsx b/x-pack/plugins/observability/public/hooks/use_get_user_cases_permissions.tsx index 2827f89626794..83481c6d1f2f4 100644 --- a/x-pack/plugins/observability/public/hooks/use_get_user_cases_permissions.tsx +++ b/x-pack/plugins/observability/public/hooks/use_get_user_cases_permissions.tsx @@ -15,7 +15,10 @@ export interface UseGetUserCasesPermissions { } export function useGetUserCasesPermissions() { - const [casesPermissions, setCasesPermissions] = useState<UseGetUserCasesPermissions | null>(null); + const [casesPermissions, setCasesPermissions] = useState<UseGetUserCasesPermissions>({ + crud: false, + read: false, + }); const uiCapabilities = useKibana().services.application.capabilities; useEffect(() => { diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index 0ead6468ad0b0..d903876c79f86 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -220,6 +220,7 @@ function AlertsPage() { const CasesContext = cases.ui.getCasesContext(); const userPermissions = useGetUserCasesPermissions(); + const casesPermissions = { all: userPermissions.crud, read: userPermissions.read }; if (!hasAnyData && !isAllRequestsComplete) { return <LoadingObservability />; @@ -265,7 +266,7 @@ function AlertsPage() { <EuiFlexItem> <CasesContext owner={[observabilityFeatureId]} - userCanCrud={userPermissions?.crud ?? false} + permissions={casesPermissions} features={{ alerts: { sync: false } }} > <AlertsTableTGrid diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx index 0f2d23967dbcf..1b6f0b1860045 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx @@ -201,7 +201,7 @@ function ObservabilityActions({ const actionsMenuItems = useMemo(() => { return [ - ...(casePermissions?.crud + ...(casePermissions.crud ? [ <EuiContextMenuItem data-test-subj="add-to-existing-case-action" @@ -246,7 +246,7 @@ function ObservabilityActions({ ], ]; }, [ - casePermissions?.crud, + casePermissions.crud, handleAddToExistingCaseClick, handleAddToNewCaseClick, linkToRule, diff --git a/x-pack/plugins/observability/public/pages/cases/cases.tsx b/x-pack/plugins/observability/public/pages/cases/cases.tsx index 0898f4aa8d071..e2d853efbf45f 100644 --- a/x-pack/plugins/observability/public/pages/cases/cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/cases.tsx @@ -13,17 +13,19 @@ import { usePluginContext } from '../../hooks/use_plugin_context'; import { LazyAlertsFlyout } from '../..'; import { useFetchAlertDetail } from './use_fetch_alert_detail'; import { useFetchAlertData } from './use_fetch_alert_data'; +import { UseGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; interface CasesProps { - userCanCrud: boolean; + permissions: UseGetUserCasesPermissions; } -export const Cases = React.memo<CasesProps>(({ userCanCrud }) => { +export const Cases = React.memo<CasesProps>(({ permissions }) => { const { cases, application: { getUrlForApp, navigateToApp }, } = useKibana().services; const { observabilityRuleTypeRegistry } = usePluginContext(); const [selectedAlertId, setSelectedAlertId] = useState<string>(''); + const casesPermissions = { all: permissions.crud, read: permissions.read }; const handleFlyoutClose = useCallback(() => { setSelectedAlertId(''); @@ -44,7 +46,7 @@ export const Cases = React.memo<CasesProps>(({ userCanCrud }) => { )} {cases.ui.getCases({ basePath: CASES_PATH, - userCanCrud, + permissions: casesPermissions, owner: [CASES_OWNER], features: { alerts: { sync: false } }, useFetchAlertData, diff --git a/x-pack/plugins/observability/public/pages/cases/index.tsx b/x-pack/plugins/observability/public/pages/cases/index.tsx index 35651e29a6f7c..a3d1505d8f488 100644 --- a/x-pack/plugins/observability/public/pages/cases/index.tsx +++ b/x-pack/plugins/observability/public/pages/cases/index.tsx @@ -38,13 +38,13 @@ export const CasesPage = React.memo(() => { docsLink: docLinks.links.observability.guide, }); - return userPermissions == null || userPermissions?.read ? ( + return userPermissions.read ? ( <ObservabilityPageTemplate isPageDataLoaded={Boolean(hasAnyData || isAllRequestsComplete)} data-test-subj={noDataConfig ? 'noDataPage' : undefined} noDataConfig={noDataConfig} > - <Cases userCanCrud={userPermissions?.crud ?? false} /> + <Cases permissions={userPermissions} /> </ObservabilityPageTemplate> ) : ( <CaseFeatureNoPermissions /> diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 19f855b760cd4..dc0a6c9667400 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -128,6 +128,7 @@ export function OverviewPage({ routeParams }: Props) { const CasesContext = cases.ui.getCasesContext(); const userPermissions = useGetUserCasesPermissions(); + const casesPermissions = { all: userPermissions.crud, read: userPermissions.read }; useEffect(() => { if (hasAnyData !== true) { @@ -199,7 +200,7 @@ export function OverviewPage({ routeParams }: Props) { > <CasesContext owner={[observabilityFeatureId]} - userCanCrud={userPermissions?.crud ?? false} + permissions={casesPermissions} features={{ alerts: { sync: false } }} > <AlertsTableTGrid diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx deleted file mode 100644 index 707c1204d1564..0000000000000 --- a/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx +++ /dev/null @@ -1,90 +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 from 'react'; -import { mount } from 'enzyme'; -import { nextTick } from '@kbn/test-jest-helpers'; -import { act } from 'react-dom/test-utils'; -import { Actions } from './actions'; -import { observabilityPublicPluginsStartMock } from '../../../observability_public_plugins_start.mock'; -import { kibanaStartMock } from '../../../utils/kibana_react.mock'; - -const mockUseKibanaReturnValue = kibanaStartMock.startContract(); - -jest.mock('../../../utils/kibana_react', () => ({ - __esModule: true, - useKibana: jest.fn(() => mockUseKibanaReturnValue), -})); - -jest.mock('@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api', () => ({ - loadAllActions: jest.fn(), -})); - -describe('Actions', () => { - async function setup() { - const ruleActions = [ - { - id: 1, - group: 'metrics.inventory_threshold.fired', - actionTypeId: '.server-log', - }, - { - id: 2, - group: 'metrics.inventory_threshold.fired', - actionTypeId: '.slack', - }, - ]; - const { loadAllActions } = jest.requireMock( - '@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api' - ); - loadAllActions.mockResolvedValueOnce([ - { - id: 'a0d2f6c0-e682-11ec-843b-213c67313f8c', - name: 'Email', - config: {}, - actionTypeId: '.email', - }, - { - id: 'f57cabc0-e660-11ec-8241-7deb55b17f15', - name: 'logs', - config: {}, - actionTypeId: '.server-log', - }, - { - id: '05b7ab30-e683-11ec-843b-213c67313f8c', - name: 'Slack', - actionTypeId: '.slack', - }, - ]); - - const actionTypeRegistryMock = - observabilityPublicPluginsStartMock.createStart().triggersActionsUi.actionTypeRegistry; - actionTypeRegistryMock.list.mockReturnValue([ - { id: '.server-log', iconClass: 'logsApp' }, - { id: '.slack', iconClass: 'logoSlack' }, - { id: '.email', iconClass: 'email' }, - { id: '.index', iconClass: 'indexOpen' }, - ]); - const wrapper = mount( - <Actions ruleActions={ruleActions} actionTypeRegistry={actionTypeRegistryMock} /> - ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); - return wrapper; - } - - it("renders action connector icons for user's selected rule actions", async () => { - const wrapper = await setup(); - wrapper.debug(); - expect(wrapper.find('[data-euiicon-type]').length).toBe(2); - expect(wrapper.find('[data-euiicon-type="logsApp"]').length).toBe(1); - expect(wrapper.find('[data-euiicon-type="logoSlack"]').length).toBe(1); - expect(wrapper.find('[data-euiicon-type="index"]').length).toBe(0); - expect(wrapper.find('[data-euiicon-type="email"]').length).toBe(0); - }); -}); diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/index.ts b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts index 8020af09dedc2..c4635fdca3e97 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/index.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts @@ -8,4 +8,3 @@ export { PageTitle } from './page_title'; export { ItemTitleRuleSummary } from './item_title_rule_summary'; export { ItemValueRuleSummary } from './item_value_rule_summary'; -export { Actions } from './actions'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 8cfc26493164a..6dc520cce971e 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -42,6 +42,7 @@ import { import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; import { AlertConsumers } from '@kbn/rule-data-utils'; +import { RuleDefinitionProps } from '@kbn/triggers-actions-ui-plugin/public'; import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation'; import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner'; import { OBSERVABILITY_SOLUTIONS } from '../rules/config'; @@ -50,11 +51,10 @@ import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useFetchRule } from '../../hooks/use_fetch_rule'; import { RULES_BREADCRUMB_TEXT } from '../rules/translations'; -import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from './components'; +import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary } from './components'; import { useKibana } from '../../utils/kibana_react'; import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts'; import { useFetchLast24hRuleExecutionLog } from '../../hooks/use_fetch_last24h_rule_execution_log'; -import { formatInterval } from './utils'; import { hasExecuteActionsCapability, hasAllPrivilege } from './config'; import { paths } from '../../config/paths'; import { observabilityFeatureId } from '../../../common'; @@ -67,9 +67,9 @@ export function RuleDetailsPage() { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout, - actionTypeRegistry, getRuleEventLogList, getAlertsStateTable, + getRuleDefinition, }, application: { capabilities, navigateToUrl }, notifications: { toasts }, @@ -79,7 +79,7 @@ export function RuleDetailsPage() { const { ObservabilityPageTemplate } = usePluginContext(); const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); const { isLoadingExecutionLog, executionLog } = useFetchLast24hRuleExecutionLog({ http, ruleId }); - const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({ + const { ruleTypes } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS, }); @@ -160,19 +160,6 @@ export function RuleDetailsPage() { ? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext : false); - const getRuleConditionsWording = () => { - const numberOfConditions = rule?.params.criteria ? (rule?.params.criteria as any[]).length : 0; - return ( - <> - {numberOfConditions}  - {i18n.translate('xpack.observability.ruleDetails.conditions', { - defaultMessage: 'condition{s}', - values: { s: numberOfConditions > 1 ? 's' : '' }, - })} - </> - ); - }; - const alertStateProps = { alertsTableConfigurationRegistry, configurationId: observabilityFeatureId, @@ -257,9 +244,6 @@ export function RuleDetailsPage() { unsnoozeRule: async (scheduleIds) => await unsnoozeRule({ http, id: rule.id, scheduleIds }), }); - const getNotifyText = () => - NOTIFY_WHEN_OPTIONS.current.find((option) => option.value === rule?.notifyWhen)?.inputDisplay || - rule.notifyWhen; return ( <ObservabilityPageTemplate data-test-subj="ruleDetails" @@ -412,113 +396,7 @@ export function RuleDetailsPage() { </EuiFlexGroup> </EuiPanel> </EuiFlexItem> - - {/* Right side of Rule Summary */} - - <EuiFlexItem data-test-subj="ruleSummaryRuleDefinition" grow={3}> - <EuiPanel color="subdued" hasBorder={false} paddingSize={'m'}> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiTitle size="s"> - <EuiFlexItem grow={false}> - {i18n.translate('xpack.observability.ruleDetails.definition', { - defaultMessage: 'Definition', - })} - </EuiFlexItem> - </EuiTitle> - {hasEditButton && ( - <EuiFlexItem grow={false}> - <EuiButtonEmpty iconType={'pencil'} onClick={() => setEditFlyoutVisible(true)} /> - </EuiFlexItem> - )} - </EuiFlexGroup> - - <EuiSpacer size="m" /> - - <EuiFlexGroup alignItems="baseline"> - <EuiFlexItem> - <EuiFlexGroup> - <ItemTitleRuleSummary> - {i18n.translate('xpack.observability.ruleDetails.ruleType', { - defaultMessage: 'Rule type', - })} - </ItemTitleRuleSummary> - <ItemValueRuleSummary - data-test-subj="ruleSummaryRuleType" - itemValue={ruleTypeIndex.get(rule.ruleTypeId)?.name || rule.ruleTypeId} - /> - </EuiFlexGroup> - - <EuiSpacer size="m" /> - - <EuiFlexGroup alignItems="flexStart" responsive={false}> - <ItemTitleRuleSummary> - {i18n.translate('xpack.observability.ruleDetails.description', { - defaultMessage: 'Description', - })} - </ItemTitleRuleSummary> - <ItemValueRuleSummary - itemValue={ruleTypeRegistry.get(rule.ruleTypeId).description} - /> - </EuiFlexGroup> - - <EuiSpacer size="m" /> - - <EuiFlexGroup> - <ItemTitleRuleSummary> - {i18n.translate('xpack.observability.ruleDetails.conditionsTitle', { - defaultMessage: 'Conditions', - })} - </ItemTitleRuleSummary> - <EuiFlexItem grow={3}> - <EuiFlexGroup alignItems="center"> - {hasEditButton ? ( - <EuiButtonEmpty onClick={() => setEditFlyoutVisible(true)}> - <EuiText size="s">{getRuleConditionsWording()}</EuiText> - </EuiButtonEmpty> - ) : ( - <EuiText size="s">{getRuleConditionsWording()}</EuiText> - )} - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - <EuiFlexItem> - <EuiFlexGroup> - <ItemTitleRuleSummary> - {i18n.translate('xpack.observability.ruleDetails.runsEvery', { - defaultMessage: 'Runs every', - })} - </ItemTitleRuleSummary> - - <ItemValueRuleSummary itemValue={formatInterval(rule.schedule.interval)} /> - </EuiFlexGroup> - - <EuiSpacer size="m" /> - - <EuiFlexGroup> - <ItemTitleRuleSummary> - {i18n.translate('xpack.observability.ruleDetails.notifyWhen', { - defaultMessage: 'Notify', - })} - </ItemTitleRuleSummary> - <ItemValueRuleSummary itemValue={String(getNotifyText())} /> - </EuiFlexGroup> - - <EuiSpacer size="m" /> - <EuiFlexGroup alignItems="baseline"> - <ItemTitleRuleSummary> - {i18n.translate('xpack.observability.ruleDetails.actions', { - defaultMessage: 'Actions', - })} - </ItemTitleRuleSummary> - <EuiFlexItem grow={3}> - <Actions ruleActions={rule.actions} actionTypeRegistry={actionTypeRegistry} /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </EuiPanel> - </EuiFlexItem> + {getRuleDefinition({ rule, onEditRule: () => reloadRule() } as RuleDefinitionProps)} </EuiFlexGroup> <EuiSpacer size="l" /> diff --git a/x-pack/plugins/observability/public/pages/rule_details/translations.ts b/x-pack/plugins/observability/public/pages/rule_details/translations.ts index bda8284c31a9e..579a709c0857c 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/translations.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/translations.ts @@ -12,12 +12,6 @@ export const RULE_LOAD_ERROR = (errorMessage: string) => values: { message: errorMessage }, }); -export const ACTIONS_LOAD_ERROR = (errorMessage: string) => - i18n.translate('xpack.observability.ruleDetails.connectorsLoadError', { - defaultMessage: 'Unable to load rule actions connectors. Reason: {message}', - values: { message: errorMessage }, - }); - export const EXECUTION_LOG_ERROR = (errorMessage: string) => i18n.translate('xpack.observability.ruleDetails.executionLogError', { defaultMessage: 'Unable to load rule execution log. Reason: {message}', diff --git a/x-pack/plugins/observability/public/pages/rule_details/utils.ts b/x-pack/plugins/observability/public/pages/rule_details/utils.ts deleted file mode 100644 index 0c907d93228a6..0000000000000 --- a/x-pack/plugins/observability/public/pages/rule_details/utils.ts +++ /dev/null @@ -1,15 +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 { formatDurationFromTimeUnitChar, TimeUnitChar } from '../../../common'; - -export const formatInterval = (ruleInterval: string) => { - const interval: string[] | null = ruleInterval.match(/(^\d*)([s|m|h|d])/); - if (!interval || interval.length < 3) return ruleInterval; - const value: number = +interval[1]; - const unit = interval[2] as TimeUnitChar; - return formatDurationFromTimeUnitChar(value, unit); -}; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 20f293f37fc26..7b44fb0893362 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -209,6 +209,7 @@ describe('SecurityNavControl', () => { } } />, + "onClick": [Function], }, Object { "data-test-subj": "userMenuLink__link1", @@ -326,6 +327,7 @@ describe('SecurityNavControl', () => { } } />, + "onClick": [Function], }, Object { "data-test-subj": "logoutLink", diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 1a32a083793f3..2f462aaba4bc6 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -45,7 +45,7 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({ userMenuLinks$, }) => { const userMenuLinks = useObservable(userMenuLinks$, []); - const [isOpen, setIsOpen] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const userProfile = useUserProfile<{ avatar: UserAvatarData }>('avatar'); const currentUser = useCurrentUser(); // User profiles do not exist for anonymous users so need to fetch current user as well @@ -55,12 +55,12 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({ const button = ( <EuiHeaderSectionItemButton aria-controls="headerUserMenu" - aria-expanded={isOpen} + aria-expanded={isPopoverOpen} aria-haspopup="true" aria-label={i18n.translate('xpack.security.navControlComponent.accountMenuAriaLabel', { defaultMessage: 'Account menu', })} - onClick={() => setIsOpen((value) => (currentUser.value ? !value : false))} + onClick={() => setIsPopoverOpen((value) => (currentUser.value ? !value : false))} data-test-subj="userMenuButton" style={{ lineHeight: 'normal' }} > @@ -105,6 +105,9 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({ ), icon: <EuiIcon type={hasCustomProfileLinks ? 'controlsHorizontal' : 'user'} size="m" />, href: editProfileUrl, + onClick: () => { + setIsPopoverOpen(false); + }, 'data-test-subj': 'profileLink', }; @@ -138,10 +141,10 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({ id="headerUserMenu" ownFocus button={button} - isOpen={isOpen} + isOpen={isPopoverOpen} anchorPosition="downRight" repositionOnScroll - closePopover={() => setIsOpen(false)} + closePopover={() => setIsPopoverOpen(false)} panelPaddingSize="none" buffer={0} > diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index b1c98ce2ab0ac..1dc8c687de436 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -75,41 +75,43 @@ describe('SecurityNavControlService', () => { expect(target).toMatchInlineSnapshot(` <div> - <div - class="euiPopover euiPopover--anchorDownRight" - id="headerUserMenu" - > + <div> <div - class="euiPopover__anchor" + class="euiPopover euiPopover--anchorDownRight" + id="headerUserMenu" > - <button - aria-controls="headerUserMenu" - aria-expanded="false" - aria-haspopup="true" - aria-label="Account menu" - class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" - data-test-subj="userMenuButton" - style="line-height: normal;" - type="button" + <div + class="euiPopover__anchor" > - <span - class="euiButtonContent euiButtonEmpty__content" + <button + aria-controls="headerUserMenu" + aria-expanded="false" + aria-haspopup="true" + aria-label="Account menu" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" + data-test-subj="userMenuButton" + style="line-height: normal;" + type="button" > <span - class="euiButtonEmpty__text" + class="euiButtonContent euiButtonEmpty__content" > <span - class="euiHeaderSectionItemButton__content" + class="euiButtonEmpty__text" > <span - aria-label="Loading" - class="euiLoadingSpinner emotion-euiLoadingSpinner-m" - role="progressbar" - /> + class="euiHeaderSectionItemButton__content" + > + <span + aria-label="Loading" + class="euiLoadingSpinner emotion-euiLoadingSpinner-m" + role="progressbar" + /> + </span> </span> </span> - </span> - </button> + </button> + </div> </div> </div> </div> diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 592f5d16f523e..91d0c33ade107 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -16,6 +16,7 @@ import { map, takeUntil } from 'rxjs/operators'; import type { CoreStart, CoreTheme } from '@kbn/core/public'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticationServiceSetup } from '../authentication'; @@ -166,7 +167,9 @@ export const Providers: FunctionComponent<ProvidersProps> = ({ <AuthenticationProvider authc={authc}> <SecurityApiClientsProvider {...securityApiClients}> <I18nProvider> - <KibanaThemeProvider theme$={theme$}>{children}</KibanaThemeProvider> + <KibanaThemeProvider theme$={theme$}> + <RedirectAppLinks coreStart={services}>{children}</RedirectAppLinks> + </KibanaThemeProvider> </I18nProvider> </SecurityApiClientsProvider> </AuthenticationProvider> diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index e4cd5b70ca65e..86793045ad0cd 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -123,6 +123,10 @@ export enum SecurityPageName { exploreLanding = 'explore', dashboardsLanding = 'dashboards', noPage = '', + cloudSecurityPosture = 'cloud_security_posture', + cloudSecurityPostureDashboard = 'cloud_security_posture-dashboard', + cloudSecurityPostureFindings = 'cloud_security_posture-findings', + cloudSecurityPostureBenchmarks = 'cloud_security_posture-benchmarks', } export const EXPLORE_PATH = '/explore' as const; @@ -151,6 +155,10 @@ export const HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/host_isolation_exceptions` as const; export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const; export const RESPONSE_ACTIONS_PATH = `${MANAGEMENT_PATH}/response_actions` as const; +export const CLOUD_SECURITY_POSTURE_PATH = '/cloud_security_posture' as const; +export const CLOUD_SECURITY_POSTURE_DASHBOARD_PATH = '/cloud_security_posture/dashboard' as const; +export const CLOUD_SECURITY_POSTURE_FINDINGS_PATH = '/cloud_security_posture/findings' as const; +export const CLOUD_SECURITY_POSTURE_BENCHMARKS_PATH = '/cloud_security_posture/benchmarks' as const; export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const; export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 24404ce1637e5..c89d12c9a2efa 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -218,7 +218,7 @@ export type HostIsolationRequestBody = TypeOf<typeof NoParametersRequestSchema.b export type ResponseActionRequestBody = TypeOf<typeof ResponseActionBodySchema>; -export type KillProcessRequestBody = TypeOf<typeof KillOrSuspendProcessRequestSchema.body>; +export type KillOrSuspendProcessRequestBody = TypeOf<typeof KillOrSuspendProcessRequestSchema.body>; export interface HostIsolationResponse { action: string; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 8ba1a611af2b6..f22a0b4af5170 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -38,6 +38,10 @@ export const allowedExperimentalValues = Object.freeze({ * Enables the Endpoint response actions console in various areas of the app */ responseActionsConsoleEnabled: false, + /** + * Enables the cloud security posture navigation inside the security solution + */ + cloudSecurityPostureNavigation: false, }); type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>; diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 244d59a2e76cb..34b717e99145c 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -56,7 +56,8 @@ const StartAppComponent: FC<StartAppComponent> = ({ cases, } = useKibana().services; const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE); - const casesPermissions = useGetUserCasesPermissions(); + const userPermissions = useGetUserCasesPermissions(); + const casesPermissions = { all: userPermissions.crud, read: userPermissions.read }; const CasesContext = cases.ui.getCasesContext(); return ( <EuiErrorBoundary> @@ -69,10 +70,7 @@ const StartAppComponent: FC<StartAppComponent> = ({ <UserPrivilegesProvider kibanaCapabilities={capabilities}> <ManageUserInfo> <ReactQueryClientProvider> - <CasesContext - owner={[APP_ID]} - userCanCrud={casesPermissions?.crud ?? false} - > + <CasesContext owner={[APP_ID]} permissions={casesPermissions}> <PageRouter history={history} onAppLeave={onAppLeave} diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts index bce1b84a23d2f..57e01813c77ed 100644 --- a/x-pack/plugins/security_solution/public/cases/links.ts +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -15,7 +15,7 @@ export const getCasesLinkItems = (): LinkItem => { extend: { [SecurityPageName.case]: { globalNavEnabled: true, - globalNavOrder: 4, + globalNavOrder: 5, capabilities: [`${CASES_FEATURE_ID}.read_cases`], }, [SecurityPageName.caseConfigure]: { diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index a3bfdab9f9114..9eb4c57da74c2 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -46,6 +46,7 @@ const CaseContainerComponent: React.FC = () => { const { cases } = useKibana().services; const { getAppUrl, navigateTo } = useNavigation(); const userPermissions = useGetUserCasesPermissions(); + const casesPermissions = { all: userPermissions.crud, read: userPermissions.read }; const dispatch = useDispatch(); const { formatUrl: detectionsFormatUrl, search: detectionsUrlSearch } = useFormatUrl( SecurityPageName.rules @@ -147,7 +148,7 @@ const CaseContainerComponent: React.FC = () => { }, }, useFetchAlertData, - userCanCrud: userPermissions?.crud ?? false, + permissions: casesPermissions, })} </CaseDetailsRefreshContext.Provider> <SpyRoute pageName={SecurityPageName.case} /> diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/index.ts b/x-pack/plugins/security_solution/public/cloud_security_posture/index.ts new file mode 100644 index 0000000000000..798658b15cd42 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { SecuritySubPlugin } from '../app/types'; +import { routes } from './routes'; + +export class CloudSecurityPosture { + public setup() {} + + public start(): SecuritySubPlugin { + return { routes }; + } +} diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts new file mode 100644 index 0000000000000..f862f86fc7247 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts @@ -0,0 +1,75 @@ +/* + * 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'; +import { IconExceptionLists } from '../management/icons/exception_lists'; +import { + CLOUD_SECURITY_POSTURE_DASHBOARD_PATH, + CLOUD_SECURITY_POSTURE_FINDINGS_PATH, + CLOUD_SECURITY_POSTURE_BENCHMARKS_PATH, + SecurityPageName, +} from '../../common/constants'; +import type { LinkItem, LinkCategories } from '../common/links/types'; +import cloudSecurityPostureDashboardImage from '../common/images/cloud_security_posture_dashboard_page.png'; + +const commonLinkProperties: Partial<LinkItem> = { + skipUrlState: true, + hideTimeline: true, + experimentalKey: 'cloudSecurityPostureNavigation', +}; + +export const rootLinks: LinkItem = { + id: SecurityPageName.cloudSecurityPostureFindings, + title: i18n.translate('xpack.securitySolution.appLinks.findings', { + defaultMessage: 'Findings ', + }), + path: CLOUD_SECURITY_POSTURE_FINDINGS_PATH, + globalNavEnabled: true, + globalNavOrder: 3, + ...commonLinkProperties, +}; + +export const dashboardLinks: LinkItem = { + id: SecurityPageName.cloudSecurityPostureDashboard, + title: i18n.translate('xpack.securitySolution.appLinks.cloudSecurityPostureDashboard', { + defaultMessage: 'Cloud Posture', + }), + path: CLOUD_SECURITY_POSTURE_DASHBOARD_PATH, + description: i18n.translate( + 'xpack.securitySolution.appLinks.cloudSecurityPostureDashboardDescription', + { + defaultMessage: 'An overview of findings across all CSP integrations.', + } + ), + landingImage: cloudSecurityPostureDashboardImage, + ...commonLinkProperties, +}; + +export const manageLinks: LinkItem = { + id: SecurityPageName.cloudSecurityPostureBenchmarks, + title: i18n.translate('xpack.securitySolution.appLinks.cloudSecurityPostureBenchmarks', { + defaultMessage: 'CSP Benchmarks', + }), + path: CLOUD_SECURITY_POSTURE_BENCHMARKS_PATH, + description: i18n.translate( + 'xpack.securitySolution.appLinks.cloudSecurityPostureBenchmarksDescription', + { + defaultMessage: 'View, enable, and or disable benchmark rules.', + } + ), + // TODO: Temporary until we have a CSP icon + landingIcon: IconExceptionLists, + ...commonLinkProperties, +}; + +export const manageCategories: LinkCategories = [ + { + label: i18n.translate('xpack.securitySolution.appLinks.category.cloudSecurityPosture', { + defaultMessage: 'CLOUD SECURITY POSTURE', + }), + linkIds: [SecurityPageName.cloudSecurityPostureBenchmarks], + }, +]; diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/routes.tsx b/x-pack/plugins/security_solution/public/cloud_security_posture/routes.tsx new file mode 100644 index 0000000000000..14cc141e30430 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/routes.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper'; +import { SpyRoute } from '../common/utils/route/spy_routes'; +import { + CLOUD_SECURITY_POSTURE_BENCHMARKS_PATH, + CLOUD_SECURITY_POSTURE_DASHBOARD_PATH, + CLOUD_SECURITY_POSTURE_FINDINGS_PATH, + CLOUD_SECURITY_POSTURE_PATH, + SecurityPageName, +} from '../../common/constants'; +import type { SecuritySubPluginRoutes } from '../app/types'; + +const CloudSecurityPosture = ({ pageName }: { pageName: SecurityPageName }) => { + return ( + <TrackApplicationView viewId={pageName}> + <SecuritySolutionPageWrapper noPadding noTimeline> + <SpyRoute pageName={pageName} /> + <EuiTitle> + <h1>{'Coming soon'}</h1> + </EuiTitle> + </SecuritySolutionPageWrapper> + </TrackApplicationView> + ); +}; + +// TODO: We'll probably use a single route here, and we'll manage all CSP pages in an internal router in the CSP plugin. +// For now we have multiple routes as we need `SpyRoute` to use a specific `pageName` for highlighting the correct +// navigation bar entry +export const routes: SecuritySubPluginRoutes = [ + { + path: CLOUD_SECURITY_POSTURE_PATH, + render: () => <CloudSecurityPosture pageName={SecurityPageName.cloudSecurityPosture} />, + exact: true, + }, + { + path: CLOUD_SECURITY_POSTURE_FINDINGS_PATH, + render: () => <CloudSecurityPosture pageName={SecurityPageName.cloudSecurityPostureFindings} />, + }, + { + path: CLOUD_SECURITY_POSTURE_DASHBOARD_PATH, + render: () => ( + <CloudSecurityPosture pageName={SecurityPageName.cloudSecurityPostureDashboard} /> + ), + }, + { + path: CLOUD_SECURITY_POSTURE_BENCHMARKS_PATH, + render: () => ( + <CloudSecurityPosture pageName={SecurityPageName.cloudSecurityPostureBenchmarks} /> + ), + }, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index 12b5bc755d35f..bd4170a9cbbe7 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -20,8 +20,16 @@ import { mockAlertDetailsData } from './__mocks__'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { TimelineTabs } from '../../../../common/types/timeline'; import { useInvestigationTimeEnrichment } from '../../containers/cti/event_enrichment'; +import { useGetUserCasesPermissions } from '../../lib/kibana'; jest.mock('../../lib/kibana'); +const originalKibanaLib = jest.requireActual('../../lib/kibana'); + +// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object +// The returned permissions object will indicate that the user does not have permissions by default +const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock; +mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions); + jest.mock('../../containers/cti/event_enrichment'); jest.mock('../../../detections/containers/detection_engine/rules/use_rule_with_fallback', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx index 86cc751cb5754..d90688d80a86e 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx @@ -29,7 +29,7 @@ export const RelatedCases: React.FC<Props> = React.memo(({ eventId, isReadOnly } const [relatedCases, setRelatedCases] = useState<RelatedCaseList>([]); const [areCasesLoading, setAreCasesLoading] = useState(true); const [hasError, setHasError] = useState<boolean>(false); - const hasCasesReadPermissions = casePermissions?.read ?? false; + const hasCasesReadPermissions = casePermissions.read; const getRelatedCases = useCallback(async () => { let relatedCaseList: RelatedCaseList = []; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index 124dc4eefb997..a3d4bf6f42557 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -24,9 +24,17 @@ import { getDefaultControlColumn } from '../../../timelines/components/timeline/ import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; import { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser'; +import { useGetUserCasesPermissions } from '../../lib/kibana'; jest.mock('../../lib/kibana'); +const originalKibanaLib = jest.requireActual('../../lib/kibana'); + +// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object +// The returned permissions object will indicate that the user does not have permissions by default +const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock; +mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions); + jest.mock('../../../timelines/containers', () => ({ useTimelineEvents: jest.fn(), })); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx index 3162df787af1c..ab05936ebd36a 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { LinkCategories } from '../../../links'; import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; @@ -37,6 +38,17 @@ const mockItems: DefaultSideNavItem[] = [ }, ]; +const mockCategories: LinkCategories = [ + { + label: 'HOSTS CATEGORY', + linkIds: [SecurityPageName.hosts], + }, + { + label: 'Empty category', + linkIds: [], + }, +]; + const PANEL_TITLE = 'test title'; const mockOnClose = jest.fn(); const mockOnOutsideClick = jest.fn(); @@ -75,6 +87,18 @@ describe('SolutionGroupedNav', () => { }); }); + it('should only render categories with items', () => { + const result = renderNavPanel({ categories: mockCategories }); + + mockCategories.forEach((mockCategory) => { + if (mockCategory.linkIds.length) { + expect(result.getByText(mockCategory.label)).toBeInTheDocument(); + } else { + expect(result.queryByText(mockCategory.label)).not.toBeInTheDocument(); + } + }); + }); + describe('links', () => { it('should contain correct href in links', () => { const result = renderNavPanel(); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx index 4c0ccc6116703..a2773f2223247 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx @@ -138,6 +138,10 @@ const SolutionNavPanelCategories: React.FC<SolutionNavPanelCategoriesProps> = ({ return acc; }, []); + if (!links.length) { + return null; + } + return ( <Fragment key={label}> <EuiTitle size="xxxs"> diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap index 293fddddf1ba2..0e178a70a6c51 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap @@ -131,6 +131,16 @@ Object { "name": "Timelines", "onClick": [Function], }, + Object { + "data-href": "securitySolutionUI/cases?query=(language:kuery,query:'host.name:%22security-solution-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-cases", + "disabled": false, + "href": "securitySolutionUI/cases?query=(language:kuery,query:'host.name:%22security-solution-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "cases", + "isSelected": false, + "name": "Cases", + "onClick": [Function], + }, ], "name": "Investigate", }, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index db7d06f899b5c..4c0861e7a3328 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -23,6 +23,13 @@ import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pag jest.mock('../../../lib/kibana/kibana_react'); jest.mock('../../../lib/kibana'); +const originalKibanaLib = jest.requireActual('../../../lib/kibana'); + +// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object +// The returned permissions object will indicate that the user does not have permissions by default +const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock; +mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions); + jest.mock('../../../hooks/use_selector'); jest.mock('../../../hooks/use_experimental_features'); jest.mock('../../../utils/route/use_route_spy'); @@ -132,7 +139,7 @@ describe('useSecuritySolutionNavigation', () => { }); it('should omit host isolation exceptions if hook reports false', () => { - (useCanSeeHostIsolationExceptionsMenu as jest.Mock).mockReturnValueOnce(false); + (useCanSeeHostIsolationExceptionsMenu as jest.Mock).mockReturnValue(false); const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>( () => useSecuritySolutionNavigation(), { wrapper: TestProviders } diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index f9a766ed3d875..090061398ae89 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -68,7 +68,7 @@ export const usePrimaryNavigationItems = ({ }; function usePrimaryNavigationItemsToDisplay(navTabs: Record<string, NavTab>) { - const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + const hasCasesReadPermissions = useGetUserCasesPermissions().read; const canSeeHostIsolationExceptions = useCanSeeHostIsolationExceptionsMenu(); const isPolicyListEnabled = useIsExperimentalFeatureEnabled('policyListEnabled'); const uiCapabilities = useKibana().services.application.capabilities; diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx index 5280f298ba99e..cad74583d7596 100644 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.test.tsx @@ -12,9 +12,17 @@ import { TEST_ID, SessionsView, defaultSessionsFilter } from '.'; import { EntityType, TimelineId } from '@kbn/timelines-plugin/common'; import { SessionsComponentsProps } from './types'; import { TimelineModel } from '../../../timelines/store/timeline/model'; +import { useGetUserCasesPermissions } from '../../lib/kibana'; jest.mock('../../lib/kibana'); +const originalKibanaLib = jest.requireActual('../../lib/kibana'); + +// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object +// The returned permissions object will indicate that the user does not have permissions by default +const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock; +mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions); + jest.mock('../url_state/normalize_time_range'); const startDate = '2022-03-22T22:10:56.794Z'; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx index 420c2e3dd6c78..ebb46a6484d0f 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/index.tsx @@ -14,7 +14,6 @@ import { ModalInspectQuery } from '../inspect/modal'; import { useInspect } from '../inspect/use_inspect'; import { useLensAttributes } from './use_lens_attributes'; import { useAddToExistingCase } from './use_add_to_existing_case'; -import { useGetUserCasesPermissions } from '../../lib/kibana'; import { useAddToNewCase } from './use_add_to_new_case'; import { VisualizationActionsProps } from './types'; import { @@ -54,8 +53,6 @@ const VisualizationActionsComponent: React.FC<VisualizationActionsProps> = ({ stackByField, }) => { const { lens } = useKibana().services; - const userPermissions = useGetUserCasesPermissions(); - const userCanCrud = userPermissions?.crud ?? false; const { canUseEditor, navigateToPrefilledEditor } = lens; const [isPopoverOpen, setPopover] = useState(false); @@ -82,14 +79,12 @@ const VisualizationActionsComponent: React.FC<VisualizationActionsProps> = ({ onAddToCaseClicked: closePopover, lensAttributes: attributes, timeRange: timerange, - userCanCrud, }); const { onAddToNewCaseClicked, disabled: isAddToNewCaseDisabled } = useAddToNewCase({ onClick: closePopover, timeRange: timerange, lensAttributes: attributes, - userCanCrud, }); const onOpenInLens = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx index 41b4fddf5bd0b..83f1290ab54e3 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.test.tsx @@ -5,25 +5,45 @@ * 2.0. */ import { renderHook } from '@testing-library/react-hooks'; -import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; -import { useKibana } from '../../lib/kibana'; +import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__'; import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric'; import { useAddToExistingCase } from './use_add_to_existing_case'; +import { useGetUserCasesPermissions } from '../../lib/kibana'; -jest.mock('../../lib/kibana/kibana_react'); +const mockedUseKibana = mockUseKibana(); +const mockGetUseCasesAddToExistingCaseModal = jest.fn(); + +jest.mock('../../lib/kibana', () => { + const original = jest.requireActual('../../lib/kibana'); + + return { + ...original, + useGetUserCasesPermissions: jest.fn(), + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + cases: { + hooks: { + getUseCasesAddToExistingCaseModal: mockGetUseCasesAddToExistingCaseModal, + }, + }, + }, + }), + }; +}); describe('useAddToExistingCase', () => { - const mockCases = mockCasesContract(); const mockOnAddToCaseClicked = jest.fn(); const timeRange = { from: '2022-03-06T16:00:00.000Z', to: '2022-03-07T15:59:59.999Z', }; + beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - cases: mockCases, - }, + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, }); }); @@ -32,47 +52,48 @@ describe('useAddToExistingCase', () => { useAddToExistingCase({ lensAttributes: kpiHostMetricLensAttributes, timeRange, - userCanCrud: true, onAddToCaseClicked: mockOnAddToCaseClicked, }) ); - expect(mockCases.hooks.getUseCasesAddToExistingCaseModal).toHaveBeenCalledWith({ + expect(mockGetUseCasesAddToExistingCaseModal).toHaveBeenCalledWith({ onClose: mockOnAddToCaseClicked, toastContent: 'Successfully added visualization to the case', }); expect(result.current.disabled).toEqual(false); }); - it("button disalbled if user Can't Crud", () => { + it("button disabled if user Can't Crud", () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + const { result } = renderHook(() => useAddToExistingCase({ lensAttributes: kpiHostMetricLensAttributes, timeRange, - userCanCrud: false, onAddToCaseClicked: mockOnAddToCaseClicked, }) ); expect(result.current.disabled).toEqual(true); }); - it('button disalbled if no lensAttributes', () => { + it('button disabled if no lensAttributes', () => { const { result } = renderHook(() => useAddToExistingCase({ lensAttributes: null, timeRange, - userCanCrud: true, onAddToCaseClicked: mockOnAddToCaseClicked, }) ); expect(result.current.disabled).toEqual(true); }); - it('button disalbled if no timeRange', () => { + it('button disabled if no timeRange', () => { const { result } = renderHook(() => useAddToExistingCase({ lensAttributes: kpiHostMetricLensAttributes, timeRange: null, - userCanCrud: true, onAddToCaseClicked: mockOnAddToCaseClicked, }) ); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx index a436a2d7e0424..ed8da682bbfd7 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx @@ -8,7 +8,7 @@ import { useCallback, useMemo } from 'react'; import { CommentType } from '@kbn/cases-plugin/common'; import { APP_ID } from '../../../../common/constants'; -import { useKibana } from '../../lib/kibana/kibana_react'; +import { useKibana, useGetUserCasesPermissions } from '../../lib/kibana'; import { ADD_TO_CASE_SUCCESS } from './translations'; import { LensAttributes } from './types'; @@ -19,13 +19,12 @@ export const useAddToExistingCase = ({ onAddToCaseClicked, lensAttributes, timeRange, - userCanCrud, }: { onAddToCaseClicked?: () => void; lensAttributes: LensAttributes | null; timeRange: { from: string; to: string } | null; - userCanCrud: boolean; }) => { + const userPermissions = useGetUserCasesPermissions(); const { cases } = useKibana().services; const attachments = useMemo(() => { return [ @@ -54,6 +53,6 @@ export const useAddToExistingCase = ({ return { onAddToExistingCaseClicked, - disabled: lensAttributes == null || timeRange == null || !userCanCrud, + disabled: lensAttributes == null || timeRange == null || !userPermissions.crud, }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx index 583d1eda66a1c..49fbde71386a5 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx @@ -5,24 +5,45 @@ * 2.0. */ import { renderHook } from '@testing-library/react-hooks'; -import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; -import { useKibana } from '../../lib/kibana'; +import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__'; import { kpiHostMetricLensAttributes } from './lens_attributes/hosts/kpi_host_metric'; import { useAddToNewCase } from './use_add_to_new_case'; +import { useGetUserCasesPermissions } from '../../lib/kibana'; jest.mock('../../lib/kibana/kibana_react'); +const mockedUseKibana = mockUseKibana(); +const mockGetUseCasesAddToNewCaseFlyout = jest.fn(); + +jest.mock('../../lib/kibana', () => { + const original = jest.requireActual('../../lib/kibana'); + + return { + ...original, + useGetUserCasesPermissions: jest.fn(), + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + cases: { + hooks: { + getUseCasesAddToNewCaseFlyout: mockGetUseCasesAddToNewCaseFlyout, + }, + }, + }, + }), + }; +}); + describe('useAddToNewCase', () => { - const mockCases = mockCasesContract(); const timeRange = { from: '2022-03-06T16:00:00.000Z', to: '2022-03-07T15:59:59.999Z', }; beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - cases: mockCases, - }, + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, }); }); @@ -31,43 +52,44 @@ describe('useAddToNewCase', () => { useAddToNewCase({ lensAttributes: kpiHostMetricLensAttributes, timeRange, - userCanCrud: true, }) ); - expect(mockCases.hooks.getUseCasesAddToNewCaseFlyout).toHaveBeenCalledWith({ + expect(mockGetUseCasesAddToNewCaseFlyout).toHaveBeenCalledWith({ toastContent: 'Successfully added visualization to the case', }); expect(result.current.disabled).toEqual(false); }); - it("button disalbled if user Can't Crud", () => { + it("button disabled if user Can't Crud", () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + const { result } = renderHook(() => useAddToNewCase({ lensAttributes: kpiHostMetricLensAttributes, timeRange, - userCanCrud: false, }) ); expect(result.current.disabled).toEqual(true); }); - it('button disalbled if no lensAttributes', () => { + it('button disabled if no lensAttributes', () => { const { result } = renderHook(() => useAddToNewCase({ lensAttributes: null, timeRange, - userCanCrud: true, }) ); expect(result.current.disabled).toEqual(true); }); - it('button disalbled if no timeRange', () => { + it('button disabled if no timeRange', () => { const { result } = renderHook(() => useAddToNewCase({ lensAttributes: kpiHostMetricLensAttributes, timeRange: null, - userCanCrud: true, }) ); expect(result.current.disabled).toEqual(true); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx index 7fa206cfff3a8..42d25036afbeb 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx @@ -9,7 +9,7 @@ import { useCallback, useMemo } from 'react'; import { CommentType } from '@kbn/cases-plugin/common'; import { APP_ID } from '../../../../common/constants'; -import { useKibana } from '../../lib/kibana/kibana_react'; +import { useKibana, useGetUserCasesPermissions } from '../../lib/kibana'; import { ADD_TO_CASE_SUCCESS } from './translations'; import { LensAttributes } from './types'; @@ -18,17 +18,12 @@ export interface UseAddToNewCaseProps { onClick?: () => void; timeRange: { from: string; to: string } | null; lensAttributes: LensAttributes | null; - userCanCrud: boolean; } const owner = APP_ID; -export const useAddToNewCase = ({ - onClick, - timeRange, - lensAttributes, - userCanCrud, -}: UseAddToNewCaseProps) => { +export const useAddToNewCase = ({ onClick, timeRange, lensAttributes }: UseAddToNewCaseProps) => { + const userPermissions = useGetUserCasesPermissions(); const { cases } = useKibana().services; const attachments = useMemo(() => { return [ @@ -57,6 +52,6 @@ export const useAddToNewCase = ({ return { onAddToNewCaseClicked, - disabled: lensAttributes == null || timeRange == null || !userCanCrud, + disabled: lensAttributes == null || timeRange == null || !userPermissions.crud, }; }; diff --git a/x-pack/plugins/security_solution/public/common/images/cloud_security_posture_dashboard_page.png b/x-pack/plugins/security_solution/public/common/images/cloud_security_posture_dashboard_page.png new file mode 100644 index 0000000000000..de64f3be902cb Binary files /dev/null and b/x-pack/plugins/security_solution/public/common/images/cloud_security_posture_dashboard_page.png differ diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 97befebc04d50..8a35034646e43 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -152,7 +152,10 @@ export interface UseGetUserCasesPermissions { } export const useGetUserCasesPermissions = () => { - const [casesPermissions, setCasesPermissions] = useState<UseGetUserCasesPermissions | null>(null); + const [casesPermissions, setCasesPermissions] = useState<UseGetUserCasesPermissions>({ + crud: false, + read: false, + }); const uiCapabilities = useKibana().services.application.capabilities; useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/common/lib/process_actions/index.ts b/x-pack/plugins/security_solution/public/common/lib/process_actions/index.ts index 02e98d099a7aa..5e34218a9f0b5 100644 --- a/x-pack/plugins/security_solution/public/common/lib/process_actions/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/process_actions/index.ts @@ -6,17 +6,26 @@ */ import type { - KillProcessRequestBody, + KillOrSuspendProcessRequestBody, ResponseActionApiResponse, } from '../../../../common/endpoint/types'; import { KibanaServices } from '../kibana'; -import { KILL_PROCESS_ROUTE } from '../../../../common/endpoint/constants'; +import { KILL_PROCESS_ROUTE, SUSPEND_PROCESS_ROUTE } from '../../../../common/endpoint/constants'; /** Kills a process specified by pid or entity id on a host running Endpoint Security */ -export const killProcess = async ( - params: KillProcessRequestBody +export const killProcess = ( + params: KillOrSuspendProcessRequestBody ): Promise<ResponseActionApiResponse> => { return KibanaServices.get().http.post<ResponseActionApiResponse>(KILL_PROCESS_ROUTE, { body: JSON.stringify(params), }); }; + +/** Suspends a process specified by pid or entity id on a host running Endpoint Security */ +export const suspendProcess = ( + params: KillOrSuspendProcessRequestBody +): Promise<ResponseActionApiResponse> => { + return KibanaServices.get().http.post<ResponseActionApiResponse>(SUSPEND_PROCESS_ROUTE, { + body: JSON.stringify(params), + }); +}; diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts index fe870caa09c37..cd57f94c0407d 100644 --- a/x-pack/plugins/security_solution/public/common/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -12,6 +12,7 @@ import { getCasesLinkItems } from '../../cases/links'; import { links as managementLinks, getManagementFilteredLinks } from '../../management/links'; import { dashboardsLandingLinks, threatHuntingLandingLinks } from '../../landing_pages/links'; import { gettingStartedLinks } from '../../overview/links'; +import { rootLinks as cloudSecurityPostureRootLinks } from '../../cloud_security_posture/links'; import { StartPlugins } from '../../types'; const casesLinks = getCasesLinkItems(); @@ -19,6 +20,7 @@ const casesLinks = getCasesLinkItems(); export const links = Object.freeze([ dashboardsLandingLinks, detectionLinks, + cloudSecurityPostureRootLinks, timelinesLinks, casesLinks, threatHuntingLandingLinks, @@ -35,6 +37,7 @@ export const getFilteredLinks = async ( return Object.freeze([ dashboardsLandingLinks, detectionLinks, + cloudSecurityPostureRootLinks, timelinesLinks, casesLinks, threatHuntingLandingLinks, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 5c2777febfb71..4746c759b0a2d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -35,7 +35,7 @@ export const useAddToCaseActions = ({ }: UseAddToCaseActions) => { const { cases: casesUi } = useKibana().services; const casePermissions = useGetUserCasesPermissions(); - const hasWritePermissions = casePermissions?.crud ?? false; + const hasWritePermissions = casePermissions.crud; const isAlert = useMemo(() => { return ecsData?.event?.kind?.includes('signal'); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx index 188cc4fdd54e1..c19f48163123e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx @@ -20,7 +20,7 @@ export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActi const { cases: casesUi } = useKibana().services; const casePermissions = useGetUserCasesPermissions(); - const hasWritePermissions = casePermissions?.crud ?? false; + const hasWritePermissions = casePermissions.crud; const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({ onClose, diff --git a/x-pack/plugins/security_solution/public/landing_pages/links.ts b/x-pack/plugins/security_solution/public/landing_pages/links.ts index e840ab7cc8716..0a53aa88208b5 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/links.ts +++ b/x-pack/plugins/security_solution/public/landing_pages/links.ts @@ -19,6 +19,7 @@ import { links as hostsLinks } from '../hosts/links'; import { links as networkLinks } from '../network/links'; import { links as usersLinks } from '../users/links'; import { links as kubernetesLinks } from '../kubernetes/links'; +import { dashboardLinks as cloudSecurityPostureLinks } from '../cloud_security_posture/links'; export const dashboardsLandingLinks: LinkItem = { id: SecurityPageName.dashboardsLanding, @@ -32,7 +33,7 @@ export const dashboardsLandingLinks: LinkItem = { defaultMessage: 'Dashboards', }), ], - links: [overviewLinks, detectionResponseLinks, kubernetesLinks], + links: [overviewLinks, detectionResponseLinks, kubernetesLinks, cloudSecurityPostureLinks], skipUrlState: true, hideTimeline: true, }; @@ -42,7 +43,7 @@ export const threatHuntingLandingLinks: LinkItem = { title: EXPLORE, path: EXPLORE_PATH, globalNavEnabled: true, - globalNavOrder: 5, + globalNavOrder: 6, capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.explore', { diff --git a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx index abced3257c681..88b063dd037be 100644 --- a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx +++ b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx @@ -25,6 +25,8 @@ import { Timelines } from './timelines'; import { Management } from './management'; import { LandingPages } from './landing_pages'; +import { CloudSecurityPosture } from './cloud_security_posture'; + /** * The classes used to instantiate the sub plugins. These are grouped into a single object for the sake of bundling them in a single dynamic import. */ @@ -42,5 +44,6 @@ const subPluginClasses = { Timelines, Management, LandingPages, + CloudSecurityPosture, }; export { subPluginClasses }; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts index 9088e793b222f..34f33de1da9eb 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts @@ -10,6 +10,7 @@ import { CommandDefinition } from '../console'; import { IsolateActionResult } from './isolate_action'; import { ReleaseActionResult } from './release_action'; import { KillProcessActionResult } from './kill_process_action'; +import { SuspendProcessActionResult } from './suspend_process_action'; import { EndpointStatusActionResult } from './status_action'; import { GetProcessesActionResult } from './get_processes_action'; import type { ParsedArgData } from '../console/service/parsed_command_input'; @@ -116,6 +117,55 @@ export const getEndpointResponseActionsConsoleCommands = ( }, }, }, + { + name: 'suspend-process', + about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.suspendProcess.about', { + defaultMessage: 'Suspend a running process', + }), + RenderComponent: SuspendProcessActionResult, + meta: { + endpointId: endpointAgentId, + }, + exampleUsage: 'suspend-process --pid 123', + exampleInstruction: 'Enter a pid or an entity id to execute', + mustHaveArgs: true, + args: { + comment: { + required: false, + allowMultiples: false, + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.arg.comment', + { defaultMessage: 'A comment to go along with the action' } + ), + }, + pid: { + required: false, + allowMultiples: false, + exclusiveOr: true, + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.pid.arg.comment', + { + defaultMessage: + 'A PID representing the process to suspend. You can enter a pid or an entity id, but not both.', + } + ), + validate: emptyArgumentValidator, + }, + entityId: { + required: false, + allowMultiples: false, + exclusiveOr: true, + about: i18n.translate( + 'xpack.securitySolution.endpointConsoleCommands.suspendProcess.entityId.arg.comment', + { + defaultMessage: + 'An entity id representing the process to suspend. You can enter a pid or an entity id, but not both.', + } + ), + validate: emptyArgumentValidator, + }, + }, + }, { name: 'status', about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.status.about', { diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.test.tsx new file mode 100644 index 0000000000000..1b3cf93bd8e1d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.test.tsx @@ -0,0 +1,238 @@ +/* + * 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 { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { + ConsoleManagerTestComponent, + getConsoleManagerMockRenderResultQueriesAndActions, +} from '../console/components/console_manager/mocks'; +import React from 'react'; +import { getEndpointResponseActionsConsoleCommands } from './endpoint_response_actions_console_commands'; +import { enterConsoleCommand } from '../console/mocks'; +import { waitFor } from '@testing-library/react'; +import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks'; + +describe('When using the suspend-process action from response actions console', () => { + let render: () => Promise<ReturnType<AppContextTestRender['render']>>; + let renderResult: ReturnType<AppContextTestRender['render']>; + let apiMocks: ReturnType<typeof responseActionsHttpMocks>; + let consoleManagerMockAccess: ReturnType< + typeof getConsoleManagerMockRenderResultQueriesAndActions + >; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + apiMocks = responseActionsHttpMocks(mockedContext.coreStart.http); + + render = async () => { + renderResult = mockedContext.render( + <ConsoleManagerTestComponent + registerConsoleProps={() => { + return { + consoleProps: { + 'data-test-subj': 'test', + commands: getEndpointResponseActionsConsoleCommands('a.b.c'), + }, + }; + }} + /> + ); + + consoleManagerMockAccess = getConsoleManagerMockRenderResultQueriesAndActions(renderResult); + + await consoleManagerMockAccess.clickOnRegisterNewConsole(); + await consoleManagerMockAccess.openRunningConsole(); + + return renderResult; + }; + }); + + it('should call `suspend-process` api when command is entered', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123'); + + await waitFor(() => { + expect(apiMocks.responseProvider.suspendProcess).toHaveBeenCalledTimes(1); + }); + }); + + it('should accept an optional `--comment`', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123 --comment "This is a comment"'); + + await waitFor(() => { + expect(apiMocks.responseProvider.suspendProcess).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('This is a comment'), + }) + ); + }); + }); + + it('should only accept one `--comment`', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123 --comment "one" --comment "two"'); + + expect(renderResult.getByTestId('test-badArgument-message').textContent).toEqual( + 'Argument can only be used once: --comment' + ); + }); + + it('should only accept one exclusive argument', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123 --entityId 123wer'); + + expect(renderResult.getByTestId('test-badArgument-message').textContent).toEqual( + 'This command supports only one of the following arguments: --pid, --entityId' + ); + }); + + it('should check for at least one exclusive argument', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process'); + + expect(renderResult.getByTestId('test-badArgument-message').textContent).toEqual( + 'This command supports only one of the following arguments: --pid, --entityId' + ); + }); + + it('should check the pid has a given value', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid'); + + expect(renderResult.getByTestId('test-badArgument-message').textContent).toEqual( + 'Invalid argument value: --pid. Argument cannot be empty' + ); + }); + + it('should check the pid has a non-empty value', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid " "'); + + expect(renderResult.getByTestId('test-badArgument-message').textContent).toEqual( + 'Invalid argument value: --pid. Argument cannot be empty' + ); + }); + + it('should check the entityId has a given value', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --entityId'); + + expect(renderResult.getByTestId('test-badArgument-message').textContent).toEqual( + 'Invalid argument value: --entityId. Argument cannot be empty' + ); + }); + + it('should check the entity id has a non-empty value', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --entityId " "'); + + expect(renderResult.getByTestId('test-badArgument-message').textContent).toEqual( + 'Invalid argument value: --entityId. Argument cannot be empty' + ); + }); + + it('should call the action status api after creating the `suspend-process` request', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123'); + + await waitFor(() => { + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalled(); + }); + }); + + it('should show success when `suspend-process` action completes with no errors when using `pid`', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123'); + + await waitFor(() => { + expect(renderResult.getByTestId('suspendProcessSuccessCallout')).toBeTruthy(); + }); + }); + + it('should show success when `suspend-process` action completes with no errors when using `entityId`', async () => { + await render(); + enterConsoleCommand(renderResult, 'suspend-process --entityId 123wer'); + + await waitFor(() => { + expect(renderResult.getByTestId('suspendProcessSuccessCallout')).toBeTruthy(); + }); + }); + + it('should show error if suspend-process failed to complete successfully', async () => { + const pendingDetailResponse = apiMocks.responseProvider.actionDetails({ + path: '/api/endpoint/action/1.2.3', + }); + pendingDetailResponse.data.wasSuccessful = false; + pendingDetailResponse.data.errors = ['error one', 'error two']; + apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse); + await render(); + enterConsoleCommand(renderResult, 'suspend-process --pid 123'); + + await waitFor(() => { + expect(renderResult.getByTestId('suspendProcessErrorCallout').textContent).toMatch( + /error one \| error two/ + ); + }); + }); + + describe('and when console is closed (not terminated) and then reopened', () => { + beforeEach(() => { + const _render = render; + + render = async () => { + const response = await _render(); + enterConsoleCommand(response, 'suspend-process --pid 123'); + + await waitFor(() => { + expect(apiMocks.responseProvider.suspendProcess).toHaveBeenCalledTimes(1); + }); + + // Hide the console + await consoleManagerMockAccess.hideOpenedConsole(); + + return response; + }; + }); + + it('should NOT send the `suspend-process` request again', async () => { + await render(); + await consoleManagerMockAccess.openRunningConsole(); + + expect(apiMocks.responseProvider.suspendProcess).toHaveBeenCalledTimes(1); + }); + + it('should continue to check action status when still pending', async () => { + const pendingDetailResponse = apiMocks.responseProvider.actionDetails({ + path: '/api/endpoint/action/1.2.3', + }); + pendingDetailResponse.data.isCompleted = false; + apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse); + await render(); + + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(2); + + await consoleManagerMockAccess.openRunningConsole(); + + await waitFor(() => { + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(3); + }); + }); + + it('should display completion output if done (no additional API calls)', async () => { + await render(); + + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1); + + await consoleManagerMockAccess.openRunningConsole(); + + expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.tsx new file mode 100644 index 0000000000000..9282453757b8c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/suspend_process_action.tsx @@ -0,0 +1,121 @@ +/* + * 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, { memo, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { ActionDetails } from '../../../../common/endpoint/types'; +import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details'; +import type { EndpointCommandDefinitionMeta } from './types'; +import { useSendSuspendProcessRequest } from '../../hooks/endpoint/use_send_suspend_process_endpoint_request'; +import type { CommandExecutionComponentProps } from '../console/types'; +import { parsedPidOrEntityIdParameter } from '../console/service/parsed_command_input'; + +export const SuspendProcessActionResult = memo< + CommandExecutionComponentProps< + { comment?: string; pid?: number; entityId?: string }, + { + actionId?: string; + actionRequestSent?: boolean; + completedActionDetails?: ActionDetails; + }, + EndpointCommandDefinitionMeta + > +>(({ command, setStore, store, status, setStatus, ResultComponent }) => { + const endpointId = command.commandDefinition?.meta?.endpointId; + const { actionId, completedActionDetails } = store; + const isPending = status === 'pending'; + const actionRequestSent = Boolean(store.actionRequestSent); + + const { mutate, data, isSuccess, error } = useSendSuspendProcessRequest(); + + const { data: actionDetails } = useGetActionDetails(actionId ?? '-', { + enabled: Boolean(actionId) && isPending, + refetchInterval: isPending ? 3000 : false, + }); + + // Send Suspend request if not yet done + useEffect(() => { + const parameters = parsedPidOrEntityIdParameter(command.args.args); + + if (!actionRequestSent && endpointId && parameters) { + mutate({ + endpoint_ids: [endpointId], + comment: command.args.args?.comment?.[0], + parameters, + }); + setStore((prevState) => { + return { ...prevState, actionRequestSent: true }; + }); + } + }, [actionRequestSent, command.args.args, endpointId, mutate, setStore]); + + // If suspend-process request was created, store the action id if necessary + useEffect(() => { + if (isSuccess && actionId !== data.data.id) { + setStore((prevState) => { + return { ...prevState, actionId: data.data.id }; + }); + } + }, [actionId, data?.data.id, isSuccess, error, setStore]); + + useEffect(() => { + if (actionDetails?.data.isCompleted) { + setStatus('success'); + setStore((prevState) => { + return { + ...prevState, + completedActionDetails: actionDetails.data, + }; + }); + } + }, [actionDetails?.data, setStatus, setStore]); + + // Show nothing if still pending + if (isPending) { + return ( + <ResultComponent showAs="pending"> + <FormattedMessage + id="xpack.securitySolution.endpointResponseActions.suspendProcess.pendingMessage" + defaultMessage="Suspending process" + /> + </ResultComponent> + ); + } + + // Show errors + if (completedActionDetails?.errors) { + return ( + <ResultComponent + showAs="failure" + title={i18n.translate( + 'xpack.securitySolution.endpointResponseActions.suspendProcess.errorMessageTitle', + { defaultMessage: 'Suspend process action failure' } + )} + data-test-subj="suspendProcessErrorCallout" + > + <FormattedMessage + id="xpack.securitySolution.endpointResponseActions.suspendProcess.errorMessage" + defaultMessage="The following errors were encountered: {errors}" + values={{ errors: completedActionDetails.errors.join(' | ') }} + /> + </ResultComponent> + ); + } + + // Show Success + return ( + <ResultComponent + title={i18n.translate( + 'xpack.securitySolution.endpointResponseActions.suspendProcess.successMessageTitle', + { defaultMessage: 'Process suspended successfully' } + )} + data-test-subj="suspendProcessSuccessCallout" + /> + ); +}); +SuspendProcessActionResult.displayName = 'SuspendProcessActionResult'; diff --git a/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_send_kill_process_endpoint_request.ts b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_send_kill_process_endpoint_request.ts index f3051890871ee..d194dd50724bb 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_send_kill_process_endpoint_request.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_send_kill_process_endpoint_request.ts @@ -8,7 +8,7 @@ import { useMutation, UseMutationOptions, UseMutationResult } from 'react-query'; import { HttpFetchError } from '@kbn/core/public'; import type { - KillProcessRequestBody, + KillOrSuspendProcessRequestBody, ResponseActionApiResponse, } from '../../../../common/endpoint/types'; import { killProcess } from '../../../common/lib/process_actions'; @@ -21,11 +21,15 @@ export const useSendKillProcessRequest = ( customOptions?: UseMutationOptions< ResponseActionApiResponse, HttpFetchError, - KillProcessRequestBody + KillOrSuspendProcessRequestBody > -): UseMutationResult<ResponseActionApiResponse, HttpFetchError, KillProcessRequestBody> => { - return useMutation<ResponseActionApiResponse, HttpFetchError, KillProcessRequestBody>( - (processData: KillProcessRequestBody) => { +): UseMutationResult< + ResponseActionApiResponse, + HttpFetchError, + KillOrSuspendProcessRequestBody +> => { + return useMutation<ResponseActionApiResponse, HttpFetchError, KillOrSuspendProcessRequestBody>( + (processData: KillOrSuspendProcessRequestBody) => { return killProcess(processData); }, customOptions diff --git a/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_send_suspend_process_endpoint_request.ts b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_send_suspend_process_endpoint_request.ts new file mode 100644 index 0000000000000..ba345789eef65 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/hooks/endpoint/use_send_suspend_process_endpoint_request.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation } from 'react-query'; +import type { UseMutationOptions, UseMutationResult } from 'react-query'; +import type { HttpFetchError } from '@kbn/core/public'; +import type { + KillOrSuspendProcessRequestBody, + ResponseActionApiResponse, +} from '../../../../common/endpoint/types'; +import { suspendProcess } from '../../../common/lib/process_actions'; + +/** + * Create kill process requests + * @param customOptions + */ +export const useSendSuspendProcessRequest = ( + customOptions?: UseMutationOptions< + ResponseActionApiResponse, + HttpFetchError, + KillOrSuspendProcessRequestBody + > +): UseMutationResult< + ResponseActionApiResponse, + HttpFetchError, + KillOrSuspendProcessRequestBody +> => { + return useMutation<ResponseActionApiResponse, HttpFetchError, KillOrSuspendProcessRequestBody>( + (processData: KillOrSuspendProcessRequestBody) => { + return suspendProcess(processData); + }, + customOptions + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index cdbdb50e9edd4..cf7e30793ba7f 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -37,7 +37,10 @@ import { import { licenseService } from '../common/hooks/use_license'; import { LinkItem } from '../common/links/types'; import { StartPlugins } from '../types'; - +import { + manageCategories as cloudSecurityPostureCategories, + manageLinks as cloudSecurityPostureLinks, +} from '../cloud_security_posture/links'; import { IconBlocklist } from './icons/blocklist'; import { IconEndpoints } from './icons/endpoints'; import { IconEndpointPolicies } from './icons/endpoint_policies'; @@ -68,6 +71,7 @@ const categories = [ SecurityPageName.blocklist, ], }, + ...cloudSecurityPostureCategories, ]; export const links: LinkItem = { @@ -77,7 +81,7 @@ export const links: LinkItem = { skipUrlState: true, hideTimeline: true, globalNavEnabled: true, - globalNavOrder: 6, + globalNavOrder: 7, capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.manage', { @@ -197,6 +201,7 @@ export const links: LinkItem = { skipUrlState: true, hideTimeline: true, }, + cloudSecurityPostureLinks, ], }; diff --git a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts index 25d5b9e01207d..8f1751009041a 100644 --- a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts +++ b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts @@ -15,6 +15,7 @@ import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE, KILL_PROCESS_ROUTE, + SUSPEND_PROCESS_ROUTE, } from '../../../common/endpoint/constants'; import { httpHandlerMockFactory, @@ -36,6 +37,8 @@ export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{ killProcess: () => ActionDetailsApiResponse; + suspendProcess: () => ActionDetailsApiResponse; + actionDetails: (options: HttpFetchOptionsWithPath) => ActionDetailsApiResponse; actionList: (options: HttpFetchOptionsWithPath) => ActionListApiResponse; @@ -72,6 +75,16 @@ export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHt return { data: response }; }, }, + { + id: 'suspendProcess', + path: SUSPEND_PROCESS_ROUTE, + method: 'post', + handler: (): ActionDetailsApiResponse => { + const generator = new EndpointActionGenerator('seed'); + const response = generator.generateActionDetails() as ActionDetails; + return { data: response }; + }, + }, { id: 'actionDetails', path: ACTION_DETAILS_ROUTE, diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx index 2019811570888..4e40832a2d718 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx @@ -14,10 +14,11 @@ const MAX_CASES_TO_SHOW = 3; const RecentCasesComponent = () => { const { cases } = useKibana().services; - const userCanCrud = useGetUserCasesPermissions()?.crud ?? false; + const permissions = useGetUserCasesPermissions(); + const casesPermissions = { all: permissions.crud, read: permissions.read }; return cases.ui.getRecentCases({ - userCanCrud, + permissions: casesPermissions, maxCasesToShow: MAX_CASES_TO_SHOW, owner: [APP_ID], }); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx index f8a0e2e7c83b9..bde783afc3a2e 100644 --- a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx @@ -41,7 +41,7 @@ export const Sidebar = React.memo<{ ); // only render the recently created cases view if the user has at least read permissions - const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + const hasCasesReadPermissions = useGetUserCasesPermissions().read; return ( <EuiFlexGroup direction="column" responsive={false} gutterSize="l"> diff --git a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx index 0112de7612fe5..ee5f2400b1a95 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx @@ -53,7 +53,7 @@ const DetectionResponseComponent = () => { const { indicesExist, indexPattern, loading: isSourcererLoading } = useSourcererDataView(); const { signalIndexName } = useSignalIndex(); const { hasKibanaREAD, hasIndexRead } = useAlertsPrivileges(); - const canReadCases = useGetUserCasesPermissions()?.read; + const canReadCases = useGetUserCasesPermissions().read; const canReadAlerts = hasKibanaREAD && hasIndexRead; if (!canReadAlerts && !canReadCases) { diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 72ba80e182547..b9633c49849e5 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -323,6 +323,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S timelines: new subPluginClasses.Timelines(), management: new subPluginClasses.Management(), landingPages: new subPluginClasses.LandingPages(), + cloudSecurityPosture: new subPluginClasses.CloudSecurityPosture(), }; } return this._subPlugins; @@ -350,6 +351,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S kubernetes: subPlugins.kubernetes.start(), management: subPlugins.management.start(core, plugins), landingPages: subPlugins.landingPages.start(), + cloudSecurityPosture: subPlugins.cloudSecurityPosture.start(), }; } /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx index b4466a3640a90..565acd1dbea22 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { mockTimelineModel, TestProviders } from '../../../../common/mock'; import { AddToCaseButton } from '.'; @@ -35,6 +35,13 @@ jest.mock('react-redux', () => { }); jest.mock('../../../../common/lib/kibana'); +const originalKibanaLib = jest.requireActual('../../../../common/lib/kibana'); + +// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object +// The returned permissions object will indicate that the user does not have permissions by default +const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock; +mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions); + jest.mock('../../../../common/hooks/use_selector'); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index b48361202e54b..736fb23e4b8a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -68,6 +68,7 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { ); const userPermissions = useGetUserCasesPermissions(); + const casesPermissions = { all: userPermissions.crud, read: userPermissions.read }; const handleButtonClick = useCallback(() => { setPopover((currentIsOpen) => !currentIsOpen); @@ -163,8 +164,8 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => { {isCaseModalOpen && cases.ui.getAllCasesSelectorModal({ onRowClick, - userCanCrud: userPermissions?.crud ?? false, owner: [APP_ID], + permissions: casesPermissions, })} </> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx index 944d061c9cca2..3471b65a73b34 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx @@ -13,7 +13,11 @@ import { TimelineId } from '../../../../../../common/types/timeline'; import { Ecs } from '../../../../../../common/ecs'; import { mockAlertDetailsData } from '../../../../../common/components/event_details/__mocks__'; import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy'; -import { KibanaServices, useKibana } from '../../../../../common/lib/kibana'; +import { + KibanaServices, + useKibana, + useGetUserCasesPermissions, +} from '../../../../../common/lib/kibana'; import { coreMock } from '@kbn/core/public/mocks'; import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; @@ -64,7 +68,15 @@ jest.mock('../../../../../common/hooks/use_experimental_features', () => ({ jest.mock('../../../../../detections/components/user_info', () => ({ useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), })); + jest.mock('../../../../../common/lib/kibana'); +const originalKibanaLib = jest.requireActual('../../../../../common/lib/kibana'); + +// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object +// The returned permissions object will indicate that the user does not have permissions by default +const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock; +mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions); + jest.mock( '../../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', () => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index 509e1b09929b4..0ddc739982072 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -33,33 +33,37 @@ jest.mock( }) ); -jest.mock('../../../../../common/lib/kibana', () => ({ - useKibana: () => ({ - services: { - application: { - navigateToApp: jest.fn(), - getUrlForApp: jest.fn(), - capabilities: { - siem: { crud_alerts: true, read_alerts: true }, +jest.mock('../../../../../common/lib/kibana', () => { + const originalKibanaLib = jest.requireActual('../../../../../common/lib/kibana'); + + return { + useKibana: () => ({ + services: { + application: { + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(), + capabilities: { + siem: { crud_alerts: true, read_alerts: true }, + }, }, + cases: mockCasesContract(), + uiSettings: { + get: jest.fn(), + }, + savedObjects: { + client: {}, + }, + timelines: { ...mockTimelines }, }, - cases: mockCasesContract(), - uiSettings: { - get: jest.fn(), - }, - savedObjects: { - client: {}, - }, - timelines: { ...mockTimelines }, - }, - }), - useToasts: jest.fn().mockReturnValue({ - addError: jest.fn(), - addSuccess: jest.fn(), - addWarning: jest.fn(), - }), - useGetUserCasesPermissions: jest.fn(), -})); + }), + useToasts: jest.fn().mockReturnValue({ + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + }), + useGetUserCasesPermissions: originalKibanaLib.useGetUserCasesPermissions, + }; +}); const defaultProps = { ariaRowindex: 2, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 59b331d4d7f11..6d329034650f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -39,29 +39,33 @@ jest.mock('../../../../../common/components/user_privileges', () => { }; }); -jest.mock('../../../../../common/lib/kibana', () => ({ - useKibana: () => ({ - services: { - timelines: { ...mockTimelines }, - data: { - search: jest.fn(), - query: jest.fn(), - }, - application: { - capabilities: { - siem: { crud_alerts: true, read_alerts: true }, +jest.mock('../../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../../common/lib/kibana'); + + return { + useKibana: () => ({ + services: { + timelines: { ...mockTimelines }, + data: { + search: jest.fn(), + query: jest.fn(), }, + application: { + capabilities: { + siem: { crud_alerts: true, read_alerts: true }, + }, + }, + cases: mockCasesContract(), }, - cases: mockCasesContract(), - }, - }), - useToasts: jest.fn().mockReturnValue({ - addError: jest.fn(), - addSuccess: jest.fn(), - addWarning: jest.fn(), - }), - useGetUserCasesPermissions: jest.fn(), -})); + }), + useToasts: jest.fn().mockReturnValue({ + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + }), + useGetUserCasesPermissions: originalModule.useGetUserCasesPermissions, + }; +}); describe('EventColumnView', () => { useIsExperimentalFeatureEnabledMock.mockReturnValue(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index f2045327a42f7..f87baf3fd055a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -34,7 +34,6 @@ import { TimelineTabs } from '../../../../../common/types/timeline'; import { defaultRowRenderers } from './renderers'; import { createStore, State } from '../../../../common/store'; -jest.mock('../../../../common/lib/kibana/hooks'); jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/components/user_privileges', () => { return { @@ -84,7 +83,6 @@ jest.mock('../../../../common/lib/kibana', () => { }, }, }), - useGetUserSavedObjectPermissions: jest.fn(), }; }); diff --git a/x-pack/plugins/security_solution/public/timelines/links.ts b/x-pack/plugins/security_solution/public/timelines/links.ts index 263070c292ec1..7b18b11f6bf7b 100644 --- a/x-pack/plugins/security_solution/public/timelines/links.ts +++ b/x-pack/plugins/security_solution/public/timelines/links.ts @@ -15,7 +15,7 @@ export const links: LinkItem = { title: TIMELINES, path: TIMELINES_PATH, globalNavEnabled: true, - globalNavOrder: 3, + globalNavOrder: 4, capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.timelines', { diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index cf63df9ca0acb..ad17fa0652491 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -47,7 +47,8 @@ import type { Overview } from './overview'; import type { Rules } from './rules'; import type { Timelines } from './timelines'; import type { Management } from './management'; -import { LandingPages } from './landing_pages'; +import type { LandingPages } from './landing_pages'; +import type { CloudSecurityPosture } from './cloud_security_posture'; export interface SetupPlugins { home?: HomePublicPluginSetup; @@ -114,6 +115,7 @@ export interface SubPlugins { timelines: Timelines; management: Management; landingPages: LandingPages; + cloudSecurityPosture: CloudSecurityPosture; } // TODO: find a better way to defined these types @@ -130,4 +132,5 @@ export interface StartedSubPlugins { timelines: ReturnType<Timelines['start']>; management: ReturnType<Management['start']>; landingPages: ReturnType<LandingPages['start']>; + cloudSecurityPosture: ReturnType<CloudSecurityPosture['start']>; } diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index f780ee704e319..0d40b03f22081 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -8,7 +8,7 @@ import Boom from '@hapi/boom'; // @ts-ignore -import type { CoreSetup, IBasePath, IRouter } from '@kbn/core/server'; +import type { CoreSetup, IBasePath, IRouter, RequestHandlerContext } from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { coreMock, elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import * as kbnTestServer from '@kbn/core/test_helpers/kbn_server'; @@ -153,7 +153,7 @@ describe.skip('onPostAuthInterceptor', () => { getSpacesService: () => spacesServiceStart, }); - const router = http.createRouter('/'); + const router = http.createRouter<RequestHandlerContext>('/'); initKbnServer(router, http.basePath); diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts index 8e3eb5a555212..7dd66381b4be3 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts @@ -12,6 +12,7 @@ import type { IRouter, KibanaRequest, KibanaResponseFactory, + RequestHandlerContext, } from '@kbn/core/server'; import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import * as kbnTestServer from '@kbn/core/test_helpers/kbn_server'; @@ -89,7 +90,7 @@ describe.skip('onRequestInterceptor', () => { http: http as unknown as CoreSetup['http'], }); - const router = http.createRouter('/'); + const router = http.createRouter<RequestHandlerContext>('/'); initKbnServer(router, http.basePath); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 141eed06e7ef8..b9ea4ea54a7f1 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -776,7 +776,7 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo( } helpText={i18n.translate('xpack.transform.stepDetailsForm.frequencyHelpText', { defaultMessage: - 'The interval between checks for changes in the source indices when the transform is running continuously. Also determines the retry interval in the event of transient failures while the transform is searching or indexing. The minimum value is 1s and the maximum is 1h.', + 'The interval to check for changes in source indices when the transformation runs continuously.', })} > <EuiFieldText @@ -814,7 +814,7 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo( 'xpack.transform.stepDetailsForm.maxPageSearchSizeHelpText', { defaultMessage: - 'Defines the initial page size to use for the composite aggregation for each checkpoint.', + 'The initial page size to use for the composite aggregation for each checkpoint.', } )} > diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c0e84f4b21693..98d0c1f15dbc8 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -22409,16 +22409,10 @@ "xpack.observability.resources.quick_start": "Vidéos de démarrage rapide", "xpack.observability.resources.title": "Ressources", "xpack.observability.resources.training": "Cours gratuit Observability", - "xpack.observability.ruleDetails.actions": "Actions", "xpack.observability.ruleDetails.alerts": "Alertes", "xpack.observability.ruleDetails.byWord": "par", - "xpack.observability.ruleDetails.conditions": "condition{s}", - "xpack.observability.ruleDetails.conditionsTitle": "Conditions", - "xpack.observability.ruleDetails.connectorsLoadError": "Impossible de charger les connecteurs d'actions de règles. Raison : {message}", "xpack.observability.ruleDetails.createdWord": "Créé", - "xpack.observability.ruleDetails.definition": "Définition", "xpack.observability.ruleDetails.deleteRule": "Supprimer la règle", - "xpack.observability.ruleDetails.description": "Description", "xpack.observability.ruleDetails.editRule": "Modifier la règle", "xpack.observability.ruleDetails.errorPromptBody": "Une erreur s'est produite lors du chargement des détails de la règle.", "xpack.observability.ruleDetails.errorPromptTitle": "Impossible de charger les détails de la règle", @@ -22427,15 +22421,11 @@ "xpack.observability.ruleDetails.last24h": "(dernières 24 h)", "xpack.observability.ruleDetails.lastRun": "Dernière exécution", "xpack.observability.ruleDetails.lastUpdatedMessage": "Dernière mise à jour", - "xpack.observability.ruleDetails.noActions": "Aucune action", - "xpack.observability.ruleDetails.notifyWhen": "Notifier", "xpack.observability.ruleDetails.onWord": "le", "xpack.observability.ruleDetails.rule.alertsTabText": "Alertes", "xpack.observability.ruleDetails.rule.eventLogTabText": "Historique d'exécution", "xpack.observability.ruleDetails.ruleIs": "La règle est", "xpack.observability.ruleDetails.ruleLoadError": "Impossible de charger la règle. Raison : {message}", - "xpack.observability.ruleDetails.ruleType": "Type de règle", - "xpack.observability.ruleDetails.runsEvery": "S'exécute toutes les", "xpack.observability.ruleDetails.tagsTitle": "Balises", "xpack.observability.ruleDetails.triggreAction.status": "Statut", "xpack.observability.rules.addRuleButtonLabel": "Créer une règle", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 74aabf615fb5a..6b6e5888cc62d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22395,16 +22395,10 @@ "xpack.observability.resources.quick_start": "クイックスタートビデオ", "xpack.observability.resources.title": "リソース", "xpack.observability.resources.training": "無料のObservabilityコース", - "xpack.observability.ruleDetails.actions": "アクション", "xpack.observability.ruleDetails.alerts": "アラート", "xpack.observability.ruleDetails.byWord": "グループ基準", - "xpack.observability.ruleDetails.conditions": "条件{s}", - "xpack.observability.ruleDetails.conditionsTitle": "条件", - "xpack.observability.ruleDetails.connectorsLoadError": "ルールアクションコネクターを読み込めません。理由:{message}", "xpack.observability.ruleDetails.createdWord": "作成済み", - "xpack.observability.ruleDetails.definition": "定義", "xpack.observability.ruleDetails.deleteRule": "ルールの削除", - "xpack.observability.ruleDetails.description": "説明", "xpack.observability.ruleDetails.editRule": "ルールを編集", "xpack.observability.ruleDetails.errorPromptBody": "ルール詳細の読み込みエラーが発生しました。", "xpack.observability.ruleDetails.errorPromptTitle": "ルール詳細を読み込めません", @@ -22413,15 +22407,11 @@ "xpack.observability.ruleDetails.last24h": "(過去24時間)", "xpack.observability.ruleDetails.lastRun": "前回の実行", "xpack.observability.ruleDetails.lastUpdatedMessage": "最終更新", - "xpack.observability.ruleDetails.noActions": "アクションなし", - "xpack.observability.ruleDetails.notifyWhen": "通知", "xpack.observability.ruleDetails.onWord": "日付", "xpack.observability.ruleDetails.rule.alertsTabText": "アラート", "xpack.observability.ruleDetails.rule.eventLogTabText": "実行履歴", "xpack.observability.ruleDetails.ruleIs": "ルールは", "xpack.observability.ruleDetails.ruleLoadError": "ルールを読み込めません。理由:{message}", - "xpack.observability.ruleDetails.ruleType": "ルールタイプ", - "xpack.observability.ruleDetails.runsEvery": "次の間隔で実行", "xpack.observability.ruleDetails.tagsTitle": "タグ", "xpack.observability.ruleDetails.triggreAction.status": "ステータス", "xpack.observability.rules.addRuleButtonLabel": "ルールを作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 38e6fb9b55601..e1a451d691c34 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22420,15 +22420,10 @@ "xpack.observability.resources.quick_start": "快速入门视频", "xpack.observability.resources.title": "资源", "xpack.observability.resources.training": "免费的可观测性课程", - "xpack.observability.ruleDetails.actions": "操作", "xpack.observability.ruleDetails.alerts": "告警", "xpack.observability.ruleDetails.byWord": "依据", - "xpack.observability.ruleDetails.conditionsTitle": "条件", - "xpack.observability.ruleDetails.connectorsLoadError": "无法加载规则操作连接器。原因:{message}", "xpack.observability.ruleDetails.createdWord": "创建时间", - "xpack.observability.ruleDetails.definition": "定义", "xpack.observability.ruleDetails.deleteRule": "删除规则", - "xpack.observability.ruleDetails.description": "描述", "xpack.observability.ruleDetails.editRule": "编辑规则", "xpack.observability.ruleDetails.errorPromptBody": "加载规则详情时出现错误。", "xpack.observability.ruleDetails.errorPromptTitle": "无法加载规则详情", @@ -22437,15 +22432,11 @@ "xpack.observability.ruleDetails.last24h": "(过去 24 小时)", "xpack.observability.ruleDetails.lastRun": "上次运行", "xpack.observability.ruleDetails.lastUpdatedMessage": "上次更新时间", - "xpack.observability.ruleDetails.noActions": "无操作", - "xpack.observability.ruleDetails.notifyWhen": "通知", "xpack.observability.ruleDetails.onWord": "在", "xpack.observability.ruleDetails.rule.alertsTabText": "告警", "xpack.observability.ruleDetails.rule.eventLogTabText": "执行历史记录", "xpack.observability.ruleDetails.ruleIs": "规则为", "xpack.observability.ruleDetails.ruleLoadError": "无法加载规则。原因:{message}", - "xpack.observability.ruleDetails.ruleType": "规则类型", - "xpack.observability.ruleDetails.runsEvery": "运行间隔", "xpack.observability.ruleDetails.tagsTitle": "标签", "xpack.observability.ruleDetails.triggreAction.status": "状态", "xpack.observability.rules.addRuleButtonLabel": "创建规则", diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule_action_connectors.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_fetch_rule_action_connectors.ts similarity index 66% rename from x-pack/plugins/observability/public/hooks/use_fetch_rule_action_connectors.ts rename to x-pack/plugins/triggers_actions_ui/public/application/hooks/use_fetch_rule_action_connectors.ts index cae7607f1d084..4e89e43d20e13 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rule_action_connectors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_fetch_rule_action_connectors.ts @@ -6,21 +6,31 @@ */ import { useEffect, useState, useCallback } from 'react'; -import { ActionConnector, loadAllActions } from '@kbn/triggers-actions-ui-plugin/public'; import { intersectionBy } from 'lodash'; -import { FetchRuleActionConnectorsProps } from '../pages/rule_details/types'; -import { ACTIONS_LOAD_ERROR } from '../pages/rule_details/translations'; +import { i18n } from '@kbn/i18n'; +import { ActionConnector, loadAllActions } from '../..'; +import { useKibana } from '../../common/lib/kibana'; +const ACTIONS_LOAD_ERROR = (errorMessage: string) => + i18n.translate('xpack.triggersActionsUI.ruleDetails.connectorsLoadError', { + defaultMessage: 'Unable to load rule actions connectors. Reason: {message}', + values: { message: errorMessage }, + }); interface FetchActionConnectors { isLoadingActionConnectors: boolean; actionConnectors: Array<ActionConnector<Record<string, unknown>>>; errorActionConnectors?: string; } +interface FetchRuleActionConnectorsProps { + ruleActions: any[]; +} + +export function useFetchRuleActionConnectors({ ruleActions }: FetchRuleActionConnectorsProps) { + const { + http, + notifications: { toasts }, + } = useKibana().services; -export function useFetchRuleActionConnectors({ - http, - ruleActions, -}: FetchRuleActionConnectorsProps) { const [actionConnectors, setActionConnector] = useState<FetchActionConnectors>({ isLoadingActionConnectors: true, actionConnectors: [] as Array<ActionConnector<Record<string, unknown>>>, @@ -47,15 +57,17 @@ export function useFetchRuleActionConnectors({ actionConnectors: actions, })); } catch (error) { + const errorMsg = ACTIONS_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ); setActionConnector((oldState: FetchActionConnectors) => ({ ...oldState, isLoadingActionConnectors: false, - errorActionConnectors: ACTIONS_LOAD_ERROR( - error instanceof Error ? error.message : typeof error === 'string' ? error : '' - ), + errorActionConnectors: errorMsg, })); + toasts.addDanger({ title: errorMsg }); } - }, [http, ruleActions]); + }, [http, ruleActions, toasts]); useEffect(() => { fetchRuleActionConnectors(); }, [fetchRuleActionConnectors]); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx index 749ddb2ced7e5..063f766c33ad0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx @@ -49,6 +49,9 @@ export const RulesList = suspendedComponentWithProps( export const RulesListNotifyBadge = suspendedComponentWithProps( lazy(() => import('./rules_list/components/rules_list_notify_badge')) ); +export const RuleDefinition = suspendedComponentWithProps( + lazy(() => import('./rule_details/components/rule_definition')) +); export const RuleTagBadge = suspendedComponentWithProps( lazy(() => import('./rules_list/components/rule_tag_badge')) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.test.tsx new file mode 100644 index 0000000000000..2f0fea9657a61 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { mount } from 'enzyme'; +import { nextTick } from '@kbn/test-jest-helpers'; +import { act } from 'react-dom/test-utils'; +import { RuleActions } from './rule_actions'; +import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; +import { ActionConnector, ActionTypeModel, RuleAction } from '../../../../types'; +import * as useFetchRuleActionConnectorsHook from '../../../hooks/use_fetch_rule_action_connectors'; + +const actionTypeRegistry = actionTypeRegistryMock.create(); +const mockedUseFetchRuleActionConnectorsHook = jest.spyOn( + useFetchRuleActionConnectorsHook, + 'useFetchRuleActionConnectors' +); +describe('Rule Actions', () => { + async function setup() { + const ruleActions = [ + { + id: '1', + group: 'metrics.inventory_threshold.fired', + actionTypeId: '.server-log', + params: {}, + }, + { + id: '2', + group: 'metrics.inventory_threshold.fired', + actionTypeId: '.slack', + params: {}, + }, + ] as RuleAction[]; + + mockedUseFetchRuleActionConnectorsHook.mockReturnValue({ + isLoadingActionConnectors: false, + actionConnectors: [ + { + id: 'f57cabc0-e660-11ec-8241-7deb55b17f15', + name: 'logs', + config: {}, + actionTypeId: '.server-log', + }, + { + id: '05b7ab30-e683-11ec-843b-213c67313f8c', + name: 'Slack', + actionTypeId: '.slack', + }, + ] as Array<ActionConnector<Record<string, unknown>>>, + errorActionConnectors: undefined, + reloadRuleActionConnectors: jest.fn(), + }); + + actionTypeRegistry.list.mockReturnValue([ + { id: '.server-log', iconClass: 'logsApp' }, + { id: '.slack', iconClass: 'logoSlack' }, + { id: '.email', iconClass: 'email' }, + { id: '.index', iconClass: 'indexOpen' }, + ] as ActionTypeModel[]); + + const wrapper = mount( + <RuleActions ruleActions={ruleActions} actionTypeRegistry={actionTypeRegistry} /> + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + return wrapper; + } + + it("renders rule action connector icons for user's selected rule actions", async () => { + const wrapper = await setup(); + expect(mockedUseFetchRuleActionConnectorsHook).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-euiicon-type]').length).toBe(2); + expect(wrapper.find('[data-euiicon-type="logsApp"]').length).toBe(1); + expect(wrapper.find('[data-euiicon-type="logoSlack"]').length).toBe(1); + expect(wrapper.find('[data-euiicon-type="index"]').length).toBe(0); + expect(wrapper.find('[data-euiicon-type="email"]').length).toBe(0); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx similarity index 70% rename from x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx index 7feb8c8d27186..ddae5bf09317b 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { EuiText, EuiSpacer, @@ -14,33 +14,24 @@ import { IconType, EuiLoadingSpinner, } from '@elastic/eui'; -import { suspendedComponentWithProps } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; -import { ActionsProps } from '../types'; +import { ActionTypeRegistryContract, RuleAction, suspendedComponentWithProps } from '../../../..'; import { useFetchRuleActionConnectors } from '../../../hooks/use_fetch_rule_action_connectors'; -import { useKibana } from '../../../utils/kibana_react'; -export function Actions({ ruleActions, actionTypeRegistry }: ActionsProps) { - const { - http, - notifications: { toasts }, - } = useKibana().services; - const { isLoadingActionConnectors, actionConnectors, errorActionConnectors } = - useFetchRuleActionConnectors({ - http, - ruleActions, - }); - useEffect(() => { - if (errorActionConnectors) { - toasts.addDanger({ title: errorActionConnectors }); - } - }, [errorActionConnectors, toasts]); +export interface RuleActionsProps { + ruleActions: RuleAction[]; + actionTypeRegistry: ActionTypeRegistryContract; +} +export function RuleActions({ ruleActions, actionTypeRegistry }: RuleActionsProps) { + const { isLoadingActionConnectors, actionConnectors } = useFetchRuleActionConnectors({ + ruleActions, + }); if (!actionConnectors || actionConnectors.length <= 0) return ( <EuiFlexItem> <EuiText size="s"> - {i18n.translate('xpack.observability.ruleDetails.noActions', { + {i18n.translate('xpack.triggersActionsUI.ruleDetails.noActions', { defaultMessage: 'No actions', })} </EuiText> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx new file mode 100644 index 0000000000000..4b065bcd63aa9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx @@ -0,0 +1,222 @@ +/* + * 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 { mount, ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; +import { nextTick } from '@kbn/test-jest-helpers'; +import { RuleDefinition } from './rule_definition'; +import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; +import { ActionTypeModel, Rule, RuleTypeModel } from '../../../../types'; +import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; + +jest.mock('./rule_actions', () => ({ + RuleActions: () => { + return <></>; + }, +})); + +jest.mock('../../../lib/capabilities', () => ({ + hasAllPrivilege: jest.fn(() => true), + hasSaveRulesCapability: jest.fn(() => true), + hasExecuteActionsCapability: jest.fn(() => true), + hasManageApiKeysCapability: jest.fn(() => true), +})); +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../..', () => ({ + useLoadRuleTypes: jest.fn(), +})); +const { useLoadRuleTypes } = jest.requireMock('../../../..'); +const ruleTypes = [ + { + id: 'test_rule_type', + name: 'some rule type', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + actionVariables: { context: [], state: [] }, + defaultActionGroupId: 'default', + producer: ALERTS_FEATURE_ID, + minimumLicenseRequired: 'basic', + enabledInLicense: true, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: false }, + }, + ruleTaskTimeout: '1m', + }, +]; + +const mockedRuleTypeIndex = new Map( + Object.entries({ + test_rule_type: { + enabledInLicense: true, + id: 'test_rule_type', + name: 'test rule', + }, + '2': { + enabledInLicense: true, + id: '2', + name: 'test rule ok', + }, + '3': { + enabledInLicense: true, + id: '3', + name: 'test rule pending', + }, + }) +); + +describe('Rule Definition', () => { + let wrapper: ReactWrapper; + async function setup() { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const ruleTypeRegistry = ruleTypeRegistryMock.create(); + const mockedRule = mockRule(); + jest.mock('../../../lib/capabilities', () => ({ + hasAllPrivilege: jest.fn(() => true), + hasSaveRulesCapability: jest.fn(() => true), + hasExecuteActionsCapability: jest.fn(() => true), + hasManageApiKeysCapability: jest.fn(() => true), + })); + ruleTypeRegistry.has.mockReturnValue(true); + const ruleTypeR: RuleTypeModel = { + id: 'my-rule-type', + iconClass: 'test', + description: 'Rule when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + ruleParamsExpression: jest.fn(), + requiresAppContext: false, + }; + ruleTypeRegistry.get.mockReturnValue(ruleTypeR); + actionTypeRegistry.list.mockReturnValue([ + { id: '.server-log', iconClass: 'logsApp' }, + { id: '.slack', iconClass: 'logoSlack' }, + { id: '.email', iconClass: 'email' }, + { id: '.index', iconClass: 'indexOpen' }, + ] as ActionTypeModel[]); + + useLoadRuleTypes.mockReturnValue({ ruleTypes, ruleTypeIndex: mockedRuleTypeIndex }); + + wrapper = mount( + <RuleDefinition + rule={mockedRule} + actionTypeRegistry={actionTypeRegistry} + onEditRule={jest.fn()} + ruleTypeRegistry={ruleTypeRegistry} + /> + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + beforeAll(async () => await setup()); + + it('renders rule definition ', async () => { + expect(wrapper.find('[data-test-subj="ruleSummaryRuleDefinition"]')).toBeTruthy(); + }); + + it('show rule type name from "useLoadRuleTypes"', async () => { + expect(useLoadRuleTypes).toHaveBeenCalledTimes(2); + const ruleType = wrapper.find('[data-test-subj="ruleSummaryRuleType"]'); + expect(ruleType).toBeTruthy(); + expect(ruleType.find('div.euiText').text()).toEqual( + mockedRuleTypeIndex.get(mockRule().ruleTypeId)?.name + ); + }); + + it('show rule type description "', async () => { + const ruleDescription = wrapper.find('[data-test-subj="ruleSummaryRuleDescription"]'); + expect(ruleDescription).toBeTruthy(); + expect(ruleDescription.find('div.euiText').text()).toEqual('Rule when testing'); + }); + + it('show rule conditions "', async () => { + const ruleConditions = wrapper.find('[data-test-subj="ruleSummaryRuleConditions"]'); + expect(ruleConditions).toBeTruthy(); + expect(ruleConditions.find('div.euiText').text()).toEqual(`0 conditions`); + }); + + it('show rule interval with human readable value', async () => { + const ruleInterval = wrapper.find('[data-test-subj="ruleSummaryRuleInterval"]'); + expect(ruleInterval).toBeTruthy(); + expect(ruleInterval.find('div.euiText').text()).toEqual('1 sec'); + }); + + it('show edit button when user has permissions', async () => { + const editButton = wrapper.find('[data-test-subj="ruleDetailsEditButton"]'); + expect(editButton).toBeTruthy(); + }); + + it('hide edit button when user DOES NOT have permissions', async () => { + jest.mock('../../../lib/capabilities', () => ({ + hasAllPrivilege: jest.fn(() => false), + hasSaveRulesCapability: jest.fn(() => true), + hasExecuteActionsCapability: jest.fn(() => true), + hasManageApiKeysCapability: jest.fn(() => true), + })); + const editButton = wrapper.find('[data-test-subj="ruleDetailsEditButton"]'); + expect(editButton).toMatchObject({}); + }); +}); +function mockRule(): Rule { + return { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + createdAt: new Date(), + updatedAt: new Date(), + consumer: 'alerts', + notifyWhen: 'onActiveAlert', + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + timestamp: 1234567, + }, + { + success: true, + duration: 200000, + timestamp: 1234567, + }, + { + success: false, + duration: 300000, + timestamp: 1234567, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx new file mode 100644 index 0000000000000..7b74e414d200a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx @@ -0,0 +1,238 @@ +/* + * 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, { useState, useEffect, useMemo } from 'react'; +import { + EuiText, + EuiSpacer, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { formatDuration } from '@kbn/alerting-plugin/common'; +import { RuleDefinitionProps } from '../../../../types'; +import { RuleType, useLoadRuleTypes } from '../../../..'; +import { useKibana } from '../../../../common/lib/kibana'; +import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; +import { NOTIFY_WHEN_OPTIONS } from '../../rule_form/rule_notify_when'; +import { RuleActions } from './rule_actions'; +import { RuleEdit } from '../../rule_form'; + +const OBSERVABILITY_SOLUTIONS = ['logs', 'uptime', 'infrastructure', 'apm']; + +export const RuleDefinition: React.FunctionComponent<RuleDefinitionProps> = ({ + rule, + actionTypeRegistry, + ruleTypeRegistry, + onEditRule, +}) => { + const { + application: { capabilities }, + } = useKibana().services; + + const [editFlyoutVisible, setEditFlyoutVisible] = useState<boolean>(false); + const [ruleType, setRuleType] = useState<RuleType>(); + const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + + const getRuleType = useMemo(() => { + if (ruleTypes.length && rule) { + return ruleTypes.find((type) => type.id === rule.ruleTypeId); + } + }, [rule, ruleTypes]); + + useEffect(() => { + setRuleType(getRuleType); + }, [getRuleType]); + + const getRuleConditionsWording = () => { + const numberOfConditions = rule?.params.criteria ? (rule?.params.criteria as any[]).length : 0; + return i18n.translate('xpack.triggersActionsUI.ruleDetails.conditions', { + defaultMessage: '{numberOfConditions, plural, one {# condition} other {# conditions}}', + values: { numberOfConditions }, + }); + }; + const getNotifyText = () => + NOTIFY_WHEN_OPTIONS.find((options) => options.value === rule?.notifyWhen)?.inputDisplay || + rule?.notifyWhen; + + const canExecuteActions = hasExecuteActionsCapability(capabilities); + const canSaveRule = + rule && + hasAllPrivilege(rule, ruleType) && + // if the rule has actions, can the user save the rule's action params + (canExecuteActions || (!canExecuteActions && rule.actions.length === 0)); + const hasEditButton = + // can the user save the rule + canSaveRule && + // is this rule type editable from within Rules Management + (ruleTypeRegistry.has(rule.ruleTypeId) + ? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext + : false); + return ( + <EuiFlexItem data-test-subj="ruleSummaryRuleDefinition" grow={3}> + <EuiPanel color="subdued" hasBorder={false} paddingSize={'m'}> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiTitle size="s"> + <EuiFlexItem grow={false}> + {i18n.translate('xpack.triggersActionsUI.ruleDetails.definition', { + defaultMessage: 'Definition', + })} + </EuiFlexItem> + </EuiTitle> + {hasEditButton && ( + <EuiFlexItem grow={false}> + <EuiButtonEmpty + data-test-subj="ruleDetailsEditButton" + iconType={'pencil'} + onClick={() => setEditFlyoutVisible(true)} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + <EuiFlexGroup alignItems="baseline"> + <EuiFlexItem> + <EuiFlexGroup> + <ItemTitleRuleSummary> + {i18n.translate('xpack.triggersActionsUI.ruleDetails.ruleType', { + defaultMessage: 'Rule type', + })} + </ItemTitleRuleSummary> + <ItemValueRuleSummary + data-test-subj="ruleSummaryRuleType" + itemValue={ruleTypeIndex.get(rule.ruleTypeId)?.name || rule.ruleTypeId} + /> + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + <EuiFlexGroup alignItems="flexStart" responsive={false}> + <ItemTitleRuleSummary> + {i18n.translate('xpack.triggersActionsUI.ruleDetails.description', { + defaultMessage: 'Description', + })} + </ItemTitleRuleSummary> + <ItemValueRuleSummary + data-test-subj="ruleSummaryRuleDescription" + itemValue={ruleTypeRegistry.get(rule.ruleTypeId).description} + /> + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + <EuiFlexGroup> + <ItemTitleRuleSummary> + {i18n.translate('xpack.triggersActionsUI.ruleDetails.conditionsTitle', { + defaultMessage: 'Conditions', + })} + </ItemTitleRuleSummary> + <EuiFlexItem grow={3}> + <EuiFlexGroup data-test-subj="ruleSummaryRuleConditions" alignItems="center"> + {hasEditButton ? ( + <EuiButtonEmpty onClick={() => setEditFlyoutVisible(true)}> + <EuiText size="s">{getRuleConditionsWording()}</EuiText> + </EuiButtonEmpty> + ) : ( + <EuiText size="s">{getRuleConditionsWording()}</EuiText> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup> + <ItemTitleRuleSummary> + {i18n.translate('xpack.triggersActionsUI.ruleDetails.runsEvery', { + defaultMessage: 'Runs every', + })} + </ItemTitleRuleSummary> + + <ItemValueRuleSummary + data-test-subj="ruleSummaryRuleInterval" + itemValue={formatDuration(rule.schedule.interval)} + /> + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + <EuiFlexGroup> + <ItemTitleRuleSummary> + {i18n.translate('xpack.triggersActionsUI.ruleDetails.notifyWhen', { + defaultMessage: 'Notify', + })} + </ItemTitleRuleSummary> + <ItemValueRuleSummary itemValue={String(getNotifyText())} /> + </EuiFlexGroup> + + <EuiSpacer size="m" /> + <EuiFlexGroup alignItems="baseline"> + <ItemTitleRuleSummary> + {i18n.translate('xpack.triggersActionsUI.ruleDetails.actions', { + defaultMessage: 'Actions', + })} + </ItemTitleRuleSummary> + <EuiFlexItem grow={3}> + <RuleActions ruleActions={rule.actions} actionTypeRegistry={actionTypeRegistry} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + {editFlyoutVisible && ( + <RuleEdit + onSave={() => { + setEditFlyoutVisible(false); + return onEditRule(); + }} + initialRule={rule} + onClose={() => setEditFlyoutVisible(false)} + ruleTypeRegistry={ruleTypeRegistry} + actionTypeRegistry={actionTypeRegistry} + /> + )} + </EuiFlexItem> + ); +}; + +export interface ItemTitleRuleSummaryProps { + children: string; +} +export interface ItemValueRuleSummaryProps { + itemValue: string; + extraSpace?: boolean; +} + +function ItemValueRuleSummary({ + itemValue, + extraSpace = true, + ...otherProps +}: ItemValueRuleSummaryProps) { + return ( + <EuiFlexItem grow={extraSpace ? 3 : 1} {...otherProps}> + <EuiText size="s">{itemValue}</EuiText> + </EuiFlexItem> + ); +} + +function ItemTitleRuleSummary({ children }: ItemTitleRuleSummaryProps) { + return ( + <EuiTitle size="xxs"> + <EuiFlexItem style={{ whiteSpace: 'nowrap' }} grow={1}> + {children} + </EuiFlexItem> + </EuiTitle> + ); +} + +// eslint-disable-next-line import/no-default-export +export { RuleDefinition as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rule_definition.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_definition.tsx new file mode 100644 index 0000000000000..66df2b2ea3b5e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_definition.tsx @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { RuleDefinition } from '../application/sections'; +import { RuleDefinitionProps } from '../types'; +export const getRuleDefinitionLazy = (props: RuleDefinitionProps) => <RuleDefinition {...props} />; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 12607b4fc2994..a55c1cf5c7c61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -39,6 +39,7 @@ export type { RuleEventLogListProps, AlertTableFlyoutComponent, GetRenderCellValue, + RuleDefinitionProps, } from './types'; export { diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 6c6e195c5e9bc..b86e2fa465fe2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -38,6 +38,7 @@ import { CreateConnectorFlyoutProps } from './application/sections/action_connec import { EditConnectorFlyoutProps } from './application/sections/action_connector_form/edit_connector_flyout'; import { getActionFormLazy } from './common/get_action_form'; import { ActionAccordionFormProps } from './application/sections/action_connector_form/action_form'; +import { getRuleDefinitionLazy } from './common/get_rule_definition'; import { getRuleStatusPanelLazy } from './common/get_rule_status_panel'; function createStartMock(): TriggersAndActionsUIPublicPluginStart { @@ -105,6 +106,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getRulesList: () => { return getRulesListLazy({ connectorServices }); }, + getRuleDefinition: (props) => { + return getRuleDefinitionLazy({ ...props, actionTypeRegistry, ruleTypeRegistry }); + }, getRuleStatusPanel: (props) => { return getRuleStatusPanelLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 3331509befcca..c249ae5c34d13 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -62,6 +62,7 @@ import type { CreateConnectorFlyoutProps, EditConnectorFlyoutProps, ConnectorServices, + RuleDefinitionProps, } from './types'; import { TriggersActionsUiConfigType } from '../common/types'; import { registerAlertsTableConfiguration } from './application/sections/alerts_table/alerts_page/register_alerts_table_configuration'; @@ -69,6 +70,7 @@ import { PLUGIN_ID } from './common/constants'; import type { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state'; import { getAlertsTableStateLazy } from './common/get_alerts_table_state'; import { ActionAccordionFormProps } from './application/sections/action_connector_form/action_form'; +import { getRuleDefinitionLazy } from './common/get_rule_definition'; import { RuleStatusPanelProps } from './application/sections/rule_details/components/rule_status_panel'; export interface TriggersAndActionsUIPublicPluginSetup { @@ -109,6 +111,7 @@ export interface TriggersAndActionsUIPublicPluginStart { props: RulesListNotifyBadgeProps ) => ReactElement<RulesListNotifyBadgeProps>; getRulesList: () => ReactElement; + getRuleDefinition: (props: RuleDefinitionProps) => ReactElement<RuleDefinitionProps>; getRuleStatusPanel: (props: RuleStatusPanelProps) => ReactElement<RuleStatusPanelProps>; } @@ -323,6 +326,15 @@ export class Plugin getRulesList: () => { return getRulesListLazy({ connectorServices: this.connectorServices! }); }, + getRuleDefinition: ( + props: Omit<RuleDefinitionProps, 'actionTypeRegistry' | 'ruleTypeRegistry'> + ) => { + return getRuleDefinitionLazy({ + ...props, + actionTypeRegistry: this.actionTypeRegistry, + ruleTypeRegistry: this.ruleTypeRegistry, + }); + }, getRuleStatusPanel: (props: RuleStatusPanelProps) => { return getRuleStatusPanelLazy(props); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 80572ebb1e7f4..5840580ec7006 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -343,6 +343,12 @@ export interface RuleAddProps<MetaData = Record<string, any>> { ruleTypeIndex?: RuleTypeIndex; filteredSolutions?: string[] | undefined; } +export interface RuleDefinitionProps { + rule: Rule; + ruleTypeRegistry: RuleTypeRegistryContract; + actionTypeRegistry: ActionTypeRegistryContract; + onEditRule: () => Promise<void>; +} export enum Percentiles { P50 = 'P50', diff --git a/x-pack/test/accessibility/apps/enterprise_search.ts b/x-pack/test/accessibility/apps/enterprise_search.ts index 0a1a5d68d9621..c2e6c4fa4a5c7 100644 --- a/x-pack/test/accessibility/apps/enterprise_search.ts +++ b/x-pack/test/accessibility/apps/enterprise_search.ts @@ -9,17 +9,21 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const a11y = getService('a11y'); - const esArchiver = getService('esArchiver'); const retry = getService('retry'); const testSubjects = getService('testSubjects'); const { common } = getPageObjects(['common']); + const kibanaServer = getService('kibanaServer'); describe('Enterprise Search Accessibility', () => { // NOTE: These accessibility tests currently only run against Enterprise Search in Kibana // without a sidecar Enterprise Search service/host configured, and as such only test // the basic setup guides and not the full application(s) before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); }); describe('Overview', () => { diff --git a/x-pack/test/accessibility/apps/kibana_overview.ts b/x-pack/test/accessibility/apps/kibana_overview.ts index 19af9c2828d35..373044c4bffc3 100644 --- a/x-pack/test/accessibility/apps/kibana_overview.ts +++ b/x-pack/test/accessibility/apps/kibana_overview.ts @@ -10,17 +10,16 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'home']); const a11y = getService('a11y'); + const kibanaServer = getService('kibanaServer'); describe('Kibana overview Accessibility', () => { - const esArchiver = getService('esArchiver'); - before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); + await kibanaServer.savedObjects.cleanStandardList(); await PageObjects.common.navigateToApp('kibanaOverview'); }); after(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/empty_kibana'); + await kibanaServer.savedObjects.cleanStandardList(); }); it('Kibana overview', async () => { diff --git a/x-pack/test/accessibility/apps/login_page.ts b/x-pack/test/accessibility/apps/login_page.ts index 6463e63fb2e49..3993d9ffcd72e 100644 --- a/x-pack/test/accessibility/apps/login_page.ts +++ b/x-pack/test/accessibility/apps/login_page.ts @@ -8,21 +8,21 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const a11y = getService('a11y'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); const PageObjects = getPageObjects(['common', 'security']); + const kibanaServer = getService('kibanaServer'); describe('Security Accessibility', () => { describe('Login Page', () => { before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); + await kibanaServer.savedObjects.cleanStandardList(); await PageObjects.security.forceLogout(); }); after(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/empty_kibana'); + await kibanaServer.savedObjects.cleanStandardList(); }); afterEach(async () => { diff --git a/x-pack/test/accessibility/apps/spaces.ts b/x-pack/test/accessibility/apps/spaces.ts index 6cc5c2dd73d70..20ce3062c3e02 100644 --- a/x-pack/test/accessibility/apps/spaces.ts +++ b/x-pack/test/accessibility/apps/spaces.ts @@ -13,17 +13,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'spaceSelector', 'home', 'header', 'security']); const a11y = getService('a11y'); const browser = getService('browser'); - const esArchiver = getService('esArchiver'); + const spacesService = getService('spaces'); + const testSubjects = getService('testSubjects'); const retry = getService('retry'); const toasts = getService('toasts'); + const kibanaServer = getService('kibanaServer'); - // Failing: See https://github.com/elastic/kibana/issues/135341 - describe.skip('Kibana Spaces Accessibility', () => { + describe('Kibana Spaces Accessibility', () => { before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); + await kibanaServer.savedObjects.cleanStandardList(); await PageObjects.common.navigateToApp('home'); }); + after(async () => { + await spacesService.delete('space_a'); + await kibanaServer.savedObjects.cleanStandardList(); + }); it('a11y test for manage spaces menu from top nav on Kibana home', async () => { await testSubjects.click('space-avatar-default'); diff --git a/x-pack/test/accessibility/apps/users.ts b/x-pack/test/accessibility/apps/users.ts index 71ed3a27c1073..6057b4d45bb09 100644 --- a/x-pack/test/accessibility/apps/users.ts +++ b/x-pack/test/accessibility/apps/users.ts @@ -12,14 +12,14 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['security', 'settings']); const a11y = getService('a11y'); - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const find = getService('find'); const retry = getService('retry'); + const kibanaServer = getService('kibanaServer'); describe('Kibana users Accessibility', () => { before(async () => { - await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); + await kibanaServer.savedObjects.cleanStandardList(); await PageObjects.security.clickElasticsearchUsers(); }); diff --git a/x-pack/test/functional/apps/lens/group2/index.ts b/x-pack/test/functional/apps/lens/group2/index.ts index 0e1c732dff41c..f63fc0ecebca2 100644 --- a/x-pack/test/functional/apps/lens/group2/index.ts +++ b/x-pack/test/functional/apps/lens/group2/index.ts @@ -72,7 +72,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./add_to_dashboard')); loadTestFile(require.resolve('./runtime_fields')); loadTestFile(require.resolve('./dashboard')); - loadTestFile(require.resolve('./multi_terms')); + loadTestFile(require.resolve('./terms')); loadTestFile(require.resolve('./epoch_millis')); loadTestFile(require.resolve('./show_underlying_data')); loadTestFile(require.resolve('./show_underlying_data_dashboard')); diff --git a/x-pack/test/functional/apps/lens/group2/multi_terms.ts b/x-pack/test/functional/apps/lens/group2/multi_terms.ts deleted file mode 100644 index 58fa172378964..0000000000000 --- a/x-pack/test/functional/apps/lens/group2/multi_terms.ts +++ /dev/null @@ -1,90 +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 expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); - const elasticChart = getService('elasticChart'); - - describe('lens multi terms suite', () => { - it('should allow creation of lens xy chart with multi terms categories', async () => { - await PageObjects.visualize.navigateToNewVisualization(); - await PageObjects.visualize.clickVisType('lens'); - await elasticChart.setNewChartUiDebugFlag(true); - await PageObjects.lens.goToTimeRange(); - - await PageObjects.lens.configureDimension({ - dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', - operation: 'average', - field: 'bytes', - }); - - await PageObjects.lens.configureDimension({ - dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', - operation: 'terms', - field: 'geo.src', - keepOpen: true, - }); - - await PageObjects.lens.addTermToAgg('geo.dest'); - - await PageObjects.lens.closeDimensionEditor(); - - expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0)).to.eql( - 'Top values of geo.src + 1 other' - ); - - await PageObjects.lens.openDimensionEditor('lnsXY_xDimensionPanel'); - - await PageObjects.lens.addTermToAgg('bytes'); - - await PageObjects.lens.closeDimensionEditor(); - - expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0)).to.eql( - 'Top values of geo.src + 2 others' - ); - - const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart'); - expect(data!.bars![0].bars[0].x).to.eql('PE › US › 19,986'); - }); - - it('should allow creation of lens xy chart with multi terms categories split', async () => { - await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel'); - - await PageObjects.lens.configureDimension({ - dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', - operation: 'date_histogram', - field: '@timestamp', - }); - - await PageObjects.lens.configureDimension({ - dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', - operation: 'terms', - field: 'geo.src', - keepOpen: true, - }); - - await PageObjects.lens.addTermToAgg('geo.dest'); - await PageObjects.lens.addTermToAgg('bytes'); - - await PageObjects.lens.closeDimensionEditor(); - - const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart'); - expect(data?.bars?.[0]?.name).to.eql('PE › US › 19,986'); - }); - - it('should not show existing defined fields for new term', async () => { - await PageObjects.lens.openDimensionEditor('lnsXY_splitDimensionPanel'); - - await PageObjects.lens.checkTermsAreNotAvailableToAgg(['bytes', 'geo.src', 'geo.dest']); - - await PageObjects.lens.closeDimensionEditor(); - }); - }); -} diff --git a/x-pack/test/functional/apps/lens/group2/terms.ts b/x-pack/test/functional/apps/lens/group2/terms.ts new file mode 100644 index 0000000000000..3b93c39eb7a6b --- /dev/null +++ b/x-pack/test/functional/apps/lens/group2/terms.ts @@ -0,0 +1,156 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const elasticChart = getService('elasticChart'); + const testSubjects = getService('testSubjects'); + const comboBox = getService('comboBox'); + const find = getService('find'); + const retry = getService('retry'); + + describe('lens terms', () => { + describe('lens multi terms suite', () => { + it('should allow creation of lens xy chart with multi terms categories', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await elasticChart.setNewChartUiDebugFlag(true); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + keepOpen: true, + }); + + await PageObjects.lens.addTermToAgg('geo.dest'); + + await PageObjects.lens.closeDimensionEditor(); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0)).to.eql( + 'Top values of geo.src + 1 other' + ); + + await PageObjects.lens.openDimensionEditor('lnsXY_xDimensionPanel'); + + await PageObjects.lens.addTermToAgg('bytes'); + + await PageObjects.lens.closeDimensionEditor(); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0)).to.eql( + 'Top values of geo.src + 2 others' + ); + + const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart'); + expect(data!.bars![0].bars[0].x).to.eql('PE › US › 19,986'); + }); + + it('should allow creation of lens xy chart with multi terms categories split', async () => { + await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + keepOpen: true, + }); + + await PageObjects.lens.addTermToAgg('geo.dest'); + await PageObjects.lens.addTermToAgg('bytes'); + + await PageObjects.lens.closeDimensionEditor(); + + const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart'); + expect(data?.bars?.[0]?.name).to.eql('PE › US › 19,986'); + }); + + it('should not show existing defined fields for new term', async () => { + await PageObjects.lens.openDimensionEditor('lnsXY_splitDimensionPanel'); + + await PageObjects.lens.checkTermsAreNotAvailableToAgg(['bytes', 'geo.src', 'geo.dest']); + + await PageObjects.lens.closeDimensionEditor(); + }); + }); + describe('sorting by custom metric', () => { + it('should allow sort by custom metric', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await elasticChart.setNewChartUiDebugFlag(true); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + keepOpen: true, + }); + await find.clickByCssSelector( + 'select[data-test-subj="indexPattern-terms-orderBy"] > option[value="custom"]' + ); + + const fnTarget = await testSubjects.find('indexPattern-reference-function'); + await comboBox.openOptionsList(fnTarget); + await comboBox.setElement(fnTarget, 'percentile'); + + const fieldTarget = await testSubjects.find( + 'indexPattern-reference-field-selection-row>indexPattern-dimension-field' + ); + await comboBox.openOptionsList(fieldTarget); + await comboBox.setElement(fieldTarget, 'bytes'); + + await retry.try(async () => { + // Can not use testSubjects because data-test-subj is placed range input and number input + const percentileInput = await find.byCssSelector( + `input[data-test-subj="lns-indexPattern-percentile-input"][type='number']` + ); + await percentileInput.click(); + await percentileInput.clearValue(); + await percentileInput.type('60'); + + const percentileValue = await percentileInput.getAttribute('value'); + if (percentileValue !== '60') { + throw new Error('layerPanelTopHitsSize not set to 60'); + } + }); + + await PageObjects.lens.waitForVisualization('xyVisChart'); + await PageObjects.lens.closeDimensionEditor(); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0)).to.eql( + 'Top 5 values of geo.src' + ); + + const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart'); + expect(data!.bars![0].bars[0].x).to.eql('BN'); + expect(data!.bars![0].bars[0].y).to.eql(19265); + }); + }); + }); +} diff --git a/yarn.lock b/yarn.lock index 9b23ac23f442d..280d5e96c0cd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2944,6 +2944,10 @@ version "0.0.0" uid "" +"@kbn/aiops-components@link:bazel-bin/x-pack/packages/ml/aiops_components": + version "0.0.0" + uid "" + "@kbn/aiops-utils@link:bazel-bin/x-pack/packages/ml/aiops_utils": version "0.0.0" uid "" @@ -5996,6 +6000,13 @@ resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-1.2.7.tgz#34dc654d34fc058c41c31dbca1ed68071a8fcc17" integrity sha512-51vHWuUyDOi+8XuwPrTw3cFqyh2Slg9y8COYkRfjCPG9TfYqY0hoNPzv/8BrcAy0FeQBzqEo/D/8Nk2caOQJnA== +"@types/d3-brush@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.1.tgz#ae5f17ce391935ca88b29000e60ee20452c6357c" + integrity sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw== + dependencies: + "@types/d3-selection" "*" + "@types/d3-color@*": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-2.0.1.tgz#570ea7f8b853461301804efa52bd790a640a26db" @@ -6020,6 +6031,11 @@ dependencies: "@types/d3-time" "^1" +"@types/d3-selection@*", "@types/d3-selection@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.2.tgz#23e48a285b24063630bbe312cc0cfe2276de4a59" + integrity sha512-d29EDd0iUBrRoKhPndhDY6U/PYxOWqgIZwKTooy2UkBfU7TNZNpRho0yLWPxlatQrFWk2mnTu71IZQ4+LRgKlQ== + "@types/d3-shape@^1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.1.tgz#1b4f92b7efd7306fe2474dc6ee94c0f0ed2e6ab6" @@ -6037,6 +6053,13 @@ resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-1.0.10.tgz#d338c7feac93a98a32aac875d1100f92c7b61f4f" integrity sha512-aKf62rRQafDQmSiv1NylKhIMmznsjRN+MnXRXTqHoqm0U/UZzVpdrtRnSIfdiLS616OuC1soYeX1dBg2n1u8Xw== +"@types/d3-transition@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.1.tgz#c9a96125567173d6163a6985b874f79154f4cc3d" + integrity sha512-Sv4qEI9uq3bnZwlOANvYK853zvpdKEm1yz9rcc8ZTsxvRklcs9Fx4YFuGA3gXoQN/c/1T6QkVNjhaRO/cWj94g== + dependencies: + "@types/d3-selection" "*" + "@types/d3@^3.5.43": version "3.5.43" resolved "https://registry.yarnpkg.com/@types/d3/-/d3-3.5.43.tgz#e9b4992817e0b6c5efaa7d6e5bb2cee4d73eab58" @@ -6468,6 +6491,10 @@ version "0.0.0" uid "" +"@types/kbn__aiops-components@link:bazel-bin/x-pack/packages/ml/aiops_components/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__aiops-utils@link:bazel-bin/x-pack/packages/ml/aiops_utils/npm_module_types": version "0.0.0" uid "" @@ -12256,6 +12283,17 @@ d3-array@2, d3-array@^2.3.0: dependencies: internmap "^1.0.0" +d3-brush@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" + integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "3" + d3-transition "3" + d3-cloud@1.2.5, d3-cloud@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/d3-cloud/-/d3-cloud-1.2.5.tgz#3e91564f2d27fba47fcc7d812eb5081ea24c603d" @@ -12302,6 +12340,14 @@ d3-delaunay@^6.0.2: resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== +"d3-drag@2 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + d3-dsv@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-1.2.0.tgz#9d5f75c3a5f8abd611f74d3f5847b0d4338b885c" @@ -12320,6 +12366,11 @@ d3-dsv@^3.0.1: iconv-lite "0.6" rw "1" +"d3-ease@1 - 3": + version "1.0.6" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.6.tgz#ebdb6da22dfac0a22222f2d4da06f66c416a0ec0" + integrity sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ== + d3-force@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4" @@ -12384,6 +12435,13 @@ d3-interpolate@1, d3-interpolate@^1.1.4: dependencies: d3-color "1" +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + "d3-interpolate@1.2.0 - 2": version "2.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" @@ -12391,13 +12449,6 @@ d3-interpolate@1, d3-interpolate@^1.1.4: dependencies: d3-color "1 - 2" -"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" - integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== - dependencies: - d3-color "1 - 3" - d3-path@1: version "1.0.9" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" @@ -12474,6 +12525,11 @@ d3-scale@^4.0.2: d3-time "2.1.1 - 3" d3-time-format "2 - 4" +d3-selection@3, d3-selection@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + d3-shape@^1.1.0, d3-shape@^1.2.0: version "1.3.7" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" @@ -12550,6 +12606,17 @@ d3-timer@^3.0.1: resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== +d3-transition@3, d3-transition@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + d3-voronoi@^1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297"